Improve data agent workspace UI

This commit is contained in:
武阳
2026-05-06 17:19:13 +08:00
parent e6e9968ede
commit d9a8110b7c
17 changed files with 910 additions and 201 deletions
+291 -4
View File
@@ -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
+2 -2
View File
@@ -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",
},
});
}
+3 -1
View File
@@ -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">
+2 -2
View File
@@ -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">
+1 -1
View File
@@ -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">
+99
View File
@@ -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";
}
+3 -3
View File
@@ -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,82 +78,33 @@ 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 = () => {
window.addEventListener("claw-active-session-cleared", clearActiveSession);
fetch("/api/claw/sessions") fetch("/api/claw/sessions")
.then((res) => (res.ok ? res.json() : [])) .then((res) => (res.ok ? res.json() : []))
.then((payload) => { .then((payload) => {
@@ -169,46 +112,103 @@ const ClawSessionList: FC = () => {
setSessions(dedupeSessions(payload).slice(0, 8)); setSessions(dedupeSessions(payload).slice(0, 8));
}) })
.catch(() => setSessions([])); .catch(() => setSessions([]));
};
const clearActiveSession = () => {
setActiveSessionId(null);
refreshSessions();
};
window.addEventListener("claw-active-session-cleared", clearActiveSession);
window.addEventListener("claw-sessions-changed", refreshSessions);
window.addEventListener("focus", refreshSessions);
refreshSessions();
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="mb-2 flex items-center justify-between gap-2 px-3">
<div className="flex items-center gap-2 font-medium text-muted-foreground text-xs">
<HistoryIcon className="size-3.5" />
Claw
</div>
{activeSessionId ? (
<button
type="button"
className="text-muted-foreground text-xs hover:text-foreground"
onClick={() => {
window.localStorage.removeItem("claw.activeSessionId");
setActiveSessionId(null);
}}
>
</button>
) : null}
</div>
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
{sessions.map((session) => { {sessions.map((session) => {
const active = activeSessionId === session.session_id; const active = activeSessionId === session.session_id;
const loading = loadingSessionId === session.session_id;
return ( return (
<button <div
key={`claw-session-${session.session_id}-${session.modified_at}`} key={`claw-session-${session.session_id}-${session.modified_at}`}
type="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"
className="rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted data-[active=true]:bg-muted"
data-active={active} data-active={active}
>
{renamingSessionId === session.session_id ? (
<div className="aui-thread-list-item-trigger flex h-full min-w-0 flex-1 items-center px-3">
<input
ref={renameInputRef}
type="text"
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"
value={draftTitle}
onChange={(event) => setDraftTitle(event.target.value)}
onBlur={() => {
saveTitle(
session.session_id,
session.preview || session.session_id,
);
}}
onKeyDown={(event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
event.preventDefault();
event.currentTarget.blur();
}
if (event.key === "Escape") {
event.preventDefault();
setRenamingSessionId(null);
setDraftTitle("");
}
}}
/>
</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 () => { onClick={async () => {
setOpenMenuSessionId(null);
window.localStorage.setItem( window.localStorage.setItem(
"claw.activeSessionId", "claw.activeSessionId",
session.session_id, session.session_id,
@@ -220,7 +220,8 @@ const ClawSessionList: FC = () => {
`/api/claw/sessions/${session.session_id}`, `/api/claw/sessions/${session.session_id}`,
); );
if (!response.ok) return; if (!response.ok) return;
const payload = (await response.json()) as ClawStoredSession; const payload =
(await response.json()) as ClawStoredSession;
replaySession( replaySession(
session.session_id, session.session_id,
toReplayRepository(payload, session.session_id), toReplayRepository(payload, session.session_id),
@@ -230,19 +231,83 @@ const ClawSessionList: FC = () => {
} }
}} }}
> >
<span className="block truncate"> <span className="truncate">
{session.preview || session.session_id} {loading
</span> ? "回放中..."
<span className="mt-0.5 block truncate text-muted-foreground text-xs"> : session.preview || session.session_id}
{active ? "下一条消息将续接此会话 · " : ""}
{loadingSessionId === session.session_id ? "回放中 · " : ""}
{session.turns} · {session.tool_calls}
</span> </span>
</button> </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,
);
}}
>
<MoreHorizontalIcon className="size-4" />
</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">
<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>
</div>
); );
}; };
+93 -32
View File
@@ -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>
+4 -4
View File
@@ -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",
+86 -1
View File
@@ -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))