Add assistant-ui data agent frontend

This commit is contained in:
武阳
2026-05-06 16:18:32 +08:00
parent d6a2359dc1
commit 7d4ae3e7ba
119 changed files with 14330 additions and 14420 deletions
+306 -38
View File
@@ -8,9 +8,12 @@ from __future__ import annotations
import asyncio
import json
import re
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
@@ -56,15 +59,42 @@ class AgentState:
self.cwd = cwd.resolve()
self.session_directory = session_directory
self._lock = Lock()
self._agent: LocalCodingAgent | None = None
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
self._build_agent()
def _build_agent(self) -> None:
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,
@@ -72,22 +102,26 @@ class AgentState:
runtime_config = AgentRuntimeConfig(
cwd=self.cwd,
permissions=permissions,
session_directory=self.session_directory,
session_directory=paths['sessions'],
scratchpad_root=paths['scratchpad'],
)
model_config = ModelConfig(
model=self.model,
base_url=self.base_url,
api_key=self.api_key,
)
self._agent = LocalCodingAgent(
return LocalCodingAgent(
model_config=model_config,
runtime_config=runtime_config,
)
@property
def agent(self) -> LocalCodingAgent:
assert self._agent is not None
return self._agent
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,
@@ -98,10 +132,13 @@ class AgentState:
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 = model
self.model = _normalize_chat_model_name(model)
if base_url is not None:
self.base_url = base_url
if api_key is not None:
@@ -115,17 +152,23 @@ class AgentState:
self.allow_shell = allow_shell
if allow_write is not None:
self.allow_write = allow_write
self._build_agent()
self._agents.clear()
def snapshot(self) -> dict[str, Any]:
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),
'session_directory': str(self.session_directory),
'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': self.agent.active_session_id,
'active_session_id': agent.active_session_id,
}
def lock(self) -> Lock:
@@ -138,7 +181,10 @@ class AgentState:
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):
@@ -148,6 +194,12 @@ class StateUpdate(BaseModel):
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
# ---------------------------------------------------------------------------
@@ -170,8 +222,8 @@ def create_app(state: AgentState) -> FastAPI:
# ------------- info ------------------------------------------------------
@app.get('/api/state')
async def get_state() -> dict[str, Any]:
return state.snapshot()
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]:
@@ -179,7 +231,7 @@ def create_app(state: AgentState) -> FastAPI:
state.update(**payload.model_dump(exclude_none=True))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
return state.snapshot()
return state.snapshot(payload.account_id)
@app.get('/api/slash-commands')
async def list_slash_commands() -> list[dict[str, Any]]:
@@ -208,45 +260,62 @@ def create_app(state: AgentState) -> FastAPI:
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() -> list[dict[str, Any]]:
directory = state.session_directory
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(
directory.glob('*.json'),
key=lambda p: p.stat().st_mtime,
reverse=True,
):
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 []
preview = ''
for msg in messages:
if isinstance(msg, dict) and msg.get('role') == 'user':
content = msg.get('content', '')
if isinstance(content, str):
preview = content[:120]
if isinstance(content, str) and not _is_internal_message(content):
preview = _strip_session_context(content)[:120]
break
results.append(
{
'session_id': data.get('session_id', path.stem),
'session_id': session_id,
'turns': data.get('turns', 0),
'tool_calls': data.get('tool_calls', 0),
'preview': preview,
'modified_at': path.stat().st_mtime,
'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) -> dict[str, Any]:
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=state.session_directory)
stored = load_agent_session(session_id, directory=directory)
except FileNotFoundError:
raise HTTPException(status_code=404, detail='Session not found')
return _serialize_stored_session(stored)
@@ -260,22 +329,42 @@ def create_app(state: AgentState) -> FastAPI:
def _run() -> dict[str, Any]:
with state.lock():
agent = state.agent
agent = state.agent_for(request.account_id)
session_directory = state.account_paths(request.account_id)['sessions']
started_at = time.perf_counter()
if request.resume_session_id is not None:
try:
stored = load_agent_session(
request.resume_session_id,
directory=state.session_directory,
directory=session_directory,
)
except FileNotFoundError:
raise HTTPException(
status_code=404,
detail='Session to resume not found',
)
result = agent.resume(prompt, stored)
result = agent.resume(
prompt,
stored,
runtime_context=request.runtime_context,
)
else:
result = agent.run(prompt)
return _serialize_run_result(result)
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,
)
_annotate_transcript_elapsed(payload, elapsed_ms)
return payload
try:
payload = await asyncio.to_thread(_run)
@@ -292,10 +381,10 @@ def create_app(state: AgentState) -> FastAPI:
return payload
@app.post('/api/clear')
async def clear_state() -> dict[str, Any]:
async def clear_state(account_id: str | None = None) -> dict[str, Any]:
with state.lock():
state.agent.clear_runtime_state()
return state.snapshot()
state.agent_for(account_id).clear_runtime_state()
return state.snapshot(account_id)
return app
@@ -338,3 +427,182 @@ def _serialize_stored_session(stored: StoredAgentSession) -> dict[str, Any]:
'total_cost_usd': stored.total_cost_usd,
'model': stored.model_config.get('model'),
}
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 = directory / session_id / 'session.json'
if not path.exists():
path = directory / f'{session_id}.json'
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 _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 _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