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} 个步骤`
+ : "暂无链路"}