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 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