896 lines
31 KiB
Python
896 lines
31 KiB
Python
"""FastAPI server backing the local web GUI.
|
||
|
||
Wraps a single global :class:`LocalCodingAgent`, exposes JSON endpoints for
|
||
chat, slash commands, and saved sessions, and serves the static SPA.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import re
|
||
import shutil
|
||
import time
|
||
from pathlib import Path
|
||
from threading import Lock
|
||
from typing import Any
|
||
from urllib import error, request
|
||
|
||
from fastapi import FastAPI, HTTPException
|
||
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 (
|
||
AgentPermissions,
|
||
AgentRuntimeConfig,
|
||
ModelConfig,
|
||
)
|
||
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'
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Agent state holder
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class AgentState:
|
||
"""Holds the live agent instance plus a lock for serialized access."""
|
||
|
||
def __init__(
|
||
self,
|
||
*,
|
||
cwd: Path,
|
||
model: str,
|
||
base_url: str,
|
||
api_key: str,
|
||
allow_shell: bool,
|
||
allow_write: bool,
|
||
session_directory: Path,
|
||
) -> None:
|
||
self.cwd = cwd.resolve()
|
||
self.session_directory = session_directory
|
||
self._lock = Lock()
|
||
self._agents: dict[str, LocalCodingAgent] = {}
|
||
self.model = model
|
||
self.base_url = base_url
|
||
self.api_key = api_key
|
||
self.allow_shell = allow_shell
|
||
self.allow_write = allow_write
|
||
|
||
def _account_base(self, account_id: str | None) -> Path:
|
||
if not account_id:
|
||
return self.session_directory.parent
|
||
safe_id = _safe_account_id(account_id)
|
||
return (self.session_directory.parent / 'accounts' / safe_id).resolve()
|
||
|
||
def account_paths(self, account_id: str | None) -> dict[str, Path]:
|
||
if not account_id:
|
||
base = self.session_directory.parent
|
||
return {
|
||
'base': base,
|
||
'sessions': self.session_directory,
|
||
'scratchpad': self.session_directory,
|
||
'uploads': self.session_directory,
|
||
'outputs': self.session_directory,
|
||
}
|
||
base = self._account_base(account_id)
|
||
return {
|
||
'base': base,
|
||
'sessions': base / 'sessions',
|
||
'scratchpad': base / 'sessions',
|
||
'uploads': base / 'sessions',
|
||
'outputs': base / 'sessions',
|
||
}
|
||
|
||
def _build_agent(self, account_id: str | None = None) -> LocalCodingAgent:
|
||
paths = self.account_paths(account_id)
|
||
for directory in paths.values():
|
||
directory.mkdir(parents=True, exist_ok=True)
|
||
permissions = AgentPermissions(
|
||
allow_file_write=self.allow_write,
|
||
allow_shell_commands=self.allow_shell,
|
||
)
|
||
runtime_config = AgentRuntimeConfig(
|
||
cwd=self.cwd,
|
||
permissions=permissions,
|
||
session_directory=paths['sessions'],
|
||
scratchpad_root=paths['scratchpad'],
|
||
)
|
||
model_config = ModelConfig(
|
||
model=self.model,
|
||
base_url=self.base_url,
|
||
api_key=self.api_key,
|
||
)
|
||
return LocalCodingAgent(
|
||
model_config=model_config,
|
||
runtime_config=runtime_config,
|
||
)
|
||
|
||
def agent_for(self, account_id: str | None = None) -> LocalCodingAgent:
|
||
key = _safe_account_id(account_id) if account_id else '__default__'
|
||
agent = self._agents.get(key)
|
||
if agent is None:
|
||
agent = self._build_agent(account_id)
|
||
self._agents[key] = agent
|
||
return agent
|
||
|
||
def update(
|
||
self,
|
||
*,
|
||
model: str | None = None,
|
||
base_url: str | None = None,
|
||
api_key: str | None = None,
|
||
cwd: str | None = None,
|
||
allow_shell: bool | None = None,
|
||
allow_write: bool | None = None,
|
||
account_id: str | None = None,
|
||
) -> None:
|
||
with self._lock:
|
||
# account_id 只用于让调用方拿到对应账号的 snapshot,不参与全局配置更新。
|
||
del account_id
|
||
if model is not None:
|
||
self.model = _normalize_chat_model_name(model)
|
||
if base_url is not None:
|
||
self.base_url = base_url
|
||
if api_key is not None:
|
||
self.api_key = api_key
|
||
if cwd is not None:
|
||
resolved = Path(cwd).expanduser().resolve()
|
||
if not resolved.is_dir():
|
||
raise ValueError(f'cwd does not exist: {resolved}')
|
||
self.cwd = resolved
|
||
if allow_shell is not None:
|
||
self.allow_shell = allow_shell
|
||
if allow_write is not None:
|
||
self.allow_write = allow_write
|
||
self._agents.clear()
|
||
|
||
def snapshot(self, account_id: str | None = None) -> dict[str, Any]:
|
||
agent = self.agent_for(account_id)
|
||
paths = self.account_paths(account_id)
|
||
return {
|
||
'model': self.model,
|
||
'base_url': self.base_url,
|
||
'cwd': str(self.cwd),
|
||
'account_id': _safe_account_id(account_id) if account_id else None,
|
||
'account_directory': str(paths['base']),
|
||
'session_directory': str(paths['sessions']),
|
||
'upload_directory': str(paths['uploads']),
|
||
'output_directory': str(paths['outputs']),
|
||
'allow_shell': self.allow_shell,
|
||
'allow_write': self.allow_write,
|
||
'active_session_id': agent.active_session_id,
|
||
}
|
||
|
||
def lock(self) -> Lock:
|
||
return self._lock
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Request models
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class ChatRequest(BaseModel):
|
||
prompt: str = Field(min_length=1)
|
||
runtime_context: str | None = None
|
||
resume_session_id: str | None = None
|
||
account_id: str | None = None
|
||
session_id: str | None = None
|
||
|
||
|
||
class StateUpdate(BaseModel):
|
||
model: str | None = None
|
||
base_url: str | None = None
|
||
api_key: str | None = None
|
||
cwd: str | None = None
|
||
allow_shell: bool | None = None
|
||
allow_write: bool | None = None
|
||
account_id: str | None = None
|
||
|
||
|
||
class ModelListRequest(BaseModel):
|
||
base_url: str | None = None
|
||
api_key: str | None = None
|
||
|
||
|
||
class SessionTitleUpdate(BaseModel):
|
||
title: str = Field(min_length=1, max_length=80)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# App factory
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def create_app(state: AgentState) -> FastAPI:
|
||
app = FastAPI(title='Claw Code GUI', version='1.0')
|
||
|
||
# ------------- static + index ------------------------------------------
|
||
app.mount(
|
||
'/static',
|
||
StaticFiles(directory=str(STATIC_DIR)),
|
||
name='static',
|
||
)
|
||
|
||
@app.get('/', include_in_schema=False)
|
||
async def root() -> FileResponse:
|
||
return FileResponse(STATIC_DIR / 'index.html')
|
||
|
||
# ------------- info ------------------------------------------------------
|
||
@app.get('/api/state')
|
||
async def get_state(account_id: str | None = None) -> dict[str, Any]:
|
||
return state.snapshot(account_id)
|
||
|
||
@app.post('/api/state')
|
||
async def post_state(payload: StateUpdate) -> dict[str, Any]:
|
||
try:
|
||
state.update(**payload.model_dump(exclude_none=True))
|
||
except ValueError as exc:
|
||
raise HTTPException(status_code=400, detail=str(exc))
|
||
return state.snapshot(payload.account_id)
|
||
|
||
@app.get('/api/slash-commands')
|
||
async def list_slash_commands() -> list[dict[str, Any]]:
|
||
commands: list[dict[str, Any]] = []
|
||
for spec in get_slash_command_specs():
|
||
commands.append(
|
||
{
|
||
'names': list(spec.names),
|
||
'primary': spec.names[0],
|
||
'description': spec.description,
|
||
}
|
||
)
|
||
return commands
|
||
|
||
@app.get('/api/skills')
|
||
async def list_skills() -> list[dict[str, Any]]:
|
||
return [
|
||
{
|
||
'name': skill.name,
|
||
'description': skill.description,
|
||
'when_to_use': skill.when_to_use,
|
||
'aliases': list(skill.aliases),
|
||
'allowed_tools': list(skill.allowed_tools),
|
||
}
|
||
for skill in get_bundled_skills(state.cwd)
|
||
if skill.user_invocable
|
||
]
|
||
|
||
@app.get('/api/models')
|
||
async def list_models_get() -> dict[str, Any]:
|
||
return _list_backend_models(state.base_url, state.api_key)
|
||
|
||
@app.post('/api/models')
|
||
async def list_models_post(payload: ModelListRequest) -> dict[str, Any]:
|
||
return _list_backend_models(
|
||
payload.base_url or state.base_url,
|
||
payload.api_key or state.api_key,
|
||
)
|
||
|
||
# ------------- sessions --------------------------------------------------
|
||
@app.get('/api/sessions')
|
||
async def list_sessions(account_id: str | None = None) -> list[dict[str, Any]]:
|
||
directory = state.account_paths(account_id)['sessions']
|
||
if not directory.exists():
|
||
return []
|
||
results: list[dict[str, Any]] = []
|
||
for path in sorted(_iter_session_files(directory), key=_session_mtime, reverse=True):
|
||
try:
|
||
data = json.loads(path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
continue
|
||
session_id = str(data.get('session_id', _session_id_from_path(path)))
|
||
usage = data.get('usage') if isinstance(data.get('usage'), dict) else {}
|
||
model_config = (
|
||
data.get('model_config')
|
||
if isinstance(data.get('model_config'), dict)
|
||
else {}
|
||
)
|
||
messages = data.get('messages') or []
|
||
title = data.get('title')
|
||
preview = ''
|
||
for msg in messages:
|
||
if isinstance(msg, dict) and msg.get('role') == 'user':
|
||
content = msg.get('content', '')
|
||
if isinstance(content, str) and not _is_internal_message(content):
|
||
preview = _strip_session_context(content)[:120]
|
||
break
|
||
results.append(
|
||
{
|
||
'session_id': session_id,
|
||
'turns': data.get('turns', 0),
|
||
'tool_calls': data.get('tool_calls', 0),
|
||
'preview': title if isinstance(title, str) and title.strip() else preview,
|
||
'modified_at': _session_mtime(path),
|
||
'model': model_config.get('model'),
|
||
'usage': usage,
|
||
}
|
||
)
|
||
return results
|
||
|
||
@app.get('/api/sessions/{session_id}')
|
||
async def get_session(session_id: str, account_id: str | None = None) -> dict[str, Any]:
|
||
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')
|
||
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]:
|
||
prompt = request.prompt.strip()
|
||
if not prompt:
|
||
raise HTTPException(status_code=400, detail='Prompt is empty')
|
||
|
||
def _run() -> dict[str, Any]:
|
||
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:
|
||
stored = load_agent_session(
|
||
request.resume_session_id,
|
||
directory=session_directory,
|
||
)
|
||
except FileNotFoundError:
|
||
raise HTTPException(
|
||
status_code=404,
|
||
detail='Session to resume not found',
|
||
)
|
||
result = agent.resume(
|
||
prompt,
|
||
stored,
|
||
runtime_context=request.runtime_context,
|
||
)
|
||
else:
|
||
result = agent.run(
|
||
prompt,
|
||
session_id=_safe_session_id(request.session_id),
|
||
runtime_context=request.runtime_context,
|
||
)
|
||
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
|
||
payload = _serialize_run_result(result)
|
||
payload['elapsed_ms'] = elapsed_ms
|
||
if result.session_id:
|
||
_annotate_last_assistant_elapsed(
|
||
session_directory,
|
||
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
|
||
|
||
try:
|
||
payload = await asyncio.to_thread(_run)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc: # surface the error in the UI
|
||
return JSONResponse(
|
||
status_code=500,
|
||
content={
|
||
'error': str(exc),
|
||
'error_type': type(exc).__name__,
|
||
},
|
||
)
|
||
return payload
|
||
|
||
@app.post('/api/clear')
|
||
async def clear_state(account_id: str | None = None) -> dict[str, Any]:
|
||
with state.lock():
|
||
state.agent_for(account_id).clear_runtime_state()
|
||
return state.snapshot(account_id)
|
||
|
||
return app
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Serialization helpers
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _serialize_run_result(result: Any) -> dict[str, Any]:
|
||
return {
|
||
'final_output': result.final_output,
|
||
'turns': result.turns,
|
||
'tool_calls': result.tool_calls,
|
||
'transcript': [_normalize_transcript_entry(entry) for entry in result.transcript],
|
||
'session_id': result.session_id,
|
||
'usage': result.usage.to_dict(),
|
||
'total_cost_usd': result.total_cost_usd,
|
||
'stop_reason': result.stop_reason,
|
||
}
|
||
|
||
|
||
def _normalize_transcript_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
||
out: dict[str, Any] = {
|
||
'role': entry.get('role', ''),
|
||
'content': entry.get('content', ''),
|
||
}
|
||
for key in ('name', 'tool_call_id', 'tool_calls', 'metadata', 'message_id'):
|
||
if key in entry and entry[key] not in (None, '', [], {}):
|
||
out[key] = entry[key]
|
||
return out
|
||
|
||
|
||
def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]:
|
||
return {
|
||
'session_id': stored.session_id,
|
||
'turns': stored.turns,
|
||
'tool_calls': stored.tool_calls,
|
||
'messages': [_normalize_transcript_entry(dict(m)) for m in stored.messages],
|
||
'usage': stored.usage,
|
||
'total_cost_usd': stored.total_cost_usd,
|
||
'model': stored.model_config.get('model'),
|
||
}
|
||
|
||
|
||
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'),
|
||
headers={
|
||
'Authorization': f'Bearer {api_key}',
|
||
'Content-Type': 'application/json',
|
||
},
|
||
method='GET',
|
||
)
|
||
try:
|
||
with request.urlopen(req, timeout=10) as response:
|
||
payload = json.loads(response.read().decode('utf-8'))
|
||
except error.HTTPError as exc:
|
||
detail = exc.read().decode('utf-8', errors='replace')
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail=f'HTTP {exc.code} from model backend: {detail}',
|
||
) from exc
|
||
except (error.URLError, OSError, json.JSONDecodeError) as exc:
|
||
raise HTTPException(
|
||
status_code=502,
|
||
detail=f'Unable to list models from {base_url}: {exc}',
|
||
) from exc
|
||
|
||
models = _normalize_model_list(payload)
|
||
return {'models': models, 'raw_count': len(models)}
|
||
|
||
|
||
def _normalize_model_list(payload: Any) -> list[dict[str, str]]:
|
||
data = payload.get('data') if isinstance(payload, dict) else payload
|
||
if not isinstance(data, list):
|
||
return []
|
||
model_ids: dict[str, str] = {}
|
||
for item in data:
|
||
if isinstance(item, str):
|
||
normalized = _normalize_model_option(item)
|
||
if normalized is not None:
|
||
model_ids.setdefault(normalized.lower(), normalized)
|
||
continue
|
||
if not isinstance(item, dict):
|
||
continue
|
||
model_id = item.get('id') or item.get('model') or item.get('name')
|
||
if not isinstance(model_id, str) or not model_id:
|
||
continue
|
||
normalized = _normalize_model_option(model_id)
|
||
if normalized is not None:
|
||
model_ids.setdefault(normalized.lower(), normalized)
|
||
return [{'id': model_id} for model_id in sorted(model_ids.values(), key=str.lower)]
|
||
|
||
|
||
def _normalize_model_option(model_id: str) -> str | None:
|
||
normalized = _normalize_chat_model_name(model_id)
|
||
lower = normalized.lower()
|
||
if lower.startswith('xiaomi/mimo-v2') and not any(
|
||
blocked in lower for blocked in ('audio', 'asr', 'tts', 'voiceclone', 'voicedesign')
|
||
):
|
||
return normalized
|
||
known_chat_models = {
|
||
'xiaomi/deepseek-r1-0528',
|
||
'xiaomi/minimax-m2.5',
|
||
'xiaomi/qwen3-32b',
|
||
}
|
||
if lower in known_chat_models:
|
||
return normalized
|
||
return None
|
||
|
||
|
||
def _normalize_chat_model_name(model: str) -> str:
|
||
value = model.strip()
|
||
lower = value.lower()
|
||
if lower.startswith('mimo-v2') or lower in {
|
||
'deepseek-r1-0528',
|
||
'minimax-m2.5',
|
||
'qwen3-32b',
|
||
}:
|
||
return f'xiaomi/{value}'
|
||
return value
|
||
|
||
|
||
def _join_url(base_url: str, suffix: str) -> str:
|
||
return f'{base_url.rstrip("/")}/{suffix.lstrip("/")}'
|
||
|
||
|
||
def _annotate_transcript_elapsed(payload: dict[str, Any], elapsed_ms: int) -> None:
|
||
transcript = payload.get('transcript')
|
||
if not isinstance(transcript, list):
|
||
return
|
||
for entry in reversed(transcript):
|
||
if not isinstance(entry, dict) or entry.get('role') != 'assistant':
|
||
continue
|
||
metadata = entry.get('metadata')
|
||
if not isinstance(metadata, dict):
|
||
metadata = {}
|
||
entry['metadata'] = metadata
|
||
metadata['elapsed_ms'] = elapsed_ms
|
||
return
|
||
|
||
|
||
def _annotate_last_assistant_elapsed(
|
||
directory: Path,
|
||
session_id: str,
|
||
elapsed_ms: int,
|
||
) -> None:
|
||
path = _session_json_path(directory, session_id)
|
||
try:
|
||
data = json.loads(path.read_text(encoding='utf-8'))
|
||
except (OSError, json.JSONDecodeError):
|
||
return
|
||
messages = data.get('messages')
|
||
if not isinstance(messages, list):
|
||
return
|
||
for message in reversed(messages):
|
||
if not isinstance(message, dict) or message.get('role') != 'assistant':
|
||
continue
|
||
metadata = message.get('metadata')
|
||
if not isinstance(metadata, dict):
|
||
metadata = {}
|
||
message['metadata'] = metadata
|
||
metadata['elapsed_ms'] = elapsed_ms
|
||
try:
|
||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding='utf-8')
|
||
except OSError:
|
||
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:
|
||
normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', (account_id or '').strip())
|
||
return normalized[:80] or 'default'
|
||
|
||
|
||
def _is_internal_message(content: str) -> bool:
|
||
return content.lstrip().startswith('<system-reminder>')
|
||
|
||
|
||
def _strip_session_context(content: str) -> str:
|
||
marker = '\n\n[当前会话目录]'
|
||
if marker in content:
|
||
return content.split(marker, 1)[0].strip()
|
||
marker = '\n[当前会话目录]'
|
||
if marker in content:
|
||
return content.split(marker, 1)[0].strip()
|
||
return content.strip()
|
||
|
||
|
||
def _iter_session_files(directory: Path) -> list[Path]:
|
||
if not directory.exists():
|
||
return []
|
||
by_session_id: dict[str, Path] = {}
|
||
for path in directory.glob('*.json'):
|
||
by_session_id[_session_id_from_path(path)] = path
|
||
for path in directory.glob('*/session.json'):
|
||
# 新目录格式包含 input/output/scratchpad,优先级高于旧的平铺 json。
|
||
by_session_id[_session_id_from_path(path)] = 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
|
||
except OSError:
|
||
return 0.0
|
||
|
||
|
||
def _session_id_from_path(path: Path) -> str:
|
||
if path.name == 'session.json':
|
||
return path.parent.name
|
||
return path.stem
|
||
|
||
|
||
def _safe_session_id(session_id: str | None) -> str | None:
|
||
if not session_id:
|
||
return None
|
||
normalized = re.sub(r'[^a-zA-Z0-9._-]+', '_', session_id.strip())
|
||
return normalized[:120] or None
|