Improve data agent workspace UI
This commit is contained in:
+291
-4
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from threading import Lock
|
from threading import Lock
|
||||||
@@ -20,6 +21,7 @@ from fastapi.responses import FileResponse, JSONResponse
|
|||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from src.agent_session import AgentMessage, AgentSessionState
|
||||||
from src.agent_runtime import LocalCodingAgent
|
from src.agent_runtime import LocalCodingAgent
|
||||||
from src.agent_slash_commands import get_slash_command_specs
|
from src.agent_slash_commands import get_slash_command_specs
|
||||||
from src.agent_types import (
|
from src.agent_types import (
|
||||||
@@ -31,8 +33,10 @@ from src.bundled_skills import get_bundled_skills
|
|||||||
from src.session_store import (
|
from src.session_store import (
|
||||||
DEFAULT_AGENT_SESSION_DIR,
|
DEFAULT_AGENT_SESSION_DIR,
|
||||||
StoredAgentSession,
|
StoredAgentSession,
|
||||||
|
deserialize_runtime_config,
|
||||||
load_agent_session,
|
load_agent_session,
|
||||||
)
|
)
|
||||||
|
from src.token_budget import calculate_token_budget
|
||||||
|
|
||||||
|
|
||||||
STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static'
|
STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static'
|
||||||
@@ -202,6 +206,10 @@ class ModelListRequest(BaseModel):
|
|||||||
api_key: str | None = None
|
api_key: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SessionTitleUpdate(BaseModel):
|
||||||
|
title: str = Field(min_length=1, max_length=80)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# App factory
|
# App factory
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -291,6 +299,7 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
else {}
|
else {}
|
||||||
)
|
)
|
||||||
messages = data.get('messages') or []
|
messages = data.get('messages') or []
|
||||||
|
title = data.get('title')
|
||||||
preview = ''
|
preview = ''
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
if isinstance(msg, dict) and msg.get('role') == 'user':
|
if isinstance(msg, dict) and msg.get('role') == 'user':
|
||||||
@@ -303,7 +312,7 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
'session_id': session_id,
|
'session_id': session_id,
|
||||||
'turns': data.get('turns', 0),
|
'turns': data.get('turns', 0),
|
||||||
'tool_calls': data.get('tool_calls', 0),
|
'tool_calls': data.get('tool_calls', 0),
|
||||||
'preview': preview,
|
'preview': title if isinstance(title, str) and title.strip() else preview,
|
||||||
'modified_at': _session_mtime(path),
|
'modified_at': _session_mtime(path),
|
||||||
'model': model_config.get('model'),
|
'model': model_config.get('model'),
|
||||||
'usage': usage,
|
'usage': usage,
|
||||||
@@ -320,6 +329,58 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
raise HTTPException(status_code=404, detail='Session not found')
|
raise HTTPException(status_code=404, detail='Session not found')
|
||||||
return _serialize_stored_session(stored)
|
return _serialize_stored_session(stored)
|
||||||
|
|
||||||
|
@app.delete('/api/sessions/{session_id}')
|
||||||
|
async def delete_session(session_id: str, account_id: str | None = None) -> dict[str, Any]:
|
||||||
|
directory = state.account_paths(account_id)['sessions']
|
||||||
|
safe_id = _safe_session_id(session_id)
|
||||||
|
if safe_id is None or not _delete_session_files(directory, safe_id):
|
||||||
|
raise HTTPException(status_code=404, detail='Session not found')
|
||||||
|
return {'deleted': True, 'session_id': safe_id}
|
||||||
|
|
||||||
|
@app.patch('/api/sessions/{session_id}')
|
||||||
|
async def update_session(
|
||||||
|
session_id: str,
|
||||||
|
payload: SessionTitleUpdate,
|
||||||
|
account_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
directory = state.account_paths(account_id)['sessions']
|
||||||
|
safe_id = _safe_session_id(session_id)
|
||||||
|
title = _clean_manual_session_title(payload.title)
|
||||||
|
if safe_id is None or title is None:
|
||||||
|
raise HTTPException(status_code=400, detail='Invalid session title')
|
||||||
|
if not _update_session_title(directory, safe_id, title):
|
||||||
|
raise HTTPException(status_code=404, detail='Session not found')
|
||||||
|
return {'session_id': safe_id, 'title': title}
|
||||||
|
|
||||||
|
@app.get('/api/context-budget')
|
||||||
|
async def get_context_budget(
|
||||||
|
session_id: str | None = None,
|
||||||
|
account_id: str | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with state.lock():
|
||||||
|
if session_id:
|
||||||
|
directory = state.account_paths(account_id)['sessions']
|
||||||
|
try:
|
||||||
|
stored = load_agent_session(session_id, directory=directory)
|
||||||
|
except FileNotFoundError:
|
||||||
|
raise HTTPException(status_code=404, detail='Session not found')
|
||||||
|
session = _session_state_from_stored(stored)
|
||||||
|
model = str(stored.model_config.get('model') or state.model)
|
||||||
|
runtime_config = deserialize_runtime_config(stored.runtime_config)
|
||||||
|
else:
|
||||||
|
agent = state.agent_for(account_id)
|
||||||
|
session = agent.last_session or agent.build_session()
|
||||||
|
model = agent.model_config.model
|
||||||
|
runtime_config = agent.runtime_config
|
||||||
|
|
||||||
|
snapshot = calculate_token_budget(
|
||||||
|
session=session,
|
||||||
|
model=model,
|
||||||
|
budget_config=runtime_config.budget_config,
|
||||||
|
output_schema=runtime_config.output_schema,
|
||||||
|
)
|
||||||
|
return _serialize_token_budget(snapshot)
|
||||||
|
|
||||||
# ------------- chat ------------------------------------------------------
|
# ------------- chat ------------------------------------------------------
|
||||||
@app.post('/api/chat')
|
@app.post('/api/chat')
|
||||||
async def chat(request: ChatRequest) -> dict[str, Any]:
|
async def chat(request: ChatRequest) -> dict[str, Any]:
|
||||||
@@ -331,6 +392,13 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
with state.lock():
|
with state.lock():
|
||||||
agent = state.agent_for(request.account_id)
|
agent = state.agent_for(request.account_id)
|
||||||
session_directory = state.account_paths(request.account_id)['sessions']
|
session_directory = state.account_paths(request.account_id)['sessions']
|
||||||
|
requested_session_id = _safe_session_id(
|
||||||
|
request.resume_session_id or request.session_id
|
||||||
|
)
|
||||||
|
previous_title = _read_session_title(
|
||||||
|
session_directory,
|
||||||
|
requested_session_id,
|
||||||
|
)
|
||||||
started_at = time.perf_counter()
|
started_at = time.perf_counter()
|
||||||
if request.resume_session_id is not None:
|
if request.resume_session_id is not None:
|
||||||
try:
|
try:
|
||||||
@@ -363,6 +431,14 @@ def create_app(state: AgentState) -> FastAPI:
|
|||||||
result.session_id,
|
result.session_id,
|
||||||
elapsed_ms,
|
elapsed_ms,
|
||||||
)
|
)
|
||||||
|
_ensure_session_title(
|
||||||
|
session_directory,
|
||||||
|
result.session_id,
|
||||||
|
model=state.model,
|
||||||
|
base_url=state.base_url,
|
||||||
|
api_key=state.api_key,
|
||||||
|
previous_title=previous_title,
|
||||||
|
)
|
||||||
_annotate_transcript_elapsed(payload, elapsed_ms)
|
_annotate_transcript_elapsed(payload, elapsed_ms)
|
||||||
return payload
|
return payload
|
||||||
|
|
||||||
@@ -429,6 +505,41 @@ def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _session_state_from_stored(stored: StoredAgentSession) -> AgentSessionState:
|
||||||
|
return AgentSessionState(
|
||||||
|
system_prompt_parts=stored.system_prompt_parts,
|
||||||
|
user_context=stored.user_context,
|
||||||
|
system_context=stored.system_context,
|
||||||
|
messages=[
|
||||||
|
AgentMessage.from_openai_message(message)
|
||||||
|
for message in stored.messages
|
||||||
|
if isinstance(message, dict)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_token_budget(snapshot: Any) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'model': snapshot.model,
|
||||||
|
'context_window_tokens': snapshot.context_window_tokens,
|
||||||
|
'projected_input_tokens': snapshot.projected_input_tokens,
|
||||||
|
'message_tokens': snapshot.message_tokens,
|
||||||
|
'chat_overhead_tokens': snapshot.chat_overhead_tokens,
|
||||||
|
'reserved_output_tokens': snapshot.reserved_output_tokens,
|
||||||
|
'reserved_compaction_buffer_tokens': snapshot.reserved_compaction_buffer_tokens,
|
||||||
|
'reserved_schema_tokens': snapshot.reserved_schema_tokens,
|
||||||
|
'hard_input_limit_tokens': snapshot.hard_input_limit_tokens,
|
||||||
|
'soft_input_limit_tokens': snapshot.soft_input_limit_tokens,
|
||||||
|
'overflow_tokens': snapshot.overflow_tokens,
|
||||||
|
'soft_overflow_tokens': snapshot.soft_overflow_tokens,
|
||||||
|
'exceeds_hard_limit': snapshot.exceeds_hard_limit,
|
||||||
|
'exceeds_soft_limit': snapshot.exceeds_soft_limit,
|
||||||
|
'token_counter_backend': snapshot.token_counter_backend,
|
||||||
|
'token_counter_source': snapshot.token_counter_source,
|
||||||
|
'token_counter_accurate': snapshot.token_counter_accurate,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _list_backend_models(base_url: str, api_key: str) -> dict[str, Any]:
|
def _list_backend_models(base_url: str, api_key: str) -> dict[str, Any]:
|
||||||
req = request.Request(
|
req = request.Request(
|
||||||
_join_url(base_url, '/models'),
|
_join_url(base_url, '/models'),
|
||||||
@@ -532,9 +643,7 @@ def _annotate_last_assistant_elapsed(
|
|||||||
session_id: str,
|
session_id: str,
|
||||||
elapsed_ms: int,
|
elapsed_ms: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
path = directory / session_id / 'session.json'
|
path = _session_json_path(directory, session_id)
|
||||||
if not path.exists():
|
|
||||||
path = directory / f'{session_id}.json'
|
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding='utf-8'))
|
data = json.loads(path.read_text(encoding='utf-8'))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
@@ -557,6 +666,154 @@ def _annotate_last_assistant_elapsed(
|
|||||||
return
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _read_session_title(directory: Path, session_id: str | None) -> str | None:
|
||||||
|
if not session_id:
|
||||||
|
return None
|
||||||
|
path = _session_json_path(directory, session_id)
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
title = data.get('title')
|
||||||
|
if isinstance(title, str) and title.strip():
|
||||||
|
return title.strip()
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_session_title(
|
||||||
|
directory: Path,
|
||||||
|
session_id: str,
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
previous_title: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
path = _session_json_path(directory, session_id)
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return
|
||||||
|
if previous_title:
|
||||||
|
data['title'] = previous_title
|
||||||
|
_write_session_metadata(path, data)
|
||||||
|
return
|
||||||
|
title = data.get('title')
|
||||||
|
if isinstance(title, str) and title.strip():
|
||||||
|
return
|
||||||
|
messages = data.get('messages')
|
||||||
|
if not isinstance(messages, list):
|
||||||
|
return
|
||||||
|
user_messages = _session_user_messages(messages)
|
||||||
|
if not user_messages:
|
||||||
|
return
|
||||||
|
generated = _generate_session_title(
|
||||||
|
user_messages,
|
||||||
|
model=model,
|
||||||
|
base_url=base_url,
|
||||||
|
api_key=api_key,
|
||||||
|
)
|
||||||
|
if not generated:
|
||||||
|
return
|
||||||
|
data['title'] = generated
|
||||||
|
data['title_source'] = 'llm'
|
||||||
|
data['title_generated_at'] = int(time.time())
|
||||||
|
_write_session_metadata(path, data)
|
||||||
|
|
||||||
|
|
||||||
|
def _session_user_messages(messages: list[Any]) -> list[str]:
|
||||||
|
user_messages: list[str] = []
|
||||||
|
for message in messages:
|
||||||
|
if not isinstance(message, dict) or message.get('role') != 'user':
|
||||||
|
continue
|
||||||
|
content = message.get('content')
|
||||||
|
if not isinstance(content, str) or _is_internal_message(content):
|
||||||
|
continue
|
||||||
|
stripped = _strip_session_context(content)
|
||||||
|
if stripped:
|
||||||
|
user_messages.append(stripped)
|
||||||
|
return user_messages
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_session_title(
|
||||||
|
user_messages: list[str],
|
||||||
|
*,
|
||||||
|
model: str,
|
||||||
|
base_url: str,
|
||||||
|
api_key: str,
|
||||||
|
) -> str | None:
|
||||||
|
conversation = '\n'.join(
|
||||||
|
f'用户第{index}轮:{message[:240]}'
|
||||||
|
for index, message in enumerate(user_messages[-6:], start=1)
|
||||||
|
)
|
||||||
|
payload = {
|
||||||
|
'model': model,
|
||||||
|
'temperature': 0.2,
|
||||||
|
'max_tokens': 32,
|
||||||
|
'messages': [
|
||||||
|
{
|
||||||
|
'role': 'system',
|
||||||
|
'content': (
|
||||||
|
'你是会话标题生成器。请根据用户对话生成一个中文短标题,'
|
||||||
|
'要求 4 到 12 个字,只输出标题,不要解释,不要引号。'
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{'role': 'user', 'content': conversation},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
req = request.Request(
|
||||||
|
_join_url(base_url, '/chat/completions'),
|
||||||
|
data=json.dumps(payload).encode('utf-8'),
|
||||||
|
headers={
|
||||||
|
'Authorization': f'Bearer {api_key}',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with request.urlopen(req, timeout=15) as response:
|
||||||
|
response_payload = json.loads(response.read().decode('utf-8'))
|
||||||
|
except (error.HTTPError, error.URLError, OSError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
choices = response_payload.get('choices')
|
||||||
|
if not isinstance(choices, list) or not choices:
|
||||||
|
return None
|
||||||
|
message = choices[0].get('message') if isinstance(choices[0], dict) else None
|
||||||
|
content = message.get('content') if isinstance(message, dict) else None
|
||||||
|
if not isinstance(content, str):
|
||||||
|
return None
|
||||||
|
return _clean_session_title(content)
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_session_title(value: str) -> str | None:
|
||||||
|
title = ' '.join(value.strip().strip('"\'“”‘’`').split())
|
||||||
|
title = title.rstrip('。.!!??')
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return title[:24]
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_manual_session_title(value: str) -> str | None:
|
||||||
|
title = ' '.join(value.strip().strip('"\'“”‘’`').split())
|
||||||
|
if not title:
|
||||||
|
return None
|
||||||
|
return title[:80]
|
||||||
|
|
||||||
|
|
||||||
|
def _write_session_metadata(path: Path, data: dict[str, Any]) -> None:
|
||||||
|
try:
|
||||||
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||||||
|
except OSError:
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
def _session_json_path(directory: Path, session_id: str) -> Path:
|
||||||
|
path = directory / session_id / 'session.json'
|
||||||
|
if path.exists():
|
||||||
|
return path
|
||||||
|
return directory / f'{session_id}.json'
|
||||||
|
|
||||||
|
|
||||||
def _safe_account_id(account_id: str | None) -> str:
|
def _safe_account_id(account_id: str | None) -> str:
|
||||||
normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', (account_id or '').strip())
|
normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', (account_id or '').strip())
|
||||||
return normalized[:80] or 'default'
|
return normalized[:80] or 'default'
|
||||||
@@ -588,6 +845,36 @@ def _iter_session_files(directory: Path) -> list[Path]:
|
|||||||
return list(by_session_id.values())
|
return list(by_session_id.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _delete_session_files(directory: Path, session_id: str) -> bool:
|
||||||
|
deleted = False
|
||||||
|
# 新目录格式:每个 session 独立目录,里面包含 session/input/output。
|
||||||
|
nested = directory / session_id
|
||||||
|
if nested.exists():
|
||||||
|
shutil.rmtree(nested)
|
||||||
|
deleted = True
|
||||||
|
# 兼容早期平铺 session json,避免历史列表删除后旧数据又出现。
|
||||||
|
legacy = directory / f'{session_id}.json'
|
||||||
|
if legacy.exists():
|
||||||
|
legacy.unlink()
|
||||||
|
deleted = True
|
||||||
|
return deleted
|
||||||
|
|
||||||
|
|
||||||
|
def _update_session_title(directory: Path, session_id: str, title: str) -> bool:
|
||||||
|
path = _session_json_path(directory, session_id)
|
||||||
|
if not path.exists():
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding='utf-8'))
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
return False
|
||||||
|
data['title'] = title
|
||||||
|
data['title_source'] = 'manual'
|
||||||
|
data['title_updated_at'] = int(time.time())
|
||||||
|
_write_session_metadata(path, data)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _session_mtime(path: Path) -> float:
|
def _session_mtime(path: Path) -> float:
|
||||||
try:
|
try:
|
||||||
return path.stat().st_mtime
|
return path.stat().st_mtime
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
This frontend is based on the official [assistant-ui](https://github.com/assistant-ui/assistant-ui) starter project.
|
# ZK Data Agent Frontend
|
||||||
|
|
||||||
The current goal is to keep the upstream assistant-ui visual structure intact, then add Claw Code Agent backend adapters in small, reviewable steps.
|
中控数据开发平台前端,基于 [assistant-ui](https://github.com/assistant-ui/assistant-ui) starter project 改造。
|
||||||
|
|
||||||
## Getting Started
|
## Getting Started
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||||
|
|
||||||
|
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
const account = await getCurrentAccount();
|
||||||
|
if (!account)
|
||||||
|
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||||
|
|
||||||
|
const incoming = new URL(req.url);
|
||||||
|
const url = new URL(`${CLAW_API_URL}/api/context-budget`);
|
||||||
|
url.searchParams.set("account_id", account.id);
|
||||||
|
const sessionId = incoming.searchParams.get("session_id");
|
||||||
|
if (sessionId) url.searchParams.set("session_id", sessionId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { cache: "no-store" });
|
||||||
|
const payload = await response.text();
|
||||||
|
return new Response(payload, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"content-type":
|
||||||
|
response.headers.get("content-type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return Response.json(
|
||||||
|
{
|
||||||
|
error:
|
||||||
|
err instanceof Error
|
||||||
|
? `Unable to reach ZK Data Agent backend: ${err.message}`
|
||||||
|
: "Unable to reach ZK Data Agent backend",
|
||||||
|
},
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,3 +23,52 @@ export async function GET(
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ sessionId: string }> },
|
||||||
|
) {
|
||||||
|
const account = await getCurrentAccount();
|
||||||
|
if (!account)
|
||||||
|
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||||
|
|
||||||
|
const { sessionId } = await params;
|
||||||
|
const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}`);
|
||||||
|
url.searchParams.set("account_id", account.id);
|
||||||
|
const response = await fetch(url, { method: "DELETE", cache: "no-store" });
|
||||||
|
const payload = await response.text();
|
||||||
|
return new Response(payload, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"content-type":
|
||||||
|
response.headers.get("content-type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
req: Request,
|
||||||
|
{ params }: { params: Promise<{ sessionId: string }> },
|
||||||
|
) {
|
||||||
|
const account = await getCurrentAccount();
|
||||||
|
if (!account)
|
||||||
|
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||||
|
|
||||||
|
const { sessionId } = await params;
|
||||||
|
const url = new URL(`${CLAW_API_URL}/api/sessions/${sessionId}`);
|
||||||
|
url.searchParams.set("account_id", account.id);
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: "PATCH",
|
||||||
|
cache: "no-store",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: await req.text(),
|
||||||
|
});
|
||||||
|
const payload = await response.text();
|
||||||
|
return new Response(payload, {
|
||||||
|
status: response.status,
|
||||||
|
headers: {
|
||||||
|
"content-type":
|
||||||
|
response.headers.get("content-type") ?? "application/json",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import {
|
|||||||
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
|
import { ClawSessionReplayProvider } from "@/lib/claw-session-replay";
|
||||||
import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
|
import { ClawAccountGate, useClawAccount } from "./claw-account-gate";
|
||||||
import { ClawLlmSettings } from "./claw-llm-settings";
|
import { ClawLlmSettings } from "./claw-llm-settings";
|
||||||
|
import { ClawThemeSwitcher } from "./claw-theme-switcher";
|
||||||
|
|
||||||
export const Assistant = () => {
|
export const Assistant = () => {
|
||||||
const { account, isLoading, setAccount } = useClawAccount();
|
const { account, isLoading, setAccount } = useClawAccount();
|
||||||
@@ -94,9 +95,10 @@ export const Assistant = () => {
|
|||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="truncate font-medium text-sm">新会话</div>
|
<div className="truncate font-medium text-sm">新会话</div>
|
||||||
<div className="text-muted-foreground text-xs">
|
<div className="text-muted-foreground text-xs">
|
||||||
Claw Data Agent
|
ZK Data Agent
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<ClawThemeSwitcher />
|
||||||
<ClawLlmSettings />
|
<ClawLlmSettings />
|
||||||
</header>
|
</header>
|
||||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
|
|||||||
@@ -74,9 +74,9 @@ export function ClawAccountGate({
|
|||||||
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
|
<div className="flex min-h-dvh items-center justify-center bg-background px-4">
|
||||||
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm">
|
<div className="w-full max-w-sm rounded-xl border bg-card p-6 shadow-sm">
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="font-semibold text-xl">Claw Data Agent</h1>
|
<h1 className="font-semibold text-xl">ZK Data Agent</h1>
|
||||||
<p className="mt-1 text-muted-foreground text-sm">
|
<p className="mt-1 text-muted-foreground text-sm">
|
||||||
登录后会按账号隔离会话、上传文件和生成产物。
|
中控数据开发平台会按账号隔离会话、上传文件和生成产物。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-3">
|
<div className="grid gap-3">
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ export function ClawLlmSettings() {
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>后端 LLM API</DialogTitle>
|
<DialogTitle>后端 LLM API</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
调试时更新 Claw 后端 Agent 使用的 Base URL 和 API Key。
|
调试时更新 ZK Data Agent 后端使用的 Base URL 和 API Key。
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<div className="grid gap-4 py-2">
|
<div className="grid gap-4 py-2">
|
||||||
|
|||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CheckIcon, MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
|
||||||
|
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
|
||||||
|
type ThemeMode = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
const THEME_STORAGE_KEY = "claw.theme";
|
||||||
|
|
||||||
|
const THEME_OPTIONS: Array<{
|
||||||
|
mode: ThemeMode;
|
||||||
|
label: string;
|
||||||
|
icon: typeof SunIcon;
|
||||||
|
}> = [
|
||||||
|
{ mode: "light", label: "亮色", icon: SunIcon },
|
||||||
|
{ mode: "dark", label: "暗色", icon: MoonIcon },
|
||||||
|
{ mode: "system", label: "跟随系统", icon: MonitorIcon },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ClawThemeSwitcher() {
|
||||||
|
const [mode, setMode] = useState<ThemeMode>("system");
|
||||||
|
const activeOption =
|
||||||
|
THEME_OPTIONS.find((option) => option.mode === mode) ?? THEME_OPTIONS[2];
|
||||||
|
const ActiveIcon = activeOption.icon;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||||
|
const initialMode = isThemeMode(stored) ? stored : "system";
|
||||||
|
setMode(initialMode);
|
||||||
|
applyTheme(initialMode);
|
||||||
|
|
||||||
|
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||||
|
const onChange = () => {
|
||||||
|
if (window.localStorage.getItem(THEME_STORAGE_KEY) === "system") {
|
||||||
|
applyTheme("system");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
media.addEventListener("change", onChange);
|
||||||
|
return () => media.removeEventListener("change", onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const updateMode = (nextMode: ThemeMode) => {
|
||||||
|
window.localStorage.setItem(THEME_STORAGE_KEY, nextMode);
|
||||||
|
setMode(nextMode);
|
||||||
|
applyTheme(nextMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PopoverPrimitive.Root>
|
||||||
|
<PopoverPrimitive.Trigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
className="rounded-full"
|
||||||
|
aria-label="切换主题"
|
||||||
|
title={`主题:${activeOption.label}`}
|
||||||
|
>
|
||||||
|
<ActiveIcon className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</PopoverPrimitive.Trigger>
|
||||||
|
<PopoverPrimitive.Portal>
|
||||||
|
<PopoverPrimitive.Content
|
||||||
|
align="end"
|
||||||
|
sideOffset={8}
|
||||||
|
className="z-50 min-w-36 rounded-xl border bg-popover p-1 text-popover-foreground shadow-lg"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const Icon = option.icon;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.mode}
|
||||||
|
type="button"
|
||||||
|
className="flex w-full items-center gap-2 rounded-lg px-2.5 py-2 text-left text-sm transition hover:bg-accent"
|
||||||
|
onClick={() => updateMode(option.mode)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4 text-muted-foreground" />
|
||||||
|
<span className="min-w-0 flex-1">{option.label}</span>
|
||||||
|
{mode === option.mode ? <CheckIcon className="size-4" /> : null}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</PopoverPrimitive.Content>
|
||||||
|
</PopoverPrimitive.Portal>
|
||||||
|
</PopoverPrimitive.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyTheme(mode: ThemeMode) {
|
||||||
|
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||||
|
const shouldUseDark = mode === "dark" || (mode === "system" && prefersDark);
|
||||||
|
document.documentElement.classList.toggle("dark", shouldUseDark);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isThemeMode(value: string | null): value is ThemeMode {
|
||||||
|
return value === "light" || value === "dark" || value === "system";
|
||||||
|
}
|
||||||
@@ -14,8 +14,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Claw Data Agent",
|
title: "ZK Data Agent",
|
||||||
description: "assistant-ui based data agent workbench",
|
description: "中控数据开发平台",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -24,7 +24,7 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN" suppressHydrationWarning>
|
||||||
<body
|
<body
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -94,6 +94,10 @@ export function ActivityPanel() {
|
|||||||
const { open, selectedId, openItem, close } = useActivityPanel();
|
const { open, selectedId, openItem, close } = useActivityPanel();
|
||||||
const messages = useAuiState((s) => s.thread.messages);
|
const messages = useAuiState((s) => s.thread.messages);
|
||||||
const items = useMemo(() => collectActivityItems(messages), [messages]);
|
const items = useMemo(() => collectActivityItems(messages), [messages]);
|
||||||
|
const selectedMessageId = selectedId ? activityMessageId(selectedId) : null;
|
||||||
|
const visibleItems = selectedMessageId
|
||||||
|
? items.filter((item) => activityMessageId(item.id) === selectedMessageId)
|
||||||
|
: items;
|
||||||
const prevCountRef = useRef(0);
|
const prevCountRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -111,7 +115,9 @@ export function ActivityPanel() {
|
|||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<h2 className="font-semibold text-base">活动</h2>
|
<h2 className="font-semibold text-base">活动</h2>
|
||||||
<p className="text-muted-foreground text-xs">
|
<p className="text-muted-foreground text-xs">
|
||||||
{items.length > 0 ? `${items.length} 个步骤` : "暂无链路"}
|
{visibleItems.length > 0
|
||||||
|
? `${visibleItems.length} 个步骤`
|
||||||
|
: "暂无链路"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
@@ -126,13 +132,13 @@ export function ActivityPanel() {
|
|||||||
</Button>
|
</Button>
|
||||||
</header>
|
</header>
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-4">
|
||||||
{items.length === 0 ? (
|
{visibleItems.length === 0 ? (
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
当前对话还没有工具链路。
|
当前这轮对话还没有工具链路。
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
{items.map((item) => (
|
{visibleItems.map((item) => (
|
||||||
<ActivityPanelItem
|
<ActivityPanelItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
item={item}
|
item={item}
|
||||||
@@ -149,6 +155,10 @@ export function ActivityPanel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function activityMessageId(activityId: string) {
|
||||||
|
return activityId.split(":", 1)[0] ?? activityId;
|
||||||
|
}
|
||||||
|
|
||||||
function ActivityPanelItem({
|
function ActivityPanelItem({
|
||||||
item,
|
item,
|
||||||
open,
|
open,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import "@assistant-ui/react-markdown/styles/dot.css";
|
import "@assistant-ui/react-markdown/styles/dot.css";
|
||||||
|
|
||||||
|
import { TextMessagePartProvider } from "@assistant-ui/core/react";
|
||||||
import {
|
import {
|
||||||
type CodeHeaderProps,
|
type CodeHeaderProps,
|
||||||
MarkdownTextPrimitive,
|
MarkdownTextPrimitive,
|
||||||
@@ -15,14 +16,25 @@ import remarkGfm from "remark-gfm";
|
|||||||
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
import { TooltipIconButton } from "@/components/assistant-ui/tooltip-icon-button";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
const MarkdownTextImpl = () => {
|
type MarkdownTextProps = {
|
||||||
return (
|
text?: string;
|
||||||
|
isRunning?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MarkdownTextImpl: FC<MarkdownTextProps> = ({ text, isRunning = false }) => {
|
||||||
|
const content = (
|
||||||
<MarkdownTextPrimitive
|
<MarkdownTextPrimitive
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
className="aui-md"
|
className="aui-md"
|
||||||
components={defaultComponents}
|
components={defaultComponents}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
if (text === undefined) return content;
|
||||||
|
return (
|
||||||
|
<TextMessagePartProvider text={text} isRunning={isRunning}>
|
||||||
|
{content}
|
||||||
|
</TextMessagePartProvider>
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MarkdownText = memo(MarkdownTextImpl);
|
export const MarkdownText = memo(MarkdownTextImpl);
|
||||||
|
|||||||
@@ -217,7 +217,9 @@ function ReasoningText({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReasoningImpl: ReasoningMessagePartComponent = () => <MarkdownText />;
|
const ReasoningImpl: ReasoningMessagePartComponent = ({ text, status }) => (
|
||||||
|
<MarkdownText text={text} isRunning={status?.type === "running"} />
|
||||||
|
);
|
||||||
|
|
||||||
const ReasoningGroupImpl: ReasoningGroupComponent = ({
|
const ReasoningGroupImpl: ReasoningGroupComponent = ({
|
||||||
children,
|
children,
|
||||||
|
|||||||
@@ -3,22 +3,22 @@ import {
|
|||||||
type ExportedMessageRepository,
|
type ExportedMessageRepository,
|
||||||
type ThreadMessage,
|
type ThreadMessage,
|
||||||
} from "@assistant-ui/core";
|
} from "@assistant-ui/core";
|
||||||
import {
|
import { ThreadListPrimitive } from "@assistant-ui/react";
|
||||||
AuiIf,
|
|
||||||
ThreadListItemMorePrimitive,
|
|
||||||
ThreadListItemPrimitive,
|
|
||||||
ThreadListPrimitive,
|
|
||||||
} from "@assistant-ui/react";
|
|
||||||
import type { UIMessage } from "ai";
|
import type { UIMessage } from "ai";
|
||||||
import {
|
import {
|
||||||
ArchiveIcon,
|
|
||||||
HistoryIcon,
|
|
||||||
MoreHorizontalIcon,
|
MoreHorizontalIcon,
|
||||||
|
PencilIcon,
|
||||||
PlusIcon,
|
PlusIcon,
|
||||||
|
Trash2Icon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { type FC, useEffect, useState } from "react";
|
import {
|
||||||
|
type FC,
|
||||||
|
type KeyboardEvent,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
|
||||||
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
import { useClawSessionReplay } from "@/lib/claw-session-replay";
|
||||||
|
|
||||||
type ClawSession = {
|
type ClawSession = {
|
||||||
@@ -59,14 +59,6 @@ export const ThreadList: FC = () => {
|
|||||||
return (
|
return (
|
||||||
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex flex-col gap-1">
|
<ThreadListPrimitive.Root className="aui-root aui-thread-list-root flex flex-col gap-1">
|
||||||
<ThreadListNew />
|
<ThreadListNew />
|
||||||
<AuiIf condition={({ threads }) => threads.isLoading}>
|
|
||||||
<ThreadListSkeleton />
|
|
||||||
</AuiIf>
|
|
||||||
<AuiIf condition={({ threads }) => !threads.isLoading}>
|
|
||||||
<ThreadListPrimitive.Items>
|
|
||||||
{() => <ThreadListItem />}
|
|
||||||
</ThreadListPrimitive.Items>
|
|
||||||
</AuiIf>
|
|
||||||
<ClawSessionList />
|
<ClawSessionList />
|
||||||
</ThreadListPrimitive.Root>
|
</ThreadListPrimitive.Root>
|
||||||
);
|
);
|
||||||
@@ -86,162 +78,235 @@ const ThreadListNew: FC = () => {
|
|||||||
className="aui-thread-list-new h-9 w-full justify-start gap-2 rounded-lg px-3 text-sm hover:bg-muted data-active:bg-muted"
|
className="aui-thread-list-new h-9 w-full justify-start gap-2 rounded-lg px-3 text-sm hover:bg-muted data-active:bg-muted"
|
||||||
>
|
>
|
||||||
<PlusIcon className="size-4" />
|
<PlusIcon className="size-4" />
|
||||||
New Thread
|
New Task
|
||||||
</Button>
|
</Button>
|
||||||
</ThreadListPrimitive.New>
|
</ThreadListPrimitive.New>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ThreadListSkeleton: FC = () => {
|
|
||||||
const skeletonKeys = ["one", "two", "three", "four", "five"];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
{skeletonKeys.map((key) => (
|
|
||||||
<div
|
|
||||||
key={key}
|
|
||||||
role="status"
|
|
||||||
aria-label="Loading threads"
|
|
||||||
className="aui-thread-list-skeleton-wrapper flex h-9 items-center px-3"
|
|
||||||
>
|
|
||||||
<Skeleton className="aui-thread-list-skeleton h-4 w-full" />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThreadListItem: FC = () => {
|
|
||||||
return (
|
|
||||||
<ThreadListItemPrimitive.Root className="aui-thread-list-item group flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted focus-visible:bg-muted focus-visible:outline-none data-active:bg-muted">
|
|
||||||
<ThreadListItemPrimitive.Trigger className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm">
|
|
||||||
<ThreadListItemPrimitive.Title fallback="New Chat" />
|
|
||||||
</ThreadListItemPrimitive.Trigger>
|
|
||||||
<ThreadListItemMore />
|
|
||||||
</ThreadListItemPrimitive.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ThreadListItemMore: FC = () => {
|
|
||||||
return (
|
|
||||||
<ThreadListItemMorePrimitive.Root>
|
|
||||||
<ThreadListItemMorePrimitive.Trigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="aui-thread-list-item-more mr-2 size-7 p-0 opacity-0 transition-opacity group-hover:opacity-100 data-[state=open]:bg-accent data-[state=open]:opacity-100 group-data-active:opacity-100"
|
|
||||||
>
|
|
||||||
<MoreHorizontalIcon className="size-4" />
|
|
||||||
<span className="sr-only">More options</span>
|
|
||||||
</Button>
|
|
||||||
</ThreadListItemMorePrimitive.Trigger>
|
|
||||||
<ThreadListItemMorePrimitive.Content
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
className="aui-thread-list-item-more-content z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md"
|
|
||||||
>
|
|
||||||
<ThreadListItemPrimitive.Archive asChild>
|
|
||||||
<ThreadListItemMorePrimitive.Item className="aui-thread-list-item-more-item flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground">
|
|
||||||
<ArchiveIcon className="size-4" />
|
|
||||||
Archive
|
|
||||||
</ThreadListItemMorePrimitive.Item>
|
|
||||||
</ThreadListItemPrimitive.Archive>
|
|
||||||
</ThreadListItemMorePrimitive.Content>
|
|
||||||
</ThreadListItemMorePrimitive.Root>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const ClawSessionList: FC = () => {
|
const ClawSessionList: FC = () => {
|
||||||
const { replaySession } = useClawSessionReplay();
|
const { replaySession } = useClawSessionReplay();
|
||||||
const [sessions, setSessions] = useState<ClawSession[]>([]);
|
const [sessions, setSessions] = useState<ClawSession[]>([]);
|
||||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||||
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
const [loadingSessionId, setLoadingSessionId] = useState<string | null>(null);
|
||||||
|
const [deletingSessionId, setDeletingSessionId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const [draftTitle, setDraftTitle] = useState("");
|
||||||
|
const [openMenuSessionId, setOpenMenuSessionId] = useState<string | null>(
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const renameInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveSessionId(window.localStorage.getItem("claw.activeSessionId"));
|
setActiveSessionId(window.localStorage.getItem("claw.activeSessionId"));
|
||||||
const clearActiveSession = () => setActiveSessionId(null);
|
const refreshSessions = () => {
|
||||||
|
fetch("/api/claw/sessions")
|
||||||
|
.then((res) => (res.ok ? res.json() : []))
|
||||||
|
.then((payload) => {
|
||||||
|
if (Array.isArray(payload))
|
||||||
|
setSessions(dedupeSessions(payload).slice(0, 8));
|
||||||
|
})
|
||||||
|
.catch(() => setSessions([]));
|
||||||
|
};
|
||||||
|
const clearActiveSession = () => {
|
||||||
|
setActiveSessionId(null);
|
||||||
|
refreshSessions();
|
||||||
|
};
|
||||||
window.addEventListener("claw-active-session-cleared", clearActiveSession);
|
window.addEventListener("claw-active-session-cleared", clearActiveSession);
|
||||||
fetch("/api/claw/sessions")
|
window.addEventListener("claw-sessions-changed", refreshSessions);
|
||||||
.then((res) => (res.ok ? res.json() : []))
|
window.addEventListener("focus", refreshSessions);
|
||||||
.then((payload) => {
|
refreshSessions();
|
||||||
if (Array.isArray(payload))
|
|
||||||
setSessions(dedupeSessions(payload).slice(0, 8));
|
|
||||||
})
|
|
||||||
.catch(() => setSessions([]));
|
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener(
|
window.removeEventListener(
|
||||||
"claw-active-session-cleared",
|
"claw-active-session-cleared",
|
||||||
clearActiveSession,
|
clearActiveSession,
|
||||||
);
|
);
|
||||||
|
window.removeEventListener("claw-sessions-changed", refreshSessions);
|
||||||
|
window.removeEventListener("focus", refreshSessions);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!renamingSessionId) return;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
renameInputRef.current?.focus();
|
||||||
|
renameInputRef.current?.select();
|
||||||
|
});
|
||||||
|
}, [renamingSessionId]);
|
||||||
|
|
||||||
if (!sessions.length) return null;
|
if (!sessions.length) return null;
|
||||||
|
|
||||||
|
const saveTitle = async (sessionId: string, fallbackTitle: string) => {
|
||||||
|
const normalized = draftTitle.trim();
|
||||||
|
if (!normalized || normalized === fallbackTitle) {
|
||||||
|
setRenamingSessionId(null);
|
||||||
|
setDraftTitle("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = await fetch(`/api/claw/sessions/${sessionId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ title: normalized }),
|
||||||
|
});
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload = (await response.json()) as { title?: string };
|
||||||
|
const nextTitle = payload.title || normalized;
|
||||||
|
setSessions((current) =>
|
||||||
|
current.map((item) =>
|
||||||
|
item.session_id === sessionId ? { ...item, preview: nextTitle } : item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setRenamingSessionId(null);
|
||||||
|
setDraftTitle("");
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-4 border-t pt-3">
|
<div className="flex flex-col gap-1">
|
||||||
<div className="mb-2 flex items-center justify-between gap-2 px-3">
|
{sessions.map((session) => {
|
||||||
<div className="flex items-center gap-2 font-medium text-muted-foreground text-xs">
|
const active = activeSessionId === session.session_id;
|
||||||
<HistoryIcon className="size-3.5" />
|
const loading = loadingSessionId === session.session_id;
|
||||||
Claw 历史会话
|
return (
|
||||||
</div>
|
<div
|
||||||
{activeSessionId ? (
|
key={`claw-session-${session.session_id}-${session.modified_at}`}
|
||||||
<button
|
className="aui-thread-list-item group relative flex h-9 items-center gap-2 rounded-lg transition-colors hover:bg-muted data-[active=true]:bg-muted"
|
||||||
type="button"
|
data-active={active}
|
||||||
className="text-muted-foreground text-xs hover:text-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
window.localStorage.removeItem("claw.activeSessionId");
|
|
||||||
setActiveSessionId(null);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
取消续接
|
{renamingSessionId === session.session_id ? (
|
||||||
</button>
|
<div className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center px-3">
|
||||||
) : null}
|
<input
|
||||||
</div>
|
ref={renameInputRef}
|
||||||
<div className="flex flex-col gap-1">
|
type="text"
|
||||||
{sessions.map((session) => {
|
className="h-7 min-w-0 flex-1 rounded-md border bg-background px-2 text-sm outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
const active = activeSessionId === session.session_id;
|
value={draftTitle}
|
||||||
return (
|
onChange={(event) => setDraftTitle(event.target.value)}
|
||||||
<button
|
onBlur={() => {
|
||||||
key={`claw-session-${session.session_id}-${session.modified_at}`}
|
saveTitle(
|
||||||
type="button"
|
session.session_id,
|
||||||
className="rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted data-[active=true]:bg-muted"
|
session.preview || session.session_id,
|
||||||
data-active={active}
|
);
|
||||||
onClick={async () => {
|
}}
|
||||||
window.localStorage.setItem(
|
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
|
||||||
"claw.activeSessionId",
|
if (event.key === "Enter") {
|
||||||
session.session_id,
|
event.preventDefault();
|
||||||
);
|
event.currentTarget.blur();
|
||||||
setActiveSessionId(session.session_id);
|
}
|
||||||
setLoadingSessionId(session.session_id);
|
if (event.key === "Escape") {
|
||||||
try {
|
event.preventDefault();
|
||||||
const response = await fetch(
|
setRenamingSessionId(null);
|
||||||
`/api/claw/sessions/${session.session_id}`,
|
setDraftTitle("");
|
||||||
);
|
}
|
||||||
if (!response.ok) return;
|
}}
|
||||||
const payload = (await response.json()) as ClawStoredSession;
|
/>
|
||||||
replaySession(
|
</div>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center truncate px-3 text-start text-sm"
|
||||||
|
onClick={async () => {
|
||||||
|
setOpenMenuSessionId(null);
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"claw.activeSessionId",
|
||||||
session.session_id,
|
session.session_id,
|
||||||
toReplayRepository(payload, session.session_id),
|
|
||||||
);
|
);
|
||||||
} finally {
|
setActiveSessionId(session.session_id);
|
||||||
setLoadingSessionId(null);
|
setLoadingSessionId(session.session_id);
|
||||||
}
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/claw/sessions/${session.session_id}`,
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
const payload =
|
||||||
|
(await response.json()) as ClawStoredSession;
|
||||||
|
replaySession(
|
||||||
|
session.session_id,
|
||||||
|
toReplayRepository(payload, session.session_id),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoadingSessionId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="truncate">
|
||||||
|
{loading
|
||||||
|
? "回放中..."
|
||||||
|
: session.preview || session.session_id}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="aui-thread-list-item-more mr-2 flex size-7 shrink-0 items-center justify-center rounded-md p-0 text-muted-foreground opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover:opacity-100 data-[busy=true]:opacity-100 data-[open=true]:bg-accent data-[open=true]:opacity-100"
|
||||||
|
data-busy={deletingSessionId === session.session_id}
|
||||||
|
data-open={openMenuSessionId === session.session_id}
|
||||||
|
aria-expanded={openMenuSessionId === session.session_id}
|
||||||
|
aria-label="打开历史会话菜单"
|
||||||
|
title="更多选项"
|
||||||
|
onClick={() => {
|
||||||
|
setOpenMenuSessionId((current) =>
|
||||||
|
current === session.session_id ? null : session.session_id,
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="block truncate">
|
<MoreHorizontalIcon className="size-4" />
|
||||||
{session.preview || session.session_id}
|
|
||||||
</span>
|
|
||||||
<span className="mt-0.5 block truncate text-muted-foreground text-xs">
|
|
||||||
{active ? "下一条消息将续接此会话 · " : ""}
|
|
||||||
{loadingSessionId === session.session_id ? "回放中 · " : ""}
|
|
||||||
{session.turns} 轮 · {session.tool_calls} 次工具
|
|
||||||
</span>
|
|
||||||
</button>
|
</button>
|
||||||
);
|
{openMenuSessionId === session.session_id ? (
|
||||||
})}
|
<div className="aui-thread-list-item-more-content absolute top-8 right-1 z-50 min-w-32 overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md">
|
||||||
</div>
|
<button
|
||||||
|
type="button"
|
||||||
|
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm outline-none hover:bg-accent focus:bg-accent"
|
||||||
|
disabled={renamingSessionId === session.session_id}
|
||||||
|
onClick={() => {
|
||||||
|
setRenamingSessionId(session.session_id);
|
||||||
|
setDraftTitle(session.preview || session.session_id);
|
||||||
|
setOpenMenuSessionId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-4" />
|
||||||
|
重命名
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="aui-thread-list-item-more-item flex w-full cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-left text-sm text-destructive outline-none hover:bg-accent focus:bg-accent"
|
||||||
|
disabled={deletingSessionId === session.session_id}
|
||||||
|
onClick={async () => {
|
||||||
|
setDeletingSessionId(session.session_id);
|
||||||
|
try {
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/claw/sessions/${session.session_id}`,
|
||||||
|
{ method: "DELETE" },
|
||||||
|
);
|
||||||
|
if (!response.ok) return;
|
||||||
|
setSessions((current) =>
|
||||||
|
current.filter(
|
||||||
|
(item) => item.session_id !== session.session_id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setOpenMenuSessionId(null);
|
||||||
|
if (activeSessionId === session.session_id) {
|
||||||
|
window.localStorage.removeItem("claw.activeSessionId");
|
||||||
|
setActiveSessionId(null);
|
||||||
|
window.dispatchEvent(
|
||||||
|
new Event("claw-active-session-cleared"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDeletingSessionId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2Icon className="size-4" />
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -68,13 +68,6 @@ type SkillSummary = {
|
|||||||
when_to_use?: string;
|
when_to_use?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UsageSummary = {
|
|
||||||
input_tokens?: number;
|
|
||||||
output_tokens?: number;
|
|
||||||
total_tokens?: number;
|
|
||||||
reasoning_tokens?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ClawState = {
|
type ClawState = {
|
||||||
model?: string;
|
model?: string;
|
||||||
active_session_id?: string | null;
|
active_session_id?: string | null;
|
||||||
@@ -83,13 +76,32 @@ type ClawState = {
|
|||||||
type ClawStoredSession = {
|
type ClawStoredSession = {
|
||||||
session_id?: string;
|
session_id?: string;
|
||||||
model?: string;
|
model?: string;
|
||||||
usage?: UsageSummary;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type ModelOption = {
|
type ModelOption = {
|
||||||
id: string;
|
id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ContextBudget = {
|
||||||
|
model?: string;
|
||||||
|
context_window_tokens?: number;
|
||||||
|
projected_input_tokens?: number;
|
||||||
|
message_tokens?: number;
|
||||||
|
chat_overhead_tokens?: number;
|
||||||
|
reserved_output_tokens?: number;
|
||||||
|
reserved_compaction_buffer_tokens?: number;
|
||||||
|
reserved_schema_tokens?: number;
|
||||||
|
hard_input_limit_tokens?: number;
|
||||||
|
soft_input_limit_tokens?: number;
|
||||||
|
overflow_tokens?: number;
|
||||||
|
soft_overflow_tokens?: number;
|
||||||
|
exceeds_hard_limit?: boolean;
|
||||||
|
exceeds_soft_limit?: boolean;
|
||||||
|
token_counter_backend?: string;
|
||||||
|
token_counter_source?: string;
|
||||||
|
token_counter_accurate?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export const Thread: FC = () => {
|
export const Thread: FC = () => {
|
||||||
return (
|
return (
|
||||||
<ThreadPrimitive.Root
|
<ThreadPrimitive.Root
|
||||||
@@ -158,10 +170,10 @@ const ThreadWelcome: FC = () => {
|
|||||||
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
|
<div className="aui-thread-welcome-center flex w-full grow flex-col items-center justify-center">
|
||||||
<div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4">
|
<div className="aui-thread-welcome-message flex size-full flex-col justify-center px-4">
|
||||||
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
|
<h1 className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both font-semibold text-2xl duration-200">
|
||||||
Hello there!
|
ZK Data Agent
|
||||||
</h1>
|
</h1>
|
||||||
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200">
|
<p className="aui-thread-welcome-message-inner fade-in slide-in-from-bottom-1 animate-in fill-mode-both text-muted-foreground text-xl delay-75 duration-200">
|
||||||
How can I help you today?
|
中控数据开发平台
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -261,9 +273,11 @@ const ComposerAction: FC = () => {
|
|||||||
|
|
||||||
const ComposerContextStatus: FC = () => {
|
const ComposerContextStatus: FC = () => {
|
||||||
const status = useComposerContextStatus();
|
const status = useComposerContextStatus();
|
||||||
const totalTokens = status.usage?.total_tokens ?? 0;
|
const totalTokens = status.contextBudget?.projected_input_tokens ?? 0;
|
||||||
const limit = inferContextLimit(status.model);
|
const limit = status.contextBudget?.context_window_tokens ?? 0;
|
||||||
const percent = Math.min(100, Math.round((totalTokens / limit) * 100));
|
const percent = limit
|
||||||
|
? Math.min(100, Math.round((totalTokens / limit) * 100))
|
||||||
|
: 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-2 hidden min-w-0 flex-1 items-center justify-end gap-1.5 sm:flex">
|
<div className="mx-2 hidden min-w-0 flex-1 items-center justify-end gap-1.5 sm:flex">
|
||||||
@@ -271,6 +285,7 @@ const ComposerContextStatus: FC = () => {
|
|||||||
percent={percent}
|
percent={percent}
|
||||||
totalTokens={totalTokens}
|
totalTokens={totalTokens}
|
||||||
limit={limit}
|
limit={limit}
|
||||||
|
contextBudget={status.contextBudget}
|
||||||
/>
|
/>
|
||||||
<ModelPickerButton status={status} />
|
<ModelPickerButton status={status} />
|
||||||
</div>
|
</div>
|
||||||
@@ -281,10 +296,12 @@ function ContextWindowButton({
|
|||||||
percent,
|
percent,
|
||||||
totalTokens,
|
totalTokens,
|
||||||
limit,
|
limit,
|
||||||
|
contextBudget,
|
||||||
}: {
|
}: {
|
||||||
percent: number;
|
percent: number;
|
||||||
totalTokens: number;
|
totalTokens: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
|
contextBudget?: ContextBudget;
|
||||||
}) {
|
}) {
|
||||||
const ringStyle = {
|
const ringStyle = {
|
||||||
background: `conic-gradient(var(--primary) ${percent * 3.6}deg, var(--muted) 0deg)`,
|
background: `conic-gradient(var(--primary) ${percent * 3.6}deg, var(--muted) 0deg)`,
|
||||||
@@ -335,8 +352,33 @@ function ContextWindowButton({
|
|||||||
已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "}
|
已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "}
|
||||||
{formatCompactTokenCount(limit)}
|
{formatCompactTokenCount(limit)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-3 font-semibold text-sm">
|
<div className="mt-3 grid gap-1 text-muted-foreground text-xs">
|
||||||
Codex 自动压缩其背景信息
|
<div>
|
||||||
|
软阈值:
|
||||||
|
{formatCompactTokenCount(
|
||||||
|
contextBudget?.soft_input_limit_tokens ?? 0,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
硬阈值:
|
||||||
|
{formatCompactTokenCount(
|
||||||
|
contextBudget?.hard_input_limit_tokens ?? 0,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
保留输出:
|
||||||
|
{formatCompactTokenCount(
|
||||||
|
contextBudget?.reserved_output_tokens ?? 0,
|
||||||
|
)}
|
||||||
|
;压缩缓冲:
|
||||||
|
{formatCompactTokenCount(
|
||||||
|
contextBudget?.reserved_compaction_buffer_tokens ?? 0,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
计数器:{contextBudget?.token_counter_backend ?? "unknown"}
|
||||||
|
{contextBudget?.token_counter_accurate ? "(精确)" : "(估算)"}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PopoverPrimitive.Content>
|
</PopoverPrimitive.Content>
|
||||||
</PopoverPrimitive.Portal>
|
</PopoverPrimitive.Portal>
|
||||||
@@ -410,12 +452,10 @@ function useComposerContextStatus() {
|
|||||||
const latestSessionId = useAuiState((s) =>
|
const latestSessionId = useAuiState((s) =>
|
||||||
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
|
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
|
||||||
);
|
);
|
||||||
const latestUsage = useAuiState((s) =>
|
const wasRunningRef = useRef(false);
|
||||||
findLatestMessageMetadataValue(s.thread.messages, "usage"),
|
|
||||||
) as UsageSummary | undefined;
|
|
||||||
const [status, setStatus] = useState<{
|
const [status, setStatus] = useState<{
|
||||||
model?: string;
|
model?: string;
|
||||||
usage?: UsageSummary;
|
contextBudget?: ContextBudget;
|
||||||
sessionId?: string | null;
|
sessionId?: string | null;
|
||||||
models: ModelOption[];
|
models: ModelOption[];
|
||||||
setModel: (model: string) => Promise<void>;
|
setModel: (model: string) => Promise<void>;
|
||||||
@@ -427,9 +467,22 @@ function useComposerContextStatus() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof latestSessionId === "string" && latestSessionId) {
|
if (typeof latestSessionId === "string" && latestSessionId) {
|
||||||
window.localStorage.setItem("claw.activeSessionId", latestSessionId);
|
window.localStorage.setItem("claw.activeSessionId", latestSessionId);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||||
|
}, 0);
|
||||||
}
|
}
|
||||||
}, [latestSessionId]);
|
}, [latestSessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (wasRunningRef.current && !isRunning) {
|
||||||
|
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.dispatchEvent(new Event("claw-sessions-changed"));
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
wasRunningRef.current = isRunning;
|
||||||
|
}, [isRunning]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
|
||||||
@@ -447,6 +500,7 @@ function useComposerContextStatus() {
|
|||||||
statePayload.active_session_id ||
|
statePayload.active_session_id ||
|
||||||
null;
|
null;
|
||||||
let sessionPayload: ClawStoredSession | null = null;
|
let sessionPayload: ClawStoredSession | null = null;
|
||||||
|
let contextBudget: ContextBudget | undefined;
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
const sessionResponse = await fetch(
|
const sessionResponse = await fetch(
|
||||||
`/api/claw/sessions/${sessionId}`,
|
`/api/claw/sessions/${sessionId}`,
|
||||||
@@ -458,10 +512,20 @@ function useComposerContextStatus() {
|
|||||||
? ((await sessionResponse.json()) as ClawStoredSession)
|
? ((await sessionResponse.json()) as ClawStoredSession)
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
const budgetUrl = new URL(
|
||||||
|
"/api/claw/context-budget",
|
||||||
|
window.location.origin,
|
||||||
|
);
|
||||||
|
if (sessionId) budgetUrl.searchParams.set("session_id", sessionId);
|
||||||
|
const budgetResponse = await fetch(budgetUrl, { cache: "no-store" });
|
||||||
|
contextBudget = budgetResponse.ok
|
||||||
|
? ((await budgetResponse.json()) as ContextBudget)
|
||||||
|
: undefined;
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setStatus((current) => ({
|
setStatus((current) => ({
|
||||||
model: sessionPayload?.model ?? statePayload.model,
|
model:
|
||||||
usage: latestUsage ?? sessionPayload?.usage,
|
contextBudget?.model ?? sessionPayload?.model ?? statePayload.model,
|
||||||
|
contextBudget,
|
||||||
sessionId,
|
sessionId,
|
||||||
models: current.models,
|
models: current.models,
|
||||||
setModel: current.setModel,
|
setModel: current.setModel,
|
||||||
@@ -470,7 +534,6 @@ function useComposerContextStatus() {
|
|||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setStatus((current) => ({
|
setStatus((current) => ({
|
||||||
...current,
|
...current,
|
||||||
usage: latestUsage ?? current.usage,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -487,7 +550,7 @@ function useComposerContextStatus() {
|
|||||||
window.removeEventListener("claw-active-session-cleared", onRefresh);
|
window.removeEventListener("claw-active-session-cleared", onRefresh);
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [isRunning, latestSessionId, latestUsage]);
|
}, [isRunning, latestSessionId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -528,6 +591,7 @@ function useComposerContextStatus() {
|
|||||||
setStatus((current) => ({
|
setStatus((current) => ({
|
||||||
...current,
|
...current,
|
||||||
model: payload.model ?? model,
|
model: payload.model ?? model,
|
||||||
|
contextBudget: undefined,
|
||||||
}));
|
}));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -562,14 +626,6 @@ function readMetadataValue(metadata: unknown, key: string): unknown {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferContextLimit(model?: string) {
|
|
||||||
const lower = (model ?? "").toLowerCase();
|
|
||||||
if (lower.includes("qwen3-32b")) return 128_000;
|
|
||||||
if (lower.includes("deepseek")) return 128_000;
|
|
||||||
if (lower.includes("mimo")) return 128_000;
|
|
||||||
return 128_000;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatCompactTokenCount(value: number) {
|
function formatCompactTokenCount(value: number) {
|
||||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||||
@@ -849,7 +905,12 @@ const AssistantMessage: FC = () => {
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case "text":
|
case "text":
|
||||||
return <MarkdownText />;
|
return (
|
||||||
|
<MarkdownText
|
||||||
|
text={part.text}
|
||||||
|
isRunning={part.status?.type === "running"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
case "reasoning":
|
case "reasoning":
|
||||||
return <Reasoning {...part} />;
|
return <Reasoning {...part} />;
|
||||||
case "tool-call":
|
case "tool-call":
|
||||||
|
|||||||
@@ -73,10 +73,10 @@ export function ThreadListSidebar({
|
|||||||
</div>
|
</div>
|
||||||
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
|
<div className="aui-sidebar-header-heading mr-6 flex flex-col gap-0.5 leading-none">
|
||||||
<span className="aui-sidebar-header-title font-semibold">
|
<span className="aui-sidebar-header-title font-semibold">
|
||||||
Claw Data Agent
|
ZK Data Agent
|
||||||
</span>
|
</span>
|
||||||
<span className="text-muted-foreground text-xs">
|
<span className="text-muted-foreground text-xs">
|
||||||
数据开发工作台
|
中控数据开发平台
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</SidebarMenuButton>
|
</SidebarMenuButton>
|
||||||
|
|||||||
@@ -6,10 +6,10 @@
|
|||||||
"dev": "next dev --turbopack",
|
"dev": "next dev --turbopack",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "biome check app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
"lint": "biome check app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/claw-theme-switcher.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
||||||
"lint:fix": "biome check --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
"lint:fix": "biome check --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/claw-theme-switcher.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
||||||
"format": "biome format app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
"format": "biome format app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/claw-theme-switcher.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json",
|
||||||
"format:fix": "biome format --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json"
|
"format:fix": "biome format --write app/assistant.tsx app/claw-account-gate.tsx app/claw-llm-settings.tsx app/claw-theme-switcher.tsx app/api components/assistant-ui/thread.tsx components/assistant-ui/thread-list.tsx components/assistant-ui/threadlist-sidebar.tsx components/assistant-ui/activity-panel.tsx hooks lib next.config.ts package.json tsconfig.json postcss.config.mjs components.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/openai": "^3.0.53",
|
"@ai-sdk/openai": "^3.0.53",
|
||||||
|
|||||||
@@ -157,6 +157,40 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
loaded = load_agent_session('thread-1', directory=root / 'sessions')
|
loaded = load_agent_session('thread-1', directory=root / 'sessions')
|
||||||
self.assertEqual(loaded.session_id, 'thread-1')
|
self.assertEqual(loaded.session_id, 'thread-1')
|
||||||
|
|
||||||
|
def test_context_budget_reports_stored_session_prompt_size(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
root = Path(d)
|
||||||
|
client, _ = _build_client(root)
|
||||||
|
session_root = root / 'accounts' / 'alice' / 'sessions'
|
||||||
|
stored = StoredAgentSession(
|
||||||
|
session_id='thread-1',
|
||||||
|
model_config={'model': 'test-model'},
|
||||||
|
runtime_config={'cwd': str(root)},
|
||||||
|
system_prompt_parts=('system prompt',),
|
||||||
|
user_context={},
|
||||||
|
system_context={},
|
||||||
|
messages=({'role': 'user', 'content': 'hello'},),
|
||||||
|
turns=1,
|
||||||
|
tool_calls=0,
|
||||||
|
usage={},
|
||||||
|
total_cost_usd=0.0,
|
||||||
|
file_history=(),
|
||||||
|
budget_state={},
|
||||||
|
plugin_state={},
|
||||||
|
)
|
||||||
|
save_agent_session(stored, directory=session_root)
|
||||||
|
|
||||||
|
response = client.get(
|
||||||
|
'/api/context-budget',
|
||||||
|
params={'account_id': 'alice', 'session_id': 'thread-1'},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
payload = response.json()
|
||||||
|
self.assertEqual(payload['model'], 'test-model')
|
||||||
|
self.assertEqual(payload['context_window_tokens'], 128000)
|
||||||
|
self.assertGreater(payload['projected_input_tokens'], 0)
|
||||||
|
self.assertGreater(payload['soft_input_limit_tokens'], 0)
|
||||||
|
|
||||||
def test_chat_rejects_blank_prompt(self) -> None:
|
def test_chat_rejects_blank_prompt(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
client, _ = _build_client(Path(d))
|
client, _ = _build_client(Path(d))
|
||||||
@@ -220,6 +254,7 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
'session_id': 'same-id',
|
'session_id': 'same-id',
|
||||||
'turns': 2,
|
'turns': 2,
|
||||||
'tool_calls': 1,
|
'tool_calls': 1,
|
||||||
|
'title': '自动生成标题',
|
||||||
'messages': [{'role': 'user', 'content': 'nested'}],
|
'messages': [{'role': 'user', 'content': 'nested'}],
|
||||||
}
|
}
|
||||||
(session_root / 'same-id.json').write_text(
|
(session_root / 'same-id.json').write_text(
|
||||||
@@ -237,7 +272,7 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
payload = response.json()
|
payload = response.json()
|
||||||
self.assertEqual(len(payload), 1)
|
self.assertEqual(len(payload), 1)
|
||||||
self.assertEqual(payload[0]['session_id'], 'same-id')
|
self.assertEqual(payload[0]['session_id'], 'same-id')
|
||||||
self.assertEqual(payload[0]['preview'], 'nested')
|
self.assertEqual(payload[0]['preview'], '自动生成标题')
|
||||||
|
|
||||||
def test_session_detail_404_when_missing(self) -> None:
|
def test_session_detail_404_when_missing(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
@@ -245,6 +280,56 @@ class GuiServerTests(unittest.TestCase):
|
|||||||
response = client.get('/api/sessions/nope')
|
response = client.get('/api/sessions/nope')
|
||||||
self.assertEqual(response.status_code, 404)
|
self.assertEqual(response.status_code, 404)
|
||||||
|
|
||||||
|
def test_delete_session_removes_nested_and_legacy_files(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
root = Path(d)
|
||||||
|
client, _ = _build_client(root)
|
||||||
|
session_root = root / 'accounts' / 'alice' / 'sessions'
|
||||||
|
nested = session_root / 'same-id'
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
(nested / 'session.json').write_text('{}', encoding='utf-8')
|
||||||
|
legacy = session_root / 'same-id.json'
|
||||||
|
legacy.write_text('{}', encoding='utf-8')
|
||||||
|
|
||||||
|
response = client.delete(
|
||||||
|
'/api/sessions/same-id',
|
||||||
|
params={'account_id': 'alice'},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertTrue(response.json()['deleted'])
|
||||||
|
self.assertFalse(nested.exists())
|
||||||
|
self.assertFalse(legacy.exists())
|
||||||
|
|
||||||
|
missing = client.delete(
|
||||||
|
'/api/sessions/same-id',
|
||||||
|
params={'account_id': 'alice'},
|
||||||
|
)
|
||||||
|
self.assertEqual(missing.status_code, 404)
|
||||||
|
|
||||||
|
def test_update_session_title(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as d:
|
||||||
|
root = Path(d)
|
||||||
|
client, _ = _build_client(root)
|
||||||
|
session_root = root / 'accounts' / 'alice' / 'sessions'
|
||||||
|
nested = session_root / 'same-id'
|
||||||
|
nested.mkdir(parents=True)
|
||||||
|
session_file = nested / 'session.json'
|
||||||
|
session_file.write_text(
|
||||||
|
json.dumps({'session_id': 'same-id', 'messages': []}),
|
||||||
|
encoding='utf-8',
|
||||||
|
)
|
||||||
|
|
||||||
|
response = client.patch(
|
||||||
|
'/api/sessions/same-id',
|
||||||
|
params={'account_id': 'alice'},
|
||||||
|
json={'title': ' 手动标题 '},
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertEqual(response.json()['title'], '手动标题')
|
||||||
|
data = json.loads(session_file.read_text(encoding='utf-8'))
|
||||||
|
self.assertEqual(data['title'], '手动标题')
|
||||||
|
self.assertEqual(data['title_source'], 'manual')
|
||||||
|
|
||||||
def test_clear_runtime_state(self) -> None:
|
def test_clear_runtime_state(self) -> None:
|
||||||
with tempfile.TemporaryDirectory() as d:
|
with tempfile.TemporaryDirectory() as d:
|
||||||
client, _ = _build_client(Path(d))
|
client, _ = _build_client(Path(d))
|
||||||
|
|||||||
Reference in New Issue
Block a user