Improve data agent workspace UI
This commit is contained in:
+291
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user