From d9a8110b7cfab0fad704ff344152849ef8a32c8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E9=98=B3?= Date: Wed, 6 May 2026 17:19:13 +0800 Subject: [PATCH] Improve data agent workspace UI --- backend/api/server.py | 295 ++++++++++++++- frontend/app/README.md | 4 +- .../app/app/api/claw/context-budget/route.ts | 37 ++ .../api/claw/sessions/[sessionId]/route.ts | 49 +++ frontend/app/app/assistant.tsx | 4 +- frontend/app/app/claw-account-gate.tsx | 4 +- frontend/app/app/claw-llm-settings.tsx | 2 +- frontend/app/app/claw-theme-switcher.tsx | 99 +++++ frontend/app/app/layout.tsx | 6 +- .../assistant-ui/activity-panel.tsx | 18 +- .../components/assistant-ui/markdown-text.tsx | 16 +- .../app/components/assistant-ui/reasoning.tsx | 4 +- .../components/assistant-ui/thread-list.tsx | 349 +++++++++++------- .../app/components/assistant-ui/thread.tsx | 125 +++++-- .../assistant-ui/threadlist-sidebar.tsx | 4 +- frontend/app/package.json | 8 +- tests/test_gui_server.py | 87 ++++- 17 files changed, 910 insertions(+), 201 deletions(-) create mode 100644 frontend/app/app/api/claw/context-budget/route.ts create mode 100644 frontend/app/app/claw-theme-switcher.tsx diff --git a/backend/api/server.py b/backend/api/server.py index 88db646..27f418d 100644 --- a/backend/api/server.py +++ b/backend/api/server.py @@ -9,6 +9,7 @@ from __future__ import annotations import asyncio import json import re +import shutil import time from pathlib import Path from threading import Lock @@ -20,6 +21,7 @@ from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, Field +from src.agent_session import AgentMessage, AgentSessionState from src.agent_runtime import LocalCodingAgent from src.agent_slash_commands import get_slash_command_specs from src.agent_types import ( @@ -31,8 +33,10 @@ from src.bundled_skills import get_bundled_skills from src.session_store import ( DEFAULT_AGENT_SESSION_DIR, StoredAgentSession, + deserialize_runtime_config, load_agent_session, ) +from src.token_budget import calculate_token_budget STATIC_DIR = Path(__file__).resolve().parents[2] / 'frontend' / 'legacy-static' @@ -202,6 +206,10 @@ class ModelListRequest(BaseModel): api_key: str | None = None +class SessionTitleUpdate(BaseModel): + title: str = Field(min_length=1, max_length=80) + + # --------------------------------------------------------------------------- # App factory # --------------------------------------------------------------------------- @@ -291,6 +299,7 @@ def create_app(state: AgentState) -> FastAPI: else {} ) messages = data.get('messages') or [] + title = data.get('title') preview = '' for msg in messages: if isinstance(msg, dict) and msg.get('role') == 'user': @@ -303,7 +312,7 @@ def create_app(state: AgentState) -> FastAPI: 'session_id': session_id, 'turns': data.get('turns', 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), 'model': model_config.get('model'), 'usage': usage, @@ -320,6 +329,58 @@ def create_app(state: AgentState) -> FastAPI: raise HTTPException(status_code=404, detail='Session not found') 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 ------------------------------------------------------ @app.post('/api/chat') async def chat(request: ChatRequest) -> dict[str, Any]: @@ -331,6 +392,13 @@ def create_app(state: AgentState) -> FastAPI: with state.lock(): agent = state.agent_for(request.account_id) 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() if request.resume_session_id is not None: try: @@ -363,6 +431,14 @@ def create_app(state: AgentState) -> FastAPI: result.session_id, 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) 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]: req = request.Request( _join_url(base_url, '/models'), @@ -532,9 +643,7 @@ def _annotate_last_assistant_elapsed( session_id: str, elapsed_ms: int, ) -> None: - path = directory / session_id / 'session.json' - if not path.exists(): - path = directory / f'{session_id}.json' + path = _session_json_path(directory, session_id) try: data = json.loads(path.read_text(encoding='utf-8')) except (OSError, json.JSONDecodeError): @@ -557,6 +666,154 @@ def _annotate_last_assistant_elapsed( 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: normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', (account_id or '').strip()) return normalized[:80] or 'default' @@ -588,6 +845,36 @@ def _iter_session_files(directory: Path) -> list[Path]: 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: try: return path.stat().st_mtime diff --git a/frontend/app/README.md b/frontend/app/README.md index 63cf4dc..eb67822 100644 --- a/frontend/app/README.md +++ b/frontend/app/README.md @@ -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 diff --git a/frontend/app/app/api/claw/context-budget/route.ts b/frontend/app/app/api/claw/context-budget/route.ts new file mode 100644 index 0000000..d1f8c05 --- /dev/null +++ b/frontend/app/app/api/claw/context-budget/route.ts @@ -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 }, + ); + } +} diff --git a/frontend/app/app/api/claw/sessions/[sessionId]/route.ts b/frontend/app/app/api/claw/sessions/[sessionId]/route.ts index edc5e01..ea3b2c3 100644 --- a/frontend/app/app/api/claw/sessions/[sessionId]/route.ts +++ b/frontend/app/app/api/claw/sessions/[sessionId]/route.ts @@ -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", + }, + }); +} diff --git a/frontend/app/app/assistant.tsx b/frontend/app/app/assistant.tsx index 2f9062a..2c891f2 100644 --- a/frontend/app/app/assistant.tsx +++ b/frontend/app/app/assistant.tsx @@ -23,6 +23,7 @@ import { import { ClawSessionReplayProvider } from "@/lib/claw-session-replay"; import { ClawAccountGate, useClawAccount } from "./claw-account-gate"; import { ClawLlmSettings } from "./claw-llm-settings"; +import { ClawThemeSwitcher } from "./claw-theme-switcher"; export const Assistant = () => { const { account, isLoading, setAccount } = useClawAccount(); @@ -94,9 +95,10 @@ export const Assistant = () => {
新会话
- Claw Data Agent + ZK Data Agent
+
diff --git a/frontend/app/app/claw-account-gate.tsx b/frontend/app/app/claw-account-gate.tsx index 650469f..cc281cc 100644 --- a/frontend/app/app/claw-account-gate.tsx +++ b/frontend/app/app/claw-account-gate.tsx @@ -74,9 +74,9 @@ export function ClawAccountGate({
-

Claw Data Agent

+

ZK Data Agent

- 登录后会按账号隔离会话、上传文件和生成产物。 + 中控数据开发平台会按账号隔离会话、上传文件和生成产物。

diff --git a/frontend/app/app/claw-llm-settings.tsx b/frontend/app/app/claw-llm-settings.tsx index ecff7de..7144c83 100644 --- a/frontend/app/app/claw-llm-settings.tsx +++ b/frontend/app/app/claw-llm-settings.tsx @@ -96,7 +96,7 @@ export function ClawLlmSettings() { 后端 LLM API - 调试时更新 Claw 后端 Agent 使用的 Base URL 和 API Key。 + 调试时更新 ZK Data Agent 后端使用的 Base URL 和 API Key。
diff --git a/frontend/app/app/claw-theme-switcher.tsx b/frontend/app/app/claw-theme-switcher.tsx new file mode 100644 index 0000000..6e25412 --- /dev/null +++ b/frontend/app/app/claw-theme-switcher.tsx @@ -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("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 ( + + + + + + + {THEME_OPTIONS.map((option) => { + const Icon = option.icon; + return ( + + ); + })} + + + + ); +} + +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"; +} diff --git a/frontend/app/app/layout.tsx b/frontend/app/app/layout.tsx index 3e85377..7a0c706 100644 --- a/frontend/app/app/layout.tsx +++ b/frontend/app/app/layout.tsx @@ -14,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Claw Data Agent", - description: "assistant-ui based data agent workbench", + title: "ZK Data Agent", + description: "中控数据开发平台", }; export default function RootLayout({ @@ -24,7 +24,7 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - + diff --git a/frontend/app/components/assistant-ui/activity-panel.tsx b/frontend/app/components/assistant-ui/activity-panel.tsx index 0669d83..8bbe224 100644 --- a/frontend/app/components/assistant-ui/activity-panel.tsx +++ b/frontend/app/components/assistant-ui/activity-panel.tsx @@ -94,6 +94,10 @@ export function ActivityPanel() { const { open, selectedId, openItem, close } = useActivityPanel(); const messages = useAuiState((s) => s.thread.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); useEffect(() => { @@ -111,7 +115,9 @@ export function ActivityPanel() {

活动

- {items.length > 0 ? `${items.length} 个步骤` : "暂无链路"} + {visibleItems.length > 0 + ? `${visibleItems.length} 个步骤` + : "暂无链路"}

); }; -const ThreadListSkeleton: FC = () => { - const skeletonKeys = ["one", "two", "three", "four", "five"]; - - return ( -
- {skeletonKeys.map((key) => ( -
- -
- ))} -
- ); -}; - -const ThreadListItem: FC = () => { - return ( - - - - - - - ); -}; - -const ThreadListItemMore: FC = () => { - return ( - - - - - - - - - Archive - - - - - ); -}; - const ClawSessionList: FC = () => { const { replaySession } = useClawSessionReplay(); const [sessions, setSessions] = useState([]); const [activeSessionId, setActiveSessionId] = useState(null); const [loadingSessionId, setLoadingSessionId] = useState(null); + const [deletingSessionId, setDeletingSessionId] = useState( + null, + ); + const [renamingSessionId, setRenamingSessionId] = useState( + null, + ); + const [draftTitle, setDraftTitle] = useState(""); + const [openMenuSessionId, setOpenMenuSessionId] = useState( + null, + ); + const renameInputRef = useRef(null); useEffect(() => { 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); - fetch("/api/claw/sessions") - .then((res) => (res.ok ? res.json() : [])) - .then((payload) => { - if (Array.isArray(payload)) - setSessions(dedupeSessions(payload).slice(0, 8)); - }) - .catch(() => setSessions([])); + window.addEventListener("claw-sessions-changed", refreshSessions); + window.addEventListener("focus", refreshSessions); + refreshSessions(); return () => { window.removeEventListener( "claw-active-session-cleared", 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; + 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 ( -
-
-
- - Claw 历史会话 -
- {activeSessionId ? ( - - ) : null} -
-
- {sessions.map((session) => { - const active = activeSessionId === session.session_id; - return ( - + )} + - ); - })} -
+ {openMenuSessionId === session.session_id ? ( +
+ + +
+ ) : null} +
+ ); + })}
); }; diff --git a/frontend/app/components/assistant-ui/thread.tsx b/frontend/app/components/assistant-ui/thread.tsx index 825142e..920b770 100644 --- a/frontend/app/components/assistant-ui/thread.tsx +++ b/frontend/app/components/assistant-ui/thread.tsx @@ -68,13 +68,6 @@ type SkillSummary = { when_to_use?: string; }; -type UsageSummary = { - input_tokens?: number; - output_tokens?: number; - total_tokens?: number; - reasoning_tokens?: number; -}; - type ClawState = { model?: string; active_session_id?: string | null; @@ -83,13 +76,32 @@ type ClawState = { type ClawStoredSession = { session_id?: string; model?: string; - usage?: UsageSummary; }; type ModelOption = { 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 = () => { return ( {

- Hello there! + ZK Data Agent

- How can I help you today? + 中控数据开发平台

@@ -261,9 +273,11 @@ const ComposerAction: FC = () => { const ComposerContextStatus: FC = () => { const status = useComposerContextStatus(); - const totalTokens = status.usage?.total_tokens ?? 0; - const limit = inferContextLimit(status.model); - const percent = Math.min(100, Math.round((totalTokens / limit) * 100)); + const totalTokens = status.contextBudget?.projected_input_tokens ?? 0; + const limit = status.contextBudget?.context_window_tokens ?? 0; + const percent = limit + ? Math.min(100, Math.round((totalTokens / limit) * 100)) + : 0; return (
@@ -271,6 +285,7 @@ const ComposerContextStatus: FC = () => { percent={percent} totalTokens={totalTokens} limit={limit} + contextBudget={status.contextBudget} />
@@ -281,10 +296,12 @@ function ContextWindowButton({ percent, totalTokens, limit, + contextBudget, }: { percent: number; totalTokens: number; limit: number; + contextBudget?: ContextBudget; }) { const ringStyle = { background: `conic-gradient(var(--primary) ${percent * 3.6}deg, var(--muted) 0deg)`, @@ -335,8 +352,33 @@ function ContextWindowButton({ 已用 {formatCompactTokenCount(totalTokens)} 标记,共{" "} {formatCompactTokenCount(limit)}
-
- Codex 自动压缩其背景信息 +
+
+ 软阈值: + {formatCompactTokenCount( + contextBudget?.soft_input_limit_tokens ?? 0, + )} +
+
+ 硬阈值: + {formatCompactTokenCount( + contextBudget?.hard_input_limit_tokens ?? 0, + )} +
+
+ 保留输出: + {formatCompactTokenCount( + contextBudget?.reserved_output_tokens ?? 0, + )} + ;压缩缓冲: + {formatCompactTokenCount( + contextBudget?.reserved_compaction_buffer_tokens ?? 0, + )} +
+
+ 计数器:{contextBudget?.token_counter_backend ?? "unknown"} + {contextBudget?.token_counter_accurate ? "(精确)" : "(估算)"} +
@@ -410,12 +452,10 @@ function useComposerContextStatus() { const latestSessionId = useAuiState((s) => findLatestMessageMetadataValue(s.thread.messages, "sessionId"), ); - const latestUsage = useAuiState((s) => - findLatestMessageMetadataValue(s.thread.messages, "usage"), - ) as UsageSummary | undefined; + const wasRunningRef = useRef(false); const [status, setStatus] = useState<{ model?: string; - usage?: UsageSummary; + contextBudget?: ContextBudget; sessionId?: string | null; models: ModelOption[]; setModel: (model: string) => Promise; @@ -427,9 +467,22 @@ function useComposerContextStatus() { useEffect(() => { if (typeof latestSessionId === "string" && latestSessionId) { window.localStorage.setItem("claw.activeSessionId", latestSessionId); + window.setTimeout(() => { + window.dispatchEvent(new Event("claw-sessions-changed")); + }, 0); } }, [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(() => { let cancelled = false; @@ -447,6 +500,7 @@ function useComposerContextStatus() { statePayload.active_session_id || null; let sessionPayload: ClawStoredSession | null = null; + let contextBudget: ContextBudget | undefined; if (sessionId) { const sessionResponse = await fetch( `/api/claw/sessions/${sessionId}`, @@ -458,10 +512,20 @@ function useComposerContextStatus() { ? ((await sessionResponse.json()) as ClawStoredSession) : 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; setStatus((current) => ({ - model: sessionPayload?.model ?? statePayload.model, - usage: latestUsage ?? sessionPayload?.usage, + model: + contextBudget?.model ?? sessionPayload?.model ?? statePayload.model, + contextBudget, sessionId, models: current.models, setModel: current.setModel, @@ -470,7 +534,6 @@ function useComposerContextStatus() { if (!cancelled) { setStatus((current) => ({ ...current, - usage: latestUsage ?? current.usage, })); } } @@ -487,7 +550,7 @@ function useComposerContextStatus() { window.removeEventListener("claw-active-session-cleared", onRefresh); window.clearInterval(interval); }; - }, [isRunning, latestSessionId, latestUsage]); + }, [isRunning, latestSessionId]); useEffect(() => { let cancelled = false; @@ -528,6 +591,7 @@ function useComposerContextStatus() { setStatus((current) => ({ ...current, model: payload.model ?? model, + contextBudget: undefined, })); }, []); @@ -562,14 +626,6 @@ function readMetadataValue(metadata: unknown, key: string): unknown { 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) { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`; @@ -849,7 +905,12 @@ const AssistantMessage: FC = () => { /> ); case "text": - return ; + return ( + + ); case "reasoning": return ; case "tool-call": diff --git a/frontend/app/components/assistant-ui/threadlist-sidebar.tsx b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx index 135c1cb..013833d 100644 --- a/frontend/app/components/assistant-ui/threadlist-sidebar.tsx +++ b/frontend/app/components/assistant-ui/threadlist-sidebar.tsx @@ -73,10 +73,10 @@ export function ThreadListSidebar({
- Claw Data Agent + ZK Data Agent - 数据开发工作台 + 中控数据开发平台
diff --git a/frontend/app/package.json b/frontend/app/package.json index dc454f5..85744f1 100644 --- a/frontend/app/package.json +++ b/frontend/app/package.json @@ -6,10 +6,10 @@ "dev": "next dev --turbopack", "build": "next build", "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: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", - "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: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" + "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/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/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/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": { "@ai-sdk/openai": "^3.0.53", diff --git a/tests/test_gui_server.py b/tests/test_gui_server.py index c54bc39..34f431f 100644 --- a/tests/test_gui_server.py +++ b/tests/test_gui_server.py @@ -157,6 +157,40 @@ class GuiServerTests(unittest.TestCase): loaded = load_agent_session('thread-1', directory=root / 'sessions') 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: with tempfile.TemporaryDirectory() as d: client, _ = _build_client(Path(d)) @@ -220,6 +254,7 @@ class GuiServerTests(unittest.TestCase): 'session_id': 'same-id', 'turns': 2, 'tool_calls': 1, + 'title': '自动生成标题', 'messages': [{'role': 'user', 'content': 'nested'}], } (session_root / 'same-id.json').write_text( @@ -237,7 +272,7 @@ class GuiServerTests(unittest.TestCase): payload = response.json() self.assertEqual(len(payload), 1) 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: with tempfile.TemporaryDirectory() as d: @@ -245,6 +280,56 @@ class GuiServerTests(unittest.TestCase): response = client.get('/api/sessions/nope') 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: with tempfile.TemporaryDirectory() as d: client, _ = _build_client(Path(d))