@@ -111,6 +165,11 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/web/server.py b/src/web/server.py
index 5268b20..840c7b9 100644
--- a/src/web/server.py
+++ b/src/web/server.py
@@ -1,3 +1,4 @@
+# server.py
"""
为 ``web`` 目录下的静态前端提供 API:会话列表、多模态发送(与 ``chatbot_agent`` 一致)。
@@ -8,6 +9,7 @@
from __future__ import annotations
import base64
+import json
import mimetypes
import os
import shutil
@@ -27,6 +29,14 @@ if str(_SRC) not in sys.path:
from agent import chatbot_agent as ca # noqa: E402
from web import ui_session as ui # noqa: E402
from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS # noqa: E402
+from agent.core.config_manager import ( # noqa: E402
+ load_agent_configs, save_agent_configs,
+ load_tool_configs, save_tool_configs,
+ load_env_config, save_env_config,
+ load_models, save_models,
+ get_registered_tool_names,
+ AGENT_CONFIG_PATH, TOOL_CONFIG_PATH, ENV_CONFIG_PATH,
+)
_WEB_DIR = Path(__file__).resolve().parent
_IMAGE_DIR = _WEB_DIR / "image"
@@ -35,17 +45,21 @@ _AUDIO_EXT = frozenset({".wav", ".mp3", ".m4a", ".flac", ".ogg", ".webm"})
_IMAGE_EXT = frozenset(IMAGE_FILE_EXTENSIONS)
-def _infer_upload_kind(fp: str) -> str | None:
- ext = os.path.splitext(fp)[1].lower()
- if ext in _IMAGE_EXT:
- return "image"
- if ext in _AUDIO_EXT:
- return "audio"
- return "file"
-
-
def _data_url_for_file(fp: str) -> str | None:
- """将本地文件读入为 data URL,供前端直接展示(不单独提供文件下载路由)。"""
+ """将本地文件读入为 data URL,供前端直接展示(不单独提供文件下载路由)。
+
+ 输入:
+ fp: 本地文件路径。
+
+ 输出:
+ data URL 字符串,失败返回 None。
+
+ 系统定位:
+ _serialize_content 将附件转为内联 data URL 以嵌入 API 响应。
+
+ 可扩展性:
+ 可增加缓存、大小限制、权限检查。
+ """
if not fp or not os.path.isfile(fp):
return None
mime, _ = mimetypes.guess_type(fp)
@@ -75,7 +89,22 @@ def _data_url_for_file(fp: str) -> str | None:
def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role: str | None = None) -> Any:
- """将一条消息中的 content 转为 JSON 友好结构;文件以内联 data URL 输出。"""
+ """将一条消息中的 content 转为 JSON 友好结构;文件以内联 data URL 输出。
+
+ 输入:
+ content: 原始 content(str、list 或 dict)。
+ meta: 消息的 meta 信息(如音频时长、自动播放等)。
+ role: 消息角色(user/assistant),用于控制自动播放行为。
+
+ 输出:
+ 序列化后的 content(str 或 list of dict)。
+
+ 系统定位:
+ _serialize_messages 中逐条转换,供前端直接渲染。
+
+ 可扩展性:
+ 可支持更多文件类型(如视频)的 data URL 输出。
+ """
meta = meta or {}
if isinstance(content, str):
return content
@@ -121,6 +150,20 @@ def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role
def _serialize_messages(hist: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
+ """将 chat_history 转为 API 响应格式,附件内联为 data URL。
+
+ 输入:
+ hist: 聊天历史(dict 列表)。
+
+ 输出:
+ 序列化后的消息列表,每条消息包含 role、content、meta。
+
+ 系统定位:
+ 所有返回给前端的 API 响应中,messages 字段均经过此函数转换。
+
+ 可扩展性:
+ 可支持更细粒度的权限控制或裁剪。
+ """
hist = hist or []
# 找到最后一条 assistant 消息的索引,用于控制自动播放
last_assistant_idx = -1
@@ -178,6 +221,19 @@ def _serialize_messages(hist: list[dict[str, Any]] | None) -> list[dict[str, Any
def _bootstrap() -> tuple[str, list[str], list[dict[str, Any]]]:
+ """初始化默认会话:取最近会话或新建时间戳 ID 及其消息列表。
+
+ 输入: 无。
+
+ 输出:
+ (session_id, sessions列表, messages列表)
+
+ 系统定位:
+ API 启动时/删除会话后回退到默认会话。
+
+ 可扩展性:
+ 可增加用户偏好默认会话逻辑。
+ """
ids = ui.list_session_ids_ordered()
if ids:
sid = ids[0]
@@ -201,12 +257,35 @@ app.add_middleware(
@app.get("/api/bootstrap")
def api_bootstrap() -> dict[str, Any]:
+ """获取初始会话信息(默认会话 ID、会话列表、消息历史)。
+
+ 输入: 无。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": list[dict]}
+
+ 系统定位:
+ 前端页面加载时调用的第一个 API,用于初始化界面。
+
+ 可扩展性:
+ 可返回用户配置、主题等元信息。
+ """
sid, ids, hist = _bootstrap()
return {"sessionId": sid, "sessions": ids, "messages": _serialize_messages(hist)}
@app.post("/api/sessions/new")
def api_new_session() -> dict[str, Any]:
+ """创建新会话(基于时间戳),返回会话信息。
+
+ 输入: 无。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": []}
+
+ 系统定位:
+ 前端点击“新建会话”时调用。
+ """
sid = ui.new_timestamp_session_id()
ids = ui.list_session_ids_ordered()
ids = [sid] + [x for x in ids if x != sid]
@@ -215,6 +294,17 @@ def api_new_session() -> dict[str, Any]:
@app.get("/api/sessions/{session_id}/messages")
def api_session_messages(session_id: str) -> dict[str, Any]:
+ """切换会话:加载指定会话的消息列表与全局会话列表。
+
+ 输入:
+ session_id: 路径参数,目标会话 ID。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": list[dict]}
+
+ 系统定位:
+ 前端切换会话时调用。
+ """
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
hist = ui.load_ui_messages_for_session(session_id)
@@ -226,6 +316,17 @@ def api_session_messages(session_id: str) -> dict[str, Any]:
@app.post("/api/sessions/clear")
def api_clear_session(session_id: str = Form(...)) -> dict[str, Any]:
+ """清空当前会话的所有消息(重置会话)。
+
+ 输入:
+ session_id: 表单字段,会话 ID。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": []}
+
+ 系统定位:
+ 前端“清空对话”按钮调用。
+ """
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
messages, _ = ui.clear_ui_and_manifest(session_id)
@@ -237,6 +338,17 @@ def api_clear_session(session_id: str = Form(...)) -> dict[str, Any]:
@app.post("/api/sessions/delete")
def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]:
+ """删除整个会话(含文件与历史),并回退到默认会话。
+
+ 输入:
+ session_id: 表单字段,会话 ID。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": list[dict]}
+
+ 系统定位:
+ 前端“删除会话”按钮调用。
+ """
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
ui.delete_session(session_id)
@@ -250,7 +362,24 @@ def _materialize_uploads_to_temp(
audio: UploadFile | None,
text: str,
) -> tuple[dict[str, Any], str]:
- """将上传文件落盘到临时目录,返回 ``(query_dict, tmp_root)``;调用方须在 finally 中删除 ``tmp_root``。"""
+ """将上传文件落盘到临时目录,返回 ``(query_dict, tmp_root)``;调用方须在 finally 中删除 ``tmp_root``。
+
+ 输入:
+ images: 前端上传的图片文件列表。
+ files: 前端上传的普通文件列表。
+ audio: 前端上传的音频文件。
+ text: 用户输入的文本。
+
+ 输出:
+ (query_dict, tmp_root)
+ query_dict 包含 "text", "files"(路径列表), "file_names"(原始文件名列表)。
+
+ 系统定位:
+ API 层处理用户多模态输入的统一临时落盘逻辑。
+
+ 可扩展性:
+ 可增加文件大小限制、病毒扫描钩子。
+ """
tmp_root = tempfile.mkdtemp(prefix="web_mm_")
file_paths: list[str] = []
file_names: list[str] = []
@@ -287,7 +416,21 @@ def _materialize_uploads_to_temp(
def _chat_start_core(session_id: str, query: dict[str, Any]) -> dict[str, Any]:
- """追加 user、复制附件到会话目录并写 manifest(不调用模型)。"""
+ """追加 user、复制附件到会话目录并写 manifest(不调用模型)。
+
+ 输入:
+ session_id: 会话 ID。
+ query: {"text": str, "files": list[str], "file_names": list[str]}
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": list[dict]}
+
+ 系统定位:
+ /api/chat/start 与 /api/chat(兼容模式)的后端核心逻辑。
+
+ 可扩展性:
+ 可增加消息预处理钩子。
+ """
hist = ui.load_ui_messages_for_session(session_id)
before = len(hist or [])
new_hist, cleared = ui.add_message(hist or [], query)
@@ -309,15 +452,31 @@ def _chat_start_core(session_id: str, query: dict[str, Any]) -> dict[str, Any]:
}
-def _chat_complete_core(session_id: str, output_type: str = "text") -> dict[str, Any]:
- """对 manifest 中末条 user 执行视觉解析与 ``chain_with_history.invoke``。"""
+def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode: str = "agent") -> dict[str, Any]:
+ """对 manifest 中末条 user 执行视觉解析与 ``chain_with_history.invoke``。
+
+ 输入:
+ session_id: 会话 ID。
+ output_type: text | audio | both。
+ agent_mode: agent | plan | team。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": list[dict]}
+
+ 系统定位:
+ /api/chat/complete 与 /api/chat(兼容模式)的后端核心逻辑。
+
+ 可扩展性:
+ 可支持异步执行、流式输出。
+ """
hist = ui.load_ui_messages_for_session(session_id)
if not hist or hist[-1].get("role") != "user":
raise HTTPException(
status_code=400,
detail="没有待完成的用户消息,请先调用 /api/chat/start",
)
- new_hist = ca.execute_agent([*hist], session_id, output_type=output_type)
+ normalized_mode = agent_mode if agent_mode in {"agent", "plan", "team"} else "agent"
+ new_hist = ca.execute_agent([*hist], session_id, output_type=output_type, agent_mode=normalized_mode)
ui.prune_memory_session_ids()
ids = ui.list_session_ids_ordered()
if session_id not in ids:
@@ -340,6 +499,19 @@ async def api_chat_start(
"""
仅追加用户消息并持久化到 manifest(含附件落盘);不调用模型。
前端应先调用本接口再调用 ``/api/chat/complete``。
+
+ 输入:
+ session_id: 表单字段,会话 ID。
+ text: 用户输入的文本。
+ images: 上传的图片文件列表。
+ files: 上传的普通文件列表。
+ audio: 上传的音频文件。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": list[dict]}
+
+ 系统定位:
+ 两步式对话的第一步,用于提前上传文件并显示用户消息。
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
@@ -352,14 +524,27 @@ async def api_chat_start(
@app.post("/api/chat/complete")
-async def api_chat_complete(
+def api_chat_complete(
session_id: str = Form(...),
output_type: str = Form("text"),
+ agent_mode: str = Form("agent"),
) -> dict[str, Any]:
- """对当前会话最近一次 ``/api/chat/start`` 提交执行模型推理并返回完整消息列表。"""
+ """对当前会话最近一次 ``/api/chat/start`` 提交执行模型推理并返回完整消息列表。
+
+ 输入:
+ session_id: 表单字段,会话 ID。
+ output_type: text | audio | both。
+ agent_mode: agent | plan | team。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": list[dict]}
+
+ 系统定位:
+ 两步式对话的第二步,执行 Agent 并返回结果。
+ """
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
- return _chat_complete_core(session_id, output_type=output_type)
+ return _chat_complete_core(session_id, output_type=output_type, agent_mode=agent_mode)
@app.post("/api/chat")
@@ -370,9 +555,25 @@ async def api_chat(
files: list[UploadFile] | None = File(None),
audio: UploadFile | None = File(None),
output_type: str = Form("text"),
+ agent_mode: str = Form("agent"),
) -> dict[str, Any]:
"""
兼容旧客户端:等价于依次 ``/api/chat/start`` + ``/api/chat/complete``(仍阻塞至推理结束)。
+
+ 输入:
+ session_id: 表单字段,会话 ID。
+ text: 用户输入的文本。
+ images: 上传的图片文件列表。
+ files: 上传的普通文件列表。
+ audio: 上传的音频文件。
+ output_type: text | audio | both。
+ agent_mode: agent | plan | team。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": list[dict]}
+
+ 系统定位:
+ 向后兼容的一次性对话接口。
"""
if not session_id:
raise HTTPException(status_code=400, detail="缺少 session_id")
@@ -380,20 +581,351 @@ async def api_chat(
query, tmp_root = _materialize_uploads_to_temp(images, files, audio, text)
try:
_chat_start_core(session_id, query)
- return _chat_complete_core(session_id, output_type=output_type)
+ return _chat_complete_core(session_id, output_type=output_type, agent_mode=agent_mode)
finally:
shutil.rmtree(tmp_root, ignore_errors=True)
+@app.post("/api/human-action")
+def api_human_action(
+ session_id: str = Form(...),
+ approval_id: str = Form(...),
+ decision: str = Form(...),
+ instruction: str = Form(""),
+ output_type: str = Form("text"),
+) -> dict[str, Any]:
+ """用户对待确认操作(HITL)做出决策后,继续执行 Agent。
+
+ 输入:
+ session_id: 会话 ID。
+ approval_id: 待确认项的 ID。
+ decision: approve | reject | edit。
+ instruction: 用户输入的补充文本(用于 edit 或 selection)。
+ output_type: 回复的输出类型。
+
+ 输出:
+ {"sessionId": str, "sessions": list[str], "messages": list[dict]}
+
+ 系统定位:
+ 前端确认框提交决策时调用的回调接口。
+ """
+ if not session_id:
+ raise HTTPException(status_code=400, detail="缺少 session_id")
+ if decision not in {"approve", "reject", "edit"}:
+ raise HTTPException(status_code=400, detail="不支持的人工处理结果")
+ hist = ui.load_ui_messages_for_session(session_id)
+ new_hist = ca.continue_after_human_action(
+ [*hist],
+ session_id=session_id,
+ approval_id=approval_id,
+ decision=decision,
+ instruction=instruction,
+ output_type=output_type,
+ )
+ ids = ui.list_session_ids_ordered()
+ if session_id not in ids:
+ ids = [session_id, *ids]
+ return {"sessionId": session_id, "sessions": ids, "messages": _serialize_messages(new_hist)}
+
+
+# ---------- 配置管理 API ----------
+@app.get("/api/config/agents")
+def api_get_agent_configs() -> dict[str, Any]:
+ """获取所有 agent 配置列表。
+
+ 输出: {"agents": list[dict], "config_path": str}
+ """
+ configs = load_agent_configs()
+ return {
+ "agents": [{"name": c.name, "system_prompt": c.system_prompt, "max_iterations": c.max_iterations,
+ "llm": {"model": c.llm.model, "api_key": c.llm.api_key, "base_url": c.llm.base_url,
+ "timeout": c.llm.timeout, "max_retries": c.llm.max_retries},
+ "llm_model_id": c.llm_model_id,
+ "tools": c.tools,
+ "enabled": c.enabled,
+ "role": c.role} for c in configs],
+ "config_path": str(AGENT_CONFIG_PATH),
+ }
+
+
+@app.post("/api/config/agents")
+def api_save_agent_configs(data: dict[str, Any]) -> dict[str, Any]:
+ """保存 agent 配置列表。
+
+ 输入: {"agents": list[dict]}
+ 输出: {"success": bool, "config_path": str}
+ """
+ agents = data.get("agents") if isinstance(data, dict) else None
+ if not isinstance(agents, list):
+ raise HTTPException(status_code=400, detail="缺少 agents 字段或格式不正确")
+ success = save_agent_configs(agents)
+ return {"success": success, "config_path": str(AGENT_CONFIG_PATH)}
+
+
+@app.get("/api/config/tools")
+def api_get_tool_configs() -> dict[str, Any]:
+ """获取所有工具配置列表。
+
+ 输出: {"tools": list[dict], "registered_tool_names": list[str],
+ "tool_parameters": dict, "forced_permissions": dict, "config_path": str}
+ """
+ from agent.core.config_manager import _get_tool_parameters, get_tool_forced_permissions
+ configs = load_tool_configs()
+ return {
+ "tools": [{"name": c.name, "display_name": c.display_name, "description": c.description,
+ "permission": c.permission, "auto_execute_rules": c.auto_execute_rules, "enabled": c.enabled}
+ for c in configs],
+ "registered_tool_names": get_registered_tool_names(),
+ "tool_parameters": _get_tool_parameters(),
+ "forced_permissions": get_tool_forced_permissions(),
+ "config_path": str(TOOL_CONFIG_PATH),
+ }
+
+
+@app.post("/api/config/tools")
+def api_save_tool_configs(data: dict[str, Any]) -> dict[str, Any]:
+ """保存工具配置列表。
+
+ 输入: {"tools": list[dict]}
+ 输出: {"success": bool, "config_path": str}
+ """
+ tools = data.get("tools") if isinstance(data, dict) else None
+ if not isinstance(tools, list):
+ raise HTTPException(status_code=400, detail="缺少 tools 字段或格式不正确")
+ success = save_tool_configs(tools)
+ # 保存后清除 tool_policy 的规则缓存,使新配置生效
+ try:
+ from agent.core.tool_policy import reload_auto_execute_rules
+ reload_auto_execute_rules()
+ except Exception:
+ pass
+ return {"success": success, "config_path": str(TOOL_CONFIG_PATH)}
+
+
+# ---------- 模型管理 API ----------
+@app.get("/api/config/models")
+def api_get_models() -> dict[str, Any]:
+ """获取所有 LLM 模型配置。
+
+ 输出: {"models": list[dict]}
+ """
+ return {"models": load_models()}
+
+
+@app.post("/api/config/models")
+def api_save_models(data: dict[str, Any]) -> dict[str, Any]:
+ """保存 LLM 模型配置列表。
+
+ 输入: {"models": list[dict]}
+ 输出: {"success": bool}
+ """
+ models = data.get("models") if isinstance(data, dict) else None
+ if not isinstance(models, list):
+ raise HTTPException(status_code=400, detail="缺少 models 字段或格式不正确")
+ success = save_models(models)
+ return {"success": success}
+
+
+@app.get("/api/config/env")
+def api_get_env_config() -> dict[str, Any]:
+ """获取环境变量配置。
+
+ 输出: {"env": dict, "models": list[dict], "config_path": str}
+ """
+ config = load_env_config()
+ models = config.get("models", [])
+ env_only = {k: v for k, v in config.items() if k != "models"}
+ return {"env": env_only, "models": models, "config_path": str(ENV_CONFIG_PATH)}
+
+
+@app.post("/api/config/env")
+def api_save_env_config(data: dict[str, Any]) -> dict[str, Any]:
+ """保存环境变量配置。
+
+ 输入: {"env": dict}
+ 输出: {"success": bool, "config_path": str}
+ """
+ env = data.get("env") if isinstance(data, dict) else None
+ if not isinstance(env, dict):
+ raise HTTPException(status_code=400, detail="缺少 env 字段或格式不正确")
+ success = save_env_config(env)
+ return {"success": success, "config_path": str(ENV_CONFIG_PATH)}
+
# 先注册 API,再挂静态文件,避免 ``/`` 抢路由
@app.get("/api/health")
def health() -> dict[str, str]:
+ """健康检查接口。
+
+ 输入: 无。
+
+ 输出: {"status": "ok"}
+ """
return {"status": "ok"}
+@app.post("/api/config/reload")
+def api_reload_configs() -> dict[str, Any]:
+ """热重载所有配置并重建 Agent 运行时,无需重启服务。
+
+ 输入: 无(或 {})。
+
+ 输出: {"success": bool, "agent": dict, "detail": str}
+ """
+ import os as _os
+ try:
+ # 1. 重新读取 env_config.json 并写入 os.environ
+ from agent.utils.env_utils import _load_config
+ _load_config()
+
+ # 2. 重建 LLM 实例
+ from agent.core.llm import reload_llm
+ reload_llm()
+
+ # 3. 清除并重新加载 tool_policy 缓存
+ from agent.core.tool_policy import reload_auto_execute_rules
+ reload_auto_execute_rules()
+
+ # 4. 重建主 Agent 运行时
+ from agent.agents.main_agent import reload_main_agent_runtime
+ agent_info = reload_main_agent_runtime()
+
+ return {"success": True, "agent": agent_info, "detail": "配置已热重载"}
+ except Exception as e:
+ return {"success": False, "agent": {}, "detail": str(e)}
+
+
+# ---------- 多 Agent 图面板 API ----------
+@app.get("/api/graph/snapshot")
+def api_get_graph_snapshot(session_id: str = "") -> dict[str, Any]:
+ """获取当前多 Agent 运行时的图结构快照,供前端 graph 面板渲染。"""
+ try:
+ from agent.agents.multi_agent_runtime import get_agent_graph_snapshot, build_multi_agent_runtime
+ from agent.agents.main_agent import main_agent_runtime
+ from agent.history.tool_call_recorder import get_tool_calls_for_latest_turn
+ runtime = build_multi_agent_runtime(main_runtime=main_agent_runtime)
+ result = get_agent_graph_snapshot(runtime)
+ if session_id:
+ recorded = get_tool_calls_for_latest_turn(session_id)
+ if recorded:
+ existing = result.get("tool_calls", {}) or {}
+ main_calls = existing.get("main", [])
+ for r in recorded:
+ main_calls.append({
+ "tool_name": r.get("name", ""),
+ "tool_args": {},
+ "result_preview": r.get("result_text", "")[:80],
+ "status": r.get("status", "done"),
+ })
+ result["tool_calls"] = {**existing, "main": main_calls}
+ return result
+ except Exception as e:
+ return {
+ "agents": [
+ {"name": "main", "role": "main", "icon": "🧠", "status": "idle", "error_message": ""}
+ ],
+ "edges": [],
+ "events": [],
+ "tool_calls": {},
+ "error": str(e),
+ }
+
+
+@app.get("/api/graph/config")
+def api_get_graph_config() -> dict[str, Any]:
+ """获取 graph 面板的初始配置(从 agent_config.json 读取各 agent 的 role/icon 等)。
+
+ 输出:
+ {"agents": [{"name": str, "role": str, "icon": str}, ...]}
+ """
+ from agent.core.config_manager import load_agent_configs
+ configs = load_agent_configs()
+ role_icons = {"main": "🧠", "code": "💻", "research": "🔍", "doc": "📄", "gui": "🖥"}
+ agents_list = []
+ for cfg in configs:
+ if cfg.enabled:
+ agents_list.append({
+ "name": cfg.name,
+ "role": cfg.role,
+ "icon": role_icons.get(cfg.name, "🔧"),
+ "tools": cfg.tools,
+ })
+ if not agents_list:
+ agents_list = [{"name": "main", "role": "main", "icon": "🧠", "tools": []}]
+ return {"agents": agents_list}
+
+
+# ---------- 工具调用历史 API ----------
+@app.get("/api/tool-calls/{session_id}/live")
+def api_get_live_tool_calls(session_id: str) -> dict[str, Any]:
+ """获取当前进行中的工具调用(图表执行期间实时轮询)。"""
+ try:
+ from agent.history.tool_call_recorder import get_live_tool_calls
+ return {"tool_calls": get_live_tool_calls(session_id)}
+ except Exception as e:
+ return {"tool_calls": [], "error": str(e)}
+
+
+@app.get("/api/tool-calls/{session_id}/recent")
+def api_get_recent_tool_calls(session_id: str) -> dict[str, Any]:
+ """获取最新 turn 的工具调用缩略(仅名称,无返回值)。"""
+ try:
+ from agent.history.tool_call_recorder import get_tool_calls_for_latest_turn
+ calls = get_tool_calls_for_latest_turn(session_id)
+ result = []
+ for c in calls:
+ result.append({
+ "name": c.get("name", ""),
+ "args_summary": json.dumps(c.get("args", {}), ensure_ascii=False)[:40],
+ "status": "done",
+ "turn_id": c.get("turn_id", ""),
+ })
+ return {"tool_calls": result}
+ except Exception as e:
+ return {"tool_calls": [], "error": str(e)}
+
+
+@app.get("/api/tool-calls/{session_id}/detail")
+def api_get_tool_call_detail(session_id: str) -> dict[str, Any]:
+ """获取最新 turn 的工具调用详情(含返回值和图片 base64)。"""
+ try:
+ from agent.history.tool_call_recorder import get_tool_calls_for_latest_turn
+ details = get_tool_calls_for_latest_turn(session_id)
+ # 图片文件转 data URL
+ import base64
+ for d in details:
+ imgs = []
+ for fp in d.get("result_files_full", []):
+ if os.path.isfile(fp):
+ with open(fp, "rb") as f:
+ b64 = base64.standard_b64encode(f.read()).decode("ascii")
+ ext = os.path.splitext(fp)[1].lower()
+ mime_map = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif"}
+ mime = mime_map.get(ext, "image/png")
+ imgs.append(f"data:{mime};base64,{b64}")
+ d["result_images"] = imgs
+ return {"details": details}
+ except Exception as e:
+ return {"details": [], "error": str(e)}
+
+
+@app.get("/api/tool-calls/{session_id}/has-pending")
+def api_has_pending_tool_calls(session_id: str) -> dict[str, Any]:
+ """检查是否有待处理的工具调用(thinking 状态时使用)。"""
+ try:
+ from agent.history.tool_call_recorder import list_turns
+ turns = list_turns(session_id)
+ has_tools = bool(turns)
+ # 判断最新 turn 是否已完成(有 assistant_response)
+ latest_complete = turns[0].get("assistant_response", "") if turns else ""
+ return {"has_recent": has_tools, "is_complete": bool(latest_complete)}
+ except Exception as e:
+ return {"has_recent": False, "is_complete": True, "error": str(e)}
+
+
@app.get("/favicon.ico")
def favicon() -> FileResponse:
+ """返回网站图标(favicon.ico/png/svg)。"""
for name, mime in (
("favicon.ico", "image/x-icon"),
("favicon.png", "image/png"),
@@ -407,6 +939,14 @@ def favicon() -> FileResponse:
@app.get("/image/{asset_name}")
def serve_image_asset(asset_name: str) -> FileResponse:
+ """提供 web/image 目录下的静态图片资源。
+
+ 输入:
+ asset_name: 图片文件名。
+
+ 输出:
+ 图片文件响应。
+ """
path = (_IMAGE_DIR / asset_name).resolve()
try:
path.relative_to(_IMAGE_DIR.resolve())
@@ -420,14 +960,17 @@ def serve_image_asset(asset_name: str) -> FileResponse:
@app.get("/")
def index_page() -> FileResponse:
+ """返回前端主页面 index.html。"""
return FileResponse(_WEB_DIR / "index.html")
@app.get("/app.css")
def serve_css() -> FileResponse:
+ """返回样式文件 app.css。"""
return FileResponse(_WEB_DIR / "app.css")
@app.get("/app.js")
def serve_js() -> FileResponse:
+ """返回前端脚本 app.js。"""
return FileResponse(_WEB_DIR / "app.js")
\ No newline at end of file
diff --git a/src/web/ui_session.py b/src/web/ui_session.py
index e18d021..099618f 100644
--- a/src/web/ui_session.py
+++ b/src/web/ui_session.py
@@ -1,3 +1,4 @@
+# ui_session.py
"""
Web/API 与会话 UI 状态:会话 id 列表、manifest、多模态 user 消息结构、附件落盘。
与 LangChain 链解耦;execute_agent 仍在 chatbot_agent 中,此处提供其依赖的同步与解析函数。
@@ -14,23 +15,58 @@ from agent.utils.agent_utils import (
CHAT_HISTORY_DB_FS,
MULTI_INPUTS_ROOT,
assistant_text,
+ get_history,
)
from agent.utils.image_utils import IMAGE_FILE_EXTENSIONS
-from agent.chatbot_agent import get_history
_AUDIO_EXTENSIONS = {".wav", ".mp3", ".m4a", ".flac", ".ogg"}
_memory_session_ids: set[str] = set()
def manifest_path(session_id: str) -> str:
+ """返回会话 UI manifest 文件(ui_messages.json)的绝对路径。
+
+ 输入:
+ session_id: 会话 ID。
+
+ 输出:
+ manifest 文件的绝对路径字符串。
+
+ 系统定位:
+ 持久化 UI 层消息结构的唯一文件路径。
+
+ 可扩展性:
+ 可改为支持不同版本的 manifest 文件(如 version 4)。
+ """
return os.path.join(MULTI_INPUTS_ROOT, session_id, "ui_messages.json")
def session_dir(session_id: str) -> str:
+ """返回会话附件与 TTS 音频的存储目录绝对路径。
+
+ 输入:
+ session_id: 会话 ID。
+
+ 输出:
+ 会话目录的绝对路径字符串。
+
+ 系统定位:
+ 所有用户上传文件和助手生成的音频文件存放于此目录下。
+ """
return os.path.join(MULTI_INPUTS_ROOT, session_id)
def list_session_ids_ordered() -> list[str]:
+ """从 SQLite 按最近消息时间倒序列出所有 session_id。
+
+ 输入: 无。
+
+ 输出:
+ session_id 列表,按最后活跃时间降序排列。
+
+ 系统定位:
+ 用于前端展示会话列表,最近对话的会话排在前面。
+ """
if not os.path.isfile(CHAT_HISTORY_DB_FS):
return []
conn = sqlite3.connect(CHAT_HISTORY_DB_FS)
@@ -44,6 +80,16 @@ def list_session_ids_ordered() -> list[str]:
def new_timestamp_session_id() -> str:
+ """生成基于时间戳的唯一会话 ID,冲突时追加序号后缀。
+
+ 输入: 无。
+
+ 输出:
+ 新的会话 ID 字符串(如 "20260609_143022" 或 "20260609_143022_1")。
+
+ 系统定位:
+ 前端点击“新建会话”时调用的 ID 生成器。
+ """
base = datetime.now().strftime("%Y%m%d_%H%M%S")
existing = set(list_session_ids_ordered()) | _memory_session_ids
if base not in existing:
@@ -59,6 +105,15 @@ def new_timestamp_session_id() -> str:
def prune_memory_session_ids() -> None:
+ """移除已写入数据库的内存 session_id 缓存,避免集合无限增长。
+
+ 输入: 无。
+
+ 输出: 无。
+
+ 系统定位:
+ 每次会话操作后调用,清理过期的内存缓存。
+ """
persisted = set(list_session_ids_ordered())
for sid in list(_memory_session_ids):
if sid in persisted:
@@ -66,6 +121,16 @@ def prune_memory_session_ids() -> None:
def delete_session(session_id: str) -> None:
+ """删除会话的 SQLite 消息、内存缓存、manifest 与附件目录。
+
+ 输入:
+ session_id: 要删除的会话 ID。
+
+ 输出: 无。
+
+ 系统定位:
+ API 层删除会话时彻底清理所有相关数据。
+ """
if not session_id:
return
if os.path.isfile(CHAT_HISTORY_DB_FS):
@@ -80,6 +145,17 @@ def delete_session(session_id: str) -> None:
def _lc_content_for_ui_messages(content) -> str | list:
+ """将 LangChain 消息的 content 转为 UI 可展示的 str 或 list。
+
+ 输入:
+ content: LangChain 消息的 content 字段。
+
+ 输出:
+ str 或 list,供前端渲染。
+
+ 系统定位:
+ 从 SQLite 历史重建 UI 消息时的降级方案(无 manifest 时使用)。
+ """
if isinstance(content, str):
return content
if isinstance(content, list):
@@ -89,6 +165,17 @@ def _lc_content_for_ui_messages(content) -> str | list:
def _load_ui_messages_from_db(session_id: str) -> list:
+ """从 SQLChatMessageHistory 重建简化版 UI 消息列表(无 manifest 元数据)。
+
+ 输入:
+ session_id: 会话 ID。
+
+ 输出:
+ 消息列表,每条包含 role 和 content(无 meta)。
+
+ 系统定位:
+ 当 manifest 文件缺失时,回退到 SQLite 历史展示。
+ """
if not session_id:
return []
rows: list[dict] = []
@@ -102,7 +189,17 @@ def _load_ui_messages_from_db(session_id: str) -> list:
def _build_user_chat_content(query) -> str | list | None:
- """根据上传文件扩展名直接生成多模态 content(与 LLM 输入格式一致)"""
+ """根据上传文件扩展名直接生成多模态 content(与 LLM 输入格式一致)。
+
+ 输入:
+ query: {"text": str, "files": list[str], "file_names": list[str]}
+
+ 输出:
+ content 结构(str 或 list),无有效内容时返回 None。
+
+ 系统定位:
+ add_message 中将前端上传的原始 query 转为 LangChain 可识别的 content。
+ """
parts: list = []
file_names = query.get("file_names") or []
for idx, filepath in enumerate(query.get("files", []) or []):
@@ -127,6 +224,17 @@ def _build_user_chat_content(query) -> str | list | None:
def _sanitize_filename_stem(name: str) -> str:
+ """清理文件名主干中的非法字符,空名回退为 file。
+
+ 输入:
+ name: 原始文件名。
+
+ 输出:
+ 清洗后的文件名主干(不含扩展名)。
+
+ 系统定位:
+ _next_session_copy_name 中生成安全的文件名前缀。
+ """
stem = os.path.splitext(name or "")[0].strip()
if not stem:
return "file"
@@ -141,6 +249,20 @@ def _sanitize_filename_stem(name: str) -> str:
def _next_session_copy_name(dest_dir: str, session_id: str, original_name: str, ext: str) -> str:
+ """在会话目录内生成不冲突的附件副本文件名(含 session_id 与递增序号)。
+
+ 输入:
+ dest_dir: 会话目录。
+ session_id: 会话 ID。
+ original_name: 原始文件名。
+ ext: 扩展名(含点)。
+
+ 输出:
+ 不冲突的文件名字符串。
+
+ 系统定位:
+ rewrite_user_message_files_to_session_dir 中为每个附件生成唯一副本名。
+ """
ext = ext or ""
base_name = _sanitize_filename_stem(original_name)
prefix = f"{base_name}_{session_id}_"
@@ -159,6 +281,17 @@ def _next_session_copy_name(dest_dir: str, session_id: str, original_name: str,
def rewrite_user_message_files_to_session_dir(user_msg: dict, session_id: str) -> None:
+ """将 user 消息中的外部附件复制到会话目录并原地更新路径引用。
+
+ 输入:
+ user_msg: 用户消息字典(原地修改)。
+ session_id: 会话 ID。
+
+ 输出: 无。
+
+ 系统定位:
+ 将临时上传的文件移动到持久化的会话目录,并修正消息中的路径。
+ """
dest_dir = session_dir(session_id)
os.makedirs(dest_dir, exist_ok=True)
raw = user_msg.get("content")
@@ -202,7 +335,18 @@ def rewrite_user_message_files_to_session_dir(user_msg: dict, session_id: str) -
user_msg["content"] = new_list
-def _serialize_user_msg_for_manifest(user_msg: dict) -> dict:
+def _serialize_user_msg_for_manifest(user_msg: dict) -> dict:
+ """将 user 消息序列化为 manifest 可持久化的 content 列表结构。
+
+ 输入:
+ user_msg: 用户消息字典。
+
+ 输出:
+ 用于写入 manifest 的 user 条目字典(含相对路径引用)。
+
+ 系统定位:
+ sync_manifest_from_chat_history 中将 user 消息转为可持久化格式。
+ """
raw = user_msg.get("content")
content_list: list[dict] = []
if isinstance(raw, str):
@@ -239,17 +383,26 @@ def _serialize_user_msg_for_manifest(user_msg: dict) -> dict:
"relpath": rel.replace("\\", "/"),
"name": piece.get("name") or os.path.basename(fp),
})
- # 不再写入 image_description 字段
return {"content": content_list}
def _manifest_user_entry_to_content(user_entry: dict) -> str | list:
+ """将 manifest 中的 user 条目还原为 UI/LLM 使用的 content(含附件绝对路径)。
+
+ 输入:
+ user_entry: manifest 中读取的 user 条目字典。
+
+ 输出:
+ content 结构(str 或 list),用于展示或模型输入。
+
+ 系统定位:
+ load_ui_messages_for_session 中从 manifest 重建用户消息。
+ """
root_norm = os.path.normpath(MULTI_INPUTS_ROOT)
parts_out: list = []
any_image_missing = False
had_image_parts = False
- # 新格式直接取 "content"
content_data = user_entry.get("content")
if content_data is None:
return ""
@@ -290,7 +443,6 @@ def _manifest_user_entry_to_content(user_entry: dict) -> str | list:
else:
parts_out.append({"type": "text", "text": f"[附件已丢失: {display_name}]"})
- # 不再处理 image_description(新格式中没有)
if had_image_parts and any_image_missing:
parts_out.append({"type": "text", "text": "[图片已丢失]"})
@@ -302,6 +454,14 @@ def _manifest_user_entry_to_content(user_entry: dict) -> str | list:
def _serialize_assistant_entry(assistant_msg: dict | None) -> dict:
+ """将 assistant 消息及其 meta 序列化为 manifest assistant 条目。
+
+ 输入:
+ assistant_msg: assistant 消息字典。
+
+ 输出:
+ 用于写入 manifest 的 assistant 条目字典。
+ """
ac = (assistant_msg or {}).get("content", "")
meta = dict((assistant_msg or {}).get("meta") or {})
if isinstance(ac, str):
@@ -323,36 +483,64 @@ def _serialize_assistant_entry(assistant_msg: dict | None) -> dict:
"autoplay": bool(meta.get("autoplay") or (audio or {}).get("autoplay")),
"audio_duration_seconds": (audio or {}).get("duration_seconds") if isinstance(audio, dict) else None,
"audio_relpath": audio_relpath.replace("\\", "/") if audio_relpath else None,
+ "pending_action": meta.get("pending_action"),
+ "agent_mode": meta.get("agent_mode"),
}
+def _serialize_message_for_manifest(msg: dict) -> dict | None:
+ """按 role 分发单条 chat_history 消息到 user/assistant manifest 结构。
+
+ 输入:
+ msg: 消息字典。
+
+ 输出:
+ manifest 格式的消息条目,或 None(无法识别)。
+ """
+ role = msg.get("role")
+ if role == "user":
+ return {"role": "user", "user": _serialize_user_msg_for_manifest(msg)}
+ if role == "assistant":
+ return {"role": "assistant", "assistant": _serialize_assistant_entry(msg)}
+ return None
+
+
def sync_manifest_from_chat_history(session_id: str, chat_history: list, latest_user_image_description: str) -> None:
+ """将当前 chat_history 全量写入 ui_messages.json(version 3 messages 格式)。
+
+ 输入:
+ session_id: 会话 ID。
+ chat_history: 当前聊天历史列表。
+ latest_user_image_description: 最新用户图片描述(保留参数,当前未使用)。
+
+ 输出: 无。
+
+ 系统定位:
+ 每轮对话结束后调用,持久化 UI 层消息结构。
+ """
if not session_id:
return
mp = manifest_path(session_id)
- turns_out: list[dict] = []
- ch = chat_history or []
- i = 0
- while i < len(ch):
- if ch[i].get("role") != "user":
- i += 1
- continue
- user_msg = ch[i]
- assistant_msg = ch[i + 1] if i + 1 < len(ch) and ch[i + 1].get("role") == "assistant" else None
-
- turns_out.append(
- {
- "user": _serialize_user_msg_for_manifest(user_msg),
- "assistant": _serialize_assistant_entry(assistant_msg),
- }
- )
- i += 2 if assistant_msg else 1
+ messages_out: list[dict] = []
+ for msg in chat_history or []:
+ item = _serialize_message_for_manifest(msg)
+ if item is not None:
+ messages_out.append(item)
os.makedirs(os.path.dirname(mp), exist_ok=True)
with open(mp, "w", encoding="utf-8") as f:
- json.dump({"version": 2, "turns": turns_out}, f, ensure_ascii=False, indent=2)
+ json.dump({"version": 3, "messages": messages_out}, f, ensure_ascii=False, indent=2)
+
def _assistant_manifest_entry_to_content(entry: dict) -> str | list:
+ """将 manifest assistant 条目还原为 UI content(文本与/或音频部件)。
+
+ 输入:
+ entry: manifest 中的 assistant 条目字典。
+
+ 输出:
+ content 结构(str 或 list),用于前端展示。
+ """
text = (entry.get("text") or "").strip()
output_type = entry.get("output_type") or "text"
autoplay = bool(entry.get("autoplay"))
@@ -386,7 +574,53 @@ def _assistant_manifest_entry_to_content(entry: dict) -> str | list:
return parts
+def _assistant_manifest_entry_to_message(asst_entry: dict) -> dict | None:
+ """将 manifest assistant 条目还原为完整 assistant 消息 dict(含 meta)。
+
+ 输入:
+ asst_entry: manifest 中的 assistant 条目字典。
+
+ 输出:
+ 消息字典,包含 role、content、meta;无有效内容时返回 None。
+ """
+ if not isinstance(asst_entry, dict):
+ asst_entry = {"text": str(asst_entry)}
+ asst_content = _assistant_manifest_entry_to_content(asst_entry)
+ if not str(asst_entry.get("text") or "").strip() and asst_content == "":
+ return None
+
+ meta = {
+ "output_type": asst_entry.get("output_type", "text"),
+ "autoplay": asst_entry.get("autoplay", False),
+ }
+ if asst_entry.get("pending_action"):
+ meta["pending_action"] = asst_entry.get("pending_action")
+ if asst_entry.get("agent_mode"):
+ meta["agent_mode"] = asst_entry.get("agent_mode")
+ audio_relpath = asst_entry.get("audio_relpath")
+ if audio_relpath:
+ root_norm = os.path.normpath(MULTI_INPUTS_ROOT)
+ audio_path = os.path.normpath(os.path.join(root_norm, audio_relpath.replace("/", os.sep)))
+ if os.path.isfile(audio_path):
+ meta["audio"] = {
+ "path": audio_path,
+ "autoplay": asst_entry.get("autoplay", False),
+ "duration_seconds": asst_entry.get("audio_duration_seconds"),
+ "format": "wav",
+ "sample_rate": 24000,
+ }
+ return {"role": "assistant", "content": asst_content, "meta": meta}
+
+
def _load_ui_messages_from_manifest(session_id: str) -> list | None:
+ """从 ui_messages.json 加载 UI 消息;文件缺失或解析失败返回 None。
+
+ 输入:
+ session_id: 会话 ID。
+
+ 输出:
+ 消息列表,或 None(表示无有效 manifest)。
+ """
mp = manifest_path(session_id)
if not os.path.isfile(mp):
return None
@@ -395,6 +629,19 @@ def _load_ui_messages_from_manifest(session_id: str) -> list | None:
data = json.load(f)
except (json.JSONDecodeError, OSError):
return None
+ if "messages" in data:
+ out: list[dict] = []
+ for item in data.get("messages") or []:
+ if not isinstance(item, dict):
+ continue
+ role = item.get("role")
+ if role == "user":
+ out.append({"role": "user", "content": _manifest_user_entry_to_content(item.get("user") or {})})
+ elif role == "assistant":
+ assistant_msg = _assistant_manifest_entry_to_message(item.get("assistant") or {})
+ if assistant_msg is not None:
+ out.append(assistant_msg)
+ return out
if "turns" not in data:
return None
turns = data.get("turns") or []
@@ -405,34 +652,23 @@ def _load_ui_messages_from_manifest(session_id: str) -> list | None:
for idx, t in enumerate(turns):
out.append({"role": "user", "content": _manifest_user_entry_to_content(t.get("user") or {})})
asst_entry = t.get("assistant") or {}
- if not isinstance(asst_entry, dict):
- asst_entry = {"text": str(asst_entry)}
- asst_content = _assistant_manifest_entry_to_content(asst_entry)
- if idx == n - 1 and not str(asst_entry.get("text") or "").strip() and asst_content == "":
+ assistant_msg = _assistant_manifest_entry_to_message(asst_entry)
+ if assistant_msg is None and idx == n - 1:
continue
-
- # 从扁平存储重建与 execute_agent 产物相同的 meta 字典
- meta = {
- "output_type": asst_entry.get("output_type", "text"),
- "autoplay": asst_entry.get("autoplay", False),
- }
- audio_relpath = asst_entry.get("audio_relpath")
- if audio_relpath:
- root_norm = os.path.normpath(MULTI_INPUTS_ROOT)
- audio_path = os.path.normpath(os.path.join(root_norm, audio_relpath.replace("/", os.sep)))
- if os.path.isfile(audio_path):
- meta["audio"] = {
- "path": audio_path,
- "autoplay": asst_entry.get("autoplay", False),
- "duration_seconds": asst_entry.get("audio_duration_seconds"),
- "format": "wav",
- "sample_rate": 24000,
- }
- out.append({"role": "assistant", "content": asst_content, "meta": meta})
+ if assistant_msg is not None:
+ out.append(assistant_msg)
return out
def load_ui_messages_for_session(session_id: str) -> list:
+ """加载会话 UI 消息:优先 manifest,回退 SQLite 历史。
+
+ 输入:
+ session_id: 会话 ID。
+
+ 输出:
+ 消息列表(总是有效列表,可能为空)。
+ """
if not session_id:
return []
m = _load_ui_messages_from_manifest(session_id)
@@ -442,11 +678,35 @@ def load_ui_messages_for_session(session_id: str) -> list:
def rewrite_user_files_for_last_turn(chat_history: list, session_id: str) -> None:
+ """若末条为 user 消息,将其附件复制到会话目录。
+
+ 输入:
+ chat_history: 聊天历史列表。
+ session_id: 会话 ID。
+
+ 输出: 无。
+
+ 系统定位:
+ API 层添加用户消息后立即调用,落盘附件。
+ """
if session_id and chat_history and chat_history[-1].get("role") == "user":
rewrite_user_message_files_to_session_dir(chat_history[-1], session_id)
def add_message(chat_history, query):
+ """将上传 query 转为 user 消息追加到 chat_history;无有效内容时返回 None。
+
+ 输入:
+ chat_history: 现有聊天历史。
+ query: {"text": str, "files": list[str], "file_names": list[str]}
+
+ 输出:
+ (new_chat_history, cleared_query) 元组;
+ cleared_query 在旧 API 兼容中保留,新调用可忽略。
+
+ 系统定位:
+ API 层构造用户消息的核心函数。
+ """
chat_history = chat_history or []
user_content = _build_user_chat_content(query)
if user_content is None:
@@ -456,6 +716,17 @@ def add_message(chat_history, query):
def clear_ui_and_manifest(session_id: str):
+ """清空会话 manifest 并返回空 chat_history 与重置后的 query 状态。
+
+ 输入:
+ session_id: 会话 ID。
+
+ 输出:
+ (empty_chat_history, reset_query) 元组。
+
+ 系统定位:
+ 清空会话时重置 UI 状态。
+ """
if session_id:
mp = manifest_path(session_id)
os.makedirs(os.path.dirname(mp), exist_ok=True)