diff --git a/.env b/.env deleted file mode 100644 index 3fbfb93..0000000 --- a/.env +++ /dev/null @@ -1,38 +0,0 @@ -LANGSMITH_TRACING=True -LANGSMITH_ENDPOINT=https://api.smith.langchain.com -LANGSMITH_API_KEY=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 -LANGSMITH_PROJECT="agent_learning" -DATABASE_URI=sqlite:///./langgraph.db -REDIS_URI=redis://localhost:6379/0 -#spark: -LLM_API_KEY='semi-ai.tech' -# LLM_BASE_URL="http://192.168.137.96:8000/v1" -LLM_BASE_URL="http://semi-ai.tech:50400/v1" -LLM_MODEL = "Qwen/Qwen3.6-35B-A3B-FP8" -# ASR_TTS_BASE_URL = "http://192.168.137.96:8100" -# ASR_BASE_URL = "http://192.168.137.96:8901/v1" -# TTS_BASE_URL = "http://192.168.137.96:8902" -# RAG_BASE_URL = "http://192.168.137.96:8903" - -#6000pro: -# LLM_API_KEY='semi-ai.tech' -# LLM_BASE_URL="http://semi-ai.tech:40056/v1" -# LLM_MODEL = "Intel/Qwen3.6-35B-A3B-int4-mixed-AutoRound" - -#A100: -# LLM_API_KEY = 'sk-kGhWAQTqLk9ECsmIDb758a456bD34e92B9771dF2E36432Fb' -# LLM_BASE_URL = "https://app.semi-ai.tech:60004/v1" -# LLM_MODEL = "Qwen3.5-122B-A10B" -# LLM_TIMEOUT = 300 -# LLM_REQUESTS_PER_SECOND = 10.0 -# LLM_MAX_BUCKET_SIZE = 10 -# LLM_CHECK_EVERY_N_SECONDS = 0.1 - -#context: -LLM_CONTEXT_WINDOW=32768 -MINI_COMPRESS_RATE=0.4 -HARD_COMPRESS_RATE=0.6 -CURRENT_TURNS=4 -IMG_SIZE=768 -RAG_CHUNK_SIZE=500 -RAG_CHUNK_OVERLAP=50 \ No newline at end of file diff --git a/docs/feishu_development_guide.md b/docs/feishu_development_guide.md new file mode 100644 index 0000000..d7185a4 --- /dev/null +++ b/docs/feishu_development_guide.md @@ -0,0 +1,373 @@ +# Minor Agent 系统架构、部署与开发指南(飞书版) + +## 1. 文档目标 + +本文面向 Minor Agent 的研发、部署和维护人员,说明当前系统的整体架构、核心运行链路、Plan TodoList 机制、工具扩展方式、Web 前端交互、部署步骤与常见排障方法。 + +## 2. 系统概览 + +Minor Agent 是一个基于 LangGraph 的多模态本地 Agent 系统,提供 Web 对话界面,支持文本、图片、音频和文件输入,并通过 LangChain Tool 接入外部能力。 + +### 2.1 核心能力 + +- 多模态对话:文本、图片、音频、普通文件。 +- Agent 模式:普通 agent、plan、team 预留。 +- ReAct 工具调用:浏览器控制、屏幕截图、目录查看、人工交互、本机软件控制、Todo 状态更新等。 +- 历史持久化:SQLite 保存对话历史与压缩元信息。 +- 上下文压缩:按 token 阈值触发轻量/全量摘要。 +- Plan TodoList:Plan 模式下生成 `.todo`,前端浮窗展示,执行过程中动态更新。 + +## 3. 目录结构 + +```text +Agent/ +├─ requirements.txt +├─ pyproject.toml +├─ README.md +├─ docs/ +│ └─ feishu_development_guide.md +└─ src/ + ├─ web/ + │ ├─ server.py # FastAPI 后端 API 与静态资源服务 + │ ├─ app.js # Web 前端主逻辑 + │ ├─ app.css # Web 前端样式 + │ └─ index.html # Web 页面入口 + └─ agent/ + ├─ agents/ + │ └─ main_agent.py # 主 Agent 装配入口 + ├─ core/ + │ ├─ runtime.py # Agent 执行、Plan 确认、人工交互续跑 + │ ├─ graph_builder.py # LangGraph ReAct 图构建 + │ ├─ nodes.py # LLM 节点与工具产物处理 + │ ├─ routing.py # 工具调用路由/审批识别 + │ ├─ plan.py # Plan 模式路径选择 pending action + │ ├─ todo_manager.py # `.todo` 读写、解析、状态更新、上下文注入 + │ └─ state.py # LangGraph 状态定义 + ├─ memory/ + │ └─ system_prompt.py # 系统提示词、Plan 提示词 + ├─ tools/ + │ ├─ tool.py # 通用工具集合 + │ ├─ tool_software_control.py # Windows 本机软件控制工具 + │ └─ __init__.py # 工具注册 + ├─ utils/ + │ └─ agent_utils.py # 历史、压缩、多模态文件处理 + ├─ history/ # 聊天历史、会话附件、数据库 + └─ workspace/ + └─ .todo # Plan TodoList 文件 +``` + +## 4. 后端架构 + +### 4.1 Web API 层 + +入口文件:`src/web/server.py` + +主要职责: + +- 提供静态页面:`/`、`/app.js`、`/app.css`、`/image/{asset}`。 +- 会话管理:新建、切换、清空、删除。 +- 多模态上传:将上传文件临时落盘,再同步到会话目录。 +- Agent 执行:调用 `chatbot_agent` 间接进入 `AgentRuntime`。 +- 人工交互回调:处理工具审批、Plan 路径确认等 pending action。 +- Todo API:`GET /api/todo` 返回当前 `.todo` 内容和结构化任务列表。 + +### 4.2 Runtime 层 + +入口文件:`src/agent/core/runtime.py` + +核心接口: + +- `execute_agent(...)`:处理用户新消息,执行普通 agent / plan / team 流程。 +- `continue_after_human_action(...)`:处理人工确认、Plan 路径选择、工具审批后的续跑。 + +运行流程: + +1. Web UI 提交用户消息。 +2. Runtime 调用 `summarizer(...)` 组装模型上下文。 +3. 普通模式直接执行 LangGraph。 +4. Plan 模式先进入研究阶段,只输出可选路径,不执行任务。 +5. 用户确认路径后,Runtime 调用模型生成 TodoList,并覆盖写入 `agent/workspace/.todo`。 +6. 后续 Plan 上下文会在系统提示词后注入 `.todo` 内容,确保压缩不影响 TodoList。 + +### 4.3 LangGraph 层 + +入口文件:`src/agent/core/graph_builder.py` + +图结构: + +```text +START -> agent -> (tools | END) +tools -> process_tool_artifact -> agent +``` + +说明: + +- `agent` 节点调用已绑定工具的 LLM。 +- `tools` 节点执行 LangChain Tool。 +- `process_tool_artifact` 处理截图等 artifact,将图片转为模型可见的多模态消息。 + +## 5. Plan TodoList 机制 + +### 5.1 设计目标 + +Plan 模式应实现: + +- 根据用户选择的路径生成动态 TodoList。 +- TodoList 写入 `src/agent/workspace/.todo`。 +- 每次 Plan 模式制定 TodoList 时覆盖旧 `.todo`。 +- 前端以浮窗展示,可收起为悬浮球。 +- Plan 模式上下文开头携带 `.todo`,紧跟系统提示词。 +- 上下文压缩不应压缩或丢失 `.todo`。 +- Agent 执行过程中可更新任务状态。 + +### 5.2 当前实现 + +核心文件:`src/agent/core/todo_manager.py` + +能力: + +- `read_todo_text()`:读取 `.todo`。 +- `write_todo_text()`:覆盖写入 `.todo`。 +- `overwrite_todo_from_plan()`:Plan 路径确认后覆盖旧 TodoList。 +- `todo_context_block()`:生成上下文注入块。 +- `system_prompt_with_todo()`:将 `.todo` 紧贴系统提示词之后注入。 +- `parse_todo_items()`:解析 Markdown checkbox 和状态前缀。 +- `update_todo_status()`:按任务描述更新状态,不匹配时追加新任务。 +- `todo_payload()`:提供给前端的结构化数据。 + +### 5.3 前端展示 + +核心文件:`src/web/app.js`、`src/web/app.css` + +- 前端启动时调用 `/api/todo`。 +- 每 2.5 秒自动刷新一次。 +- TodoList 显示为右侧浮窗。 +- 点击收起后变成悬浮球。 +- 展示任务状态、完成数量、任务文本。 + +### 5.4 Agent 更新 Todo 状态 + +工具:`todo_status_update` + +注册位置:`src/agent/tools/__init__.py` + +适用场景: + +- 任务开始:更新为 `in_progress`。 +- 任务完成:更新为 `completed`。 +- 任务取消:更新为 `cancelled`。 +- 计划变化:追加或修改任务状态。 + +## 6. 工具系统 + +### 6.1 工具注册 + +入口文件:`src/agent/tools/__init__.py` + +当前主 Agent 工具包含: + +- `web_search_class`:网络搜索。 +- `date_query_class`:当前日期时间。 +- `screen_capture`:屏幕截图。 +- `browser_control`:浏览器打开/关闭/刷新。 +- `human_interaction`:人工补充、选择、审批。 +- `software_control`:Windows 本机软件打开/关闭/强制关闭。 +- `todo_status_update`:更新 `.todo` 中任务状态。 +- `directory_listing`:查看目录。 + +### 6.2 本机软件控制工具 + +文件:`src/agent/tools/tool_software_control.py` + +能力: + +- 扫描 Windows 开始菜单、桌面快捷方式和 App Paths。 +- 建立软件索引:`src/agent/history/data_base/software_index.json`。 +- 根据软件名做模糊匹配。 +- 支持打开、正常关闭、强制关闭。 + +安全策略: + +- 仅当用户明确要求操作本机软件时调用。 +- `close` 只发送正常关闭请求。 +- `force_close` 或 `force=True` 才允许强制结束进程。 +- 非 Windows 平台返回明确不支持提示。 + +## 7. 历史与上下文压缩 + +核心文件:`src/agent/utils/agent_utils.py` + +### 7.1 历史存储 + +- SQLite 数据库:`src/agent/history/data_base/chat_history.db` +- 表: + - `message_store`:聊天消息。 + - `session_meta`:压缩游标、摘要、上次 token 用量。 + +### 7.2 压缩策略 + +- 当 token 超过轻量阈值,触发轻量压缩,保留最近若干轮对话。 +- 当 token 超过硬阈值,触发全量压缩。 +- `.todo` 不进入历史压缩摘要,而是每次 Plan 模式构造系统提示词时重新从文件读取并注入,因此不会被压缩丢失。 + +## 8. 前端架构 + +文件:`src/web/index.html`、`src/web/app.js`、`src/web/app.css` + +主要模块: + +- 会话侧栏:新建、切换、删除会话。 +- 消息区:Markdown 渲染、代码块复制、图片/音频展示。 +- 输入区:文本、文件、麦克风、自动语音输入。 +- Pending Action 卡片:工具审批、人工交互、Plan 路径选择。 +- Todo 浮窗:Plan TodoList 展示和自动刷新。 + +## 9. 部署指南 + +### 9.1 环境准备 + +推荐 Python 3.10+。 + +```bash +cd Agent +python -m venv myenv +``` + +Windows PowerShell: + +```powershell +.\myenv\Scripts\Activate.ps1 +``` + +macOS / Linux: + +```bash +source myenv/bin/activate +``` + +安装依赖: + +```bash +pip install --upgrade pip +pip install -r requirements.txt +pip install -e . +``` + +### 9.2 环境变量 + +在 `Agent/.env` 中配置: + +```text +LLM_BASE_URL=... +LLM_API_KEY=... +LLM_MODEL=... +TTS_BASE_URL=... +ASR_BASE_URL=... +RAG_BASE_URL=... +``` + +其中 TTS、ASR、RAG 可按实际功能启用情况配置。 + +### 9.3 启动 Web 服务 + +Windows PowerShell: + +```powershell +cd Agent +$env:PYTHONPATH="src;$env:PYTHONPATH" +uvicorn web.server:app --host 127.0.0.1 --port 8765 +``` + +macOS / Linux: + +```bash +cd Agent +export PYTHONPATH=src:$PYTHONPATH +uvicorn web.server:app --host 127.0.0.1 --port 8765 +``` + +访问: + +```text +http://127.0.0.1:8765 +``` + +### 9.4 公网访问(可选) + +```bash +cloudflared tunnel --url http://localhost:8765 +``` + +## 10. 开发指南 + +### 10.1 新增工具 + +1. 在 `src/agent/tools/tool.py` 或独立工具文件中实现 `BaseTool`。 +2. 定义 `args_schema`、`name`、`description`、`_run()`、可选 `_arun()`。 +3. 在 `src/agent/tools/__init__.py` 的 `_all_tools` 中注册。 +4. 重启服务,让 `llm.bind_tools(...)` 重新绑定工具。 + +### 10.2 修改 Plan 流程 + +相关文件: + +- `src/agent/memory/system_prompt.py` +- `src/agent/core/plan.py` +- `src/agent/core/runtime.py` +- `src/agent/core/todo_manager.py` + +注意事项: + +- 研究阶段不能执行实际任务或调用工具。 +- 用户确认路径后才能生成 TodoList。 +- 生成 TodoList 后必须覆盖 `.todo`。 +- 执行阶段应根据任务进度调用 `todo_status_update`。 + +### 10.3 修改前端 Todo 浮窗 + +相关文件: + +- `src/web/app.js`:渲染、刷新、收起逻辑。 +- `src/web/app.css`:浮窗视觉样式。 +- `src/web/server.py`:`/api/todo` 数据接口。 + +## 11. 常见问题排查 + +### 11.1 前端看不到 Todo 浮窗 + +检查: + +1. 后端 `/api/todo` 是否返回内容。 +2. `src/agent/workspace/.todo` 是否存在且非空。 +3. 浏览器控制台是否有 JS 错误。 +4. 是否被收起为右侧悬浮球。 + +### 11.2 Plan 模式没有写入 `.todo` + +检查: + +1. 是否完成了路径选择确认。 +2. `continue_after_human_action` 中 pending type 是否为 `plan_choice`。 +3. 模型是否返回了 TodoList 文本。 +4. 进程是否有写入 `src/agent/workspace/.todo` 的权限。 + +### 11.3 软件控制工具找不到软件 + +处理: + +1. 使用更具体的软件名称。 +2. 调用时设置 `rescan=True` 重建索引。 +3. 确认软件有开始菜单或桌面快捷方式,或注册了 App Paths。 +4. 检查索引文件:`src/agent/history/data_base/software_index.json`。 + +### 11.4 上下文过长 + +系统会基于 token 阈值自动压缩历史。如果超过硬限制,会提示新建会话。Plan TodoList 不依赖历史摘要保存,而是从 `.todo` 文件注入,因此不会因压缩丢失。 + +## 12. 维护建议 + +- `.todo` 属于运行态工作区文件,不建议作为长期知识库。 +- 对高风险工具应保持人工审批或明确调用条件。 +- 工具描述应写清楚“何时调用”和“何时严禁调用”,降低误调用概率。 +- 修改工具注册后必须重启服务。 +- 修改前端静态文件后刷新浏览器缓存。 diff --git a/pyproject.toml b/pyproject.toml index df5644b..0d68180 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,6 @@ license = { text = "MIT" } requires-python = ">=3.10" dependencies = [ "langgraph>=1.0.0", - "python-dotenv>=1.0.1", "requests>=2.31.0", "langchain>=0.1.0", "langchain-core>=0.1.0", diff --git a/requirements.txt b/requirements.txt index d6f1aad..0026379 100644 --- a/requirements.txt +++ b/requirements.txt @@ -35,7 +35,6 @@ python-docx>=1.0.0 pdfplumber>=0.10.0 # Utilities -python-dotenv>=1.0.1 loguru>=0.7.0 pydantic>=2.0.0 diff --git a/src/__pycache__/__init__.cpython-312.pyc b/src/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..72175c5 Binary files /dev/null and b/src/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/__pycache__/__init__.cpython-313.pyc b/src/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..8743c99 Binary files /dev/null and b/src/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agent/__pycache__/__init__.cpython-313.pyc b/src/agent/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..3d30ff6 Binary files /dev/null and b/src/agent/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agent/__pycache__/chatbot_agent.cpython-312.pyc b/src/agent/__pycache__/chatbot_agent.cpython-312.pyc index 5669ab9..2a92f8e 100644 Binary files a/src/agent/__pycache__/chatbot_agent.cpython-312.pyc and b/src/agent/__pycache__/chatbot_agent.cpython-312.pyc differ diff --git a/src/agent/__pycache__/chatbot_agent.cpython-313.pyc b/src/agent/__pycache__/chatbot_agent.cpython-313.pyc new file mode 100644 index 0000000..a7bc611 Binary files /dev/null and b/src/agent/__pycache__/chatbot_agent.cpython-313.pyc differ diff --git a/src/agent/__pycache__/llm.cpython-312.pyc b/src/agent/__pycache__/llm.cpython-312.pyc index 16446df..4d2f8a0 100644 Binary files a/src/agent/__pycache__/llm.cpython-312.pyc and b/src/agent/__pycache__/llm.cpython-312.pyc differ diff --git a/src/agent/__pycache__/llm.cpython-313.pyc b/src/agent/__pycache__/llm.cpython-313.pyc new file mode 100644 index 0000000..63cd085 Binary files /dev/null and b/src/agent/__pycache__/llm.cpython-313.pyc differ diff --git a/src/agent/agents/__init__.py b/src/agent/agents/__init__.py new file mode 100644 index 0000000..0f3c597 --- /dev/null +++ b/src/agent/agents/__init__.py @@ -0,0 +1,31 @@ +"""Agent 实例注册包。 + +系统定位: + 对外导出各 Agent 的 ``AgentRuntime`` 单例及多 Agent 运行时。 + +Agent 列表: + - main_agent_runtime: 主 Agent 运行时(单 Agent 模式)。 + - build_multi_agent_runtime: 构建一主多子 Agent 运行时。 + - get_agent_graph_snapshot: 获取多 Agent 图状态快照。 + +可扩展性: + 子 Agent 实现后在此追加导出,如:: + from agent.agents.research_agent import research_agent_runtime + __all__ = ["main_agent_runtime", "research_agent_runtime"] +""" +from agent.agents.main_agent import main_agent_runtime, reload_main_agent_runtime +from agent.agents.multi_agent_runtime import ( + MultiAgentRuntime, + SubAgentConfig, + build_multi_agent_runtime, + get_agent_graph_snapshot, +) + +__all__ = [ + "main_agent_runtime", + "reload_main_agent_runtime", + "MultiAgentRuntime", + "SubAgentConfig", + "build_multi_agent_runtime", + "get_agent_graph_snapshot", +] diff --git a/src/agent/agents/__pycache__/__init__.cpython-312.pyc b/src/agent/agents/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..62a1d83 Binary files /dev/null and b/src/agent/agents/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/agent/agents/__pycache__/__init__.cpython-313.pyc b/src/agent/agents/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..57814d3 Binary files /dev/null and b/src/agent/agents/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agent/agents/__pycache__/browser_agent.cpython-312.pyc b/src/agent/agents/__pycache__/browser_agent.cpython-312.pyc new file mode 100644 index 0000000..3591b5e Binary files /dev/null and b/src/agent/agents/__pycache__/browser_agent.cpython-312.pyc differ diff --git a/src/agent/agents/__pycache__/browser_agent.cpython-313.pyc b/src/agent/agents/__pycache__/browser_agent.cpython-313.pyc new file mode 100644 index 0000000..2c3181a Binary files /dev/null and b/src/agent/agents/__pycache__/browser_agent.cpython-313.pyc differ diff --git a/src/agent/agents/__pycache__/code_agent.cpython-312.pyc b/src/agent/agents/__pycache__/code_agent.cpython-312.pyc new file mode 100644 index 0000000..7518ff6 Binary files /dev/null and b/src/agent/agents/__pycache__/code_agent.cpython-312.pyc differ diff --git a/src/agent/agents/__pycache__/code_agent.cpython-313.pyc b/src/agent/agents/__pycache__/code_agent.cpython-313.pyc new file mode 100644 index 0000000..304a822 Binary files /dev/null and b/src/agent/agents/__pycache__/code_agent.cpython-313.pyc differ diff --git a/src/agent/agents/__pycache__/main_agent.cpython-312.pyc b/src/agent/agents/__pycache__/main_agent.cpython-312.pyc new file mode 100644 index 0000000..bb84c50 Binary files /dev/null and b/src/agent/agents/__pycache__/main_agent.cpython-312.pyc differ diff --git a/src/agent/agents/__pycache__/main_agent.cpython-313.pyc b/src/agent/agents/__pycache__/main_agent.cpython-313.pyc new file mode 100644 index 0000000..c784715 Binary files /dev/null and b/src/agent/agents/__pycache__/main_agent.cpython-313.pyc differ diff --git a/src/agent/agents/__pycache__/multi_agent_runtime.cpython-312.pyc b/src/agent/agents/__pycache__/multi_agent_runtime.cpython-312.pyc new file mode 100644 index 0000000..6ef8c03 Binary files /dev/null and b/src/agent/agents/__pycache__/multi_agent_runtime.cpython-312.pyc differ diff --git a/src/agent/agents/__pycache__/research_agent.cpython-312.pyc b/src/agent/agents/__pycache__/research_agent.cpython-312.pyc new file mode 100644 index 0000000..63ddb09 Binary files /dev/null and b/src/agent/agents/__pycache__/research_agent.cpython-312.pyc differ diff --git a/src/agent/agents/__pycache__/research_agent.cpython-313.pyc b/src/agent/agents/__pycache__/research_agent.cpython-313.pyc new file mode 100644 index 0000000..de0c8c0 Binary files /dev/null and b/src/agent/agents/__pycache__/research_agent.cpython-313.pyc differ diff --git a/src/agent/agents/code_agent.py b/src/agent/agents/code_agent.py new file mode 100644 index 0000000..2c65d72 --- /dev/null +++ b/src/agent/agents/code_agent.py @@ -0,0 +1,10 @@ +"""代码子 Agent(预留)。 + +系统定位: + 计划用于代码生成、静态分析、仓库操作等,可绑定代码执行沙箱工具 + 与目录读写类工具。 + +可扩展性: + 可实现独立 AgentRuntime,或在 team 模式下由 main Agent 委派任务; + 建议对执行类工具保持 confirm 策略。 +""" diff --git a/src/agent/agents/doc_agent.py b/src/agent/agents/doc_agent.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agent/agents/gui_agent.py b/src/agent/agents/gui_agent.py new file mode 100644 index 0000000..72f6ace --- /dev/null +++ b/src/agent/agents/gui_agent.py @@ -0,0 +1,10 @@ +"""GUI子 Agent(预留)。 + +系统定位: + 计划用于GUI自动化、页面理解与操作编排,工具以 gui_control + 及 screen_capture 为主。 + +可扩展性: + 实现时可单独配置 tool_policy 白名单、更严格的 confirm 策略, + 并绑定专用 system_prompt 与较短上下文窗口。 +""" diff --git a/src/agent/agents/main_agent.py b/src/agent/agents/main_agent.py new file mode 100644 index 0000000..894599d --- /dev/null +++ b/src/agent/agents/main_agent.py @@ -0,0 +1,74 @@ +"""主 Agent 装配入口:工具、模型、图与运行时单例。 + +系统定位: + 项目默认 Agent 实例 ``main_agent_runtime``,由 Web 层导入并调用 + ``core.runtime.execute_agent`` / ``continue_after_human_action``。 + +装配内容: + - get_tools(): 主 Agent 可用工具集 + - llm.bind_tools: 工具调用能力 + - build_agent_graph: 标准 ReAct 图 + - MAIN_SYSTEM_PROMPT: 系统提示词 + +可扩展性: + - 可按环境变量切换工具子集或模型。 + - 子 Agent(research/browser/code)可仿照本文件独立装配 AgentRuntime。 +""" +from __future__ import annotations + +from agent.core.graph_builder import build_agent_graph +from agent.core.llm import llm +from agent.core.runtime import AgentRuntime +from agent.memory.system_prompt import MAIN_SYSTEM_PROMPT +from agent.tools import get_tools + + +def _build_main_agent_runtime(): + """根据当前配置重新构建主 Agent 运行时。""" + from agent.core.llm import llm as _llm + _system_prompt = MAIN_SYSTEM_PROMPT + _max_iterations = 200 + _tools = get_tools() + try: + from agent.core.config_manager import load_agent_configs + configs = load_agent_configs() + for cfg in configs: + if cfg.name == "main" and cfg.enabled: + if cfg.system_prompt: + _system_prompt = cfg.system_prompt + _max_iterations = cfg.max_iterations + break + except Exception: + pass + + model_with_tools = _llm.bind_tools(_tools, tool_choice="auto") + return AgentRuntime( + name="main", + graph=build_agent_graph(_tools, model_with_tools), + llm=_llm, + tools=_tools, + tools_by_name={tool.name: tool for tool in _tools}, + system_prompt=_system_prompt, + max_iterations=_max_iterations, + ) + + +main_agent_runtime = _build_main_agent_runtime() + + +def reload_main_agent_runtime() -> dict: + """重新加载配置并重建主 Agent 运行时,返回当前状态供调试。""" + global main_agent_runtime + import agent.chatbot_agent as ca + main_agent_runtime = _build_main_agent_runtime() + # 更新 chatbot_agent 中缓存的引用 + ca.main_agent_runtime = main_agent_runtime + ca.agent_graph = main_agent_runtime.graph + ca.tools = main_agent_runtime.tools + ca.TOOLS_BY_NAME = main_agent_runtime.tools_by_name + ca.llm = main_agent_runtime.llm + return { + "name": main_agent_runtime.name, + "tools_count": len(main_agent_runtime.tools), + "max_iterations": main_agent_runtime.max_iterations, + } diff --git a/src/agent/agents/multi_agent_runtime.py b/src/agent/agents/multi_agent_runtime.py new file mode 100644 index 0000000..ef9b7e5 --- /dev/null +++ b/src/agent/agents/multi_agent_runtime.py @@ -0,0 +1,252 @@ +"""多 Agent 运行时:一主多子 Agent 体系的初始化与管理。 + +系统定位: + 提供 ``MultiAgentRuntime`` 将主 Agent 与多个子 Agent 组装为 + 完整的 SupervisorGraph 运行时,供 Web 层调用。 + +与单 Agent 的区别: + - 使用 MultiAgentState 替代 AgentState + - 使用 build_multi_agent_graph 替代 build_agent_graph + - 额外维护 graph_events / agent_status / tool_calls_history 供前端 graph 面板 + +可扩展性: + - 支持运行时动态添加/移除子 Agent + - 支持并行子 Agent 执行策略 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from agent.core.multi_agent_graph import build_multi_agent_graph +from agent.core.multi_agent_state import ( + AgentStatusInfo, + GraphEvent, + ToolCallEvent, + init_multi_agent_state, +) +from agent.tools.tool_agent_call import AgentCallTool + + +@dataclass +class SubAgentConfig: + """子 Agent 配置容器。 + + 字段: + name: 子 Agent 名称。 + graph: 编译后的 LangGraph 图。 + llm: 原始 LLM 实例。 + tools: 工具列表。 + tools_by_name: 工具名 -> 工具实例映射。 + system_prompt: 系统提示词。 + icon: 前端展示图标。 + """ + name: str + graph: Any + llm: Any + tools: list[Any] = field(default_factory=list) + tools_by_name: dict[str, Any] = field(default_factory=dict) + system_prompt: str = "" + icon: str = "" + + +@dataclass +class MultiAgentRuntime: + """多 Agent 运行时配置容器。 + + 字段: + name: 运行时标识(如 "multi_agent_v1")。 + graph: 编译后的 SupervisorGraph。 + llm: 主 Agent 的 LLM 实例。 + main_agent_name: 主 Agent 名称。 + agents: {name: SubAgentConfig} 所有 agent 配置(含主 agent 信息)。 + graph_events: 最近一次执行的图事件列表(浅拷贝)。 + agent_status: 最近一次执行的 agent 状态。 + tool_calls_history: 最近一次执行的工具调用历史。 + """ + name: str + graph: Any + llm: Any + main_agent_name: str = "main" + agents: dict[str, SubAgentConfig] = field(default_factory=dict) + graph_events: list[GraphEvent] = field(default_factory=list) + agent_status: dict[str, AgentStatusInfo] = field(default_factory=dict) + tool_calls_history: dict[str, list[ToolCallEvent]] = field(default_factory=dict) + + +def build_multi_agent_runtime( + main_runtime: Any, + sub_runtimes: list[dict[str, Any]] | None = None, +) -> MultiAgentRuntime: + """根据主 Agent 和子 Agent 运行时列表构建多 Agent 运行时。 + + 输入: + main_runtime: 主 Agent 的 AgentRuntime 实例(name/graph/llm/tools/tools_by_name/system_prompt)。 + sub_runtimes: 子 Agent 配置列表,每项为 dict,含: + - runtime: AgentRuntime 实例(必填) + - icon: 前端图标(可选,默认 🔧) + sub_runtimes 为 None 时构建纯主 Agent 的单 Agent 运行时。 + + 输出: + MultiAgentRuntime 实例,其 graph 为编译后的 SupervisorGraph。 + + 示例:: + + from agent.agents.main_agent import main_agent_runtime + runtime = build_multi_agent_runtime( + main_runtime=main_agent_runtime, + sub_runtimes=[ + {"runtime": code_agent_runtime, "icon": "💻"}, + {"runtime": research_agent_runtime, "icon": "🔍"}, + ], + ) + """ + sub_runtimes = sub_runtimes or [] + + # 组装主 agent 工具(含 agent_call) + main_tools = list(main_runtime.tools) + [AgentCallTool()] + main_tools_by_name = dict(main_runtime.tools_by_name) + main_tools_by_name["agent_call"] = AgentCallTool() + main_model_with_tools = main_runtime.llm.bind_tools(main_tools, tool_choice="auto") + + # 子 agent 配置列表 + sub_agent_configs: list[dict[str, Any]] = [] + agent_configs: dict[str, SubAgentConfig] = {} + + main_agent_name = main_runtime.name + + agent_configs[main_agent_name] = SubAgentConfig( + name=main_agent_name, + graph=main_runtime.graph, + llm=main_runtime.llm, + tools=main_tools, + tools_by_name=main_tools_by_name, + system_prompt=main_runtime.system_prompt, + icon="🧠", + ) + + for sr in sub_runtimes: + sr_runtime = sr["runtime"] + sr_icon = sr.get("icon", "🔧") + sa_config = SubAgentConfig( + name=sr_runtime.name, + graph=sr_runtime.graph, + llm=sr_runtime.llm, + tools=sr_runtime.tools, + tools_by_name=sr_runtime.tools_by_name, + system_prompt=sr_runtime.system_prompt, + icon=sr_icon, + ) + agent_configs[sr_runtime.name] = sa_config + sub_agent_configs.append({ + "name": sr_runtime.name, + "graph": sr_runtime.graph, + "tools_by_name": sr_runtime.tools_by_name, + }) + + # 构建 SupervisorGraph + graph = build_multi_agent_graph( + main_model_with_tools=main_model_with_tools, + main_tools=main_tools, + main_tools_by_name=main_tools_by_name, + sub_agents=sub_agent_configs, + main_agent_name=main_agent_name, + ) + + return MultiAgentRuntime( + name=f"multi_{main_agent_name}", + graph=graph, + llm=main_runtime.llm, + main_agent_name=main_agent_name, + agents=agent_configs, + ) + + +def get_agent_graph_snapshot(runtime: MultiAgentRuntime) -> dict: + """获取当前多 Agent 运行时状态快照,供前端 graph 面板渲染。 + + 输入: + runtime: MultiAgentRuntime 实例。 + + 输出: + { + "agents": [{"name": str, "role": "main"|"sub", "icon": str, "status": str, "error_message": str}, ...], + "edges": [{"source": str, "target": str, "highlighted": bool}, ...], + "events": [...], + "tool_calls": {agent_name: [ToolCallEvent, ...]}, + } + """ + agents_list = [] + for name, cfg in runtime.agents.items(): + status_info = runtime.agent_status.get(name) + status = status_info.status if status_info and isinstance(status_info, AgentStatusInfo) else "idle" + error_msg = status_info.error_message if status_info and isinstance(status_info, AgentStatusInfo) else "" + agents_list.append({ + "name": name, + "role": "main" if name == runtime.main_agent_name else "sub", + "icon": cfg.icon, + "status": status, + "error_message": error_msg, + }) + + # 构建边:主 agent 与每个子 agent 之间两条单向线 + edges = [] + main_name = runtime.main_agent_name + for name in runtime.agents: + if name == main_name: + continue + # 检查是否有消息传递事件 + highlighted_to_sub = any( + e.event_type == "message_pass" and e.source_agent == main_name and e.target_agent == name + for e in runtime.graph_events + ) + highlighted_to_main = any( + e.event_type == "message_pass" and e.source_agent == name and e.target_agent == main_name + for e in runtime.graph_events + ) + edges.append({ + "source": main_name, + "target": name, + "highlighted": highlighted_to_sub, + }) + edges.append({ + "source": name, + "target": main_name, + "highlighted": highlighted_to_main, + }) + + # 序列化事件 + events_list = [] + for e in runtime.graph_events: + events_list.append({ + "event_type": e.event_type, + "source_agent": e.source_agent, + "target_agent": e.target_agent, + "agent_name": e.agent_name, + "tool_name": e.tool_name, + "tool_args": e.tool_args, + "tool_result_preview": e.tool_result_preview, + "timestamp": e.timestamp, + "message_preview": e.message_preview, + }) + + # 序列化工具调用历史 + tool_calls_serialized = {} + for agent_name, calls in runtime.tool_calls_history.items(): + tool_calls_serialized[agent_name] = [ + { + "tool_name": c.tool_name, + "tool_args": c.tool_args, + "result_preview": c.result_preview, + "status": c.status, + "timestamp": c.timestamp, + } + for c in calls + ] + + return { + "agents": agents_list, + "edges": edges, + "events": events_list, + "tool_calls": tool_calls_serialized, + } diff --git a/src/agent/agents/research_agent.py b/src/agent/agents/research_agent.py new file mode 100644 index 0000000..76a7d8a --- /dev/null +++ b/src/agent/agents/research_agent.py @@ -0,0 +1,12 @@ +"""研究子 Agent(预留)。 + +系统定位: + 计划用于深度检索、文献综述等专用任务,与 main Agent 通过 team 模式 + 或子图委派协作。 + +可扩展性: + 实现步骤建议: + 1. 定义 RESEARCH_SYSTEM_PROMPT 与精简工具集(web_search、RAG 等) + 2. build_agent_graph(tools, model) 或自定义图(含检索节点) + 3. 导出 research_agent_runtime = AgentRuntime(...) +""" diff --git a/src/agent/chatbot_agent.py b/src/agent/chatbot_agent.py index 53bd378..5579c56 100644 --- a/src/agent/chatbot_agent.py +++ b/src/agent/chatbot_agent.py @@ -1,280 +1,51 @@ -from typing import Any, Literal, Sequence, Annotated, TypedDict -import json +from __future__ import annotations -from langgraph.graph import StateGraph, START, END -from langgraph.prebuilt import ToolNode -from langgraph.graph.message import add_messages -from langchain_core.messages import BaseMessage, HumanMessage, AIMessage, ToolMessage +from typing import Any, Literal -from agent.utils.image_utils import image_bytes_to_openai_image_url_part -from agent.llm import llm -from agent.tools import get_tools -from agent.utils.agent_utils import ( - assistant_text, - get_history, - summarizer, - get_session_meta, - update_session_meta, -) -from web.ui_session import ( - rewrite_user_message_files_to_session_dir, - session_dir, - sync_manifest_from_chat_history, -) -from agent.memory.system_prompt import MAIN_SYSTEM_PROMPT +from agent.agents.main_agent import main_agent_runtime +from agent.core.runtime import continue_after_human_action as continue_runtime_after_human_action +from agent.core.runtime import execute_agent as execute_runtime_agent -# ---------- 1. 工具与模型 ---------- -tools = get_tools() -model_with_tools = llm.bind_tools(tools) +AgentMode = Literal["agent", "team"] -# ---------- 2. 定义状态 ---------- -class AgentState(TypedDict): - messages: Annotated[Sequence[BaseMessage], add_messages] -# ---------- 3. 节点定义 ---------- -def call_model(state: AgentState) -> dict: - """调用 LLM 生成回复或工具调用""" - response = model_with_tools.invoke(state["messages"]) - - # ----- 打印 token 使用情况 ----- - usage = response.usage_metadata if hasattr(response, 'usage_metadata') else {} - if usage: - input_tokens = usage.get('input_tokens', '?') - output_tokens = usage.get('output_tokens', '?') - total_tokens = usage.get('total_tokens', '?') - print(f"[LLM] Token使用: input={input_tokens}, output={output_tokens}, total={total_tokens}") - - # ----- 打印工具调用信息 ----- - if hasattr(response, 'tool_calls') and response.tool_calls: - for tc in response.tool_calls: - # tc 是 ToolCall 对象,可直接访问 .name 和 .args - tool_name = tc.get('name') if isinstance(tc, dict) else tc.name - args = tc.get('args') if isinstance(tc, dict) else tc.args - if isinstance(args, str): - try: - args = json.loads(args) - except Exception: - pass - print(f"[LLM] 工具调用: {tool_name}, 参数: {args}") - - return {"messages": [response]} - -# 工具执行节点(LangGraph 内置,自动解包 content_and_artifact 到 ToolMessage.artifact) -tool_node = ToolNode(tools) - -def process_tool_artifact(state: AgentState) -> dict: - last_msg = state["messages"][-1] - - # 日志打印 - if isinstance(last_msg, ToolMessage): - content_preview = str(last_msg.content) - if len(content_preview) > 200: - content_preview = content_preview[:200] + '...' - print(f"[Tool] 工具返回 (id={last_msg.tool_call_id}): {content_preview}") - - # 只处理 ToolMessage 且有 artifact 的情况 - if not isinstance(last_msg, ToolMessage) or not last_msg.artifact: - return {} - - artifact = last_msg.artifact - - # ---------- 统一提取所有 image_bytes ---------- - img_bytes_list: list[bytes] = [] - - if isinstance(artifact, dict) and "image_bytes" in artifact: - # 单张图片:artifact = {"image_bytes": b"..."} - img_bytes_list.append(artifact["image_bytes"]) - - elif isinstance(artifact, list): - for item in artifact: - if isinstance(item, dict) and "image_bytes" in item: - img_bytes_list.append(item["image_bytes"]) - elif isinstance(item, bytes): - img_bytes_list.append(item) - - # 兼容 artifact 直接就是 bytes 的情况 - elif isinstance(artifact, bytes): - img_bytes_list.append(artifact) - - if not img_bytes_list: - return {} - - # ---------- 将图片转换为多模态 content ---------- - content_parts = [] - failed_count = 0 - - for img_bytes in img_bytes_list: - data_url = image_bytes_to_openai_image_url_part(img_bytes) - if data_url is None: - failed_count += 1 - continue - content_parts.append(data_url) - - # 所有图片都处理失败时的降级方案 - if not content_parts: - human_msg = HumanMessage( - content="工具返回了截图,但图片处理失败,请向用户说明。", - additional_kwargs={"synthetic": True} - ) - return {"messages": [human_msg]} - - # 追加引导文本(放在最后,也可以放在最前,取决于你的需求) - content_parts.append({ - "type": "text", - "text": "上面是工具返回的截图。" - }) - - # 如果有部分图片失败,可以在 text 中提示(可选) - if failed_count > 0: - content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)" - - human_msg = HumanMessage( - content=content_parts, - additional_kwargs={"synthetic": True} - ) - return {"messages": [human_msg]} - -# ---------- 4. 条件路由 ---------- -def should_continue(state: AgentState) -> Literal["tools", END]: - last_msg = state["messages"][-1] - if hasattr(last_msg, "tool_calls") and last_msg.tool_calls: - return "tools" - return END - -# ---------- 5. 构建 Graph ---------- -workflow = StateGraph(AgentState) - -workflow.add_node("agent", call_model) -workflow.add_node("tools", tool_node) -workflow.add_node("process_tool_artifact", process_tool_artifact) - -workflow.add_edge(START, "agent") -workflow.add_conditional_edges("agent", should_continue, { - "tools": "tools", - END: END, -}) -workflow.add_edge("tools", "process_tool_artifact") -workflow.add_edge("process_tool_artifact", "agent") - -agent_graph = workflow.compile() - -# ---------- 6. 辅助函数:保存消息(过滤合成消息) ---------- -def save_messages_to_history(session_id: str, user_msg: HumanMessage, assistant_msg: AIMessage) -> None: - if not session_id: - return - history = get_history(session_id) - # 只保存非合成消息 - if not user_msg.additional_kwargs.get("synthetic"): - history.add_message(user_msg) - history.add_message(assistant_msg) - -# ---------- 7. 核心执行函数 ---------- def execute_agent( chat_history: list[dict[str, Any]], session_id: str = "test", output_type: str = "text", + agent_mode: AgentMode = "agent", ) -> list[dict[str, Any]]: - chat_history = chat_history or [] - - if not chat_history or chat_history[-1].get("role") != "user": - return chat_history - - # 上传文件处理 - if session_id: - rewrite_user_message_files_to_session_dir(chat_history[-1], session_id) - - user_content = chat_history[-1]["content"] - - # 上下文感知构建 - messages, context_exceeded = summarizer( + """Web/API 层入口:使用 main_agent 运行时处理用户新一轮输入。""" + return execute_runtime_agent( + runtime=main_agent_runtime, + chat_history=chat_history, session_id=session_id, - agent_name="main", - system_prompt=MAIN_SYSTEM_PROMPT, - user_content=user_content, - llm=llm, + output_type=output_type, + agent_mode=agent_mode, ) - if context_exceeded: - chat_history.append({ - "role": "assistant", - "content": "上下文已满,请新建会话继续对话。", - }) - if session_id: - sync_manifest_from_chat_history(session_id, chat_history, "") - return chat_history - graph_config = { - "configurable": {"session_id": session_id}, - "metadata": { - "output_type": output_type, - "audio_output_dir": session_dir(session_id) if session_id else None, - }, - } +def continue_after_human_action( + chat_history: list[dict[str, Any]], + session_id: str, + approval_id: str, + decision: Literal["approve", "reject", "edit"], + instruction: str | None = None, + output_type: str = "text", +) -> list[dict[str, Any]]: + """Web/API 层入口:用户对待确认项决策后继续执行 main_agent。""" + return continue_runtime_after_human_action( + runtime=main_agent_runtime, + chat_history=chat_history, + session_id=session_id, + approval_id=approval_id, + decision=decision, + instruction=instruction, + output_type=output_type, + ) - try: - # 调用自定义 Graph - result = agent_graph.invoke({"messages": messages}, config=graph_config) - # 提取最后一条 AI 消息 - all_messages = result["messages"] - assistant_msg = all_messages[-1] if all_messages else AIMessage(content="") - text_content = assistant_text(assistant_msg.content) - - # token 统计更新 - usage = assistant_msg.usage_metadata if hasattr(assistant_msg, 'usage_metadata') else {} - total_tokens = usage.get('total_tokens', 0) if isinstance(usage, dict) else 0 - if total_tokens > 0: - meta = get_session_meta(session_id, "main") - update_session_meta( - session_id=session_id, - agent_name="main", - cursor_index=meta["cursor_index"], - compressed_content=meta["compressed_content"], - last_total_tokens=total_tokens, - ) - - # TTS 处理 - output_type_from_config = graph_config.get("metadata", {}).get("output_type", "text") - audio_data = llm.last_audio_kwargs or {} - if audio_data.get("output_type") != output_type_from_config: - audio_data = {} - - assistant_meta = { - "output_type": output_type_from_config, - "autoplay": False, - "audio": audio_data.get("audio"), - } - - if output_type_from_config == "audio" and assistant_meta.get("audio"): - assistant_content = [] - elif output_type_from_config == "both" and assistant_meta.get("audio"): - assistant_content = text_content - else: - assistant_content = text_content if text_content else "(空回复)" - - chat_history.append({ - "role": "assistant", - "content": assistant_content, - "meta": assistant_meta, - }) - - # 持久化历史消息(注意过滤掉合成消息) - save_messages_to_history( - session_id, - HumanMessage(content=user_content), - AIMessage(content=text_content), - ) - - llm.last_audio_kwargs = None - - except Exception as e: - import traceback - print("[ERROR] 调用模型时出错:") - traceback.print_exc() - err_text = "抱歉,我遇到了一些问题,请稍后再试。" - chat_history.append({"role": "assistant", "content": err_text}) - - if session_id: - sync_manifest_from_chat_history(session_id, chat_history, "") - - return chat_history \ No newline at end of file +agent_graph = main_agent_runtime.graph +tools = main_agent_runtime.tools +TOOLS_BY_NAME = main_agent_runtime.tools_by_name +llm = main_agent_runtime.llm diff --git a/src/agent/core/__init__.py b/src/agent/core/__init__.py new file mode 100644 index 0000000..c2a4525 --- /dev/null +++ b/src/agent/core/__init__.py @@ -0,0 +1,16 @@ +"""Agent 核心运行时包。 + +包含: + - state / graph_builder: LangGraph 状态与图拓扑 + - nodes / routing: 图节点与条件路由 + - runtime: 对外执行入口 execute_agent / continue_after_human_action + - llm: ChatQwen 多模态网关 + - approvals / human_interaction / tool_policy: HITL 与安全策略 + - plan: Plan 模式 UI 构建 + +系统定位: + Agent 业务逻辑中枢,``agents`` 包在此之上组装具体 Agent 实例。 + +可扩展性: + 子 Agent 可复用本包组件,仅替换 tools、system_prompt 与 graph 变体。 +""" diff --git a/src/agent/core/__pycache__/__init__.cpython-312.pyc b/src/agent/core/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..ef548de Binary files /dev/null and b/src/agent/core/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/__init__.cpython-313.pyc b/src/agent/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a535309 Binary files /dev/null and b/src/agent/core/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/approvals.cpython-312.pyc b/src/agent/core/__pycache__/approvals.cpython-312.pyc new file mode 100644 index 0000000..2cc2dab Binary files /dev/null and b/src/agent/core/__pycache__/approvals.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/approvals.cpython-313.pyc b/src/agent/core/__pycache__/approvals.cpython-313.pyc new file mode 100644 index 0000000..a159393 Binary files /dev/null and b/src/agent/core/__pycache__/approvals.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/config_manager.cpython-312.pyc b/src/agent/core/__pycache__/config_manager.cpython-312.pyc new file mode 100644 index 0000000..fe336dc Binary files /dev/null and b/src/agent/core/__pycache__/config_manager.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/graph_builder.cpython-312.pyc b/src/agent/core/__pycache__/graph_builder.cpython-312.pyc new file mode 100644 index 0000000..acff7f3 Binary files /dev/null and b/src/agent/core/__pycache__/graph_builder.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/graph_builder.cpython-313.pyc b/src/agent/core/__pycache__/graph_builder.cpython-313.pyc new file mode 100644 index 0000000..e0a80ee Binary files /dev/null and b/src/agent/core/__pycache__/graph_builder.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/human_interaction.cpython-312.pyc b/src/agent/core/__pycache__/human_interaction.cpython-312.pyc new file mode 100644 index 0000000..c9b0bd3 Binary files /dev/null and b/src/agent/core/__pycache__/human_interaction.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/human_interaction.cpython-313.pyc b/src/agent/core/__pycache__/human_interaction.cpython-313.pyc new file mode 100644 index 0000000..e2ca262 Binary files /dev/null and b/src/agent/core/__pycache__/human_interaction.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/llm.cpython-312.pyc b/src/agent/core/__pycache__/llm.cpython-312.pyc new file mode 100644 index 0000000..61e5fb7 Binary files /dev/null and b/src/agent/core/__pycache__/llm.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/llm.cpython-313.pyc b/src/agent/core/__pycache__/llm.cpython-313.pyc new file mode 100644 index 0000000..f882e02 Binary files /dev/null and b/src/agent/core/__pycache__/llm.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/multi_agent_graph.cpython-312.pyc b/src/agent/core/__pycache__/multi_agent_graph.cpython-312.pyc new file mode 100644 index 0000000..d31a527 Binary files /dev/null and b/src/agent/core/__pycache__/multi_agent_graph.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/multi_agent_state.cpython-312.pyc b/src/agent/core/__pycache__/multi_agent_state.cpython-312.pyc new file mode 100644 index 0000000..8d56cbc Binary files /dev/null and b/src/agent/core/__pycache__/multi_agent_state.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/multi_agent_state.cpython-313.pyc b/src/agent/core/__pycache__/multi_agent_state.cpython-313.pyc new file mode 100644 index 0000000..99b879c Binary files /dev/null and b/src/agent/core/__pycache__/multi_agent_state.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/nodes.cpython-312.pyc b/src/agent/core/__pycache__/nodes.cpython-312.pyc new file mode 100644 index 0000000..fd97c8f Binary files /dev/null and b/src/agent/core/__pycache__/nodes.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/nodes.cpython-313.pyc b/src/agent/core/__pycache__/nodes.cpython-313.pyc new file mode 100644 index 0000000..c9163d8 Binary files /dev/null and b/src/agent/core/__pycache__/nodes.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/plan.cpython-312.pyc b/src/agent/core/__pycache__/plan.cpython-312.pyc new file mode 100644 index 0000000..e060a31 Binary files /dev/null and b/src/agent/core/__pycache__/plan.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/plan.cpython-313.pyc b/src/agent/core/__pycache__/plan.cpython-313.pyc new file mode 100644 index 0000000..eb601fb Binary files /dev/null and b/src/agent/core/__pycache__/plan.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/routing.cpython-312.pyc b/src/agent/core/__pycache__/routing.cpython-312.pyc new file mode 100644 index 0000000..7c64f49 Binary files /dev/null and b/src/agent/core/__pycache__/routing.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/routing.cpython-313.pyc b/src/agent/core/__pycache__/routing.cpython-313.pyc new file mode 100644 index 0000000..751a04d Binary files /dev/null and b/src/agent/core/__pycache__/routing.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/runtime.cpython-312.pyc b/src/agent/core/__pycache__/runtime.cpython-312.pyc new file mode 100644 index 0000000..e99b13e Binary files /dev/null and b/src/agent/core/__pycache__/runtime.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/runtime.cpython-313.pyc b/src/agent/core/__pycache__/runtime.cpython-313.pyc new file mode 100644 index 0000000..0ba67fa Binary files /dev/null and b/src/agent/core/__pycache__/runtime.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/state.cpython-312.pyc b/src/agent/core/__pycache__/state.cpython-312.pyc new file mode 100644 index 0000000..13cb367 Binary files /dev/null and b/src/agent/core/__pycache__/state.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/state.cpython-313.pyc b/src/agent/core/__pycache__/state.cpython-313.pyc new file mode 100644 index 0000000..f387c17 Binary files /dev/null and b/src/agent/core/__pycache__/state.cpython-313.pyc differ diff --git a/src/agent/core/__pycache__/todo_manager.cpython-312.pyc b/src/agent/core/__pycache__/todo_manager.cpython-312.pyc new file mode 100644 index 0000000..5814011 Binary files /dev/null and b/src/agent/core/__pycache__/todo_manager.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/tool_policy.cpython-312.pyc b/src/agent/core/__pycache__/tool_policy.cpython-312.pyc new file mode 100644 index 0000000..67d1119 Binary files /dev/null and b/src/agent/core/__pycache__/tool_policy.cpython-312.pyc differ diff --git a/src/agent/core/__pycache__/tool_policy.cpython-313.pyc b/src/agent/core/__pycache__/tool_policy.cpython-313.pyc new file mode 100644 index 0000000..08578dc Binary files /dev/null and b/src/agent/core/__pycache__/tool_policy.cpython-313.pyc differ diff --git a/src/agent/core/agent_config.json b/src/agent/core/agent_config.json new file mode 100644 index 0000000..67a19c5 --- /dev/null +++ b/src/agent/core/agent_config.json @@ -0,0 +1,26 @@ +[ + { + "name": "main", + "system_prompt": "你是一个多模态助手,能够直接理解用户上传的图片、语音等内容。\n 【关键行为准则】\n - 你的默认行为是直接回答用户问题,不要调用任何工具。\n - 只有当某个信息是你**绝对无法从当前对话或上传内容中获取**,且该信息必须通过外部工具才能获得时,才允许调用工具。\n - 对于任何图片/音频输入,请始终**直接**利用你的视觉/听觉能力回答,**绝对不要**调用截图、OCR、日期查询等无关工具。\n - 如果你不确定是否需要工具,选择**不调用**,直接给出你能给出的最佳答案。\n - 违反上述规则是一种严重错误。", + "max_iterations": 200, + "llm": { + "model": "qwen3.6-flash", + "api_key": "sk-a44b226ec79a40fd8fcdbbaa74ba133e", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "timeout": 60.0, + "max_retries": 0 + }, + "llm_model_id": "a44b226e", + "tools": [ + "web_search_class", + "date_query_class", + "screen_capture", + "browser_control", + "human_interaction", + "software_control", + "directory_listing" + ], + "enabled": true, + "role": "main" + } +] diff --git a/src/agent/core/approvals.py b/src/agent/core/approvals.py new file mode 100644 index 0000000..e94d348 --- /dev/null +++ b/src/agent/core/approvals.py @@ -0,0 +1,166 @@ +"""待确认操作(HITL)内存注册与 UI 元数据构建。 + +系统定位: + 维护进程内 ``PENDING_TOOL_APPROVALS`` 字典,在图因需确认工具而暂停时 + 保存上下文;``runtime.continue_after_human_action`` 消费并清理。 + +注意: + 当前为内存存储,进程重启后待确认项丢失;生产环境可改为 Redis/DB。 + +可扩展性: + - 增加 TTL、session 级清理、持久化后端。 + - ``build_*_meta`` 可与前端组件协议版本化。 +""" +from __future__ import annotations + +import json +import uuid +from typing import Any, Sequence + +from langchain_core.messages import BaseMessage +from langchain_core.messages import messages_to_dict + +from agent.core.human_interaction import normalize_human_interaction_args +from agent.utils.tool_call_utils import json_safe + +PENDING_TOOL_APPROVALS: dict[str, dict[str, Any]] = {} + + +def build_approval_meta(session_id: str, tool_call: dict[str, Any], messages: Sequence[BaseMessage]) -> dict[str, Any]: + """为需确认的普通工具调用构建 UI 元数据并注册 pending。 + + 输入: + session_id: 会话 ID。 + tool_call: normalize 后的工具调用 dict。 + messages: 当前图内消息序列(序列化后存入 pending)。 + + 输出: + ``{"pending_action": {...}}``,含 id、type=tool_call、args、options 等。 + + 系统定位: + ``routing.first_pending_tool_meta`` 对 confirm 类工具调用。 + + 可扩展性: + 可按工具名定制 title、风险提示、参数展示模板。 + """ + approval_id = uuid.uuid4().hex + tool_name = tool_call.get("name") or "unknown_tool" + args = json_safe(tool_call.get("args") or {}) + display_call = {"name": tool_name, "args": args} + instruction = json.dumps(display_call, ensure_ascii=False, indent=2) + PENDING_TOOL_APPROVALS[approval_id] = { + "session_id": session_id, + "type": "tool_call", + "tool_call": {"id": tool_call.get("id"), "name": tool_name, "args": args}, + "messages": messages_to_dict(list(messages)), + } + return { + "pending_action": { + "id": approval_id, + "type": "tool_call", + "title": f"待确认执行工具:{tool_name}", + "tool_name": tool_name, + "args": display_call, + "instruction": instruction, + "options": ["approve", "reject"], + "resolved": False, + } + } + + +def build_human_interaction_meta(session_id: str, tool_call: dict[str, Any], messages: Sequence[BaseMessage]) -> dict[str, Any]: + """为 human_interaction 工具构建 UI 元数据并注册 pending。 + + 输入: + session_id: 会话 ID。 + tool_call: 含 name=args 的工具调用。 + messages: 当前消息序列。 + + 输出: + ``{"pending_action": {...}}``,type=human_interaction,含 interaction_type 等。 + + 系统定位: + 模型主动请求用户补充信息/选择/审批时使用。 + + 可扩展性: + 可扩展表单 schema、多步向导式交互。 + """ + approval_id = uuid.uuid4().hex + args = normalize_human_interaction_args(tool_call.get("args") or {}) + interaction_type = args.get("interaction_type") or "information" + default_titles = { + "information": "请补充信息", + "selection": "请选择一个选项", + "approval": "需要人工审批", + } + PENDING_TOOL_APPROVALS[approval_id] = { + "session_id": session_id, + "type": "human_interaction", + "tool_call": {"id": tool_call.get("id"), "name": "human_interaction", "args": args}, + "messages": messages_to_dict(list(messages)), + } + return { + "pending_action": { + "id": approval_id, + "type": "human_interaction", + "interaction_type": interaction_type, + "title": args.get("title") or default_titles.get(str(interaction_type), "需要人工处理"), + "prompt": args.get("prompt") or "请处理后继续。", + "tool_name": "human_interaction", + "args": { + "交互类型": interaction_type, + "说明": args.get("prompt") or "请处理后继续。", + "可选项": args.get("options") or [], + }, + "instruction": args.get("suggested_response") or "", + "options": args.get("options") or [], + "resolved": False, + } + } + + +def pending_assistant_message(meta: dict[str, Any], content: str = "需要你确认后继续执行。") -> dict[str, Any]: + """构造带 pending_action 的 assistant 聊天消息 dict。 + + 输入: + meta: 通常含 pending_action 的字典。 + content: 展示给用户的提示文案。 + + 输出: + Web UI 使用的 ``{"role": "assistant", "content": ..., "meta": ...}``。 + + 系统定位: + ``runtime.execute_agent`` 检测到待确认时追加到 chat_history。 + + 可扩展性: + 可统一 output_type、国际化 content 模板。 + """ + return { + "role": "assistant", + "content": content, + "meta": {"output_type": "text", "autoplay": False, **meta}, + } + + +def remove_pending_ui_message(chat_history: list[dict[str, Any]], approval_id: str) -> None: + """在 chat_history 中将指定 approval_id 的 pending_action 标记为已解决。 + + 输入: + chat_history: 可变聊天历史列表(原地修改)。 + approval_id: 待确认项 ID。 + + 输出: + 无(副作用:匹配消息的 meta.pending_action.resolved = True)。 + + 系统定位: + ``runtime.continue_after_human_action`` 用户操作后更新 UI 状态。 + + 可扩展性: + 可改为从 history 中移除而非仅标记 resolved。 + """ + for msg in reversed(chat_history): + pending = ((msg.get("meta") or {}).get("pending_action") or {}) if isinstance(msg, dict) else {} + if pending.get("id") == approval_id: + pending["resolved"] = True + msg.setdefault("meta", {})["pending_action"] = pending + return diff --git a/src/agent/core/config_manager.py b/src/agent/core/config_manager.py new file mode 100644 index 0000000..7815c66 --- /dev/null +++ b/src/agent/core/config_manager.py @@ -0,0 +1,339 @@ +"""Agent 配置管理:从 JSON 文件加载/保存 agent、tool、env、model 配置。 + +系统定位: + 提供统一接口读写 ``agent/core/`` 下的 JSON 配置文件, + 使前端手动配置的 JSON 可以替代 core 底下的相应 .py 硬编码。 + +配置文件: + agent_config.json - Agent 运行时配置(名称、模型引用、工具列表、system_prompt) + tool_config.json - 工具权限与参数配置 + env_config.json - 环境变量与 LLM 模型列表 + +可扩展性: + 可增加版本号、校验 schema、多环境配置切换。 +""" +from __future__ import annotations + +import json +import os +import uuid +from dataclasses import dataclass, field, asdict +from pathlib import Path +from typing import Any + +# ---------- 配置文件路径 ---------- +_CORE_DIR = Path(__file__).resolve().parent +AGENT_CONFIG_PATH = _CORE_DIR / "agent_config.json" +TOOL_CONFIG_PATH = _CORE_DIR / "tool_config.json" +ENV_CONFIG_PATH = _CORE_DIR / "env_config.json" + + +# ---------- 模型配置 ---------- +@dataclass +class ModelConfig: + id: str = "" # 唯一标识 + name: str = "" # 显示名称 + api_key: str = "" + base_url: str = "" + model: str = "" # API 模型名 + timeout: float = 60.0 + max_retries: int = 0 + + +# ---------- LLM 配置结构(保留兼容) ---------- +@dataclass +class LLMConfig: + model: str = "" + api_key: str = "" + base_url: str = "" + timeout: float = 60.0 + max_retries: int = 0 + + +@dataclass +class AgentConfig: + name: str = "" + system_prompt: str = "" + max_iterations: int = 200 + llm: LLMConfig = field(default_factory=LLMConfig) # 保留兼容 + llm_model_id: str = "" # 新:引用 models 列表中的模型 ID + tools: list[str] = field(default_factory=list) + enabled: bool = True + role: str = "sub" # "main" | "sub" + + +@dataclass +class ToolConfig: + name: str = "" + display_name: str = "" + description: str = "" + permission: str = "confirm" # "direct" | "confirm" + auto_execute_rules: list[dict] = field(default_factory=list) # [{"parameter":"action","operator":"equals","value":"open"}, ...] + enabled: bool = True + + +# ---------- 通用 JSON 读写 ---------- +def _read_json(path: Path) -> dict | list | None: + """读取 JSON 文件,不存在或格式错误返回 None。""" + if not path.is_file(): + return None + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return None + + +def _write_json(path: Path, data: Any) -> bool: + """写入 JSON 文件,成功返回 True。""" + try: + with open(path, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return True + except OSError: + return False + + +# ---------- Agent 配置 ---------- +def _default_agent_config() -> list[dict]: + """生成默认 agent 配置(单 agent: main)。""" + from agent.memory.system_prompt import MAIN_SYSTEM_PROMPT + from agent.tools import get_all_available_tool_names + models = load_models() + default_model_id = models[0]["id"] if models else "" + return [ + { + "name": "main", + "system_prompt": MAIN_SYSTEM_PROMPT, + "max_iterations": 200, + "llm_model_id": default_model_id, + "tools": get_all_available_tool_names(), + "enabled": True, + "role": "main", + } + ] + + +def load_agent_configs() -> list[AgentConfig]: + """从 agent_config.json 加载 agent 配置列表,无文件时返回默认。""" + raw = _read_json(AGENT_CONFIG_PATH) + if not raw or not isinstance(raw, list): + raw = _default_agent_config() + _write_json(AGENT_CONFIG_PATH, raw) + + configs = [] + for item in raw: + llm_raw = item.get("llm", {}) + configs.append(AgentConfig( + name=item.get("name", ""), + system_prompt=item.get("system_prompt", ""), + max_iterations=item.get("max_iterations", 200), + llm=LLMConfig( + model=llm_raw.get("model", ""), + api_key=llm_raw.get("api_key", ""), + base_url=llm_raw.get("base_url", ""), + timeout=llm_raw.get("timeout", 60.0), + max_retries=llm_raw.get("max_retries", 0), + ), + llm_model_id=item.get("llm_model_id", ""), + tools=item.get("tools", []), + enabled=item.get("enabled", True), + role=item.get("role", "sub"), + )) + return configs + + +def save_agent_configs(configs: list[dict]) -> bool: + """将 agent 配置列表写入 agent_config.json。""" + return _write_json(AGENT_CONFIG_PATH, configs) + + +# ---------- Tool 配置 ---------- + +# 各工具的默认 auto_execute_rules —— 与 tool_policy.py 的硬编码规则对应 +_TOOL_DEFAULT_AUTO_RULES: dict[str, list[dict]] = { + "browser_control": [ + {"parameter": "action", "operator": "equals", "value": "open"}, + ], + "rag_tool": [ + {"parameter": "action_type", "operator": "equals", "value": "query"}, + ], +} + + +def _get_tool_parameters() -> dict[str, list[dict]]: + """获取每个工具的参数名和类型信息,供前端生成 auto_execute_rules 提示。 + + 返回: + {tool_name: [{"name": "action", "type": "Literal[open,close]"}, ...], ...} + """ + from agent.tools import _ALL_AVAILABLE_TOOL_CLASSES + import typing + result: dict[str, list[dict]] = {} + for name, cls in _ALL_AVAILABLE_TOOL_CLASSES.items(): + try: + inst = cls() + schema = getattr(inst, "args_schema", None) + if schema is None: + result[name] = [] + continue + fields_info = getattr(schema, "model_fields", {}) + params = [] + for fname, finfo in fields_info.items(): + annotation = finfo.annotation + type_str = str(annotation) if annotation is not None else "Any" + # 简化 Literal 显示 + import re + m = re.search(r"Literal\[(.+?)\]", type_str) + if m: + type_str = "Literal[" + m.group(1) + "]" + params.append({"name": fname, "type": type_str}) + result[name] = params + except Exception: + result[name] = [] + return result + + +def _default_tool_config() -> list[dict]: + """根据所有可用工具生成默认 tool 配置。""" + from agent.tools import get_all_available_tool_names, get_all_available_tool_descriptions + from agent.core.tool_policy import classify_tool_execution + all_names = get_all_available_tool_names() + all_descriptions = get_all_available_tool_descriptions() + result = [] + for tname in all_names: + display_name, desc = all_descriptions.get(tname, (tname, "")) + perm = classify_tool_execution(tname, {}) + default_rules = _TOOL_DEFAULT_AUTO_RULES.get(tname, []) + result.append({ + "name": tname, + "display_name": display_name, + "description": desc, + "permission": perm, + "auto_execute_rules": default_rules, + "enabled": True, + }) + return result + + +def load_tool_configs() -> list[ToolConfig]: + """从 tool_config.json 加载工具配置,无文件时返回默认。""" + raw = _read_json(TOOL_CONFIG_PATH) + if not raw or not isinstance(raw, list): + raw = _default_tool_config() + _write_json(TOOL_CONFIG_PATH, raw) + + configs = [] + for item in raw: + configs.append(ToolConfig( + name=item.get("name", ""), + display_name=item.get("display_name", ""), + description=item.get("description", ""), + permission=item.get("permission", "confirm"), + auto_execute_rules=item.get("auto_execute_rules", []), + enabled=item.get("enabled", True), + )) + return configs + + +def save_tool_configs(configs: list[dict]) -> bool: + """将 tool 配置列表写入 tool_config.json。""" + return _write_json(TOOL_CONFIG_PATH, configs) + + +# ---------- 强制权限工具(前端锁死,不可修改) ---------- +# 仅必须 confirm 的工具锁死;safe_tools 默认 direct 但用户可改为 confirm +_FORCED_CONFIRM_TOOLS = {"human_interaction"} + + +def get_tool_forced_permissions() -> dict[str, str]: + """返回强制权限的工具映射 {tool_name: "confirm"}, + 前端应对这些工具禁用权限下拉框和 auto_execute_rules 编辑。""" + return {name: "confirm" for name in _FORCED_CONFIRM_TOOLS} + + +# ---------- 模型管理 ---------- +def _default_models() -> list[dict]: + """生成默认模型列表,首次启动时从 .env 继承已有 LLM 配置。""" + import os + api_key = os.environ.get("LLM_API_KEY", "") + base_url = os.environ.get("LLM_BASE_URL", "") + model = os.environ.get("LLM_MODEL", "") + timeout = os.environ.get("LLM_TIMEOUT", "60") + if api_key and base_url and model: + return [ + { + "id": str(uuid.uuid4())[:8], + "name": "默认模型", + "api_key": api_key, + "base_url": base_url, + "model": model, + "timeout": float(timeout) if timeout else 60.0, + "max_retries": 0, + } + ] + return [] + + +def load_models() -> list[dict]: + """从 env_config.json 的 models 字段加载模型列表。""" + raw = _read_json(ENV_CONFIG_PATH) + if isinstance(raw, dict) and "models" in raw and isinstance(raw["models"], list) and raw["models"]: + return raw["models"] + return _default_models() + + +def save_models(models: list[dict]) -> bool: + """将模型列表写入 env_config.json(合并其他字段)。""" + current = load_env_config() + current["models"] = models + return _write_json(ENV_CONFIG_PATH, current) + + +# ---------- Env 配置 ---------- +def _default_env_config() -> dict: + """生成默认 env 配置(不含 models,models 由 load_models 独立处理)。""" + return { + "TTS_BASE_URL": "", + "ASR_BASE_URL": "", + "RAG_BASE_URL": "", + "LLM_CONTEXT_WINDOW": "131072", + "MINI_COMPRESS_RATE": "0.5", + "HARD_COMPRESS_RATE": "0.75", + "CURRENT_TURNS": "4", + "IMG_SIZE": "768", + "RAG_CHUNK_SIZE": "500", + "RAG_CHUNK_OVERLAP": "50", + "GROUNDING_WIDTH": "1000", + "GROUNDING_HEIGHT": "1000", + } + + +def load_env_config() -> dict: + """从 env_config.json 加载环境变量配置,无文件时返回默认。""" + raw = _read_json(ENV_CONFIG_PATH) + if not raw or not isinstance(raw, dict): + raw = _default_env_config() + raw["models"] = _default_models() + _write_json(ENV_CONFIG_PATH, raw) + return raw + # 确保有 models 字段(向后兼容旧配置文件) + if "models" not in raw or not isinstance(raw.get("models"), list): + raw["models"] = _default_models() + return raw + + +def save_env_config(config: dict) -> bool: + """将 env 配置写入 env_config.json(保留已有 models)。""" + # 保留已有的 models 列表(由 models API 单独管理) + existing = _read_json(ENV_CONFIG_PATH) + if isinstance(existing, dict) and "models" in existing: + config["models"] = existing["models"] + return _write_json(ENV_CONFIG_PATH, config) + + +# ---------- 获取所有已注册工具的基础信息 ---------- +def get_registered_tool_names() -> list[str]: + """返回所有可用工具名称(含数据库工具)。""" + from agent.tools import get_all_available_tool_names + return get_all_available_tool_names() diff --git a/src/agent/core/env_config.json b/src/agent/core/env_config.json new file mode 100644 index 0000000..bdb3a46 --- /dev/null +++ b/src/agent/core/env_config.json @@ -0,0 +1,25 @@ +{ + "TTS_BASE_URL": "", + "ASR_BASE_URL": "", + "RAG_BASE_URL": "", + "LLM_CONTEXT_WINDOW": "262144", + "MINI_COMPRESS_RATE": "0.4", + "HARD_COMPRESS_RATE": "0.6", + "CURRENT_TURNS": "4", + "IMG_SIZE": "768", + "RAG_CHUNK_SIZE": "500", + "RAG_CHUNK_OVERLAP": "50", + "GROUNDING_WIDTH": "1000", + "GROUNDING_HEIGHT": "1000", + "models": [ + { + "id": "a44b226e", + "name": "阿里云Qwen", + "api_key": "sk-a44b226ec79a40fd8fcdbbaa74ba133e", + "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1", + "model": "qwen3.6-flash", + "timeout": 60, + "max_retries": 0 + } + ] +} \ No newline at end of file diff --git a/src/agent/core/graph_builder.py b/src/agent/core/graph_builder.py new file mode 100644 index 0000000..1168926 --- /dev/null +++ b/src/agent/core/graph_builder.py @@ -0,0 +1,53 @@ +"""LangGraph ReAct 工作流图构建。 + +系统定位: + 将 LLM(已 bind_tools)、工具列表组装为可编译的 StateGraph, + 供 ``agents/main_agent`` 及各子 Agent 的 ``AgentRuntime`` 使用。 + +图结构: + START -> agent -> (tools | END) + tools -> process_tool_artifact -> agent + +可扩展性: + - 可增加 checkpoint、interrupt 节点、子图路由。 + - 可为不同 Agent 注入不同的 conditional_edges 或额外节点(如 RAG 检索)。 +""" +from __future__ import annotations + +from typing import Any + +from langgraph.graph import END, START, StateGraph +from langgraph.prebuilt import ToolNode + +from agent.core.nodes import make_call_model_node, process_tool_artifact +from agent.core.routing import should_continue +from agent.core.state import AgentState + + +def build_agent_graph(tools: list[Any], model_with_tools: Any): + """构建并编译标准 ReAct Agent 图。 + + 输入: + tools: LangChain BaseTool 列表,供 ToolNode 执行。 + model_with_tools: 已 ``bind_tools`` 的聊天模型,供 agent 节点调用。 + + 输出: + 编译后的 LangGraph CompiledGraph,支持 ``invoke({"messages": ...})``。 + + 系统定位: + ``agents/main_agent`` 入口;子 Agent 可复用同一拓扑并替换 tools/model。 + + 可扩展性: + 返回前可 ``workflow.compile(checkpointer=...)`` 启用持久化; + 可参数化节点名以支持多 Agent 图合并。 + """ + workflow = StateGraph(AgentState) + workflow.add_node("agent", make_call_model_node(model_with_tools)) + workflow.add_node("tools", ToolNode(tools)) + workflow.add_node("process_tool_artifact", process_tool_artifact) + + workflow.add_edge(START, "agent") + workflow.add_conditional_edges("agent", should_continue, {"tools": "tools", END: END}) + workflow.add_edge("tools", "process_tool_artifact") + workflow.add_edge("process_tool_artifact", "agent") + return workflow.compile() diff --git a/src/agent/core/human_interaction.py b/src/agent/core/human_interaction.py new file mode 100644 index 0000000..f0bf1ba --- /dev/null +++ b/src/agent/core/human_interaction.py @@ -0,0 +1,75 @@ +"""人工交互(HITL)参数规范化与结果文案生成。 + +系统定位: + 连接 ``human_interaction`` 工具与 ``core/approvals`` / ``core/runtime``, + 将模型发起的人工交互请求转为 UI 可展示结构,并在用户操作后生成 + ToolMessage 内容供图继续执行。 + +可扩展性: + - 新增 interaction_type 时同步扩展 ``normalize_human_interaction_args`` + 与 ``human_interaction_result`` 的分支。 + - 可与前端约定更丰富的 payload(表单字段、文件上传等)。 +""" +from __future__ import annotations + +from typing import Any + +from agent.tools.tool import HumanInteraction + + +def normalize_human_interaction_args(args: dict[str, Any]) -> dict[str, Any]: + """规范化 human_interaction 工具参数为固定 schema。 + + 输入: + args: 模型传入的原始参数字典。 + + 输出: + 含 interaction_type、title、prompt、options、suggested_response 的字典; + interaction_type 限定为 information | selection | approval。 + + 系统定位: + ``approvals.build_human_interaction_meta`` 构建待确认 UI 前调用。 + + 可扩展性: + 可增加 locale、超时、默认值校验;options 可支持 label/value 结构。 + """ + normalized = dict(args or {}) + normalized["interaction_type"] = str(normalized.get("interaction_type") or "information") + if normalized["interaction_type"] not in {"information", "selection", "approval"}: + normalized["interaction_type"] = "information" + normalized["title"] = str(normalized.get("title") or "需要人工处理") + normalized["prompt"] = str(normalized.get("prompt") or "请处理后继续。") + normalized["options"] = HumanInteraction.normalize_options(normalized.get("options")) + normalized["suggested_response"] = str(normalized.get("suggested_response") or "") + return normalized + + +def human_interaction_result(pending: dict[str, Any], decision: str, instruction: str | None) -> str: + """根据用户决策生成写入 ToolMessage 的文本结果。 + + 输入: + pending: PENDING_TOOL_APPROVALS 中 type=human_interaction 的条目。 + decision: approve | reject | edit。 + instruction: 用户输入文本(补充信息、选项、修改说明等)。 + + 输出: + 供 LangGraph 继续推理的 ToolMessage 字符串内容。 + + 系统定位: + ``runtime.continue_after_human_action`` 在人工交互分支调用。 + + 可扩展性: + 可返回结构化 JSON 字符串,供下游节点解析;支持多语言模板。 + """ + tool_call = pending.get("tool_call") or {} + args = normalize_human_interaction_args(tool_call.get("args") or {}) + interaction_type = args.get("interaction_type") or "information" + text = (instruction or "").strip() + if interaction_type == "approval": + if decision == "reject": + return "用户拒绝" + return "用户同意" + if interaction_type == "selection": + selected = text or args.get("suggested_response") or "" + return f"用户选择:{selected}" + return f"用户补充信息如下:{text}" diff --git a/src/agent/core/llm.py b/src/agent/core/llm.py new file mode 100644 index 0000000..37c1db3 --- /dev/null +++ b/src/agent/core/llm.py @@ -0,0 +1,670 @@ +"""ChatQwen:基于 OpenAI 兼容 API 的多模态 LangChain 聊天模型。 + +系统定位: + 项目默认 LLM 单例 ``llm``,被 agents 绑定工具,并承担: + - 用户消息预处理(附件、ASR、图片 data URL) + - TTS 输出与音频元数据 + - 非标准 tool_call XML/JSON 解析 + - Vision 描述接口 + +依赖: + env_utils 中的 LLM_* / TTS_BASE_URL / ASR_BASE_URL + +可扩展性: + - 可子类化覆盖 _preprocess_messages、_create_chat_result + - 流式、重试、限流可在 _generate 层扩展 +""" +import io +import os +import tempfile +import wave +import pyaudio +from contextlib import closing +from typing import Any, List, Dict, Optional +from pydantic import Field +import re, json +from datetime import datetime as dt +import requests +from concurrent.futures import ThreadPoolExecutor, as_completed + +from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage, ToolCall +from langchain_core.outputs import ChatResult, ChatGeneration +from langchain_openai import ChatOpenAI + +from agent.utils.image_utils import image_bytes_to_openai_image_url_part +from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT +import agent.utils.env_utils as env_utils +from agent.utils.agent_utils import assistant_text, Documents_process, split_text, merge_wavs +from agent.utils.image_utils import image_path_to_openai_image_url_part, image_bytes_to_openai_image_url_part, IMAGE_FILE_EXTENSIONS +from agent.memory.system_prompt import VISION_SYSTEM_PROMPT + +class ChatQwen(ChatOpenAI): + """Qwen 系列模型的 LangChain 封装,扩展多模态与 TTS 能力。 + + 属性: + output_type: 默认输出模式 text | audio | both。 + stream_play: TTS 后是否本地播放。 + last_audio_kwargs: 最近一次 TTS 的文件路径与时长等元数据。 + + 系统定位: + agents/main_agent 的 llm 与 bind_tools 基底。 + + 可扩展性: + 新增厂商响应字段时在 _create_chat_result 中适配。 + """ + + output_type: str = Field(default="text") + stream_play: bool = Field(default=False) + last_audio_kwargs: Optional[dict] = Field(default=None) + + def __init__(self, **kwargs: Any) -> None: + """从环境变量或 kwargs 初始化 OpenAI 兼容客户端。 + + 输入: + kwargs: 可覆盖 model、api_key、base_url、timeout、max_retries。 + + 输出: + 无。 + + 系统定位: + 模块级 ``llm = ChatQwen()`` 单例构造。 + + 可扩展性: + 可增加 default_headers、proxy 等 ChatOpenAI 参数。 + """ + super().__init__( + model=kwargs.pop("model", os.environ.get("LLM_MODEL", "")), + api_key=kwargs.pop("api_key", os.environ.get("LLM_API_KEY", "")), + base_url=kwargs.pop("base_url", os.environ.get("LLM_BASE_URL", "")), + timeout=kwargs.pop("timeout", float(os.environ.get("LLM_TIMEOUT", 60))), + max_retries=kwargs.pop("max_retries", 0), + extra_body={ + "chat_template_kwargs": {"enable_thinking": False} + }, + **kwargs + ) + + @staticmethod + def play_audio_bytes(audio_bytes: bytes, chunk_size: int = 1024) -> None: + """通过 pyaudio 播放 WAV 字节流。 + + 输入: audio_bytes WAV 二进制;chunk_size 播放块大小。 + 输出: 无。 + 系统定位: _apply_tts 在 stream_play=True 时调用。 + 可扩展性: 可换用 sounddevice 或仅写文件不播放。 + """ + if not audio_bytes: + return + wf = wave.open(io.BytesIO(audio_bytes), 'rb') + p = pyaudio.PyAudio() + stream = p.open( + format=p.get_format_from_width(wf.getsampwidth()), + channels=wf.getnchannels(), + rate=wf.getframerate(), + output=True, + ) + data = wf.readframes(chunk_size) + while data: + stream.write(data) + data = wf.readframes(chunk_size) + stream.stop_stream() + stream.close() + p.terminate() + wf.close() + + @staticmethod + def _process_image_content(part: Dict[str, Any]) -> Dict[str, Any]: + """将 image_url 块中的本地路径或字节转为 data URL。 + + 输入: OpenAI 风格 content part dict。 + 输出: 处理后的 part 或失败时 text 占位。 + 系统定位: _preprocess_messages 多模态分支。 + 可扩展性: 委托 image_utils,保持单一图片处理逻辑。 + """ + image_type = part.get("type") + if image_type != "image_url": + return part + image_url = part.get("image_url", {}) + url = image_url.get("url", "") + if url.startswith(("http://", "https://", "data:")): + return part + if isinstance(url, str) and os.path.exists(url): + ext = os.path.splitext(url)[1].lower() + if ext in IMAGE_FILE_EXTENSIONS: + result = image_path_to_openai_image_url_part(url) + return result if result else {"type": "text", "text": "[图片处理失败]"} + if isinstance(url, bytes): + result = image_bytes_to_openai_image_url_part(url) + return result if result else {"type": "text", "text": "[图片处理失败]"} + return part + + @staticmethod + def _convert_audio_to_wav(audio_path: str) -> str: + """非 WAV 音频经 pydub 转为临时 WAV 文件路径。 + + 输入: audio_path 本地音频路径。 + 输出: WAV 文件路径(可能为临时文件,调用方负责清理)。 + 系统定位: _audio_to_text 前置格式统一。 + 可扩展性: 需系统安装 ffmpeg;可缓存转换结果。 + """ + ext = os.path.splitext(audio_path)[1].lower() + if ext == ".wav": + return audio_path + try: + from pydub import AudioSegment + except ImportError as exc: + raise RuntimeError("当前 ASR 仅支持 WAV,但未安装 pydub/ffmpeg") from exc + try: + audio = AudioSegment.from_file(audio_path) + except Exception as exc: + raise RuntimeError(f"无法读取音频文件: {exc}") from exc + fd, wav_path = tempfile.mkstemp(suffix=".wav", prefix="asr_") + os.close(fd) + try: + audio.export(wav_path, format="wav") + return wav_path + except Exception: + if os.path.exists(wav_path): + os.remove(wav_path) + raise + + def _audio_to_text(self, audio_path: str) -> str: + """调用 ASR 服务将音频转为文本。 + + 输入: audio_path 本地音频文件。 + 输出: 识别文本字符串。 + 系统定位: 用户消息含 audio part 时替换为 text。 + 可扩展性: ASR_BASE_URL 需配置;可支持流式识别。 + """ + if not os.path.exists(audio_path): + raise FileNotFoundError(f"音频文件不存在: {audio_path}") + + wav_path = self._convert_audio_to_wav(audio_path) + cleanup_wav = (wav_path != audio_path) + + try: + # 读取音频文件二进制内容 + with open(wav_path, "rb") as f: + audio_bytes = f.read() + + # 构建 Data URL + import base64 + b64 = base64.b64encode(audio_bytes).decode("ascii") + mime_type = "audio/wav" # 我们已经转换为 wav + data_url = f"data:{mime_type};base64,{b64}" + + payload = { + "messages": [ + { + "role": "user", + "content": [ + {"type": "audio_url", "audio_url": {"url": data_url}} + ] + } + ] + } + endpoint = f"{env_utils.ASR_BASE_URL.rstrip('/')}/chat/completions" + resp = requests.post( + endpoint, + headers={"Content-Type": "application/json"}, + json=payload, + timeout=120 + ) + resp.raise_for_status() + result = resp.json() + content = result["choices"][0]["message"]["content"] + return content.strip() + finally: + if cleanup_wav and os.path.exists(wav_path): + os.remove(wav_path) + + def _preprocess_messages(self, messages: List[BaseMessage]) -> List[BaseMessage]: + """入模前统一处理:JSON 列表、附件、ASR、图片、文档解析。 + + 输入: LangChain BaseMessage 列表。 + 输出: 规范化后的新消息列表(不修改原列表)。 + 系统定位: _generate 调用 super()._generate 之前的适配层。 + 可扩展性: 新增 part.type 时在此增加分支。 + """ + new_messages = [] + for msg in messages: + if isinstance(msg, HumanMessage) and isinstance(msg.content, str): + text = msg.content.strip() + if text.startswith("[") and text.endswith("]"): + try: + possible_list = json.loads(text) + if isinstance(possible_list, list): + msg = HumanMessage(content=possible_list, additional_kwargs=msg.additional_kwargs) + except (json.JSONDecodeError, ValueError): + pass + if isinstance(msg, HumanMessage) and isinstance(msg.content, dict): + # 尝试从 dict 中提取文本,否则序列化为字符串 + text = msg.content.get("text") or msg.content.get("content") or "" + if text: + msg = HumanMessage(content=str(text), additional_kwargs=msg.additional_kwargs) + else: + msg = HumanMessage(content=json.dumps(msg.content, ensure_ascii=False), additional_kwargs=msg.additional_kwargs) + elif isinstance(msg, HumanMessage) and isinstance(msg.content, list): + new_content: List[Dict[str, Any] | str] = [] + for part in msg.content: + if isinstance(part, dict): + if part.get("type") == "audio": + audio_path = part.get("audio_path") + if audio_path: + try: + text = self._audio_to_text(audio_path) + new_content.append({"type": "text", "text": text}) + except Exception as e: + new_content.append({"type": "text", "text": f"[语音识别失败: {e}]"}) + elif part.get("type") == "image_url": + new_content.append(self._process_image_content(part)) + elif part.get("type") == "text": + text = (part.get("text") or "").strip() + if text: + new_content.append({"type": "text", "text": text}) + elif part.get("path") or (part.get("file") or {}).get("path"): + file_path = part.get("path") or (part.get("file") or {}).get("path") + if isinstance(file_path, str) and os.path.isfile(file_path): + ext = os.path.splitext(file_path)[1].lower() + normalized_path = file_path.replace("\\", "/") + if ext in IMAGE_FILE_EXTENSIONS: + new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}})) + else: + try: + file_text, file_images = Documents_process.process(file_path) + new_content.append({"type": "text", "text": f"用户上传了一个名为\"{normalized_path}\"的文件。"}) + if file_text.strip(): + new_content.append({ + "type": "text", + "text": f"以下是文件 {normalized_path} 的内容:\n{file_text.strip()}" + }) + for image_bytes in file_images or []: + image_part = image_bytes_to_openai_image_url_part(image_bytes) + if image_part is not None: + new_content.append(image_part) + if not file_text.strip() and not file_images: + new_content.append({"type": "text", "text": f"[用户上传了附件文件: {normalized_path}]"}) + except Exception as e: + new_content.append({"type": "text", "text": f"[附件处理失败: {normalized_path}: {e}]"}) + else: + text = assistant_text(part) + if text: + new_content.append({"type": "text", "text": text}) + elif isinstance(part, str): + text = part.strip() + if text: + new_content.append(text) + if len(new_content) == 1 and isinstance(new_content[0], str): + msg = HumanMessage(content=new_content[0], additional_kwargs=msg.additional_kwargs) + else: + msg = HumanMessage(content=new_content, additional_kwargs=msg.additional_kwargs) + new_messages.append(msg) + return new_messages + + def _text_to_audio(self, text: str) -> bytes: + """调用 TTS 服务合成语音,长文本分块并行后 merge_wavs。 + + 输入: text 待朗读文本。 + 输出: WAV 二进制。 + 系统定位: _apply_tts 核心。 + 可扩展性: TTS_BASE_URL、并发 max_workers 可配置化。 + """ + chunks = split_text(text, min_effective_chars=20) + if len(chunks) == 1: + resp = requests.get(f"{env_utils.TTS_BASE_URL}/tts", params={"text": chunks[0]}) + resp.raise_for_status() + return resp.content + + # 多块并行请求 + audio_parts = [None] * len(chunks) + with ThreadPoolExecutor(max_workers=min(len(chunks), 4)) as pool: + future_to_idx = { + pool.submit( + lambda t: requests.get( + f"{env_utils.TTS_BASE_URL}/tts", params={"text": t} + ).content, + chunk + ): idx for idx, chunk in enumerate(chunks) + } + for future in as_completed(future_to_idx): + idx = future_to_idx[future] + try: + audio_parts[idx] = future.result() + except Exception as e: + raise RuntimeError(f"TTS request failed for chunk {idx}: {e}") + return merge_wavs(audio_parts) + + @staticmethod + def _get_wav_duration_seconds(audio_bytes: bytes) -> float | None: + """从 WAV 字节计算时长(秒)。 + + 输入: audio_bytes WAV 数据。 + 输出: 秒数或 None(解析失败)。 + 系统定位: _apply_tts 写入 last_audio_kwargs.duration_seconds。 + 可扩展性: 可支持 mp3 等格式。 + """ + if not audio_bytes: + return None + try: + with closing(wave.open(io.BytesIO(audio_bytes), "rb")) as wf: + frame_rate = wf.getframerate() + frame_count = wf.getnframes() + if frame_rate <= 0: + return None + return round(frame_count / frame_rate, 2) + except wave.Error: + return None + + def _apply_tts(self, result: ChatResult, output_type: str, audio_output_dir: str | None = None) -> ChatResult: + """根据 output_type 为 AI 回复合成语音并写入会话目录。 + + 输入: + result: ChatResult 含 AIMessage。 + output_type: audio | both。 + audio_output_dir: WAV 保存目录。 + + 输出: + 可能清空 audio 模式下 content 的 ChatResult;设置 last_audio_kwargs。 + + 系统定位: + _generate 在模型返回后的后处理。 + + 可扩展性: + 可上传对象存储而非本地路径。 + """ + if output_type not in ("audio", "both") or not audio_output_dir: + return result + os.makedirs(audio_output_dir, exist_ok=True) + for idx, gen in enumerate(result.generations): + if not isinstance(gen, ChatGeneration): + continue + msg = gen.message + if not isinstance(msg, AIMessage): + continue + text = assistant_text(msg.content) + if not text.strip(): + continue + try: + audio_bytes = self._text_to_audio(text) + timestamp = dt.now().strftime("%H%M%S") + audio_path = os.path.join(audio_output_dir, f"assistant_tts_{timestamp}_{idx}.wav") + with open(audio_path, "wb") as f: + f.write(audio_bytes) + duration_seconds = self._get_wav_duration_seconds(audio_bytes) + # 仅保存到实例属性,不再写入 additional_kwargs + if idx == 0: + self.last_audio_kwargs = { + "audio": { + "sample_rate": 24000, + "format": "wav", + "path": audio_path, + "duration_seconds": duration_seconds, + }, + "output_type": output_type, + } + if self.stream_play: + self.play_audio_bytes(audio_bytes) + if output_type == "audio": + msg.content = "" + except Exception as e: + print(f"[TTS ERROR] 合成语音失败: {e}") + # 发生错误时也清空 last_audio_kwargs,避免污染 + self.last_audio_kwargs = None + return result + + def _generate( + self, + messages: List[BaseMessage], + stop: Optional[List[str]] = None, + run_manager: Optional[Any] = None, + **kwargs: Any, + ) -> ChatResult: + """LangChain 生成入口:预处理 -> API 调用 -> 可选 TTS。 + + 输入: + messages: 对话消息。 + stop: 停止词。 + run_manager: 含 metadata.output_type、audio_output_dir。 + + 输出: + ChatResult。 + + 系统定位: + 所有 invoke/stream 最终经过此方法。 + + 可扩展性: + 可在此注入 token 统计回调、链路追踪 span。 + """ + metadata = {} + if run_manager: + metadata = getattr(run_manager, 'metadata', {}) + output_type = metadata.get("output_type", self.output_type) + audio_output_dir = metadata.get("audio_output_dir") + + # 移除自定义参数,防止它们传入 OpenAI API + kwargs.pop("output_type", None) + kwargs.pop("audio_output_dir", None) + + messages = self._preprocess_messages(messages) + + result = super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs) + + if output_type in ("audio", "both"): + result = self._apply_tts(result, output_type, audio_output_dir=audio_output_dir) + if output_type == "audio": + for gen in result.generations: + if isinstance(gen, ChatGeneration) and isinstance(gen.message, AIMessage): + gen.message.content = "" + return result + + def _create_chat_result(self, response, generation_info=None) -> ChatResult: + """解析 API 响应:reasoning 合并、XML/JSON tool_call 提取。 + + 输入: + response: OpenAI 兼容 API 原始响应。 + generation_info: LangChain 生成元信息。 + + 输出: + ChatResult,AIMessage 可能含 tool_calls。 + + 系统定位: + 兼容非 OpenAI 标准 tool_calls 格式的关键适配点。 + + 可扩展性: + 新增模型格式时在 regex/JSON 分支扩展。 + """ + # ---------- 打印原始 tool_calls ---------- + try: + raw = response if isinstance(response, dict) else response.model_dump() + print("[RAW_RESPONSE]", json.dumps(raw, indent=2, ensure_ascii=False)) + choices = raw.get("choices", []) + if choices: + msg = choices[0].get("message", {}) + tool_calls = msg.get("tool_calls") + if tool_calls: + print("[RAW_TOOLCALLS]", json.dumps(tool_calls, indent=2, ensure_ascii=False)) + except Exception: + pass + + result = super()._create_chat_result(response, generation_info) + try: + dumped = response if isinstance(response, dict) else response.model_dump(exclude={"choices": {"__all__": {"message": {"parsed"}}}}) + for i, choice in enumerate(dumped.get("choices") or []): + if i >= len(result.generations): + break + lc_msg = result.generations[i].message + msg = choice.get("message") or {} + ak = lc_msg.additional_kwargs + # 优先处理 reasoning 内容 + merged = "" + for key in ("reasoning_content", "reasoning_details", "reasoning"): + merged = assistant_text(msg.get(key)) + if merged: + break + if merged: + ak.setdefault("reasoning_content", merged) + raw_content = assistant_text(msg.get("content")) + if not raw_content and merged: + lc_msg.content = merged + elif raw_content: + lc_msg.content = raw_content + + content_text = lc_msg.content if isinstance(lc_msg.content, str) else "" + + # 匹配 ... 块 + pattern = re.compile(r'\s*(.*?)\s*', re.DOTALL) + matches = pattern.findall(content_text) + if matches: + tool_calls = [] + for idx, match in enumerate(matches): + # 先尝试 JSON 解析(兼容旧格式) + try: + call_data = json.loads(match) + tool_calls.append({ + "id": f"call_{idx}", + "type": "function", + "function": { + "name": call_data.get("name", ""), + "arguments": json.dumps(call_data.get("arguments", {})) + } + }) + continue # 解析成功,跳过 XML 尝试 + except json.JSONDecodeError: + pass + + # 再尝试 XML 解析 + func_match = re.search(r'', match) + if not func_match: + continue + func_name = func_match.group(1) + + params = {} + for param_match in re.finditer(r'\s*(.*?)\s*', match, re.DOTALL): + key = param_match.group(1) + value = param_match.group(2).strip() + # 可选:智能类型转换,先保持简单 + params[key] = value + + tool_calls.append({ + "id": f"call_{idx}", + "type": "function", + "function": { + "name": func_name, + "arguments": json.dumps(params) + } + }) + + if tool_calls: + ak["tool_calls"] = tool_calls + # 构造 LangChain 的 ToolCall 对象列表 + lc_tool_calls = [] + for idx, tc in enumerate(tool_calls): + lc_tool_calls.append( + ToolCall( + id=tc["id"], + name=tc["function"]["name"], + args=json.loads(tc["function"]["arguments"]) + ) + ) + lc_msg.tool_calls = lc_tool_calls + # 清理内容中的 XML/JSON 工具调用痕迹 + cleaned = pattern.sub("", content_text).strip() + lc_msg.content = cleaned if cleaned else "" + except Exception: + pass + return result + + def describe_image(self, image_path_or_url: str, prompt: str = "请详细描述这张图片的内容。") -> str: + """Vision 便捷接口:单张图片 + 提示词 -> 文本描述。 + + 输入: + image_path_or_url: 路径或 http(s)/data URL。 + prompt: 用户侧提示。 + + 输出: + 模型描述文本。 + + 系统定位: + 工具或测试脚本直接调用,不经 LangGraph。 + + 可扩展性: + 可返回结构化 JSON(物体列表等)。 + """ + messages = [ + SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]), + HumanMessage(content=[ + {"type": "image_url", "image_url": {"url": image_path_or_url}}, + {"type": "text", "text": prompt} + ]) + ] + result = self.invoke(messages) + return result.content + + def describe_image_bytes(self, image_bytes: bytes, prompt: str = "请详细描述这张图片的内容。") -> str: + """Vision 便捷接口:内存图片字节 -> 文本描述。 + + 输入: + image_bytes: 图片二进制。 + prompt: 提示词。 + + 输出: + 描述文本;处理失败返回固定错误文案。 + + 系统定位: + 截屏、工具 artifact 等无路径场景。 + + 可扩展性: + 与 describe_image 共用 VISION_SYSTEM_PROMPT。 + """ + img_part = image_bytes_to_openai_image_url_part(image_bytes) + if img_part is None: + return "图片处理失败" + messages = [ + SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]), + HumanMessage(content=[img_part, {"type": "text", "text": prompt}]) + ] + result = self.invoke(messages) + return result.content + +llm = ChatQwen() + + +def reload_llm() -> None: + """热重载:重新从环境变量创建 LLM 实例。""" + global llm + llm = ChatQwen() + + +if __name__ == "__main__": + import sys + + # 使用原始字符串避免转义问题 + TEST_IMAGE_PATH = r"C:\Users\86166\Desktop\2026-05-22\20260522-DSC01513.jpg" + + if not os.path.exists(TEST_IMAGE_PATH): + print(f"❌ 图片不存在: {TEST_IMAGE_PATH}") + sys.exit(1) + + # 测试1: 纯图片识别 + print("=" * 50) + print("测试1: 纯图片识别(无文本)") + print("=" * 50) + result1 = llm.invoke([ + HumanMessage(content=[{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}}]) + ]) + print(f"返回类型: {type(result1)}") + print(f"返回内容: {result1.content}") + print(f"所有属性: {[attr for attr in dir(result1) if not attr.startswith('_')]}") + + # 测试2: 图片 + 文本 + print("\n" + "=" * 50) + print("测试2: 图文混合输入") + print("=" * 50) + result2 = llm.invoke([ + HumanMessage(content=[ + {"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}}, + {"type": "text", "text": "这张图片里有什么?请详细描述"} + ]) + ]) + print(f"回答: {result2.content}") + diff --git a/src/agent/core/multi_agent_graph.py b/src/agent/core/multi_agent_graph.py new file mode 100644 index 0000000..a5f6ccf --- /dev/null +++ b/src/agent/core/multi_agent_graph.py @@ -0,0 +1,533 @@ +"""多 Agent 体系 SupervisorGraph:一主多子 / 主从工作流编排。 + +系统定位: + 为 "一主多子" 和 "主从" 架构提供统一的 LangGraph 图构建, + 支持主 Agent 向子 Agent 委派任务,子 Agent 完成后回传结果。 + +图结构:: + + START -> supervisor + supervisor -> main_agent | END + main_agent -> (根据 tool_calls 中的 agent_call) 路由到子 agent | supervisor + sub_agent_N -> supervisor (子 agent 完成后回到 supervisor) + +Agent 节点签名: + 每个 agent 节点接收 MultiAgentState,返回 {"messages": [...], "agent_status": {...}, ...} + +可扩展性: + - 子 Agent 可拥有独立图(自定义 build_graph),也可复用 ReAct。 + - 可增加并行子 Agent 执行、超时、重试等策略。 +""" +from __future__ import annotations + +import json +import time +from typing import Any, Callable + +from langgraph.graph import END, START, StateGraph +from langgraph.prebuilt import ToolNode + +from agent.core.multi_agent_state import ( + AgentStatusInfo, + GraphEvent, + MultiAgentState, + ToolCallEvent, +) +from agent.core.nodes import make_call_model_node, process_tool_artifact +from agent.core.routing import should_continue + + +# ---------- 事件发射 ---------- +def _emit_event(state: dict, event: GraphEvent) -> None: + """向 state 中追加一个图事件。""" + events: list[GraphEvent] = state.setdefault("graph_events", []) + # 从 dict 还原为 GraphEvent 对象 + if isinstance(events, list): + events.append(event) + + +def _update_agent_status(state: dict, name: str, status: str, error_msg: str = "") -> None: + """更新 state 中指定 agent 的运行状态。""" + agent_status: dict = state.setdefault("agent_status", {}) + if name in agent_status: + info = agent_status[name] + if isinstance(info, dict): + info["status"] = status + if error_msg: + info["error_message"] = error_msg + elif isinstance(info, AgentStatusInfo): + info.status = status # type: ignore[misc] + if error_msg: + info.error_message = error_msg + + +def _record_tool_call(state: dict, agent_name: str, tool_name: str, args: dict, result_preview: str = "", status: str = "success") -> None: + """记录工具调用到指定 agent 的工具调用历史。""" + history: dict = state.setdefault("tool_calls_history", {}) + if agent_name not in history: + history[agent_name] = [] + history[agent_name].append(ToolCallEvent( + tool_name=tool_name, + tool_args=args, + result_preview=result_preview[:200] if result_preview else "", + status=status, # type: ignore[arg-type] + timestamp=time.time(), + )) + + +# ---------- Supervisor 节点(调度器) ---------- +def make_supervisor_node( + agent_names: list[str], + main_agent_name: str = "main", +) -> Callable[[dict], dict]: + """创建 supervisor 调度节点。 + + Supervisor 职责: + 1. 首次进入:设定 active_agent 为 main,标记 main 为 running。 + 2. 子 agent 完成返回:若还有待处理,标记下一个 agent;否则标记 main 为 done。 + 3. 错误恢复:若子 agent 出错,回主 agent。 + + 输入: + agent_names: 所有 agent 名称列表(含 main)。 + main_agent_name: 主 agent 名称。 + + 输出: + 符合 LangGraph 节点签名的 supervisor 函数。 + """ + sub_agent_names = [n for n in agent_names if n != main_agent_name] + + def supervisor(state: dict) -> dict: + now = time.time() + agent_status: dict = state.get("agent_status", {}) + messages: list = state.get("messages", []) + + # 首次进入 + current_active: str | None = state.get("active_agent") + if current_active is None: + # 启动主 agent + _update_agent_status(state, main_agent_name, "running") + _emit_event(state, GraphEvent( + event_type="agent_start", + agent_name=main_agent_name, + timestamp=now, + )) + return { + "active_agent": main_agent_name, + "agent_status": agent_status, + "graph_events": state.get("graph_events", []), + } + + # 主 agent 执行完毕回来 + if current_active == main_agent_name: + # 检查最后一条消息是否含 agent_call(委派子 agent 的标记) + last_msg = messages[-1] if messages else None + delegated_agent = _extract_delegation(last_msg) + if delegated_agent and delegated_agent in sub_agent_names: + # 主 agent 委派子 agent + _update_agent_status(state, main_agent_name, "done") + _emit_event(state, GraphEvent( + event_type="agent_end", + agent_name=main_agent_name, + timestamp=now, + )) + _update_agent_status(state, delegated_agent, "running") + _emit_event(state, GraphEvent( + event_type="agent_start", + agent_name=delegated_agent, + timestamp=now, + )) + _emit_event(state, GraphEvent( + event_type="message_pass", + source_agent=main_agent_name, + target_agent=delegated_agent, + message_preview=f"主 Agent 委派任务给 {delegated_agent}", + timestamp=now, + )) + return { + "active_agent": delegated_agent, + "agent_status": agent_status, + "graph_events": state.get("graph_events", []), + } + else: + # 主 agent 结束,整体完成 + _update_agent_status(state, main_agent_name, "done") + _emit_event(state, GraphEvent( + event_type="agent_end", + agent_name=main_agent_name, + timestamp=now, + )) + return { + "active_agent": "__end__", + "agent_status": agent_status, + "graph_events": state.get("graph_events", []), + } + + # 子 agent 执行完毕回来 + if current_active in sub_agent_names: + _update_agent_status(state, current_active, "done") + _emit_event(state, GraphEvent( + event_type="agent_end", + agent_name=current_active, + timestamp=now, + )) + _emit_event(state, GraphEvent( + event_type="message_pass", + source_agent=current_active, + target_agent=main_agent_name, + message_preview=f"{current_active} 回传结果给主 Agent", + timestamp=now, + )) + # 回到主 agent + _update_agent_status(state, main_agent_name, "running") + _emit_event(state, GraphEvent( + event_type="agent_start", + agent_name=main_agent_name, + timestamp=now, + )) + return { + "active_agent": main_agent_name, + "agent_status": agent_status, + "graph_events": state.get("graph_events", []), + } + + return { + "active_agent": "__end__", + "agent_status": agent_status, + "graph_events": state.get("graph_events", []), + } + + return supervisor + + +def _extract_delegation(last_msg: Any) -> str | None: + """从最后一条 AI 消息中提取 agent_call 委派信息。 + + 委派格式: + - 在消息 content 中包含 ``[DELEGATE:agent_name]`` + - 或在 tool_calls 中有一条名为 ``agent_call`` 的工具调用 + """ + if last_msg is None: + return None + # 方式1: tool_calls 中的 agent_call + if hasattr(last_msg, "tool_calls") and last_msg.tool_calls: + for tc in last_msg.tool_calls: + tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "") + if tc_name == "agent_call": + args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {}) + return args.get("agent_name") or args.get("agent") + # 方式2: 文本标记 + content = getattr(last_msg, "content", "") + if isinstance(content, str) and "[DELEGATE:" in content: + import re + m = re.search(r"\[DELEGATE:(\w+)\]", content) + if m: + return m.group(1) + return None + + +# ---------- 路由函数 ---------- +def supervisor_route(state: dict) -> str: + """Supervisor 之后的路由:根据 active_agent 决定进入哪个 agent 节点或 END。""" + active = state.get("active_agent", "") + if active == "__end__": + return END + if active: + return active + return END + + +# ---------- 子 Agent 节点工厂 ---------- +def make_sub_agent_node( + agent_graph: Any, + agent_name: str, + tools_by_name: dict[str, Any] | None = None, +) -> Callable[[dict], dict]: + """创建一个子 Agent 的图节点。 + + 子 Agent 节点: + 1. 用自己的图执行推理 + 2. 发出 agent_start / tool_call / agent_end 事件 + 3. 更新自己的工具调用历史 + + 输入: + agent_graph: 编译后的子 Agent LangGraph 图。 + agent_name: 子 Agent 名称。 + tools_by_name: 工具名 -> 工具实例映射(用于记录 tool_call)。 + + 输出: + LangGraph 节点函数。 + """ + tools_by_name = tools_by_name or {} + + def sub_agent_node(state: dict) -> dict: + now = time.time() + messages: list = state.get("messages", []) + + # 标记自己为 running + _update_agent_status(state, agent_name, "running") + _emit_event(state, GraphEvent( + event_type="agent_start", + agent_name=agent_name, + timestamp=now, + )) + + # 用子 Agent 图执行 + try: + result = agent_graph.invoke({"messages": messages}) + result_messages: list = result.get("messages", []) + except Exception as e: + _update_agent_status(state, agent_name, "error", str(e)) + _emit_event(state, GraphEvent( + event_type="error", + agent_name=agent_name, + message_preview=str(e)[:200], + timestamp=time.time(), + )) + return { + "messages": messages, + "agent_status": state.get("agent_status", {}), + "graph_events": state.get("graph_events", []), + "tool_calls_history": state.get("tool_calls_history", {}), + } + + # 记录工具调用 + for msg in result_messages: + if hasattr(msg, "tool_calls") and msg.tool_calls: + for tc in msg.tool_calls: + tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "") + tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {}) + if isinstance(tc_args, str): + try: + tc_args = json.loads(tc_args) + except Exception: + pass + _record_tool_call(state, agent_name, tc_name, tc_args or {}) + _emit_event(state, GraphEvent( + event_type="tool_call", + agent_name=agent_name, + tool_name=tc_name, + tool_args=tc_args or {}, + timestamp=time.time(), + )) + + _update_agent_status(state, agent_name, "done") + _emit_event(state, GraphEvent( + event_type="agent_end", + agent_name=agent_name, + timestamp=time.time(), + )) + + # 追加 agent 标记信息到消息 + from langchain_core.messages import AIMessage + final_text = "" + for msg in reversed(result_messages): + if isinstance(msg, AIMessage) and not getattr(msg, "tool_calls", None): + content = msg.content + if isinstance(content, str): + final_text = content + break + + tag_msg = AIMessage( + content=f"[AGENT:{agent_name}] {final_text}" if final_text else f"[AGENT:{agent_name}] 任务完成", + additional_kwargs={"agent_name": agent_name}, + ) + new_messages = list(messages) + list(result_messages) + [tag_msg] + + return { + "messages": new_messages, + "agent_status": state.get("agent_status", {}), + "graph_events": state.get("graph_events", []), + "tool_calls_history": state.get("tool_calls_history", {}), + "agent_messages": state.get("agent_messages", {}), + } + + return sub_agent_node + + +# ---------- 主 Agent 节点工厂(多 Agent 版) ---------- +def make_main_agent_node( + model_with_tools: Any, + tools: list[Any], + agent_name: str = "main", + tools_by_name: dict[str, Any] | None = None, +) -> Callable[[dict], dict]: + """创建主 Agent 的图节点(含完整的 ReAct 循环 + 委派能力)。 + + 与单 agent 的 call_model 不同,主 agent 节点内含完整的 + agent -> tools -> process_tool_artifact -> agent 循环, + 并检测 agent_call 工具调用来委派子 agent。 + + 输入: + model_with_tools: bind_tools 后的 LLM。 + tools: 工具列表(含 agent_call 工具)。 + agent_name: 主 agent 名称。 + tools_by_name: 工具名映射。 + + 输出: + LangGraph 节点函数。 + """ + tools_by_name = tools_by_name or {} + + def main_agent_node(state: dict) -> dict: + now = time.time() + messages: list = list(state.get("messages", [])) + + _update_agent_status(state, agent_name, "running") + + # 执行完整的 ReAct 循环(限制步数防止无限循环) + max_steps = 50 + for step in range(max_steps): + # 调用 LLM + response = model_with_tools.invoke(messages) + messages.append(response) + + # 记录工具调用事件 + if hasattr(response, "tool_calls") and response.tool_calls: + for tc in response.tool_calls: + tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "") + tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {}) + if isinstance(tc_args, str): + try: + tc_args = json.loads(tc_args) + except Exception: + pass + + # 检测 agent_call(委派) + if tc_name == "agent_call": + # 不在 ToolNode 中执行,由 supervisor 处理 + _record_tool_call(state, agent_name, tc_name, tc_args or {}, "委派子 agent", "pending") + _emit_event(state, GraphEvent( + event_type="tool_call", + agent_name=agent_name, + tool_name=tc_name, + tool_args=tc_args or {}, + timestamp=time.time(), + )) + _update_agent_status(state, agent_name, "done") + return { + "messages": messages, + "agent_status": state.get("agent_status", {}), + "graph_events": state.get("graph_events", []), + "tool_calls_history": state.get("tool_calls_history", {}), + } + + _record_tool_call(state, agent_name, tc_name, tc_args or {}) + _emit_event(state, GraphEvent( + event_type="tool_call", + agent_name=agent_name, + tool_name=tc_name, + tool_args=tc_args or {}, + timestamp=time.time(), + )) + + # 执行工具(非 agent_call) + for tc in response.tool_calls: + tc_name = tc.get("name") if isinstance(tc, dict) else getattr(tc, "name", "") + if tc_name == "agent_call": + continue + tc_args = tc.get("args") if isinstance(tc, dict) else getattr(tc, "args", {}) + tc_id = tc.get("id") if isinstance(tc, dict) else getattr(tc, "id", "") + tool = tools_by_name.get(tc_name) + if tool: + try: + result = tool.invoke(tc_args) + result_content = result[0] if isinstance(result, tuple) else str(result) + except Exception as e: + result_content = f"工具执行出错: {e}" + from langchain_core.messages import ToolMessage + messages.append(ToolMessage( + content=str(result_content), + tool_call_id=tc_id or f"tc_{step}", + name=tc_name, + )) + _record_tool_call(state, agent_name, tc_name, tc_args or {}, str(result_content)[:200]) + _emit_event(state, GraphEvent( + event_type="tool_call", + agent_name=agent_name, + tool_name=tc_name, + tool_args=tc_args or {}, + tool_result_preview=str(result_content)[:200], + timestamp=time.time(), + )) + else: + # 没有工具调用,主 agent 完成 + break + + _update_agent_status(state, agent_name, "done") + return { + "messages": messages, + "agent_status": state.get("agent_status", {}), + "graph_events": state.get("graph_events", []), + "tool_calls_history": state.get("tool_calls_history", {}), + } + + return main_agent_node + + +# ---------- 多 Agent 图构建 ---------- +def build_multi_agent_graph( + main_model_with_tools: Any, + main_tools: list[Any], + main_tools_by_name: dict[str, Any], + sub_agents: list[dict[str, Any]], + main_agent_name: str = "main", +) -> Any: + """构建一主多子 SupervisorGraph。 + + 输入: + main_model_with_tools: 主 Agent 的已 bind_tools LLM。 + main_tools: 主 Agent 工具列表(含 agent_call)。 + main_tools_by_name: 主 Agent 工具名映射。 + sub_agents: 子 Agent 配置列表,每项含: + - name: str + - graph: 编译后的子 Agent LangGraph 图 + - tools_by_name: dict(可选) + main_agent_name: 主 agent 名称。 + + 输出: + 编译后的 LangGraph CompiledGraph。 + + 图结构:: + + START -> supervisor + supervisor --路由--> main | sub_1 | sub_2 | ... | END + main -> supervisor + sub_N -> supervisor + """ + workflow = StateGraph(MultiAgentState) + + # 所有 agent 名称 + all_agent_names = [main_agent_name] + [sa["name"] for sa in sub_agents] + + # Supervisor 节点 + workflow.add_node("supervisor", make_supervisor_node(all_agent_names, main_agent_name)) + + # 主 Agent 节点 + workflow.add_node( + main_agent_name, + make_main_agent_node(main_model_with_tools, main_tools, main_agent_name, main_tools_by_name), + ) + + # 子 Agent 节点 + for sa in sub_agents: + sa_name = sa["name"] + sa_graph = sa["graph"] + sa_tools_by_name = sa.get("tools_by_name", {}) + workflow.add_node( + sa_name, + make_sub_agent_node(sa_graph, sa_name, sa_tools_by_name), + ) + + # 路由 + workflow.add_edge(START, "supervisor") + + route_map = {name: name for name in all_agent_names} + route_map[END] = END + workflow.add_conditional_edges("supervisor", supervisor_route, route_map) + + # 所有 agent 节点完成后回到 supervisor + workflow.add_edge(main_agent_name, "supervisor") + for sa in sub_agents: + workflow.add_edge(sa["name"], "supervisor") + + return workflow.compile() diff --git a/src/agent/core/multi_agent_state.py b/src/agent/core/multi_agent_state.py new file mode 100644 index 0000000..c236458 --- /dev/null +++ b/src/agent/core/multi_agent_state.py @@ -0,0 +1,172 @@ +"""多 Agent 体系共享状态:Agent 运行态、图事件、工具调用记录。 + +系统定位: + 为一主多子 / 主从 Agent 体系提供统一的运行时状态类型, + 被 ``multi_agent_graph`` 的 SupervisorGraph 使用。 + +状态字段: + - messages: 全局对话消息(所有 agent 共享)。 + - agent_status: 各 agent 当前运行状态(idle/running/done/error)。 + - active_agent: 当前正在执行的 agent 名称。 + - graph_events: 图执行事件时间线,供前端 graph 面板渲染。 + - tool_calls_history: 按 agent 分组的工具调用记录。 + - agent_messages: 按 agent 分组的主-子消息传递记录。 + +可扩展性: + - 可增加 token 用量统计、执行耗时、错误堆栈等字段。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Annotated, Any, Literal, Sequence, TypedDict + +from langchain_core.messages import BaseMessage +from langgraph.graph.message import add_messages + + +# ---------- Agent 运行状态 ---------- +AgentStatus = Literal["idle", "running", "done", "error"] + + +@dataclass +class AgentStatusInfo: + """单个 Agent 的运行时状态信息。 + + 字段: + name: Agent 名称。 + status: 当前状态:idle(等待)/ running(执行中)/ done(已完成)/ error(出错)。 + role: main | sub。 + icon: 前端展示图标(默认 🧠)。 + error_message: 出错时的错误信息。 + """ + name: str = "" + status: AgentStatus = "idle" + role: Literal["main", "sub"] = "sub" + icon: str = "🧠" + error_message: str = "" + + +# ---------- 图事件 ---------- +@dataclass +class GraphEvent: + """图执行过程中的单个事件,供前端 graph 面板实时更新。 + + 字段: + event_type: 事件类型(agent_start / agent_end / tool_call / message_pass / error)。 + source_agent: 源 agent 名称(message_pass 时为主 agent)。 + target_agent: 目标 agent 名称(message_pass 时为子 agent)。 + agent_name: 当前 agent 名称(非 message_pass 事件)。 + tool_name: 工具名称(tool_call 事件)。 + tool_args: 工具参数(tool_call 事件)。 + tool_result_preview: 工具返回摘要(tool_call 事件)。 + timestamp: 事件发生时间戳。 + message_preview: 消息传递内容摘要。 + """ + event_type: Literal["agent_start", "agent_end", "tool_call", "message_pass", "error"] = "agent_start" + source_agent: str = "" + target_agent: str = "" + agent_name: str = "" + tool_name: str = "" + tool_args: dict[str, Any] = field(default_factory=dict) + tool_result_preview: str = "" + timestamp: float = 0.0 + message_preview: str = "" + + +@dataclass +class ToolCallEvent: + """工具调用记录(持久化到历史)。""" + tool_name: str = "" + tool_args: dict[str, Any] = field(default_factory=dict) + result_preview: str = "" + status: Literal["success", "error", "pending"] = "success" + timestamp: float = 0.0 + + +# ---------- 多 Agent 状态 ---------- +class MultiAgentState(TypedDict, total=False): + """多 Agent 体系共享运行时状态。 + + 字段: + messages: 全局对话消息序列(add_messages 合并)。 + agent_status: {agent_name: AgentStatusInfo} 各 agent 运行状态。 + active_agent: 当前正在执行的 agent 名称。 + graph_events: 图执行事件时间线(追加)。 + tool_calls_history: {agent_name: [ToolCallEvent]} 工具调用记录。 + agent_messages: {agent_name: [dict]} 各 agent 收发的消息摘要。 + user_query: 本轮用户原始查询。 + final_response: 最终响应的文本内容。 + """ + messages: Annotated[Sequence[BaseMessage], add_messages] + agent_status: dict[str, AgentStatusInfo] + active_agent: str | None + graph_events: list[GraphEvent] + tool_calls_history: dict[str, list[ToolCallEvent]] + agent_messages: dict[str, list[dict[str, Any]]] + user_query: str + final_response: str + + +# ---------- 状态初始化辅助 ---------- +def init_agent_status( + agents: list[dict[str, str]], +) -> dict[str, AgentStatusInfo]: + """根据 agent 配置列表初始化 agent_status。 + + 输入: + agents: [{"name": "main", "role": "main", "icon": "🧠"}, ...] + + 输出: + {agent_name: AgentStatusInfo},所有 agent 初始为 idle。 + """ + result: dict[str, AgentStatusInfo] = {} + for a in agents: + name = a.get("name", "") + result[name] = AgentStatusInfo( + name=name, + status="idle", + role=a.get("role", "sub"), # type: ignore[arg-type] + icon=a.get("icon", "🧠"), + ) + return result + + +def init_multi_agent_state( + agents: list[dict[str, str]], + messages: list[BaseMessage] | None = None, + user_query: str = "", +) -> dict: + """创建 MultiAgentState 的初始输入字典。 + + 输入: + agents: agent 配置列表。 + messages: 初始消息列表。 + user_query: 用户查询文本。 + + 输出: + 可直接传入 graph.invoke() 的初始状态字典。 + """ + return { + "messages": messages or [], + "agent_status": init_agent_status(agents), + "active_agent": None, + "graph_events": [], + "tool_calls_history": {}, + "agent_messages": {}, + "user_query": user_query, + "final_response": "", + } + + +def create_empty_multi_agent_state() -> MultiAgentState: + """创建空的 MultiAgentState,用于类型标注时的默认值。""" + return MultiAgentState( + messages=[], + agent_status={}, + active_agent=None, + graph_events=[], + tool_calls_history={}, + agent_messages={}, + user_query="", + final_response="", + ) diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py new file mode 100644 index 0000000..72e57eb --- /dev/null +++ b/src/agent/core/nodes.py @@ -0,0 +1,143 @@ +"""LangGraph 图节点:模型调用与工具产物后处理。 + +系统定位: + 实现 ReAct 循环中的 ``agent`` 节点工厂与 ``process_tool_artifact`` 节点, + 被 ``graph_builder`` 注册到 StateGraph。 + +可扩展性: + - ``make_call_model_node`` 可注入 streaming、重试、用量统计回调。 + - ``process_tool_artifact`` 可扩展 audio、文件、结构化 JSON 等 artifact 类型。 +""" +from __future__ import annotations + +import json +import time +from typing import Any, Callable + +from langchain_core.messages import HumanMessage, ToolMessage + +from agent.core.state import AgentState +from agent.utils.image_utils import image_bytes_to_openai_image_url_part +from agent.utils.tool_call_utils import normalize_tool_call + + +def make_call_model_node(model_with_tools: Any) -> Callable[[AgentState], dict]: + """创建调用已绑定工具的 LLM 的图节点闭包。 + + 输入: + model_with_tools: bind_tools 后的 LangChain 聊天模型。 + + 输出: + 符合 LangGraph 节点签名的 ``call_model(state) -> {"messages": [AIMessage]}``。 + + 系统定位: + 图中 ``agent`` 节点:读取 state.messages,调用模型,追加 AI 响应。 + + 可扩展性: + 可包装为 async 节点;可在此统一记录 token 用量到 session_meta。 + """ + + def call_model(state: AgentState) -> dict: + response = model_with_tools.invoke(state["messages"]) + + usage = response.usage_metadata if hasattr(response, "usage_metadata") else {} + if usage: + input_tokens = usage.get("input_tokens", "?") + output_tokens = usage.get("output_tokens", "?") + total_tokens = usage.get("total_tokens", "?") + print(f"[LLM] Token使用: input={input_tokens}, output={output_tokens}, total={total_tokens}") + + if hasattr(response, "tool_calls") and response.tool_calls: + for tc in response.tool_calls: + tc_normalized = normalize_tool_call(tc) + tool_name = tc_normalized.get("name", "unknown") + tool_args = tc_normalized.get("args", {}) + print(f"[LLM] 工具调用: {tool_name}, 参数: {tool_args}") + # 实时记录工具调用 + try: + from agent.history.tool_call_recorder import record_tool_call_live, get_current_session + sid = get_current_session() + if sid: + record_tool_call_live(sid, tool_name, tool_args) + except Exception: + pass + + return {"messages": [response]} + + return call_model + + +def process_tool_artifact(state: AgentState) -> dict: + last_msg = state["messages"][-1] + + if isinstance(last_msg, ToolMessage): + content_preview = str(last_msg.content) + if len(content_preview) > 200: + content_preview = content_preview[:200] + "..." + print(f"[Tool] 工具返回 (id={last_msg.tool_call_id}): {content_preview}") + + # 实时记录工具返回结果 + try: + from agent.history.tool_call_recorder import record_tool_result_live, get_current_session + sid = get_current_session() + if sid: + img_bytes = None + if last_msg.artifact: + if isinstance(last_msg.artifact, dict) and "image_bytes" in last_msg.artifact: + img_bytes = last_msg.artifact["image_bytes"] + elif isinstance(last_msg.artifact, list): + for item in last_msg.artifact: + if isinstance(item, dict) and "image_bytes" in item: + img_bytes = item["image_bytes"] + break + elif isinstance(item, bytes): + img_bytes = item + break + elif isinstance(last_msg.artifact, bytes): + img_bytes = last_msg.artifact + record_tool_result_live(sid, last_msg.name or "unknown", str(last_msg.content) if last_msg.content else "", img_bytes) + except Exception: + pass + + if not isinstance(last_msg, ToolMessage) or not last_msg.artifact: + return {} + + artifact = last_msg.artifact + img_bytes_list: list[bytes] = [] + + if isinstance(artifact, dict) and "image_bytes" in artifact: + img_bytes_list.append(artifact["image_bytes"]) + elif isinstance(artifact, list): + for item in artifact: + if isinstance(item, dict) and "image_bytes" in item: + img_bytes_list.append(item["image_bytes"]) + elif isinstance(item, bytes): + img_bytes_list.append(item) + elif isinstance(artifact, bytes): + img_bytes_list.append(artifact) + + if not img_bytes_list: + return {} + + content_parts = [] + failed_count = 0 + for img_bytes in img_bytes_list: + data_url = image_bytes_to_openai_image_url_part(img_bytes) + if data_url is None: + failed_count += 1 + continue + content_parts.append(data_url) + + if not content_parts: + human_msg = HumanMessage( + content="工具返回了截图,但图片处理失败,请向用户说明。", + additional_kwargs={"synthetic": True}, + ) + return {"messages": [human_msg]} + + content_parts.append({"type": "text", "text": "上面是工具返回的截图。"}) + if failed_count > 0: + content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)" + + human_msg = HumanMessage(content=content_parts, additional_kwargs={"synthetic": True}) + return {"messages": [human_msg]} diff --git a/src/agent/core/routing.py b/src/agent/core/routing.py new file mode 100644 index 0000000..8aa88fd --- /dev/null +++ b/src/agent/core/routing.py @@ -0,0 +1,77 @@ +"""LangGraph 条件路由:工具循环 vs 结束 vs 待确认暂停。 + +系统定位: + 连接 ``graph_builder`` 的条件边与 ``runtime.execute_agent`` 的 + 后处理逻辑,在模型输出 tool_calls 时决定图走向或提取 UI 待确认元数据。 + +可扩展性: + - ``should_continue`` 可扩展为多目标路由(如专用 RAG 节点)。 + - ``first_pending_tool_meta`` 可支持多个并行待确认项的优先级队列。 +""" +from __future__ import annotations + +from typing import Any, Literal, Sequence + +from langchain_core.messages import BaseMessage +from langgraph.graph import END + +from agent.core.approvals import build_approval_meta, build_human_interaction_meta +from agent.core.state import AgentState +from agent.core.tool_policy import classify_tool_execution +from agent.utils.tool_call_utils import normalize_tool_call + + +def should_continue(state: AgentState) -> Literal["tools", "__end__"]: + """根据最后一条 AI 消息中的 tool_calls 决定下一节点。 + + 输入: + state: 含 messages 的 AgentState。 + + 输出: + ``"tools"`` 进入 ToolNode;``"__end__"`` 结束图(含需确认工具场景)。 + + 系统定位: + ``graph_builder`` 中 agent 节点的 conditional_edges 路由函数。 + 若任一 tool_call 策略为 confirm,则直接 END,由 runtime 展示确认 UI。 + + 可扩展性: + 可先执行 direct 类工具、仅对 confirm 类暂停(需拆分 tool_calls 处理)。 + """ + last_msg = state["messages"][-1] + if hasattr(last_msg, "tool_calls") and last_msg.tool_calls: + for raw_tool_call in last_msg.tool_calls: + tool_call = normalize_tool_call(raw_tool_call) + if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm": + return END + return "tools" + return END + + +def first_pending_tool_meta(messages: Sequence[BaseMessage], session_id: str) -> dict[str, Any] | None: + """从图执行结果中提取第一个需 UI 处理的待确认项元数据。 + + 输入: + messages: 图 invoke 返回的完整消息列表。 + session_id: 当前会话 ID,写入 PENDING_TOOL_APPROVALS。 + + 输出: + 含 ``pending_action`` 的 meta 字典;无待确认项时返回 None。 + + 系统定位: + ``runtime.execute_agent`` 在图结束后调用,决定是否追加确认型 assistant 消息。 + + 可扩展性: + 可返回列表以支持批量确认;可按工具类型定制 meta 结构。 + """ + if not messages: + return None + last_msg = messages[-1] + if not hasattr(last_msg, "tool_calls") or not last_msg.tool_calls: + return None + for raw_tool_call in last_msg.tool_calls: + tool_call = normalize_tool_call(raw_tool_call) + if tool_call.get("name") == "human_interaction": + return build_human_interaction_meta(session_id, tool_call, messages) + if classify_tool_execution(tool_call.get("name"), tool_call.get("args") or {}) == "confirm": + return build_approval_meta(session_id, tool_call, messages) + return None diff --git a/src/agent/core/runtime.py b/src/agent/core/runtime.py new file mode 100644 index 0000000..8a605e8 --- /dev/null +++ b/src/agent/core/runtime.py @@ -0,0 +1,476 @@ +"""Agent 运行时:对话执行、历史持久化与人工确认续跑。 + +系统定位: + Web 层(如 ``web.ui_session``)与 LangGraph 之间的编排层。 + ``agents/*`` 组装 ``AgentRuntime`` 实例,本模块提供统一执行 API。 + +主要流程: + execute_agent: 用户消息 -> summarizer 压缩 -> graph.invoke -> 响应/待确认 + continue_after_human_action: 用户确认/拒绝 -> 工具执行或图续跑 + +可扩展性: + - ``AgentRuntime`` 可增加 checkpointer、tracing、多模态配置。 + - 可将 team 模式拆为独立 executor 类。 +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Literal + +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langchain_core.messages import messages_from_dict +from langgraph.errors import GraphRecursionError + +from agent.core.approvals import PENDING_TOOL_APPROVALS, pending_assistant_message, remove_pending_ui_message +from agent.core.human_interaction import human_interaction_result +from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, set_current_turn, set_current_session +from agent.memory.system_prompt import TEAM_MODE_PROMPT +from agent.core.routing import first_pending_tool_meta +from agent.core.tool_policy import classify_tool_execution +from agent.utils.agent_utils import assistant_text, get_history, get_session_meta, summarizer, update_session_meta +from agent.utils.tool_call_utils import normalize_tool_call +from web.ui_session import rewrite_user_message_files_to_session_dir, session_dir, sync_manifest_from_chat_history + +AgentMode = Literal["agent", "team"] + + +@dataclass +class AgentRuntime: + """单个 Agent 的运行时配置容器。 + + 字段: + name: Agent 标识,用于 session_meta 与日志。 + graph: 编译后的 LangGraph。 + llm: 原始 LLM 实例(压缩、TTS 等)。 + tools: 工具列表。 + tools_by_name: 工具名 -> 工具实例,供确认后手动 invoke。 + system_prompt: 系统提示词,传入 summarizer。 + max_iterations: 最大迭代次数,超过则输出 OOM(默认 200)。 + + 系统定位: + ``agents/main_agent`` 等模块的装配结果,传给 execute_agent。 + + 可扩展性: + 可增加 default_mode、专属 checkpointer 等字段。 + """ + + name: str + graph: Any + llm: Any + tools: list[Any] + tools_by_name: dict[str, Any] + system_prompt: str + max_iterations: int = 200 + + +_OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。请简化任务或增加 max_iterations 配置后重试。" + + +def _invoke_with_recursion_limit( + runtime: AgentRuntime, + graph: Any, + input_data: dict, + config: dict | None = None, +) -> dict: + """包装 graph.invoke,注入 recursion_limit 并捕获超限错误返回 OOM 消息。""" + cfg = dict(config or {}) + cfg["recursion_limit"] = runtime.max_iterations + try: + return graph.invoke(input_data, config=cfg) + except GraphRecursionError: + print(f"[OOM] Agent '{runtime.name}' 达到最大迭代次数 {runtime.max_iterations},输出 OOM。") + return {"messages": [AIMessage(content=_OOM_MESSAGE)], "_oom": True} + + +def save_messages_to_history(session_id: str, user_msg: HumanMessage, assistant_msg: AIMessage) -> None: + """将本轮用户与助手消息写入 SQLite 聊天历史。 + + 输入: + session_id: 会话 ID;空则跳过。 + user_msg: 用户 HumanMessage;含 synthetic 标记时不写入。 + assistant_msg: 助手 AIMessage。 + + 输出: + 无。 + + 系统定位: + execute_agent 正常回复结束后持久化对话。 + + 可扩展性: + 可改为异步写入、双写向量库、按 agent_name 分表。 + """ + if not session_id: + return + history = get_history(session_id) + if not user_msg.additional_kwargs.get("synthetic"): + history.add_message(user_msg) + history.add_message(assistant_msg) + + +def _message_content_equals(left: Any, right: Any) -> bool: + """宽松比较消息 content,兼容纯文本与多模态列表。""" + if left == right: + return True + return assistant_text(left) == assistant_text(right) + + +def _pending_action_id(meta: dict[str, Any] | None) -> str | None: + """从 assistant meta 中提取 pending_action.id。""" + pending_action = (meta or {}).get("pending_action") or {} + return pending_action.get("id") + + +def _remember_pending_user_content(pending_meta: dict[str, Any], user_content: Any) -> None: + """将本轮用户输入缓存到 PENDING_TOOL_APPROVALS,供确认续跑时对齐历史。""" + approval_id = _pending_action_id(pending_meta) + if approval_id and approval_id in PENDING_TOOL_APPROVALS: + PENDING_TOOL_APPROVALS[approval_id]["user_content"] = user_content + + +def _persist_messages_from_pending_turn( + session_id: str, + messages: list[Any], + pending: dict[str, Any], +) -> None: + if not session_id or not messages: + return + # 找到本轮用户消息 + user_content = pending.get("user_content") + start_index = 0 + if user_content is not None: + for idx in range(len(messages) - 1, -1, -1): + msg = messages[idx] + if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"): + if _message_content_equals(msg.content, user_content): + start_index = idx + break + # 只提取: + # - 用户消息(跳过 synthetic) + # - 最终一条不包含 tool_calls 的助手消息 + user_msg = None + final_assistant = None + + for msg in messages[start_index:]: + if isinstance(msg, HumanMessage) and not msg.additional_kwargs.get("synthetic"): + user_msg = msg + if isinstance(msg, AIMessage) and not msg.tool_calls: + final_assistant = msg + + history = get_history(session_id) + if user_msg: + history.add_message(user_msg) + if final_assistant: + history.add_message(final_assistant) + + +def _persist_pending_rejection(session_id: str, pending: dict[str, Any], content: str) -> None: + """将用户拒绝待确认操作这一轮写入历史,避免下一轮上下文断层。""" + prior_messages = messages_from_dict(pending.get("messages") or []) + prior_messages.append(AIMessage(content=content)) + _persist_messages_from_pending_turn(session_id, prior_messages, pending) + + +def _invoke_after_manual_tool_result( + runtime: AgentRuntime, + messages: list[Any], + session_id: str, + *, + max_steps: int = 8, +) -> tuple[list[Any], dict[str, Any] | None]: + """人工确认后继续推进图,并在遇到新的待确认项时暂停。""" + current_messages = messages + for _ in range(max_steps): + result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": current_messages}, {"configurable": {"session_id": session_id}}) + all_messages = result["messages"] + pending_meta = first_pending_tool_meta(all_messages, session_id) + if pending_meta: + return all_messages, pending_meta + + last_msg = all_messages[-1] if all_messages else None + if not isinstance(last_msg, AIMessage) or not last_msg.tool_calls: + return all_messages, None + + next_messages = [*all_messages] + progressed = False + for raw_tool_call in last_msg.tool_calls: + tool_call = normalize_tool_call(raw_tool_call) + tool_name = tool_call.get("name") + tool_args = tool_call.get("args") or {} + if classify_tool_execution(tool_name, tool_args) != "direct": + continue + tool = runtime.tools_by_name.get(tool_name or "") + if not tool: + continue + result = tool.invoke(tool_args) + tool_content = result[0] if isinstance(result, tuple) else result + call_id = tool_call.get("id") or f"manual_{session_id}_{len(next_messages)}" + next_messages.append(ToolMessage(content=str(tool_content), tool_call_id=call_id, name=tool_name or "unknown_tool")) + progressed = True + if not progressed: + return all_messages, None + current_messages = next_messages + return current_messages, None + + +def execute_agent( + runtime: AgentRuntime, + chat_history: list[dict[str, Any]], + session_id: str = "test", + output_type: str = "text", + agent_mode: AgentMode = "agent", +) -> list[dict[str, Any]]: + """处理用户新一轮输入,驱动 Agent 图执行并更新 chat_history。 + + 输入: + runtime: Agent 运行时配置。 + chat_history: Web UI 聊天历史(dict 列表);末条须为 user。 + session_id: 会话 ID,用于历史、文件目录、manifest 同步。 + output_type: text | audio | both,传入图 metadata。 + agent_mode: agent(默认)| team(占位提示)。 + + 输出: + 更新后的 chat_history;可能追加 assistant 消息或 pending_action。 + + 系统定位: + 对外主入口,由 Web/API 层在每轮用户发送后调用。 + + 可扩展性: + 可返回 generator 支持流式;可注入 middleware 做鉴权、限流。 + """ + chat_history = chat_history or [] + + if not chat_history or chat_history[-1].get("role") != "user": + return chat_history + + if session_id: + rewrite_user_message_files_to_session_dir(chat_history[-1], session_id) + + user_content = chat_history[-1]["content"] + + if agent_mode == "team": + chat_history.append({ + "role": "assistant", + "content": TEAM_MODE_PROMPT, + "meta": {"output_type": "text", "autoplay": False, "agent_mode": "team"}, + }) + if session_id: + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history + + messages, context_exceeded = summarizer( + session_id=session_id, + agent_name=runtime.name, + system_prompt=runtime.system_prompt, + user_content=user_content, + llm=runtime.llm, + ) + + if context_exceeded: + chat_history.append({"role": "assistant", "content": "上下文已满,请新建会话继续对话。"}) + if session_id: + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history + + graph_config = { + "configurable": {"session_id": session_id}, + "metadata": { + "output_type": output_type, + "audio_output_dir": session_dir(session_id) if session_id else None, + }, + } + + try: + # 开始实时记录(新 turn 隔离旧数据) + turn_id = "" + if session_id: + turn_id = start_live_turn(session_id) + set_current_session(session_id) + result = _invoke_with_recursion_limit(runtime, runtime.graph, {"messages": messages}, graph_config) + set_current_turn("") + set_current_session("") + all_messages = result["messages"] + pending_meta = first_pending_tool_meta(all_messages, session_id) + if pending_meta: + end_live_turn(session_id) + _remember_pending_user_content(pending_meta, user_content) + chat_history.append(pending_assistant_message(pending_meta)) + if session_id: + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history + + end_live_turn(session_id) + + assistant_msg = all_messages[-1] if all_messages else AIMessage(content="") + text_content = assistant_text(assistant_msg.content) + + usage = assistant_msg.usage_metadata if hasattr(assistant_msg, "usage_metadata") else {} + total_tokens = usage.get("total_tokens", 0) if isinstance(usage, dict) else 0 + if total_tokens > 0: + meta = get_session_meta(session_id, runtime.name) + update_session_meta( + session_id=session_id, + agent_name=runtime.name, + cursor_index=meta["cursor_index"], + compressed_content=meta["compressed_content"], + last_total_tokens=total_tokens, + ) + + output_type_from_config = graph_config.get("metadata", {}).get("output_type", "text") + audio_data = runtime.llm.last_audio_kwargs or {} + if audio_data.get("output_type") != output_type_from_config: + audio_data = {} + + assistant_meta = { + "output_type": output_type_from_config, + "autoplay": False, + "audio": audio_data.get("audio"), + } + + if output_type_from_config == "audio" and assistant_meta.get("audio"): + assistant_content = [] + elif output_type_from_config == "both" and assistant_meta.get("audio"): + assistant_content = text_content + else: + assistant_content = text_content if text_content else "(空回复)" + + chat_history.append({"role": "assistant", "content": assistant_content, "meta": assistant_meta}) + save_messages_to_history(session_id, HumanMessage(content=user_content), AIMessage(content=text_content)) + runtime.llm.last_audio_kwargs = None + + # 保存工具调用记录 + if session_id and turn_id: + save_turn(session_id, turn_id, {"role": "user", "content": user_content}, all_messages) + + except Exception: + import traceback + set_current_turn("") + set_current_session("") + end_live_turn(session_id) + print("[ERROR] 调用模型时出错:") + traceback.print_exc() + chat_history.append({"role": "assistant", "content": "抱歉,我遇到了一些问题,请稍后再试。"}) + + if session_id: + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history + + +def continue_after_human_action( + runtime: AgentRuntime, + chat_history: list[dict[str, Any]], + session_id: str, + approval_id: str, + decision: Literal["approve", "reject", "edit"], + instruction: str | None = None, + output_type: str = "text", +) -> list[dict[str, Any]]: + """用户对待确认项做出决策后,继续执行或结束该轮交互。 + + 输入: + runtime: Agent 运行时。 + chat_history: 当前聊天历史。 + session_id: 会话 ID。 + approval_id: pending_action.id。 + decision: approve | reject | edit(edit 用于 human_interaction selection 提交文本)。 + instruction: selection 时为用户输入文本。 + output_type: 续跑后的回复输出类型。 + + 输出: + 更新后的 chat_history。 + + 系统定位: + UI 确认框回调入口;按 pending.type 分支: + human_interaction / tool_call。 + + 可扩展性: + 可增加 timeout 自动 reject;支持批量 approve 多个 tool_call。 + """ + chat_history = chat_history or [] + pending = PENDING_TOOL_APPROVALS.pop(approval_id, None) + if not pending: + chat_history.append({"role": "assistant", "content": "该待确认项已处理或已过期。", "meta": {"output_type": "text"}}) + return chat_history + + remove_pending_ui_message(chat_history, approval_id) + + if decision == "reject" and pending.get("type") != "human_interaction": + content = "已拒绝,本次待确认操作不会执行。" + chat_history.append({"role": "assistant", "content": content, "meta": {"output_type": "text"}}) + _persist_pending_rejection(session_id, pending, content) + if session_id: + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history + + if pending.get("type") == "human_interaction": + try: + tool_call = dict(pending.get("tool_call") or {}) + prior_messages = messages_from_dict(pending.get("messages") or []) + call_id = tool_call.get("id") or f"human_{approval_id}" + tool_content = human_interaction_result(pending, decision, instruction) + prior_messages.append(ToolMessage(content=tool_content, tool_call_id=call_id, name="human_interaction")) + all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id) + if pending_meta: + _remember_pending_user_content(pending_meta, pending.get("user_content")) + chat_history.append(pending_assistant_message(pending_meta)) + if session_id: + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history + assistant_msg = all_messages[-1] if all_messages else AIMessage(content="") + content = assistant_text(assistant_msg.content) or "(空回复)" + persisted_messages = all_messages + except Exception: + import traceback + traceback.print_exc() + content = "处理人工交互结果时遇到问题。" + persisted_messages = messages_from_dict(pending.get("messages") or []) + chat_history.append({"role": "assistant", "content": content, "meta": {"output_type": output_type, "autoplay": False}}) + _persist_messages_from_pending_turn(session_id, persisted_messages, pending) + + if session_id: + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history + + tool_call = dict(pending.get("tool_call") or {}) + tool_name = tool_call.get("name") + tool_args = tool_call.get("args") or {} + tool = runtime.tools_by_name.get(tool_name) + if not tool: + chat_history.append({"role": "assistant", "content": f"找不到工具:{tool_name}", "meta": {"output_type": "text"}}) + if session_id: + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history + + try: + result = tool.invoke(tool_args) + tool_content = result[0] if isinstance(result, tuple) else result + prior_messages = messages_from_dict(pending.get("messages") or []) + call_id = tool_call.get("id") or f"manual_{approval_id}" + prior_messages.append(ToolMessage(content=str(tool_content), tool_call_id=call_id, name=tool_name)) + all_messages, pending_meta = _invoke_after_manual_tool_result(runtime, prior_messages, session_id) + if pending_meta: + _remember_pending_user_content(pending_meta, pending.get("user_content")) + chat_history.append(pending_assistant_message(pending_meta)) + if session_id: + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history + assistant_msg = all_messages[-1] if all_messages else AIMessage(content="") + content = assistant_text(assistant_msg.content) or "(空回复)" + persisted_messages = all_messages + except Exception: + import traceback + traceback.print_exc() + content = "执行确认后的工具调用时遇到问题。" + prior_messages = messages_from_dict(pending.get("messages") or []) + call_id = tool_call.get("id") or f"manual_{approval_id}" + prior_messages.extend([ + ToolMessage(content=content, tool_call_id=call_id, name=tool_name or "unknown_tool"), + AIMessage(content=content), + ]) + persisted_messages = prior_messages + + chat_history.append({"role": "assistant", "content": content, "meta": {"output_type": output_type, "autoplay": False}}) + _persist_messages_from_pending_turn(session_id, persisted_messages, pending) + if session_id: + save_turn(session_id, pending.get("user_content") or {}, persisted_messages) + sync_manifest_from_chat_history(session_id, chat_history, "") + return chat_history diff --git a/src/agent/core/state.py b/src/agent/core/state.py new file mode 100644 index 0000000..aaf9c01 --- /dev/null +++ b/src/agent/core/state.py @@ -0,0 +1,32 @@ +"""LangGraph Agent 状态类型定义。 + +系统定位: + 定义图运行时共享状态 ``AgentState``,被 ``graph_builder``、``nodes``、 + ``routing`` 作为 StateGraph 的 schema 使用。 + +可扩展性: + - 可在此增加 ``session_id``、``agent_mode``、``pending_approval`` 等字段, + 需配合 reducer(如 add_messages)声明合并策略。 +""" +from __future__ import annotations + +from typing import Annotated, Sequence, TypedDict + +from langchain_core.messages import BaseMessage +from langgraph.graph.message import add_messages + + +class AgentState(TypedDict): + """LangGraph 图状态:消息列表为唯一状态字段。 + + 字段: + messages: 对话消息序列,通过 ``add_messages`` 追加合并。 + + 系统定位: + ReAct 循环(agent -> tools -> process_tool_artifact -> agent)的载体。 + + 可扩展性: + 扩展字段时保持 TypedDict 与 reducer 注解一致,避免并发更新冲突。 + """ + + messages: Annotated[Sequence[BaseMessage], add_messages] diff --git a/src/agent/core/tool_config.json b/src/agent/core/tool_config.json new file mode 100644 index 0000000..2d14630 --- /dev/null +++ b/src/agent/core/tool_config.json @@ -0,0 +1,58 @@ +[ + { + "name": "web_search_class", + "display_name": "WebSearch", + "description": "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true + }, + { + "name": "date_query_class", + "display_name": "DateQuery", + "description": "一个日期查询工具,可以返回当前的日期信息。仅当需要查询当前时间时调用,其余任何情况严禁调用。", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true + }, + { + "name": "screen_capture", + "display_name": "ScreenCapture", + "description": "截取当前主屏幕画面。除了返回文字描述外,也会提供原始截图给模型进行视觉分析。注意:这个工具仅用于获取当前屏幕的实时画面,其余任何情况严禁调用。", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true + }, + { + "name": "browser_control", + "display_name": "BrowserControl", + "description": "统一控制浏览器:打开/关闭浏览器、刷新/关闭标签页、打开指定 URL。", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true + }, + { + "name": "human_interaction", + "display_name": "HumanInteraction", + "description": "当任务缺少关键信息、需要人类选择路径、或需要人类审批后才能继续时调用。三种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑;approval=请求人类审批高影响决策或继续执行。工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。", + "permission": "confirm", + "auto_execute_rules": [], + "enabled": true + }, + { + "name": "software_control", + "display_name": "SoftwareControl", + "description": "操作本机已安装的软件。工具会自动扫描 Windows 开始菜单快捷方式、桌面快捷方式和 App Paths,建立软件索引后根据用户输入做模糊匹配。action=open 时打开软件;action=close 时仅做正常关闭;action=force_close 时才允许在正常关闭失败后强制结束进程。仅当用户明确要求打开、关闭或强制关闭某个本机软件时调用。", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true + }, + { + "name": "directory_listing", + "display_name": "DirectoryListing", + "description": "列出指定目录下的所有文件和子目录。可用于探索文件系统、查找文件位置。仅当需要获取目录内容或确认文件结构时调用,其余情况严禁调用。", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true + } +] \ No newline at end of file diff --git a/src/agent/core/tool_policy.py b/src/agent/core/tool_policy.py new file mode 100644 index 0000000..af8dc3e --- /dev/null +++ b/src/agent/core/tool_policy.py @@ -0,0 +1,144 @@ +"""工具执行安全策略:直接执行 vs 需人工确认。 + +系统定位: + 在 ``core/routing.should_continue`` 中决定图是否进入 ToolNode, + 或在遇到需确认工具时提前结束图执行,交由 UI 与 ``runtime`` 处理。 + +自动执行规则 (auto_execute_rules): + 对于权限为 ``confirm`` 的工具,可通过 tool_config.json 中的 + ``auto_execute_rules`` 定义参数条件,匹配时自动升级为 ``direct``。 + 每条规则格式: ``{"parameter": "action", "operator": "equals", "value": "query"}`` + 含义: 当工具调用的 ``action`` 参数等于 ``"query"`` 时,跳过确认直接执行。 + +可扩展性: + - 可增加 operator 类型(contains、in、regex)。 + - 可返回 ``Literal["direct", "confirm", "deny"]`` 支持完全禁止类工具。 +""" +from __future__ import annotations + +from contextlib import contextmanager +from contextvars import ContextVar +from typing import Literal + +_FORCE_CONFIRM_TOOLS: ContextVar[bool] = ContextVar("force_confirm_tools", default=False) + +# 从 tool_config.json 加载的自动执行规则缓存 +_AUTO_EXECUTE_RULES_CACHE: dict[str, list[dict]] | None = None +# 从 tool_config.json 加载的工具权限缓存 +_TOOL_PERMISSIONS_CACHE: dict[str, str] | None = None + + +def _get_tool_permissions() -> dict[str, str]: + """懒加载 tool_config.json 中各工具的 permission 字段。 + + 返回: + {tool_name: "direct"|"confirm"} 映射;加载失败返回空字典。 + """ + global _TOOL_PERMISSIONS_CACHE + if _TOOL_PERMISSIONS_CACHE is not None: + return _TOOL_PERMISSIONS_CACHE + try: + from agent.core.config_manager import load_tool_configs + configs = load_tool_configs() + _TOOL_PERMISSIONS_CACHE = {} + for c in configs: + if c.enabled: + _TOOL_PERMISSIONS_CACHE[c.name] = c.permission + except Exception: + _TOOL_PERMISSIONS_CACHE = {} + return _TOOL_PERMISSIONS_CACHE + + +def _get_auto_execute_rules() -> dict[str, list[dict]]: + """懒加载 tool_config.json 中的 auto_execute_rules。 + + 返回: + {tool_name: [rule, ...]} 映射;加载失败返回空字典。 + """ + global _AUTO_EXECUTE_RULES_CACHE + if _AUTO_EXECUTE_RULES_CACHE is not None: + return _AUTO_EXECUTE_RULES_CACHE + try: + from agent.core.config_manager import load_tool_configs + configs = load_tool_configs() + _AUTO_EXECUTE_RULES_CACHE = {} + for c in configs: + if c.enabled and c.auto_execute_rules: + _AUTO_EXECUTE_RULES_CACHE[c.name] = c.auto_execute_rules + except Exception: + _AUTO_EXECUTE_RULES_CACHE = {} + return _AUTO_EXECUTE_RULES_CACHE + + +def reload_auto_execute_rules() -> None: + """清除缓存,下次 classify 时重新从 JSON 加载。""" + global _AUTO_EXECUTE_RULES_CACHE, _TOOL_PERMISSIONS_CACHE + _AUTO_EXECUTE_RULES_CACHE = None + _TOOL_PERMISSIONS_CACHE = None + + +@contextmanager +def force_confirm_tools(): + token = _FORCE_CONFIRM_TOOLS.set(True) + try: + yield + finally: + _FORCE_CONFIRM_TOOLS.reset(token) + + +def _match_auto_execute_rule(rule: dict, args: dict) -> bool: + """检查工具调用的 args 是否匹配单条自动执行规则。 + + 输入: + rule: {"parameter": str, "operator": str, "value": str} + args: 工具调用的参数 dict。 + + 输出: + 匹配则返回 True。 + + 支持的 operator: + - "equals": 参数值(转为字符串后)完全等于 rule value。 + """ + param = rule.get("parameter", "") + op = rule.get("operator", "equals") + expected = str(rule.get("value", "")) + actual = str(args.get(param, "")) + if op == "equals": + return actual == expected + return False + + +def classify_tool_execution(tool_name: str | None, args: dict) -> Literal["direct", "confirm"]: + """判定工具调用是否可在图内自动执行。 + + 判定优先级: + 1. 空名称 / force_confirm / human_interaction → 强制 confirm + 2. tool_config.json 中 permission == "direct" → direct + 3. tool_config.json 中 auto_execute_rules 匹配 → direct + 4. 其余 → confirm + + 注意: + 参数提取时使用安全默认值,避免 KeyError 或 None 导致的误判。 + """ + if not tool_name: + return "confirm" + if _FORCE_CONFIRM_TOOLS.get(): + return "confirm" + # human_interaction 必须人工确认,不可绕过 + if tool_name == "human_interaction": + return "confirm" + + # 读取 tool_config.json 中的权限(含 safe_tools 默认 direct,用户可改) + permissions = _get_tool_permissions() + if permissions.get(tool_name) == "direct": + return "direct" + + # 检查 JSON 配置的 auto_execute_rules + rules = _get_auto_execute_rules().get(tool_name) + if rules: + for rule in rules: + if _match_auto_execute_rule(rule, args): + return "direct" + + # 未匹配默认需确认 + return "confirm" diff --git a/src/agent/history/data_base/chat_history.db b/src/agent/history/data_base/chat_history.db deleted file mode 100644 index 5972071..0000000 Binary files a/src/agent/history/data_base/chat_history.db and /dev/null differ diff --git a/src/agent/history/multi_inputs/20260527_130428/ui_messages.json b/src/agent/history/multi_inputs/20260527_130428/ui_messages.json deleted file mode 100644 index a2d4907..0000000 --- a/src/agent/history/multi_inputs/20260527_130428/ui_messages.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "\"C:\\Users\\86166\\Desktop\\rag_test\",帮我把这个文件夹下的文档全部归入rag个人知识库" - } - ] - }, - "assistant": { - "text": "已成功将 `C:\\Users\\86166\\Desktop\\rag_test` 文件夹下的所有文档(共4个文件)添加到您的RAG个人知识库中,系统共提取并添加了11条知识块。\n\n包含的文件如下:\n- 曾嘉翔-用户画像.docx\n- 朱振宁-用户画像.docx\n- 赵永顺-今日行程安排.docx\n- 赵永顺-用户画像.docx\n\n如果您需要查询这些内容或进行其他操作,请随时告诉我!", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260527_130748/ui_messages.json b/src/agent/history/multi_inputs/20260527_130748/ui_messages.json deleted file mode 100644 index 550b169..0000000 --- a/src/agent/history/multi_inputs/20260527_130748/ui_messages.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "赵永顺今天上午吃了什么" - } - ] - }, - "assistant": { - "text": "我无法知道赵永顺今天上午吃了什么,因为我不了解他的个人生活细节,也没有相关的实时信息。如果您是他本人或认识他,可以直接询问他本人。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - }, - { - "user": { - "content": [ - { - "type": "text", - "text": "你可以去rag库查一查" - } - ] - }, - "assistant": { - "text": "根据工具返回的文本信息,赵永顺今天上午10点5分醒来后,吃了**两个酱肉包**。\n\n之后他进行了rag开发测试,并计划下午剪辑照片、召开讨论和分享会。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - }, - { - "user": { - "content": [ - { - "type": "text", - "text": "那他的系统架构是什么样的呢" - } - ] - }, - "assistant": { - "text": "根据这张截图,赵永顺的系统架构是一个名为 **`chatbot_agent`** 的编排系统,其核心是一个多模态智能体,主要包含以下几个部分:\n\n1. **用户层 (User Layer)**\n * **Gradio 桌面 UI**: 提供桌面应用程序的交互界面。\n * **FastAPI Web 静态前端**: 提供基于Web的交互界面。\n * 这两个前端都作为用户入口,与核心的 `chatbot_agent` 进行交互。\n\n2. **chatbot_agent 编排 (Orchestration)**\n 这是系统的核心处理逻辑,具体流程如下:\n * **用户输入**: 接收来自 `MultimodalTextbox` 或 `Chatbot` 的消息。\n * **消息记录**: 通过 `add_message` 将用户消息写入对话记录。\n * **代理执行 (`execute_agent`)**: 这是核心逻辑处理单元。\n * **图片处理**: 首先判断输入中是否包含图片。\n * **有图片**: 调用 `describe_images` (基于 Qwen-VL 多模态模型) 生成图片描述 (`image_description`)。\n * **无图片**: 直接使用纯文本输入。\n * **输入组合**: 将图片描述(如果有)和用户查询 (`query`) 组合成纯文本 (`chain_input_from_image_and_query`)。\n * **文件处理**: 用户上传的文件会被复制到 `history/multi_inputs/(session_id)/` 目录下。\n * **上下文组合**: 将图片路径、图片描述和用户查询组合成最终的输入 (`chain_input`)。\n * **历史会话管理**:\n * **读取历史**: 从 SQLite 数据库中读取会话历史 (`summarize_history`)。\n * **摘要与截断**: 如果消息数量超过8条,系统会将旧消息生成一个对话摘要,并只保留最近的8条消息 (`chat_history`)。\n * **构建完整上下文**: 将系统提示词 (包含助手人设和摘要占位符) 和对话历史 (`chat_history`) 组合在一起 (`assign`)。\n * **绑定历史**: 使用 `RunnableWithMessageHistory` 将构建好的上下文与历史会话绑定,并准备将新的交互写回数据库。\n\n3. **模型与外部 (Model & External)**\n * 系统通过一个 **ChatOpenAI 兼容客户端** 与外部的 **Qwen35 网关** (以 OpenAI API 风格) 进行交互,调用大语言模型。\n\n4. **持久化 (Persistence)**\n * **SQLite**: 用于存储 `chat_history.db` 和 `message_store`。\n * **ui_messages.json**: 存储会话清单。\n * **multi_inputs 附件目录**: 存储用户上传的文件。\n\n**总结来说**,这是一个结构清晰的智能体应用架构,它通过LangChain进行编排,能够处理多模态输入(文本和图片),智能地管理长对话历史(通过摘要和截断),并持久化存储所有会话和附件信息,最终通过API调用外部的大模型来生成回复。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260527_214854/ui_messages.json b/src/agent/history/multi_inputs/20260527_214854/ui_messages.json deleted file mode 100644 index 8f0f73d..0000000 --- a/src/agent/history/multi_inputs/20260527_214854/ui_messages.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "赵永顺是谁,简要介绍一下" - } - ] - }, - "assistant": { - "text": "赵永顺通常指的是中国著名的**话剧导演**、**表演艺术家**,也是**北京人民艺术剧院**(北京人艺)的资深导演和演员。\n\n以下是关于他的简要介绍:\n\n1. **身份背景**:他是北京人民艺术剧院的导演、演员,也是中国戏剧家协会会员。他在北京人艺工作多年,是该院艺术创作的中坚力量之一。\n2. **代表作品**:\n * **导演作品**:他执导过多部具有影响力的话剧,包括《天下第一楼》(复排)、《小井胡同》(复排)、《万家灯火》、《全家福》、《玩家》等。其中,《天下第一楼》和《万家灯火》等作品深受观众喜爱,展现了老北京的风土人情和时代变迁。\n * **表演作品**:作为演员,他也曾在多部人艺经典剧目中担任重要角色,如《茶馆》、《雷雨》等。\n3. **艺术风格**:赵永顺的导演风格细腻、严谨,擅长挖掘人物内心,注重舞台调度的生活化和真实感。他继承和发扬了北京人艺“现实主义”的优良传统,致力于塑造鲜活、立体的中国老百姓形象。\n4. **贡献**:他在传承北京人艺艺术风格、培养青年演员以及推动中国话剧发展方面做出了重要贡献。\n\n*注:如果您查询的是其他同名人物(如某位学者、地方官员或特定领域的专家),请提供更多背景信息,以便我进行更精准的查询。*", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - }, - { - "user": { - "content": [ - { - "type": "text", - "text": "去rag库中找一下赵永顺的信息再回复我" - } - ] - }, - "assistant": { - "text": "根据您提供的截图(`chatbot_agent` 编排流程图)以及RAG库中检索到的信息,这里的“赵永顺”并非指北京人艺的导演,而是**山东大学信息科学与工程学院的一位优秀学生**。\n\n以下是基于RAG库内容的详细介绍:\n\n**1. 基本身份**\n* **姓名**:赵永顺\n* **院校/专业**:山东大学(青岛)信息科学与工程学院,2022级**电子科学与技术**专业本科生。\n* **核心标签**:顶尖学术型、**双国奖得主**(连续两年获得国家奖学金)、学生工作骨干。\n\n**2. 学业与荣誉**\n* **学业表现**:专业排名前列,是学院“榜样的力量”学院榜样人物。\n* **核心荣誉**:\n * 2023—2024、2024—2025 连续两年获得**国家奖学金**。\n * 2025年青岛市千名优秀大学生。\n * 信息学院优秀共青团员。\n* **专业方向**:电子电路基础、半导体物理扎实,适配半导体、微电子、集成电路方向。\n\n**3. 能力与特质**\n* **硬技能**:掌握C/C++、Python、数据处理、学术文档撰写及科研辅助实验。\n* **软技能**:严谨自律、执行力强、沟通高效、抗压能力强。\n* **性格**:沉稳务实、目标导向、低调肯干,属于典型的“科研/技术潜力股”。\n\n**4. 近期动态与项目(结合截图信息)**\n* **项目参与**:根据截图内容,赵永顺近期正在参与 **chatbot_agent**(智能体)的**RAG(检索增强生成)开发测试**。\n * 该截图详细展示了该Agent的架构,包括用户层(Gradio/FastAPI)、核心编排逻辑(图片处理、上下文记忆管理、历史摘要)、以及持久化存储(SQLite)。\n * 这表明他具备较强的工程落地能力和对前沿AI技术(如多模态处理、LangChain编排)的掌握。\n* **行程安排**:\n * 近期计划包括解决代码Bug、模型训练、参数调优等**技术攻坚**工作。\n * 正在完善论文结构、制作答辩PPT,准备**学术输出**。\n * 曾计划与曾嘉翔、朱振宁、刘殿阳等同学进行成果分享会。\n\n**总结**\n这里的赵永顺是一位在**微电子/半导体**领域有深厚积累,同时具备**AI工程开发能力**的山东大学顶尖本科生。他不仅在学术上成绩斐然(双国奖),还积极投身于具体的技术研发项目(如截图中的chatbot_agent系统开发),是典型的复合型技术人才。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260527_215121/ui_messages.json b/src/agent/history/multi_inputs/20260527_215121/ui_messages.json deleted file mode 100644 index 09754ba..0000000 --- a/src/agent/history/multi_inputs/20260527_215121/ui_messages.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "file", - "relpath": "20260527_215121/英语演讲——我的故事_20260527_215121_1.pdf", - "name": "英语演讲——我的故事.pdf" - }, - { - "type": "text", - "text": "帮我再精简一下" - } - ] - }, - "assistant": { - "text": "这份演讲稿的核心情感非常真挚,但在语法、用词准确性和口语流畅度上还有提升空间。原稿中存在一些中式英语表达(如 \"throw reading\" 应为 \"through reading\")、重复用词以及部分句子结构松散的问题。\n\n以下是为您精简并润色后的版本,保留了原意,但语言更地道、有力,更适合演讲:\n\n### 精简润色版 (Polished & Concise Version)\n\n**Title: My Story and the Chinese Dream**\n\nGood morning, everyone.\n\nIf you ask me, \"What is the Chinese Dream?\" I would tell you: \"It is a dream belonging to all Chinese people.\"\n\nIt may seem distant, but let me explain it through my own story.\n\nI come from Liaocheng, once one of the poorest cities in Shandong. Growing up, we were told to be shrewd and vigilant because survival was a daily struggle. While city children were reading and writing, we farmers' kids spent our days working in the fields. We wanted to change our lives through education, but the opportunity was out of reach. Back then, my only dream was to leave and never return.\n\nHowever, things have changed. Today, our country encourages young people to return home and contribute. Success stories are bringing knowledge and hope back to our hometowns. Although I am away now, I will return again and again to dedicate my strength, my heart, and everything I have to make my hometown better. As the saying goes, \"He who has been through the rain wants to hold an umbrella for others.\"\n\nA profound realization has hit me: the ground beneath us is our hometown, called **China**. The dream within us is our dream, called the **Chinese Dream**.\n\nDo you see it? The Chinese Dream is neither mysterious nor distant. Unlike the self-serving actions some nations have taken in the name of democracy, our dream is simple: to widen our roads, clear our skies, refresh our air, and bring happiness to our people.\n\nThis is the Chinese Dream in my heart: a dream of peace and harmony. And it is a dream shared by people all over the world.\n\nThank you.\n\n---\n\n### 主要修改点说明 (Key Changes):\n\n1. **修正语法与拼写**:\n * \"throw reading\" -> \"through education\" (更正式且准确)。\n * \"cit ies\" -> \"cities\", \"glib\" (圆滑通常含贬义) -> \"shrewd\" (精明) 或直接调整语境。\n * \"donate my body, my blood...\" -> \"dedicate my strength, my heart...\" (原表达在英语中听起来过于血腥或医疗化,改为“奉献”更得体)。\n\n2. **提升流畅度与节奏**:\n * 将 \"never never never back again\" 简化为 \"never return\",在演讲中更有力。\n * 将 \"More and more people who succeed in their domains...\" 简化为 \"Success stories are bringing...\",减少冗余。\n\n3. **优化对比表达**:\n * 关于美国/西方的部分,原句 \"in Garza\" 可能是拼写错误或指代不明(可能是指 Gaza 加沙,或者是特定的地缘政治事件,但在演讲中容易引发歧义或过于具体)。修改版将其概括为 \"self-serving actions some nations have taken...\",既保留了原意(反对以民主自由为名的损人利己),又让表达更通用、安全且有力。\n\n4. **强化结尾**:\n * 将 \"road wider, sky bluer...\" 的排比句保留并理顺,使其朗朗上口。\n\n这个版本更适合口语表达,听众更容易抓住重点,同时保留了演讲者从贫困到奋斗再到回馈家乡的感人逻辑。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - }, - { - "user": { - "content": [ - { - "type": "image_url", - "relpath": "20260527_215121/后端建议_20260527_215121_1.png", - "name": "后端建议.png" - }, - { - "type": "text", - "text": "帮我总结一下这张图片的内容" - } - ] - }, - "assistant": { - "text": "这张图片是一份关于**后端编程语言推荐**的个人建议清单,内容如下:\n\n**标题**:后端编程语言推荐 \n**副标题**:以下仅代表我个人建议\n\n**具体建议**:\n\n1. **Java**:如果你打算未来深耕 Web 后端开发,推荐选择 Java。理由是 Java 在国内 Web 开发领域的地位非常稳固,难以撼动。\n2. **Node.js**:如果你是前端程序员,想转行做后端,推荐选择 Node.js(因为前后端技术栈接近,上手快)。\n3. **Python**:除上述两种情况外,其他场景都推荐 Python。其最大优点是**简单、效率高**。\n4. **PHP**:明确建议“千万不要学 PHP”,表达了一种强烈的否定态度。\n\n✅ 总结: \n这是一份带有强烈个人倾向的后端语言选择指南,核心逻辑是:\n- 想稳扎稳打做企业级 Web 后端 → 选 Java;\n- 前端转后端 → 选 Node.js;\n- 其他通用或快速开发场景 → 选 Python;\n- PHP → 不推荐。\n\n⚠️ 注意:图中明确标注“仅代表我个人建议”,说明这不是权威结论,而是作者基于自身经验或市场观察给出的主观推荐。\n\n(注:虽然图中未提及 Go、C#、Rust 等现代后端语言,但这是“个人建议”,并非全面技术选型指南。)", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260527_215121/后端建议_20260527_215121_1.png b/src/agent/history/multi_inputs/20260527_215121/后端建议_20260527_215121_1.png deleted file mode 100644 index 4847cb5..0000000 Binary files a/src/agent/history/multi_inputs/20260527_215121/后端建议_20260527_215121_1.png and /dev/null differ diff --git a/src/agent/history/multi_inputs/20260527_215121/英语演讲——我的故事_20260527_215121_1.pdf b/src/agent/history/multi_inputs/20260527_215121/英语演讲——我的故事_20260527_215121_1.pdf deleted file mode 100644 index 0ccc36f..0000000 Binary files a/src/agent/history/multi_inputs/20260527_215121/英语演讲——我的故事_20260527_215121_1.pdf and /dev/null differ diff --git a/src/agent/history/multi_inputs/20260527_215307/22级-25年优秀学生干部_20260527_215307_1.xlsx b/src/agent/history/multi_inputs/20260527_215307/22级-25年优秀学生干部_20260527_215307_1.xlsx deleted file mode 100644 index 8cdc84e..0000000 Binary files a/src/agent/history/multi_inputs/20260527_215307/22级-25年优秀学生干部_20260527_215307_1.xlsx and /dev/null differ diff --git a/src/agent/history/multi_inputs/20260527_215307/ui_messages.json b/src/agent/history/multi_inputs/20260527_215307/ui_messages.json deleted file mode 100644 index 9da7e4e..0000000 --- a/src/agent/history/multi_inputs/20260527_215307/ui_messages.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "file", - "relpath": "20260527_215307/22级-25年优秀学生干部_20260527_215307_1.xlsx", - "name": "22级-25年优秀学生干部.xlsx" - }, - { - "type": "text", - "text": "帮我提取内容并加以说明" - } - ] - }, - "assistant": { - "text": "这份文件是一份关于**22级学生评选2025年优秀学生干部**的名单汇总表。\n\n### 📋 内容提取与整理\n\n该表格共包含9个班级/项目,每个单位评选出了一名**校级**优秀学生干部和一名**院级**优秀学生干部。具体名单如下:\n\n| 班级/项目 | 校级优秀学生干部 | 院级优秀学生干部 |\n| :--- | :--- | :--- |\n| **通信一班** | 刘和宝 | 郭思宇 |\n| **通信二班** | 董卓然 | 王雨萌 |\n| **通信三班** | 朱洪亿 | 李爽爽 |\n| **电科一班** | 李皓威 | 邱浩原 |\n| **电科二班** | 赵永顺 | 王子瑶 |\n| **光信** | 纪天泽 | 温致涵 |\n| **光创** | 王志成 | 曹晶晶 |\n| **微纳** | 王隆昊 | 张加祥 |\n| **崇新** | 庞开升 | 刘希言 |\n\n---\n\n### 💡 说明与分析\n\n1. **评选层级**:\n * **校级**:代表该班级/项目中最优秀的学生干部,将代表学院参加学校层面的评选或表彰。\n * **院级**:代表学院内部评选出的优秀学生干部,是校级评选的后备或并列荣誉。\n\n2. **涉及专业/班级**:\n * 名单涵盖了**通信工程**(分三个班)、**电子信息科学与技术**(分两个班)以及**光电信息**相关方向(光信、光创、微纳、崇新等创新班或项目班)。这表明该评选主要面向电子信息类相关专业的大二学生(22级)。\n\n3. **文件命名含义**:\n * `22级-25年优秀学生干部`:表明这是针对2022级入学学生,在2025年进行的优秀学生干部评选(通常评选周期可能跨越学年,或指2025年度的荣誉)。\n * `20260527_215307`:文件生成的时间戳显示为 **2026年5月27日 21:53:07**。这暗示该文件可能是基于未来时间设定的模拟数据,或者是系统时间设置在未来/测试环境下的产物。\n\n4. **数据用途**:\n * 该表格通常用于公示评选结果、录入教务系统、制作荣誉证书或作为档案留存。\n\n如果您需要将这些数据转换为特定的格式(如CSV、JSON)或进行统计分析,请随时告诉我!", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260603_121405/ui_messages.json b/src/agent/history/multi_inputs/20260603_121405/ui_messages.json deleted file mode 100644 index 808aa20..0000000 --- a/src/agent/history/multi_inputs/20260603_121405/ui_messages.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "你好" - } - ] - }, - "assistant": { - "text": "抱歉,我遇到了一些问题,请稍后再试。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260603_121521/ui_messages.json b/src/agent/history/multi_inputs/20260603_121521/ui_messages.json deleted file mode 100644 index 808aa20..0000000 --- a/src/agent/history/multi_inputs/20260603_121521/ui_messages.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "你好" - } - ] - }, - "assistant": { - "text": "抱歉,我遇到了一些问题,请稍后再试。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260603_121652/ui_messages.json b/src/agent/history/multi_inputs/20260603_121652/ui_messages.json deleted file mode 100644 index 6441e65..0000000 --- a/src/agent/history/multi_inputs/20260603_121652/ui_messages.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "你好" - } - ] - }, - "assistant": { - "text": "抱歉,我遇到了一些问题,请稍后再试。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - }, - { - "user": { - "content": [ - { - "type": "text", - "text": "你好" - } - ] - }, - "assistant": { - "text": "抱歉,我遇到了一些问题,请稍后再试。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260603_121729/ui_messages.json b/src/agent/history/multi_inputs/20260603_121729/ui_messages.json deleted file mode 100644 index 808aa20..0000000 --- a/src/agent/history/multi_inputs/20260603_121729/ui_messages.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "你好" - } - ] - }, - "assistant": { - "text": "抱歉,我遇到了一些问题,请稍后再试。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260603_122026/ui_messages.json b/src/agent/history/multi_inputs/20260603_122026/ui_messages.json deleted file mode 100644 index 808aa20..0000000 --- a/src/agent/history/multi_inputs/20260603_122026/ui_messages.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "你好" - } - ] - }, - "assistant": { - "text": "抱歉,我遇到了一些问题,请稍后再试。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260603_122112/ui_messages.json b/src/agent/history/multi_inputs/20260603_122112/ui_messages.json deleted file mode 100644 index 808aa20..0000000 --- a/src/agent/history/multi_inputs/20260603_122112/ui_messages.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "你好" - } - ] - }, - "assistant": { - "text": "抱歉,我遇到了一些问题,请稍后再试。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260603_122357/ui_messages.json b/src/agent/history/multi_inputs/20260603_122357/ui_messages.json deleted file mode 100644 index 808aa20..0000000 --- a/src/agent/history/multi_inputs/20260603_122357/ui_messages.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "你好" - } - ] - }, - "assistant": { - "text": "抱歉,我遇到了一些问题,请稍后再试。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260603_172211/ui_messages.json b/src/agent/history/multi_inputs/20260603_172211/ui_messages.json deleted file mode 100644 index c995483..0000000 --- a/src/agent/history/multi_inputs/20260603_172211/ui_messages.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "帮我打开b站" - } - ] - }, - "assistant": { - "text": "\n\n\nopen\n\n\nhttps://www.bilibili.com\n\n\n", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - }, - { - "user": { - "content": [ - { - "type": "text", - "text": "现在几点了" - } - ] - }, - "assistant": { - "text": "\n\n\n", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/multi_inputs/20260603_175632/ui_messages.json b/src/agent/history/multi_inputs/20260603_175632/ui_messages.json deleted file mode 100644 index 999dab1..0000000 --- a/src/agent/history/multi_inputs/20260603_175632/ui_messages.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "version": 2, - "turns": [ - { - "user": { - "content": [ - { - "type": "text", - "text": "现在几点了" - } - ] - }, - "assistant": { - "text": "现在是 2026年6月3日 17:56:36。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - }, - { - "user": { - "content": [ - { - "type": "text", - "text": "帮我打开b站和虎牙" - } - ] - }, - "assistant": { - "text": "好的!从截图中可以看到:\n\n1. **B站已经打开了**:第5个标签页显示的是\"啊哈啊哈! - bilibili\"\n2. **虎牙也打开了**:当前页面正在浏览虎牙直播网站,可以看到虎牙的Logo、导航栏(首页、直播、分类、视频、游戏等),以及正在播放的直播内容和右侧的推荐直播间\n\n两个网站都已经成功打开。当前显示的是虎牙直播页面,如果您想查看B站,可以点击第5个标签页切换到B站。", - "output_type": "text", - "autoplay": false, - "audio_duration_seconds": null, - "audio_relpath": null - } - } - ] -} \ No newline at end of file diff --git a/src/agent/history/tool_call_recorder.py b/src/agent/history/tool_call_recorder.py new file mode 100644 index 0000000..7101914 --- /dev/null +++ b/src/agent/history/tool_call_recorder.py @@ -0,0 +1,263 @@ +"""工具调用历史记录器:按 session/turn 存储及实时推送。 + +设计: + 每次用户输入 → 新建一个 turn,图节点执行期间通过 live 字典实时推送, + 前端轮询显示;turn 结束后 persist 到 JSON 并清除 live 记录。 + 不同 turn 间数据完全隔离,不会泄漏到新对话中。 +""" +from __future__ import annotations + +import json +import os +import base64 +import time +import threading +from contextvars import ContextVar +from datetime import datetime +from pathlib import Path +from typing import Any + +from langchain_core.messages import BaseMessage, AIMessage, HumanMessage, ToolMessage + +_HISTORY_ROOT = Path(__file__).resolve().parent +TOOL_CALLING_ROOT = _HISTORY_ROOT / "tool_calling" + +# ---------- 当前 turn 上下文 ---------- +_current_turn_id: ContextVar[str] = ContextVar("current_turn_id", default="") +_current_session_id: ContextVar[str] = ContextVar("current_session_id", default="") + +# ---------- 实时记录(线程安全) ---------- +_live_lock = threading.Lock() +_live_records: dict[str, list[dict[str, Any]]] = {} # key = session_id + + +def set_current_turn(turn_id: str) -> None: + _current_turn_id.set(turn_id) + + +def get_current_turn() -> str: + return _current_turn_id.get() + + +def set_current_session(session_id: str) -> None: + _current_session_id.set(session_id) + + +def get_current_session() -> str: + return _current_session_id.get() + + +def start_live_turn(session_id: str) -> str: + """开始一个新 turn 并返回 turn_id。清除该 session 旧 live 记录。""" + turn_id = datetime.now().strftime("%Y%m%d_%H%M%S") + set_current_turn(turn_id) + with _live_lock: + _live_records[session_id] = [] + return turn_id + + +def record_tool_call_live(session_id: str, name: str, args: dict[str, Any]) -> None: + """图节点中调用:记录工具调用开始。""" + with _live_lock: + if session_id not in _live_records: + _live_records[session_id] = [] + _live_records[session_id].append({ + "name": name, + "args_summary": _short_args(args), + "status": "running", + "result_text": "", + "timestamp": time.time(), + }) + + +def record_tool_result_live(session_id: str, name: str, result_text: str, + image_bytes: bytes | None = None) -> None: + """图节点中调用:记录工具返回结果。""" + with _live_lock: + records = _live_records.get(session_id, []) + for rec in reversed(records): + if rec["name"] == name and rec["status"] == "running": + rec["status"] = "done" + rec["result_text"] = (result_text or "")[:500] + if image_bytes: + rec["image_base64"] = base64.standard_b64encode(image_bytes).decode("ascii") + return + + +def end_live_turn(session_id: str) -> list[dict[str, Any]]: + """结束实时记录并返回该 turn 所有记录。""" + set_current_turn("") + with _live_lock: + return _live_records.pop(session_id, []) + + +def get_live_tool_calls(session_id: str) -> list[dict[str, Any]]: + """轮询接口:当前执行中 turn 的实时工具调用列表。""" + with _live_lock: + return list(_live_records.get(session_id, [])) + + +def _short_args(args: dict[str, Any], max_len: int = 40) -> str: + s = json.dumps(args, ensure_ascii=False) + if len(s) > max_len: + return s[:max_len] + "..." + return s + + +# ---------- 持久化 ---------- +def _ensure_dir(path: Path) -> None: + path.mkdir(parents=True, exist_ok=True) + + +def session_tool_dir(session_id: str) -> Path: + return TOOL_CALLING_ROOT / session_id + + +def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[str, Any]]: + """从图执行后的消息列表中提取完整的工具调用链。""" + records: list[dict[str, Any]] = [] + pending: dict[str, dict[str, Any]] = {} + for msg in messages: + if isinstance(msg, AIMessage) and msg.tool_calls: + for tc in msg.tool_calls: + d = tc if isinstance(tc, dict) else {"name": tc.name, "args": tc.args, "id": tc.id} + if d.get("id"): + pending[d["id"]] = { + "name": d.get("name", "unknown"), + "args": _json_safe(d.get("args", {})), + "result_text": "", + "result_files": [], + } + elif isinstance(msg, ToolMessage) and msg.tool_call_id: + cid = msg.tool_call_id + if cid in pending: + entry = pending.pop(cid) + entry["result_text"] = str(msg.content) if msg.content else "" + if msg.artifact: + entry["result_files"] = _save_artifact_files(msg.artifact, msg.tool_call_id) + records.append(entry) + for entry in pending.values(): + records.append(entry) + return records + + +def _json_safe(value: Any) -> Any: + try: + json.dumps(value, ensure_ascii=False) + return value + except TypeError: + if isinstance(value, dict): + return {str(k): _json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [_json_safe(v) for v in value] + return str(value) + + +def _save_artifact_files(artifact: Any, base_name: str) -> list[str]: + files: list[str] = [] + if isinstance(artifact, dict) and "image_bytes" in artifact: + files.append(f"_artifact_{base_name}_0.png") + elif isinstance(artifact, list): + for i, item in enumerate(artifact): + if isinstance(item, dict) and "image_bytes" in item: + files.append(f"_artifact_{base_name}_{i}.png") + elif isinstance(item, bytes): + files.append(f"_artifact_{base_name}_{i}.png") + elif isinstance(artifact, bytes): + files.append(f"_artifact_{base_name}.png") + return files + + +def _find_artifact_bytes(messages: list[BaseMessage], fname: str) -> bytes | None: + for msg in messages: + if isinstance(msg, ToolMessage) and msg.artifact: + a = msg.artifact + if isinstance(a, dict) and "image_bytes" in a: + return a["image_bytes"] + if isinstance(a, list): + for item in a: + if isinstance(item, dict) and "image_bytes" in item: + return item["image_bytes"] + if isinstance(item, bytes): + return item + if isinstance(a, bytes): + return a + return None + + +def save_turn(session_id: str, turn_id: str, user_message: dict[str, Any], + messages: list[BaseMessage]) -> str | None: + """持久化一轮完整的工具调用记录。""" + tool_calls = extract_tool_calls_from_messages(messages) + if not tool_calls: + return None + + assistant_response = "" + for msg in reversed(messages): + if isinstance(msg, AIMessage) and not msg.tool_calls: + assistant_response = msg.content if isinstance(msg.content, str) else str(msg.content) + break + + session_dir = session_tool_dir(session_id) + turn_dir = session_dir / turn_id + _ensure_dir(turn_dir) + + for tc in tool_calls: + for fname in list(tc.get("result_files", [])): + img_bytes = _find_artifact_bytes(messages, fname) + if img_bytes: + (turn_dir / fname).write_bytes(img_bytes) + else: + tc["result_files"].remove(fname) + + record = { + "session_id": session_id, + "turn_id": turn_id, + "user_message": _json_safe(user_message), + "tool_calls": tool_calls, + "assistant_response": assistant_response, + "timestamp": turn_id, + } + json_path = session_dir / f"turn_{turn_id}.json" + with open(json_path, "w", encoding="utf-8") as f: + json.dump(record, f, ensure_ascii=False, indent=2) + return str(json_path) + + +def list_turns(session_id: str) -> list[dict[str, Any]]: + """列出会话下所有 turn(按时间倒序,最近5轮)。""" + session_dir = session_tool_dir(session_id) + if not session_dir.is_dir(): + return [] + turns = [] + for f in sorted(session_dir.glob("turn_*.json"), reverse=True): + try: + with open(f, "r", encoding="utf-8") as fh: + turns.append(json.load(fh)) + except (json.JSONDecodeError, OSError): + pass + return turns[:5] + + +def get_latest_turn(session_id: str) -> dict[str, Any] | None: + """获取最近一个 turn 的完整记录。""" + turns = list_turns(session_id) + return turns[0] if turns else None + + +def get_tool_calls_for_latest_turn(session_id: str) -> list[dict[str, Any]]: + """获取最近一个 turn 的工具调用(供 UI 渲染)。""" + latest = get_latest_turn(session_id) + if not latest: + return [] + turn_id = latest.get("turn_id", "") + result = [] + for tc in latest.get("tool_calls", []): + entry = dict(tc) + full_files = [] + for fname in tc.get("result_files", []): + full_files.append(str(session_tool_dir(session_id) / turn_id / fname)) + entry["result_files_full"] = full_files + entry["turn_id"] = turn_id + result.append(entry) + return result diff --git a/src/agent/llm.py b/src/agent/llm.py index 6f29272..a88b66b 100644 --- a/src/agent/llm.py +++ b/src/agent/llm.py @@ -1,489 +1,3 @@ -# agent/llm.py -import io -import os -import tempfile -import wave -import pyaudio -from contextlib import closing -from typing import Any, List, Dict, Optional -from pydantic import Field -import re, json -from datetime import datetime as dt -import requests -from concurrent.futures import ThreadPoolExecutor, as_completed - -from langchain_core.messages import HumanMessage, AIMessage, BaseMessage, SystemMessage, ToolCall -from langchain_core.outputs import ChatResult, ChatGeneration -from langchain_openai import ChatOpenAI - -from agent.utils.image_utils import image_bytes_to_openai_image_url_part -from agent.utils.env_utils import LLM_API_KEY, LLM_BASE_URL, LLM_MODEL, LLM_TIMEOUT, TTS_BASE_URL, ASR_BASE_URL -from agent.utils.agent_utils import assistant_text, Documents_process, split_text, merge_wavs -from agent.utils.image_utils import image_path_to_openai_image_url_part, image_bytes_to_openai_image_url_part, IMAGE_FILE_EXTENSIONS -from agent.memory.system_prompt import VISION_SYSTEM_PROMPT - -class ChatQwen(ChatOpenAI): - output_type: str = Field(default="text") - stream_play: bool = Field(default=False) - last_audio_kwargs: Optional[dict] = Field(default=None) - def __init__(self, **kwargs: Any) -> None: - super().__init__( - model=kwargs.pop("model", LLM_MODEL), - api_key=kwargs.pop("api_key", LLM_API_KEY), - base_url=kwargs.pop("base_url", LLM_BASE_URL), - timeout=kwargs.pop("timeout", LLM_TIMEOUT), - max_retries=kwargs.pop("max_retries", 0), - **kwargs - ) - - @staticmethod - def play_audio_bytes(audio_bytes: bytes, chunk_size: int = 1024) -> None: - if not audio_bytes: - return - wf = wave.open(io.BytesIO(audio_bytes), 'rb') - p = pyaudio.PyAudio() - stream = p.open( - format=p.get_format_from_width(wf.getsampwidth()), - channels=wf.getnchannels(), - rate=wf.getframerate(), - output=True, - ) - data = wf.readframes(chunk_size) - while data: - stream.write(data) - data = wf.readframes(chunk_size) - stream.stop_stream() - stream.close() - p.terminate() - wf.close() - - @staticmethod - def _process_image_content(part: Dict[str, Any]) -> Dict[str, Any]: - image_type = part.get("type") - if image_type != "image_url": - return part - image_url = part.get("image_url", {}) - url = image_url.get("url", "") - if url.startswith(("http://", "https://", "data:")): - return part - if isinstance(url, str) and os.path.exists(url): - ext = os.path.splitext(url)[1].lower() - if ext in IMAGE_FILE_EXTENSIONS: - result = image_path_to_openai_image_url_part(url) - return result if result else {"type": "text", "text": "[图片处理失败]"} - if isinstance(url, bytes): - result = image_bytes_to_openai_image_url_part(url) - return result if result else {"type": "text", "text": "[图片处理失败]"} - return part - - @staticmethod - def _convert_audio_to_wav(audio_path: str) -> str: - ext = os.path.splitext(audio_path)[1].lower() - if ext == ".wav": - return audio_path - try: - from pydub import AudioSegment - except ImportError as exc: - raise RuntimeError("当前 ASR 仅支持 WAV,但未安装 pydub/ffmpeg") from exc - try: - audio = AudioSegment.from_file(audio_path) - except Exception as exc: - raise RuntimeError(f"无法读取音频文件: {exc}") from exc - fd, wav_path = tempfile.mkstemp(suffix=".wav", prefix="asr_") - os.close(fd) - try: - audio.export(wav_path, format="wav") - return wav_path - except Exception: - if os.path.exists(wav_path): - os.remove(wav_path) - raise - - def _audio_to_text(self, audio_path: str) -> str: - if not os.path.exists(audio_path): - raise FileNotFoundError(f"音频文件不存在: {audio_path}") - - wav_path = self._convert_audio_to_wav(audio_path) - cleanup_wav = (wav_path != audio_path) - - try: - # 读取音频文件二进制内容 - with open(wav_path, "rb") as f: - audio_bytes = f.read() - - # 构建 Data URL - import base64 - b64 = base64.b64encode(audio_bytes).decode("ascii") - mime_type = "audio/wav" # 我们已经转换为 wav - data_url = f"data:{mime_type};base64,{b64}" - - payload = { - "messages": [ - { - "role": "user", - "content": [ - {"type": "audio_url", "audio_url": {"url": data_url}} - ] - } - ] - } - endpoint = f"{ASR_BASE_URL.rstrip('/')}/chat/completions" - resp = requests.post( - endpoint, - headers={"Content-Type": "application/json"}, - json=payload, - timeout=120 - ) - resp.raise_for_status() - result = resp.json() - content = result["choices"][0]["message"]["content"] - return content.strip() - finally: - if cleanup_wav and os.path.exists(wav_path): - os.remove(wav_path) - - def _preprocess_messages(self, messages: List[BaseMessage]) -> List[BaseMessage]: - new_messages = [] - for msg in messages: - if isinstance(msg, HumanMessage) and isinstance(msg.content, str): - text = msg.content.strip() - if text.startswith("[") and text.endswith("]"): - try: - possible_list = json.loads(text) - if isinstance(possible_list, list): - msg = HumanMessage(content=possible_list, additional_kwargs=msg.additional_kwargs) - except (json.JSONDecodeError, ValueError): - pass - if isinstance(msg, HumanMessage) and isinstance(msg.content, dict): - # 尝试从 dict 中提取文本,否则序列化为字符串 - text = msg.content.get("text") or msg.content.get("content") or "" - if text: - msg = HumanMessage(content=str(text), additional_kwargs=msg.additional_kwargs) - else: - msg = HumanMessage(content=json.dumps(msg.content, ensure_ascii=False), additional_kwargs=msg.additional_kwargs) - elif isinstance(msg, HumanMessage) and isinstance(msg.content, list): - new_content: List[Dict[str, Any] | str] = [] - for part in msg.content: - if isinstance(part, dict): - if part.get("type") == "audio": - audio_path = part.get("audio_path") - if audio_path: - try: - text = self._audio_to_text(audio_path) - new_content.append({"type": "text", "text": text}) - except Exception as e: - new_content.append({"type": "text", "text": f"[语音识别失败: {e}]"}) - elif part.get("type") == "image_url": - new_content.append(self._process_image_content(part)) - elif part.get("type") == "text": - text = (part.get("text") or "").strip() - if text: - new_content.append({"type": "text", "text": text}) - elif part.get("path") or (part.get("file") or {}).get("path"): - file_path = part.get("path") or (part.get("file") or {}).get("path") - if isinstance(file_path, str) and os.path.isfile(file_path): - ext = os.path.splitext(file_path)[1].lower() - normalized_path = file_path.replace("\\", "/") - if ext in IMAGE_FILE_EXTENSIONS: - new_content.append(self._process_image_content({"type": "image_url", "image_url": {"url": file_path}})) - else: - try: - file_text, file_images = Documents_process.process(file_path) - new_content.append({"type": "text", "text": f"用户上传了一个名为\"{normalized_path}\"的文件。"}) - if file_text.strip(): - new_content.append({ - "type": "text", - "text": f"以下是文件 {normalized_path} 的内容:\n{file_text.strip()}" - }) - for image_bytes in file_images or []: - image_part = image_bytes_to_openai_image_url_part(image_bytes) - if image_part is not None: - new_content.append(image_part) - if not file_text.strip() and not file_images: - new_content.append({"type": "text", "text": f"[用户上传了附件文件: {normalized_path}]"}) - except Exception as e: - new_content.append({"type": "text", "text": f"[附件处理失败: {normalized_path}: {e}]"}) - else: - text = assistant_text(part) - if text: - new_content.append({"type": "text", "text": text}) - elif isinstance(part, str): - text = part.strip() - if text: - new_content.append(text) - if len(new_content) == 1 and isinstance(new_content[0], str): - msg = HumanMessage(content=new_content[0], additional_kwargs=msg.additional_kwargs) - else: - msg = HumanMessage(content=new_content, additional_kwargs=msg.additional_kwargs) - new_messages.append(msg) - return new_messages - - def _text_to_audio(self, text: str) -> bytes: - chunks = split_text(text, min_effective_chars=20) - if len(chunks) == 1: - resp = requests.get(f"{TTS_BASE_URL}/tts", params={"text": chunks[0]}) - resp.raise_for_status() - return resp.content - - # 多块并行请求 - audio_parts = [None] * len(chunks) - with ThreadPoolExecutor(max_workers=min(len(chunks), 4)) as pool: - future_to_idx = { - pool.submit( - lambda t: requests.get( - f"{TTS_BASE_URL}/tts", params={"text": t} - ).content, - chunk - ): idx for idx, chunk in enumerate(chunks) - } - for future in as_completed(future_to_idx): - idx = future_to_idx[future] - try: - audio_parts[idx] = future.result() - except Exception as e: - raise RuntimeError(f"TTS request failed for chunk {idx}: {e}") - return merge_wavs(audio_parts) - - @staticmethod - def _get_wav_duration_seconds(audio_bytes: bytes) -> float | None: - if not audio_bytes: - return None - try: - with closing(wave.open(io.BytesIO(audio_bytes), "rb")) as wf: - frame_rate = wf.getframerate() - frame_count = wf.getnframes() - if frame_rate <= 0: - return None - return round(frame_count / frame_rate, 2) - except wave.Error: - return None - - def _apply_tts(self, result: ChatResult, output_type: str, audio_output_dir: str | None = None) -> ChatResult: - if output_type not in ("audio", "both") or not audio_output_dir: - return result - os.makedirs(audio_output_dir, exist_ok=True) - for idx, gen in enumerate(result.generations): - if not isinstance(gen, ChatGeneration): - continue - msg = gen.message - if not isinstance(msg, AIMessage): - continue - text = assistant_text(msg.content) - if not text.strip(): - continue - try: - audio_bytes = self._text_to_audio(text) - timestamp = dt.now().strftime("%H%M%S") - audio_path = os.path.join(audio_output_dir, f"assistant_tts_{timestamp}_{idx}.wav") - with open(audio_path, "wb") as f: - f.write(audio_bytes) - duration_seconds = self._get_wav_duration_seconds(audio_bytes) - # 仅保存到实例属性,不再写入 additional_kwargs - if idx == 0: - self.last_audio_kwargs = { - "audio": { - "sample_rate": 24000, - "format": "wav", - "path": audio_path, - "duration_seconds": duration_seconds, - }, - "output_type": output_type, - } - if self.stream_play: - self.play_audio_bytes(audio_bytes) - if output_type == "audio": - msg.content = "" - except Exception as e: - print(f"[TTS ERROR] 合成语音失败: {e}") - # 发生错误时也清空 last_audio_kwargs,避免污染 - self.last_audio_kwargs = None - return result - - def _generate( - self, - messages: List[BaseMessage], - stop: Optional[List[str]] = None, - run_manager: Optional[Any] = None, - **kwargs: Any, - ) -> ChatResult: - metadata = {} - if run_manager: - metadata = getattr(run_manager, 'metadata', {}) - output_type = metadata.get("output_type", self.output_type) - audio_output_dir = metadata.get("audio_output_dir") - - # 移除自定义参数,防止它们传入 OpenAI API - kwargs.pop("output_type", None) - kwargs.pop("audio_output_dir", None) - - messages = self._preprocess_messages(messages) - - result = super()._generate(messages, stop=stop, run_manager=run_manager, **kwargs) - - if output_type in ("audio", "both"): - result = self._apply_tts(result, output_type, audio_output_dir=audio_output_dir) - if output_type == "audio": - for gen in result.generations: - if isinstance(gen, ChatGeneration) and isinstance(gen.message, AIMessage): - gen.message.content = "" - return result - - def _create_chat_result(self, response, generation_info=None) -> ChatResult: - # ---------- 打印原始 tool_calls ---------- - try: - raw = response if isinstance(response, dict) else response.model_dump() - print("[RAW_RESPONSE]", json.dumps(raw, indent=2, ensure_ascii=False)) - choices = raw.get("choices", []) - if choices: - msg = choices[0].get("message", {}) - tool_calls = msg.get("tool_calls") - if tool_calls: - print("[RAW_TOOLCALLS]", json.dumps(tool_calls, indent=2, ensure_ascii=False)) - except Exception: - pass - - result = super()._create_chat_result(response, generation_info) - try: - dumped = response if isinstance(response, dict) else response.model_dump(exclude={"choices": {"__all__": {"message": {"parsed"}}}}) - for i, choice in enumerate(dumped.get("choices") or []): - if i >= len(result.generations): - break - lc_msg = result.generations[i].message - msg = choice.get("message") or {} - ak = lc_msg.additional_kwargs - # 优先处理 reasoning 内容 - merged = "" - for key in ("reasoning_content", "reasoning_details", "reasoning"): - merged = assistant_text(msg.get(key)) - if merged: - break - if merged: - ak.setdefault("reasoning_content", merged) - raw_content = assistant_text(msg.get("content")) - if not raw_content and merged: - lc_msg.content = merged - elif raw_content: - lc_msg.content = raw_content - - content_text = lc_msg.content if isinstance(lc_msg.content, str) else "" - - # 匹配 ... 块 - pattern = re.compile(r'\s*(.*?)\s*', re.DOTALL) - matches = pattern.findall(content_text) - if matches: - tool_calls = [] - for idx, match in enumerate(matches): - # 先尝试 JSON 解析(兼容旧格式) - try: - call_data = json.loads(match) - tool_calls.append({ - "id": f"call_{idx}", - "type": "function", - "function": { - "name": call_data.get("name", ""), - "arguments": json.dumps(call_data.get("arguments", {})) - } - }) - continue # 解析成功,跳过 XML 尝试 - except json.JSONDecodeError: - pass - - # 再尝试 XML 解析 - func_match = re.search(r'', match) - if not func_match: - continue - func_name = func_match.group(1) - - params = {} - for param_match in re.finditer(r'\s*(.*?)\s*', match, re.DOTALL): - key = param_match.group(1) - value = param_match.group(2).strip() - # 可选:智能类型转换,先保持简单 - params[key] = value - - tool_calls.append({ - "id": f"call_{idx}", - "type": "function", - "function": { - "name": func_name, - "arguments": json.dumps(params) - } - }) - - if tool_calls: - ak["tool_calls"] = tool_calls - # 构造 LangChain 的 ToolCall 对象列表 - lc_tool_calls = [] - for idx, tc in enumerate(tool_calls): - lc_tool_calls.append( - ToolCall( - id=tc["id"], - name=tc["function"]["name"], - args=json.loads(tc["function"]["arguments"]) - ) - ) - lc_msg.tool_calls = lc_tool_calls - # 清理内容中的 XML/JSON 工具调用痕迹 - cleaned = pattern.sub("", content_text).strip() - lc_msg.content = cleaned if cleaned else "" - except Exception: - pass - return result - - def describe_image(self, image_path_or_url: str, prompt: str = "请详细描述这张图片的内容。") -> str: - messages = [ - SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]), - HumanMessage(content=[ - {"type": "image_url", "image_url": {"url": image_path_or_url}}, - {"type": "text", "text": prompt} - ]) - ] - result = self.invoke(messages) - return result.content - - def describe_image_bytes(self, image_bytes: bytes, prompt: str = "请详细描述这张图片的内容。") -> str: - img_part = image_bytes_to_openai_image_url_part(image_bytes) - if img_part is None: - return "图片处理失败" - messages = [ - SystemMessage(content=[{"type": "text", "text": VISION_SYSTEM_PROMPT}]), - HumanMessage(content=[img_part, {"type": "text", "text": prompt}]) - ] - result = self.invoke(messages) - return result.content - -llm = ChatQwen() - -if __name__ == "__main__": - import sys - - # 使用原始字符串避免转义问题 - TEST_IMAGE_PATH = r"C:\Users\86166\Desktop\4.png" - - if not os.path.exists(TEST_IMAGE_PATH): - print(f"❌ 图片不存在: {TEST_IMAGE_PATH}") - sys.exit(1) - - # 测试1: 纯图片识别 - print("=" * 50) - print("测试1: 纯图片识别(无文本)") - print("=" * 50) - result1 = llm.invoke([ - HumanMessage(content=[{"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}}]) - ]) - print(f"返回类型: {type(result1)}") - print(f"返回内容: {result1.content}") - print(f"所有属性: {[attr for attr in dir(result1) if not attr.startswith('_')]}") - - # 测试2: 图片 + 文本 - print("\n" + "=" * 50) - print("测试2: 图文混合输入") - print("=" * 50) - result2 = llm.invoke([ - HumanMessage(content=[ - {"type": "image_url", "image_url": {"url": TEST_IMAGE_PATH}}, - {"type": "text", "text": "这张图片里有什么?请详细描述"} - ]) - ]) - print(f"回答: {result2.content}") +from agent.core.llm import ChatQwen, llm +__all__ = ["ChatQwen", "llm"] diff --git a/src/agent/memory/__pycache__/__init__.cpython-313.pyc b/src/agent/memory/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..a2d3427 Binary files /dev/null and b/src/agent/memory/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agent/memory/__pycache__/system_prompt.cpython-312.pyc b/src/agent/memory/__pycache__/system_prompt.cpython-312.pyc index 0bfd68a..81b095e 100644 Binary files a/src/agent/memory/__pycache__/system_prompt.cpython-312.pyc and b/src/agent/memory/__pycache__/system_prompt.cpython-312.pyc differ diff --git a/src/agent/memory/__pycache__/system_prompt.cpython-313.pyc b/src/agent/memory/__pycache__/system_prompt.cpython-313.pyc new file mode 100644 index 0000000..a3d7fa1 Binary files /dev/null and b/src/agent/memory/__pycache__/system_prompt.cpython-313.pyc differ diff --git a/src/agent/memory/system_prompt.py b/src/agent/memory/system_prompt.py index b49ef18..1e0040d 100644 --- a/src/agent/memory/system_prompt.py +++ b/src/agent/memory/system_prompt.py @@ -5,7 +5,7 @@ MAIN_SYSTEM_PROMPT = """你是一个多模态助手,能够直接理解用户 - 对于任何图片/音频输入,请始终**直接**利用你的视觉/听觉能力回答,**绝对不要**调用截图、OCR、日期查询等无关工具。 - 如果你不确定是否需要工具,选择**不调用**,直接给出你能给出的最佳答案。 - 违反上述规则是一种严重错误。 -""" +""".strip() SUMMARY_SYSTEM_PROMPT =""" 你是一个名为“Summary Agent”的专业摘要助手,专门负责对用户提供的文本内容进行高度凝练、准确的总结。 @@ -34,7 +34,7 @@ SUMMARY_SYSTEM_PROMPT =""" 你输出:“某公司于2026年5月发布了新一代量子芯片,性能较前代提升10倍,预计2027年量产。此举被业界视为量子计算商业化的关键突破,同时引发了对加密安全的讨论。” 现在,请以 Summary Agent 的身份,完成用户的摘要请求。 -""" +""".strip() VISION_SYSTEM_PROMPT = """ @@ -44,7 +44,7 @@ VISION_SYSTEM_PROMPT = """ 如果用户提供了多张图片,请分别编号描述(如「图1」「图2」等),便于后续问答引用。 看不清的内容请如实说明,不要臆测。 其次请认真回答用户的问题,结合图片内容进行分析推理,给出具体、简短、清晰、实用的回复; -""" +""".strip() DB_SYSTEM_PROMT = """ 你是一位敏捷的数据库分析助手,能够高效利用内置功能帮助用户从PostgreSQL数据库中获取答案。 @@ -65,4 +65,9 @@ DB_SYSTEM_PROMT = """ 5. **非数据库问题快速响应**:遇到问候、常识性问题或超出数据库范围的内容时,可以直接友好回应,不必强制调用功能。 请记住,你是一个协作伙伴,目标是让用户以最小的学习成本从数据库中获得有价值的信息。开始工作吧! -""" +""".strip() + +TEAM_MODE_PROMPT = """ +team 模式目前仅作为多 agent 协同的预留模式,尚未实现。 +请简短说明当前无法启动 team 协同,并建议用户改用 agent 模式。 +""".strip() diff --git a/src/agent/tools/__init__.py b/src/agent/tools/__init__.py index 17d29ff..d8191d6 100644 --- a/src/agent/tools/__init__.py +++ b/src/agent/tools/__init__.py @@ -1,34 +1,38 @@ from .tool import * -# from .tool_rag import RAGTool - -# from .tool_database import * -# from utils.database_utils import PostgreSQL_Manager - -# 创建数据库管理器实例 -# connection = "postgresql+psycopg2://postgres:12345678@localhost:5432/agent_controller" -# connection = "sqlite:///Agent/src/agent/history/data_base/chat_history.db" -# _db_manager = PostgreSQL_Manager(connection) - -# # 创建工具实例 -# _all_database_tools = [ -# Websearchclass(), -# DateQueryClass(), -# ListTableComment(db_manager=_db_manager), -# ListTableSchema(db_manager=_db_manager), -# SQLQuery(db_manager=_db_manager), -# SQLQueryChecker(db_manager=_db_manager) -# ] -# def get_sql_tools(): -# return _all_database_tools +from .tool_software_control import SoftwareControl +from .tool_rag import RAGTool _all_tools = [ WebSearch(), DateQuery(), ScreenCapture(), BrowserControl(), - # RAGTool(), + HumanInteraction(), + SoftwareControl(), + RAGTool(), DirectoryListing(), + TodoList(), ] +# 注册所有可用工具名称(供前端配置面板展示),从 _all_tools 实例推导 +_ALL_AVAILABLE_TOOL_CLASSES = {tool.name: type(tool) for tool in _all_tools} + + def get_tools(): + """返回当前 Agent 注册的全部 LangChain 工具列表。""" return _all_tools + + +def get_all_available_tool_names(): + """返回所有可用工具名称(含数据库工具,不管当前是否实例化)。""" + return list(_ALL_AVAILABLE_TOOL_CLASSES.keys()) + + +def get_all_available_tool_descriptions(): + """返回所有可用工具的名称和描述映射(用于前端展示)。""" + result = {} + for name, cls in _ALL_AVAILABLE_TOOL_CLASSES.items(): + inst = cls() + desc = getattr(inst, "description", "") + result[name] = (cls.__name__, (desc or "").split("\n")[0][:200]) + return result diff --git a/src/agent/tools/__pycache__/__init__.cpython-312.pyc b/src/agent/tools/__pycache__/__init__.cpython-312.pyc index d4f4247..a75a70b 100644 Binary files a/src/agent/tools/__pycache__/__init__.cpython-312.pyc and b/src/agent/tools/__pycache__/__init__.cpython-312.pyc differ diff --git a/src/agent/tools/__pycache__/__init__.cpython-313.pyc b/src/agent/tools/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..100e5fe Binary files /dev/null and b/src/agent/tools/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agent/tools/__pycache__/tool.cpython-312.pyc b/src/agent/tools/__pycache__/tool.cpython-312.pyc index 59fddd5..5c25270 100644 Binary files a/src/agent/tools/__pycache__/tool.cpython-312.pyc and b/src/agent/tools/__pycache__/tool.cpython-312.pyc differ diff --git a/src/agent/tools/__pycache__/tool.cpython-313.pyc b/src/agent/tools/__pycache__/tool.cpython-313.pyc new file mode 100644 index 0000000..baddf9d Binary files /dev/null and b/src/agent/tools/__pycache__/tool.cpython-313.pyc differ diff --git a/src/agent/tools/__pycache__/tool_agent_call.cpython-312.pyc b/src/agent/tools/__pycache__/tool_agent_call.cpython-312.pyc new file mode 100644 index 0000000..bc2aeb1 Binary files /dev/null and b/src/agent/tools/__pycache__/tool_agent_call.cpython-312.pyc differ diff --git a/src/agent/tools/__pycache__/tool_database.cpython-312.pyc b/src/agent/tools/__pycache__/tool_database.cpython-312.pyc new file mode 100644 index 0000000..5a32225 Binary files /dev/null and b/src/agent/tools/__pycache__/tool_database.cpython-312.pyc differ diff --git a/src/agent/tools/__pycache__/tool_database.cpython-313.pyc b/src/agent/tools/__pycache__/tool_database.cpython-313.pyc new file mode 100644 index 0000000..93f071c Binary files /dev/null and b/src/agent/tools/__pycache__/tool_database.cpython-313.pyc differ diff --git a/src/agent/tools/__pycache__/tool_rag.cpython-312.pyc b/src/agent/tools/__pycache__/tool_rag.cpython-312.pyc index 848cac0..b32880f 100644 Binary files a/src/agent/tools/__pycache__/tool_rag.cpython-312.pyc and b/src/agent/tools/__pycache__/tool_rag.cpython-312.pyc differ diff --git a/src/agent/tools/__pycache__/tool_rag.cpython-313.pyc b/src/agent/tools/__pycache__/tool_rag.cpython-313.pyc new file mode 100644 index 0000000..3816508 Binary files /dev/null and b/src/agent/tools/__pycache__/tool_rag.cpython-313.pyc differ diff --git a/src/agent/tools/__pycache__/tool_software_control.cpython-312.pyc b/src/agent/tools/__pycache__/tool_software_control.cpython-312.pyc new file mode 100644 index 0000000..9173923 Binary files /dev/null and b/src/agent/tools/__pycache__/tool_software_control.cpython-312.pyc differ diff --git a/src/agent/tools/__pycache__/tool_software_control.cpython-313.pyc b/src/agent/tools/__pycache__/tool_software_control.cpython-313.pyc new file mode 100644 index 0000000..2240ded Binary files /dev/null and b/src/agent/tools/__pycache__/tool_software_control.cpython-313.pyc differ diff --git a/src/agent/tools/doc_tool.py b/src/agent/tools/doc_tool.py new file mode 100644 index 0000000..e69de29 diff --git a/src/agent/tools/gui_tool.py b/src/agent/tools/gui_tool.py new file mode 100644 index 0000000..318d037 --- /dev/null +++ b/src/agent/tools/gui_tool.py @@ -0,0 +1 @@ +from agent.utils.env_utils import GROUNDING_WIDTH, GROUNDING_HEIGHT \ No newline at end of file diff --git a/src/agent/tools/tool.py b/src/agent/tools/tool.py index 920b23e..49b9a77 100644 --- a/src/agent/tools/tool.py +++ b/src/agent/tools/tool.py @@ -2,6 +2,7 @@ from __future__ import annotations import os import io +import json from typing import Optional, Literal, Tuple import sys import time @@ -13,8 +14,8 @@ from pathlib import Path from langchain.tools import BaseTool from pydantic import BaseModel, Field, create_model -from agent.llm import llm -from utils.env_utils import BROWSER_MAP +from agent.core.llm import llm +from agent.utils.env_utils import BROWSER_MAP class WebSearch(BaseTool): @@ -24,6 +25,7 @@ class WebSearch(BaseTool): description : str = "一个网络搜索工具,可以根据用户的查询返回相关的搜索结果。仅当需要查询近期知识或已有知识无法解答需要联网查询时调用,其余任何情况严禁调用。" def _get_search_wrapper(self): + """懒加载 Google Serper 搜索客户端。""" from langchain_community.utilities import GoogleSerperAPIWrapper SERPER_API_KEY = "d6348dcce0c72bb8102f0eacfd2f536f797da0c5" return GoogleSerperAPIWrapper(serper_api_key=SERPER_API_KEY, content_size=2048) @@ -142,6 +144,7 @@ class BrowserControl(BaseTool): # ---------- 辅助方法(与原版相同,无改动) ---------- def _get_browser_info(self, browser_name: Optional[str]): + """解析浏览器名称,返回 webbrowser 控制器与窗口标题关键字。""" if browser_name is None: return webbrowser.get(), None name_lower = browser_name.lower().strip() @@ -155,6 +158,7 @@ class BrowserControl(BaseTool): return controller, title_keyword def _activate_window(self, title_keyword: str) -> bool: + """在 Windows 上按标题关键字激活浏览器窗口(非 Win32 返回 False)。""" if sys.platform != "win32": return False try: @@ -171,10 +175,12 @@ class BrowserControl(BaseTool): return False def _send_keys(self, *keys): + """通过 pyautogui 发送组合键。""" pyautogui.hotkey(*keys) # ---------- 核心操作(返回操作结果描述) ---------- def _open_action(self, url: Optional[str], browser: Optional[str]) -> str: + """执行打开首页、刷新当前页或打开指定 URL。""" controller, title_keyword = self._get_browser_info(browser) if url is None: controller.open("about:blank") @@ -199,6 +205,7 @@ class BrowserControl(BaseTool): return f"已尝试打开 URL: {url}" def _close_action(self, url: Optional[str], browser: Optional[str]) -> str: + """执行关闭浏览器窗口或当前标签页。""" _, title_keyword = self._get_browser_info(browser) if url is None: if title_keyword: @@ -298,6 +305,109 @@ class DirectoryListing(BaseTool): async def _arun(self, path: Optional[str] = None, show_hidden: bool = False) -> str: return await asyncio.to_thread(self._run, path, show_hidden) -if __name__ == "__main__": - # 这里可以进行一些简单的测试,例如创建工具实例并调用方法 - pass + +class HumanInteractionInput(BaseModel): + interaction_type: Literal["information", "selection", "approval"] = Field( + default="information", + description="交互类型:information=人工补充信息,selection=人工选择,approval=人工审批。", + ) + title: str = Field(default="需要人工处理", description="展示给人类的交互标题。") + prompt: str = Field(default="请处理后继续。", description="需要人类理解并响应的具体问题或说明。") + options: Optional[list[str] | str] = Field( + default=None, + description="selection/approval 可选项。优先传字符串数组;如果传 JSON 字符串,系统会自动解析。", + ) + suggested_response: Optional[str] = Field(default=None, description="可选的建议回复、默认选择或默认审批意见。") + + +class HumanInteraction(BaseTool): + """向人类请求补充信息、选择或审批;实际 UI 确认由图级人工干预层承接。""" + + args_schema: type[BaseModel] = HumanInteractionInput + name: str = "human_interaction" + description: str = ( + "当任务缺少关键信息、需要人类选择路径、或需要人类审批后才能继续时调用。" + "三种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑;" + "approval=请求人类审批高影响决策或继续执行。" + "工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。" + ) + + @staticmethod + def normalize_options(options: Optional[list[str] | str]) -> list[str]: + """将 options 参数规范化为字符串列表(支持 JSON 与逗号分隔)。""" + if options is None: + return [] + if isinstance(options, str): + raw = options.strip() + if not raw: + return [] + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + return [item.strip() for item in raw.replace(",", ",").split(",") if item.strip()] + if isinstance(parsed, list): + return [str(item) for item in parsed] + return [str(parsed)] + return [str(item) for item in options] + + def _run( + self, + interaction_type: str = "information", + title: str = "需要人工处理", + prompt: str = "请处理后继续。", + options: Optional[list[str] | str] = None, + suggested_response: Optional[str] = None, + ) -> str: + normalized_options = self.normalize_options(options) + if interaction_type == "approval": + default = (suggested_response or "").strip() + return default or "用户同意" + if interaction_type == "selection": + default = (suggested_response or "").strip() + selected = default or (normalized_options[0] if normalized_options else "") + return f"用户选择:{selected}" if selected else "用户选择:" + response = (suggested_response or "").strip() + return f"用户补充信息如下:{response}" if response else "用户没有补充信息" + + async def _arun( + self, + interaction_type: str = "information", + title: str = "需要人工处理", + prompt: str = "请处理后继续。", + options: Optional[list[str] | str] = None, + suggested_response: Optional[str] = None, + ) -> str: + return await asyncio.to_thread(self._run, interaction_type, title, prompt, options, suggested_response) + + +class TodoListInput(BaseModel): + steps: list[str] = Field(default_factory=list, description="任务步骤列表,每项为一步要做什么的描述") + step: int = Field(default=-1, description="已完成的步骤索引(从0开始),-1表示无已完成步骤") + + +class TodoList(BaseTool): + """可选的任务列表工具,模型可主动调用以跟踪任务进度。""" + + args_schema: type[BaseModel] = TodoListInput + name: str = "todo_list" + description: str = ( + "可选的任务管理工具。当你需要拆分复杂任务为多个步骤并跟踪进度时调用。" + "输入 steps (list[str]) 为每一步的描述,step (int) 为已完成的步骤索引(从0开始)。" + "任务默认状态为 pending,根据 step 自动标记完成。工具返回当前 todo list 文本。" + "仅在需要跟踪多步骤任务进度时调用,简单任务不需要调用。" + ) + response_format: Literal["content", "content_and_artifact"] = "content" + + def _run(self, steps: list[str] | None = None, step: int = -1) -> str: + steps = steps or [] + step = max(-1, min(step, len(steps) - 1)) + lines = ["## 📋 任务列表"] + for i, s in enumerate(steps): + status = "✅ 已完成" if i <= step else "⏳ 待处理" + lines.append(f"- [{i+1}/{len(steps)}] {status}: {s}") + if not steps: + lines.append("(无任务)") + return "\n".join(lines) + + async def _arun(self, steps: list[str] | None = None, step: int = -1) -> str: + return self._run(steps, step) diff --git a/src/agent/tools/tool_agent_call.py b/src/agent/tools/tool_agent_call.py new file mode 100644 index 0000000..b705d4f --- /dev/null +++ b/src/agent/tools/tool_agent_call.py @@ -0,0 +1,52 @@ +"""Agent 委派工具:主 Agent 通过此工具将任务委派给子 Agent。 + +系统定位: + 作为主 Agent 工具集中的特殊工具,调用此工具将触发 + SupervisorGraph 将执行路由到对应子 Agent。 + +工具签名: + agent_call(agent_name, task_description) +""" +from __future__ import annotations + +from typing import Literal + +from langchain.tools import BaseTool +from pydantic import BaseModel, Field, create_model + + +class AgentCallInput(BaseModel): + """agent_call 工具的输入参数。""" + agent_name: str = Field( + description="要委派的子 Agent 名称,例如 'code_agent'、'research_agent'。" + ) + task_description: str = Field( + description="委派给子 Agent 的详细任务描述,子 Agent 将根据此描述执行任务。" + ) + + +class AgentCallTool(BaseTool): + """主 Agent 委派任务给子 Agent 的工具。 + + 调用后 SupervisorGraph 将暂停主 Agent、激活对应子 Agent, + 子 Agent 完成后结果回传给主 Agent。 + """ + + args_schema: type[BaseModel] = AgentCallInput + name: str = "agent_call" + description: str = ( + "将子任务委派给指定的子 Agent 执行。" + "仅在需要专业子 Agent 协助完成特定类型任务时调用。" + "可用的子 Agent 名称由系统配置决定。" + ) + + def _run(self, agent_name: str, task_description: str) -> str: + """占位实现:实际路由由 SupervisorGraph 处理。""" + return ( + f"已委派任务给子 Agent [{agent_name}]。\n" + f"任务描述: {task_description}\n" + f"等待子 Agent 返回结果..." + ) + + async def _arun(self, agent_name: str, task_description: str) -> str: + return self._run(agent_name, task_description) diff --git a/src/agent/tools/tool_rag.py b/src/agent/tools/tool_rag.py index a395a31..993d8e8 100644 --- a/src/agent/tools/tool_rag.py +++ b/src/agent/tools/tool_rag.py @@ -14,15 +14,16 @@ from langchain.tools import BaseTool from pydantic import BaseModel, Field, create_model from langchain_text_splitters import RecursiveCharacterTextSplitter -from utils.env_utils import RAG_BASE_URL, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP -from utils.agent_utils import Documents_process -from ..llm import llm +from agent.utils.env_utils import RAG_BASE_URL, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP +from agent.utils.agent_utils import Documents_process +from agent.core.llm import llm # ---------- 前置函数---------- ENDING_PUNCTUATION = set(".!?。!?…\"'”’);:;:…") def has_ending_punctuation(text: str) -> bool: + """判断文本是否以句末标点结尾(空文本视为已结束)。""" stripped = text.rstrip() if not stripped: return True @@ -30,6 +31,7 @@ def has_ending_punctuation(text: str) -> bool: def pre_process(text_content: str, list_image_byte: List[bytes]) -> Tuple[str, List[bytes]]: + """合并图文行并为 占位符生成 Vision 描述,供 RAG 入库前预处理。""" lines = text_content.split('\n') new_lines = [] i = 0 @@ -115,6 +117,7 @@ GLOBAL_COLLECTION_NAME = "rag_global" def get_chroma_client(): + """获取或懒初始化 Chroma 持久化客户端单例。""" global _chroma_client if _chroma_client is None: _chroma_client = chromadb.PersistentClient(path=CHROMA_PERSIST_DIR) @@ -122,6 +125,7 @@ def get_chroma_client(): def get_global_collection(): + """获取或创建全局 RAG 向量集合(cosine 空间)。""" global _global_collection if _global_collection is None: client = get_chroma_client() @@ -138,6 +142,7 @@ def get_global_collection(): def embed_texts(queries: List[str], documents: List[str] = None) -> tuple: + """调用 RAG 嵌入服务,返回 (query_embeddings, doc_embeddings)。""" payload = { "task_description": "Given a web search query, retrieve relevant passages that answer the query", "queries": queries, @@ -153,6 +158,7 @@ def embed_texts(queries: List[str], documents: List[str] = None) -> tuple: def rerank_documents(query: str, documents: List[str]) -> List[float]: + """调用 RAG 重排序服务,返回与 documents 对齐的相关度分数列表。""" payload = { "task_description": "Given a web search query, retrieve relevant passages that answer the query", "query": query, @@ -167,6 +173,7 @@ def rerank_documents(query: str, documents: List[str]) -> List[float]: def split_into_chunks(input_data) -> List[dict]: + """将文本/图文元组切分为 chunk 列表,图片与表格块标记为 protected。""" if isinstance(input_data, str): text = input_data image_bytes_list = [] @@ -256,6 +263,7 @@ class RAGTool(BaseTool): return await asyncio.to_thread(self._run, **kwargs) def _get_batch_chunks(self, coll, batch_id: str) -> dict: + """按 batch_id 拉取同批次全部 chunk,以 chunk_index 为键索引。""" res = coll.get(where={"batch_id": batch_id}) batch_chunks = {} if res and res["metadatas"] and res["documents"]: @@ -273,6 +281,7 @@ class RAGTool(BaseTool): # ---------- 查询(返回 artifact)---------- def _handle_query(self, query: str, filter: Optional[Union[str, List[str]]]) -> Tuple[str, list]: + """向量检索 + rerank,返回拼接文本与图片 artifact 列表。""" if not query: return "查询内容不能为空。", [] @@ -376,6 +385,7 @@ class RAGTool(BaseTool): # ---------- 添加文档 ---------- def _handle_add(self, content: Optional[str], source: Optional[Union[str, List[str]]]) -> Tuple[str, dict]: + """解析文本或文件、分块嵌入后写入 Chroma 全局集合。""" if not content and not source: return "必须提供 content 或 source。", {} if content and source: @@ -508,6 +518,7 @@ class RAGTool(BaseTool): # ---------- 清除 ---------- def _handle_clear(self, filter: Optional[Union[str, List[str]]]) -> Tuple[str, dict]: + """按 source 过滤条件删除知识库记录及关联图片文件。""" if not filter: return "清除操作必须提供 filter 条件。", {} diff --git a/src/agent/tools/tool_software_control.py b/src/agent/tools/tool_software_control.py new file mode 100644 index 0000000..8f33d63 --- /dev/null +++ b/src/agent/tools/tool_software_control.py @@ -0,0 +1,519 @@ +# pyright: reportMissingImports=false, reportAttributeAccessIssue=false +from __future__ import annotations + +import json +import os +import re +import subprocess +import time +import csv +import ctypes +from dataclasses import asdict, dataclass, field +from difflib import SequenceMatcher +from pathlib import Path + +from langchain.tools import BaseTool +from pydantic import BaseModel, Field, create_model + +try: + import win32com.client as win32com_client # type: ignore +except Exception: + win32com_client = None + +_windll = getattr(ctypes, "windll", None) +user32: object | None = _windll.user32 if os.name == "nt" and _windll is not None else None +WM_CLOSE = 0x0010 + + +_INDEX_PATH = Path(__file__).resolve().parents[1] / "history" / "data_base" / "software_index.json" +_SHORTCUT_ROOTS = [ + Path(os.environ.get("ProgramData", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs", + Path(os.environ.get("APPDATA", "")) / "Microsoft" / "Windows" / "Start Menu" / "Programs", + Path(os.environ.get("USERPROFILE", "")) / "Desktop", + Path(os.environ.get("PUBLIC", "")) / "Desktop", +] +_APP_PATH_KEYS = [ + r"Software\Microsoft\Windows\CurrentVersion\App Paths", +] +_SKIP_KEYWORDS = ( + "卸载", + "uninstall", + "help", + "manual", + "documentation", + "release notes", + "readme", + "website", + "faq", + "license", + "update", +) + + +@dataclass +class SoftwareRecord: + display_name: str + aliases: list[str] = field(default_factory=list) + launch_type: str = "" + launch_target: str = "" + shortcut_path: str = "" + working_dir: str = "" + process_names: list[str] = field(default_factory=list) + source: str = "" + + +def _normalize(text: str) -> str: + value = text.casefold().strip() + value = re.sub(r"\.lnk$", "", value) + value = re.sub(r"\.exe$", "", value) + value = re.sub(r"[\s\-_()()\[\]【】]+", "", value) + return value + + +def _should_skip(name: str) -> bool: + lowered = name.casefold() + return any(keyword in lowered for keyword in _SKIP_KEYWORDS) + + +def _add_aliases(*values: str) -> list[str]: + aliases: list[str] = [] + seen: set[str] = set() + for value in values: + if not value: + continue + cleaned = value.strip() + if not cleaned: + continue + for item in (cleaned, Path(cleaned).stem): + key = _normalize(item) + if not key or key in seen: + continue + seen.add(key) + aliases.append(item) + return aliases + + +def _resolve_shortcut(path: Path) -> tuple[str, str]: + if win32com_client is None: + return "", "" + try: + shell = win32com_client.Dispatch("WScript.Shell") + shortcut = shell.CreateShortCut(str(path)) + target = (shortcut.Targetpath or "").strip() + working_dir = (shortcut.WorkingDirectory or "").strip() + return target, working_dir + except Exception: + return "", "" + + +def _scan_shortcuts() -> list[SoftwareRecord]: + records: list[SoftwareRecord] = [] + for root in _SHORTCUT_ROOTS: + if not root.exists(): + continue + for shortcut in root.rglob("*.lnk"): + display_name = shortcut.stem.strip() + if not display_name or _should_skip(display_name): + continue + target, working_dir = _resolve_shortcut(shortcut) + process_names: list[str] = [] + if target: + process_names.append(Path(target).stem) + records.append( + SoftwareRecord( + display_name=display_name, + aliases=_add_aliases(display_name, Path(target).stem if target else ""), + launch_type="shortcut", + launch_target=str(shortcut), + shortcut_path=str(shortcut), + working_dir=working_dir, + process_names=process_names, + source="shortcut", + ) + ) + return records + + +def _scan_app_paths() -> list[SoftwareRecord]: + try: + import winreg as winreg_module # type: ignore + except Exception: + return [] + records: list[SoftwareRecord] = [] + for hive in (winreg_module.HKEY_LOCAL_MACHINE, winreg_module.HKEY_CURRENT_USER): + for subkey in _APP_PATH_KEYS: + try: + root = winreg_module.OpenKey(hive, subkey) + except OSError: + continue + try: + count = winreg_module.QueryInfoKey(root)[0] + for index in range(count): + name = winreg_module.EnumKey(root, index) + try: + child = winreg_module.OpenKey(root, name) + exe_path, _ = winreg_module.QueryValueEx(child, "") + except OSError: + continue + exe_path = str(exe_path).strip() + if not exe_path: + continue + display_name = Path(name).stem.strip() or Path(exe_path).stem.strip() + if not display_name or _should_skip(display_name): + continue + records.append( + SoftwareRecord( + display_name=display_name, + aliases=_add_aliases(display_name, Path(exe_path).stem), + launch_type="app_path", + launch_target=exe_path, + process_names=[Path(exe_path).stem], + source="app_path", + ) + ) + finally: + winreg_module.CloseKey(root) + return records + + +def _deduplicate(records: list[SoftwareRecord]) -> list[SoftwareRecord]: + merged: dict[tuple[str, str], SoftwareRecord] = {} + for record in records: + key = (record.launch_type, record.launch_target.casefold()) + existing = merged.get(key) + if existing is None: + merged[key] = record + continue + existing.aliases = sorted(set(existing.aliases + record.aliases), key=str.casefold) + existing.process_names = sorted(set(existing.process_names + record.process_names), key=str.casefold) + if not existing.display_name and record.display_name: + existing.display_name = record.display_name + return sorted(merged.values(), key=lambda item: item.display_name.casefold()) + + +def _write_index(records: list[SoftwareRecord]) -> None: + _INDEX_PATH.parent.mkdir(parents=True, exist_ok=True) + payload = { + "generated_at": int(time.time()), + "count": len(records), + "items": [asdict(record) for record in records], + } + _INDEX_PATH.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + +def _load_index() -> list[SoftwareRecord]: + if not _INDEX_PATH.exists(): + return [] + try: + payload = json.loads(_INDEX_PATH.read_text(encoding="utf-8")) + except Exception: + return [] + items = payload.get("items") or [] + records: list[SoftwareRecord] = [] + for item in items: + if not isinstance(item, dict): + continue + records.append( + SoftwareRecord( + display_name=str(item.get("display_name") or ""), + aliases=list(item.get("aliases") or []), + launch_type=str(item.get("launch_type") or ""), + launch_target=str(item.get("launch_target") or ""), + shortcut_path=str(item.get("shortcut_path") or ""), + working_dir=str(item.get("working_dir") or ""), + process_names=list(item.get("process_names") or []), + source=str(item.get("source") or ""), + ) + ) + return records + + +def _build_index() -> list[SoftwareRecord]: + records = _deduplicate(_scan_shortcuts() + _scan_app_paths()) + _write_index(records) + return records + + +def _match_score(query: str, candidate: str) -> float: + q = _normalize(query) + c = _normalize(candidate) + if not q or not c: + return 0.0 + if q == c: + return 1.0 + if q in c: + return 0.95 + if c in q and len(c) >= 4 and len(c) / max(len(q), 1) >= 0.6: + return 0.9 + return SequenceMatcher(None, q, c).ratio() + + +def _rank_matches(query: str, records: list[SoftwareRecord]) -> list[tuple[float, SoftwareRecord]]: + ranked: list[tuple[float, SoftwareRecord]] = [] + for record in records: + candidates = [record.display_name, *record.aliases] + score = max((_match_score(query, candidate) for candidate in candidates), default=0.0) + if score >= 0.55: + ranked.append((score, record)) + ranked.sort(key=lambda item: (-item[0], item[1].display_name.casefold())) + return ranked + + +def _open_record(record: SoftwareRecord) -> str: + if not hasattr(os, "startfile"): + raise RuntimeError("software_control 目前仅支持 Windows 本机软件启动") + target = record.shortcut_path or record.launch_target + if not target: + raise RuntimeError(f"未找到 {record.display_name} 的启动入口") + if not Path(target).exists(): + raise RuntimeError(f"启动入口不存在:{target}") + os.startfile(target) # type: ignore[attr-defined] + return target + + +def _coerce_bool(value: object) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + lowered = value.strip().casefold() + if lowered in {"true", "1", "yes", "y", "on"}: + return True + if lowered in {"false", "0", "no", "n", "off", ""}: + return False + return bool(value) + + +def _list_running_processes() -> list[dict[str, str]]: + try: + output = subprocess.check_output( + ["tasklist", "/FO", "CSV", "/NH"], + text=True, + encoding="gbk", + errors="ignore", + ) + except Exception: + return [] + processes: list[dict[str, str]] = [] + for row in csv.reader(output.splitlines()): + if len(row) < 2: + continue + image_name = row[0].strip() + pid = row[1].strip() + if not image_name or not pid: + continue + processes.append( + { + "image_name": image_name, + "pid": pid, + "stem": Path(image_name).stem, + } + ) + return processes + + +def _find_running_pids(record: SoftwareRecord) -> list[int]: + process_keys = {_normalize(name) for name in record.process_names if name} + process_keys.add(_normalize(record.display_name)) + process_keys.update(_normalize(alias) for alias in record.aliases if alias) + matched: list[int] = [] + for proc in _list_running_processes(): + if _normalize(proc["stem"]) in process_keys: + try: + matched.append(int(proc["pid"])) + except ValueError: + continue + return sorted(set(matched)) + + +def _find_visible_windows_for_pids(pids: list[int]) -> list[tuple[int, int, str]]: + u32 = user32 + if u32 is None: + return [] + pid_set = set(pids) + results: list[tuple[int, int, str]] = [] + + @ctypes.WINFUNCTYPE(ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p) + def enum_proc(hwnd, lparam): + if not u32.IsWindowVisible(hwnd): + return True + length = u32.GetWindowTextLengthW(hwnd) + if length <= 0: + return True + buffer = ctypes.create_unicode_buffer(length + 1) + u32.GetWindowTextW(hwnd, buffer, length + 1) + title = buffer.value.strip() + if not title: + return True + pid = ctypes.c_ulong() + u32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) + if pid.value in pid_set: + results.append((pid.value, int(hwnd), title)) + return True + + u32.EnumWindows(enum_proc, 0) + return results + + +def _wait_for_pids_exit(pids: list[int], timeout_seconds: float = 5.0) -> list[int]: + deadline = time.time() + timeout_seconds + remaining = set(pids) + while time.time() < deadline and remaining: + current = set() + for proc in _list_running_processes(): + try: + pid = int(proc["pid"]) + except ValueError: + continue + if pid in remaining: + current.add(pid) + remaining = current + if remaining: + time.sleep(0.4) + return sorted(remaining) + + +def _close_windows(windows: list[tuple[int, int, str]]) -> None: + u32 = user32 + if u32 is None: + return + for _, hwnd, _ in windows: + u32.PostMessageW(hwnd, WM_CLOSE, 0, 0) + + +def _force_kill_pids(pids: list[int]) -> list[int]: + killed: list[int] = [] + for pid in pids: + try: + subprocess.run( + ["taskkill", "/PID", str(pid), "/F"], + check=False, + capture_output=True, + text=True, + encoding="gbk", + errors="ignore", + ) + killed.append(pid) + except Exception: + continue + return killed + + +class SoftwareControl(BaseTool): + args_schema: type[BaseModel] = create_model( + "SoftwareControlInput", + action=(str, Field(default="open", description="操作类型:open 表示打开软件,close 表示正常关闭软件,force_close 表示强制关闭软件。")), + software_name=(str, Field(description="要操作的软件名称,例如微信、Visual Studio Code、MATLAB。")), + rescan=(bool, Field(default=False, description="是否强制重新扫描本机软件并刷新索引。")), + force=(bool, Field(default=False, description="兼容旧参数。当前仅当 action=force_close 时才会真正触发强制关闭。")), + ) + name: str = "software_control" + description: str = ( + "操作本机已安装的软件。工具会自动扫描 Windows 开始菜单快捷方式、桌面快捷方式和 App Paths," + "建立软件索引后根据用户输入做模糊匹配。action=open 时打开软件;action=close 时仅做正常关闭;" + "action=force_close 时才允许在正常关闭失败后强制结束进程。仅当用户明确要求打开、关闭或强制关闭某个本机软件时调用。" + ) + + def _run(self, action: str = "open", software_name: str = "", rescan: bool = False, force: bool = False) -> str: + if os.name != "nt": + return "software_control 目前仅支持 Windows 本机软件操作。" + query = (software_name or "").strip() + if not query: + return "软件名称不能为空。" + action = (action or "open").strip().lower() + rescan = _coerce_bool(rescan) + force = _coerce_bool(force) + if action not in {"open", "close", "force_close"}: + return "action 仅支持 open、close 或 force_close。" + if force and action == "close": + action = "force_close" + force_requested = action == "force_close" + + records = _build_index() if rescan else _load_index() + if not records: + records = _build_index() + + ranked = _rank_matches(query, records) + if not ranked: + records = _build_index() + ranked = _rank_matches(query, records) + if not ranked: + return f"未找到与“{query}”匹配的软件,请换一个更具体的软件名。" + + best_score, best_record = ranked[0] + if best_score < 0.72: + return f"未找到与“{query}”足够接近的软件,请换一个更具体的软件名。" + if len(ranked) > 1 and best_score < 0.88 and (best_score - ranked[1][0]) < 0.08: + candidates = [ + f"{item.display_name} (score={score:.2f}, source={item.source})" + for score, item in ranked[:5] + ] + return ( + f"找到多个相近软件,暂不自动打开。你可以说得更具体一些。\n" + f"候选项:\n" + "\n".join(candidates) + ) + + if action == "open": + try: + opened_target = _open_record(best_record) + except Exception as exc: + return f"已匹配到软件“{best_record.display_name}”,但启动失败:{exc}" + + process_hint = ", ".join(best_record.process_names) if best_record.process_names else "unknown" + return ( + f"已打开软件:{best_record.display_name}\n" + f"匹配得分:{best_score:.2f}\n" + f"启动方式:{best_record.launch_type}\n" + f"启动入口:{opened_target}\n" + f"进程提示:{process_hint}\n" + f"索引文件:{_INDEX_PATH}" + ) + + running_pids = _find_running_pids(best_record) + if not running_pids: + process_hint = ", ".join(best_record.process_names) if best_record.process_names else "unknown" + return ( + f"已匹配到软件:{best_record.display_name}\n" + f"匹配得分:{best_score:.2f}\n" + f"但当前没有发现正在运行的对应进程。\n" + f"进程提示:{process_hint}" + ) + + windows = _find_visible_windows_for_pids(running_pids) + if windows: + _close_windows(windows) + remaining = _wait_for_pids_exit(running_pids, timeout_seconds=5.0) + if not remaining: + titles = ";".join(title for _, _, title in windows[:3]) + return ( + f"已正常关闭软件:{best_record.display_name}\n" + f"匹配得分:{best_score:.2f}\n" + f"关闭方式:窗口关闭请求\n" + f"涉及 PID:{', '.join(str(pid) for pid in running_pids)}\n" + f"窗口标题示例:{titles}" + ) + if not force_requested: + return ( + f"已向软件“{best_record.display_name}”发送正常关闭请求,但仍有进程未退出。\n" + f"剩余 PID:{', '.join(str(pid) for pid in remaining)}\n" + f"如需强制关闭,请明确使用 force_close 动作后重试。" + ) + killed = _force_kill_pids(remaining) + return ( + f"软件“{best_record.display_name}”正常关闭未完全成功,已执行强制关闭。\n" + f"强制结束 PID:{', '.join(str(pid) for pid in killed) if killed else 'none'}" + ) + + if not force_requested: + return ( + f"已找到软件“{best_record.display_name}”的运行进程,但未找到可见主窗口,暂未自动强制关闭。\n" + f"运行 PID:{', '.join(str(pid) for pid in running_pids)}\n" + f"如需强制关闭,请明确使用 force_close 动作后重试。" + ) + killed = _force_kill_pids(running_pids) + return ( + f"软件“{best_record.display_name}”未找到可见主窗口,已按进程强制关闭。\n" + f"强制结束 PID:{', '.join(str(pid) for pid in killed) if killed else 'none'}" + ) + + async def _arun(self, action: str = "open", software_name: str = "", rescan: bool = False, force: bool = False) -> str: + return self._run(action, software_name, rescan, force) diff --git a/src/agent/utils/__pycache__/__init__.cpython-313.pyc b/src/agent/utils/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..99c4056 Binary files /dev/null and b/src/agent/utils/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/agent/utils/__pycache__/agent_utils.cpython-312.pyc b/src/agent/utils/__pycache__/agent_utils.cpython-312.pyc index 4e10deb..1742c04 100644 Binary files a/src/agent/utils/__pycache__/agent_utils.cpython-312.pyc and b/src/agent/utils/__pycache__/agent_utils.cpython-312.pyc differ diff --git a/src/agent/utils/__pycache__/agent_utils.cpython-313.pyc b/src/agent/utils/__pycache__/agent_utils.cpython-313.pyc new file mode 100644 index 0000000..6fbbddf Binary files /dev/null and b/src/agent/utils/__pycache__/agent_utils.cpython-313.pyc differ diff --git a/src/agent/utils/__pycache__/database_utils.cpython-312.pyc b/src/agent/utils/__pycache__/database_utils.cpython-312.pyc new file mode 100644 index 0000000..5479945 Binary files /dev/null and b/src/agent/utils/__pycache__/database_utils.cpython-312.pyc differ diff --git a/src/agent/utils/__pycache__/database_utils.cpython-313.pyc b/src/agent/utils/__pycache__/database_utils.cpython-313.pyc new file mode 100644 index 0000000..8303bea Binary files /dev/null and b/src/agent/utils/__pycache__/database_utils.cpython-313.pyc differ diff --git a/src/agent/utils/__pycache__/env_utils.cpython-312.pyc b/src/agent/utils/__pycache__/env_utils.cpython-312.pyc index 7c78584..dcd0fef 100644 Binary files a/src/agent/utils/__pycache__/env_utils.cpython-312.pyc and b/src/agent/utils/__pycache__/env_utils.cpython-312.pyc differ diff --git a/src/agent/utils/__pycache__/env_utils.cpython-313.pyc b/src/agent/utils/__pycache__/env_utils.cpython-313.pyc new file mode 100644 index 0000000..bcadbaa Binary files /dev/null and b/src/agent/utils/__pycache__/env_utils.cpython-313.pyc differ diff --git a/src/agent/utils/__pycache__/image_utils.cpython-312.pyc b/src/agent/utils/__pycache__/image_utils.cpython-312.pyc index 00adb64..1abe555 100644 Binary files a/src/agent/utils/__pycache__/image_utils.cpython-312.pyc and b/src/agent/utils/__pycache__/image_utils.cpython-312.pyc differ diff --git a/src/agent/utils/__pycache__/image_utils.cpython-313.pyc b/src/agent/utils/__pycache__/image_utils.cpython-313.pyc new file mode 100644 index 0000000..9815099 Binary files /dev/null and b/src/agent/utils/__pycache__/image_utils.cpython-313.pyc differ diff --git a/src/agent/utils/__pycache__/log_utils.cpython-312.pyc b/src/agent/utils/__pycache__/log_utils.cpython-312.pyc new file mode 100644 index 0000000..c236a80 Binary files /dev/null and b/src/agent/utils/__pycache__/log_utils.cpython-312.pyc differ diff --git a/src/agent/utils/__pycache__/log_utils.cpython-313.pyc b/src/agent/utils/__pycache__/log_utils.cpython-313.pyc new file mode 100644 index 0000000..ad22777 Binary files /dev/null and b/src/agent/utils/__pycache__/log_utils.cpython-313.pyc differ diff --git a/src/agent/utils/__pycache__/tool_call_utils.cpython-312.pyc b/src/agent/utils/__pycache__/tool_call_utils.cpython-312.pyc new file mode 100644 index 0000000..abab379 Binary files /dev/null and b/src/agent/utils/__pycache__/tool_call_utils.cpython-312.pyc differ diff --git a/src/agent/utils/__pycache__/tool_call_utils.cpython-313.pyc b/src/agent/utils/__pycache__/tool_call_utils.cpython-313.pyc new file mode 100644 index 0000000..b49d4f7 Binary files /dev/null and b/src/agent/utils/__pycache__/tool_call_utils.cpython-313.pyc differ diff --git a/src/agent/utils/agent_utils.py b/src/agent/utils/agent_utils.py index 56eeea0..37d1e97 100644 --- a/src/agent/utils/agent_utils.py +++ b/src/agent/utils/agent_utils.py @@ -1,4 +1,19 @@ -# agent/utils/agent_utils.py +"""Agent 通用工具:会话历史、上下文压缩、文档解析与多模态辅助。 + +系统定位: + - ``core/runtime``、``core/llm`` 依赖 summarizer、assistant_text、Documents_process + - 模块加载时初始化 SQLite 历史库与 session_meta 表 + +主要子系统: + 1. 历史与元数据: get_history / get_session_meta / update_session_meta + 2. 上下文压缩: summarizer + 轻量/全量压缩 + 3. 文档处理: Documents_process 多格式解析 + 4. 文本/音频: split_text、merge_wavs、assistant_text + +可扩展性: + - 压缩策略阈值见 env_utils;可插拔向量记忆替代纯摘要 + - DOC_EXT_MAP 可扩展新文档类型处理器 +""" import json import os import shutil @@ -38,6 +53,16 @@ CHAT_HISTORY_DB_FS = os.path.join(_DB_DIR, "chat_history.db") MULTI_INPUTS_ROOT = os.path.join(HISTORY_ROOT, "multi_inputs") def ensure_dirs_and_migrate_db() -> None: + """创建历史目录、迁移旧库并初始化 SQLite 表结构。 + + 输入: 无。 + + 输出: 无(副作用:创建 history/data_base、message_store、session_meta 表)。 + + 系统定位: 模块 import 时自动执行一次,保证 SQLChatMessageHistory 可用。 + + 可扩展性: 可增加 schema 版本号与增量迁移脚本。 + """ os.makedirs(_DB_DIR, exist_ok=True) os.makedirs(MULTI_INPUTS_ROOT, exist_ok=True) # 迁移旧数据库 @@ -72,6 +97,18 @@ ensure_dirs_and_migrate_db() # ---------- 历史记录操作 ---------- def get_history(session_id: str) -> SQLChatMessageHistory: + """获取指定会话的 LangChain SQL 聊天历史对象。 + + 输入: + session_id: 会话唯一标识。 + + 输出: + SQLChatMessageHistory 实例,底层为 CHAT_HISTORY_DB_FS。 + + 系统定位: summarizer、save_messages_to_history 等读写对话记录。 + + 可扩展性: 可改为按 agent_name 分 session_id 命名空间。 + """ return SQLChatMessageHistory( session_id=session_id, connection="sqlite:///" + CHAT_HISTORY_DB_FS.replace("\\", "/"), @@ -79,6 +116,20 @@ def get_history(session_id: str) -> SQLChatMessageHistory: # ---------- session_meta 元数据管理 ---------- def get_session_meta(session_id: str, agent_name: str) -> dict: + """读取会话在指定 Agent 下的压缩元数据。 + + 输入: + session_id: 会话 ID。 + agent_name: Agent 名称(如 main)。 + + 输出: + dict: cursor_index, compressed_content, last_total_tokens; + 无记录时返回默认值(cursor=-1, 空摘要, tokens=0)。 + + 系统定位: summarizer 判断压缩档位与 runtime 更新 token 用量。 + + 可扩展性: 可增加 compress_version、embedding_id 等字段。 + """ conn = sqlite3.connect(CHAT_HISTORY_DB_FS) cur = conn.execute( "SELECT cursor_index, compressed_content, last_total_tokens FROM session_meta WHERE session_id=? AND agent_name=?", @@ -96,6 +147,20 @@ def get_session_meta(session_id: str, agent_name: str) -> dict: def update_session_meta(session_id: str, agent_name: str, cursor_index: int, compressed_content: str, last_total_tokens: int) -> None: + """写入或更新 session_meta 行(UPSERT)。 + + 输入: + session_id, agent_name: 联合主键。 + cursor_index: 已压缩消息截止索引。 + compressed_content: LLM 生成的历史摘要文本。 + last_total_tokens: 上一轮模型 reported total_tokens。 + + 输出: 无。 + + 系统定位: 压缩后与 execute_agent 记录 token 时调用。 + + 可扩展性: 可记录压缩时间戳便于调试与回滚。 + """ conn = sqlite3.connect(CHAT_HISTORY_DB_FS) conn.execute(""" INSERT INTO session_meta (session_id, agent_name, cursor_index, compressed_content, last_total_tokens) @@ -110,12 +175,37 @@ def update_session_meta(session_id: str, agent_name: str, # ---------- 工具函数 ---------- def _get_messages_since_cursor(history: SQLChatMessageHistory, cursor_index: int) -> List[BaseMessage]: + """返回游标之后未纳入摘要的消息切片。 + + 输入: + history: 聊天历史对象。 + cursor_index: 已压缩截止索引;-1 表示全部未压缩。 + + 输出: + BaseMessage 列表。 + + 系统定位: summarizer 构建发给模型的近期消息。 + + 可扩展性: 私有函数;对外行为通过 summarizer 暴露。 + """ all_msgs = history.messages if cursor_index < 0 or cursor_index >= len(all_msgs) - 1: return all_msgs return all_msgs[cursor_index + 1:] def _count_turns(messages: List[BaseMessage]) -> int: + """统计 human + ai/assistant 配对的对话轮数。 + + 输入: + messages: 消息列表。 + + 输出: + 整数轮数。 + + 系统定位: 轻量压缩时计算需保留的 CURRENT_TURNS 轮。 + + 可扩展性: 可将 tool 消息纳入轮次定义。 + """ turns = 0 i = 0 n = len(messages) @@ -132,7 +222,19 @@ def _count_turns(messages: List[BaseMessage]) -> int: # ---------- 压缩函数 ---------- def _lightweight_compress(llm, messages_to_compress: List[BaseMessage]) -> str: - """轻量压缩:保留关键事实、实体、决策,删除冗余""" + """轻量压缩:用 LLM 摘要待压缩消息,保留关键事实与决策。 + + 输入: + llm: 用于压缩的语言模型。 + messages_to_compress: 待压缩的 BaseMessage 列表。 + + 输出: + 摘要字符串。 + + 系统定位: summarizer 在 token 超过 MINI_COMPRESS_RATE 时触发。 + + 可扩展性: 可换用更小/专用摘要模型降低成本。 + """ prompt = ChatPromptTemplate.from_messages([ ("system", SUMMARY_SYSTEM_PROMPT), ("human", "请对以下对话进行轻量压缩,保留核心事实、实体、决策和对话流向。\n\n{chat}"), @@ -147,7 +249,20 @@ def _lightweight_compress(llm, messages_to_compress: List[BaseMessage]) -> str: return result.content def _hard_compress(llm, compressed_existing: str, new_chat_text: str) -> str: - """全量压缩:将已有压缩摘要和新对话内容融合为最终摘要""" + """全量压缩:合并已有摘要与游标后新对话为单一摘要。 + + 输入: + llm: 压缩用模型。 + compressed_existing: 先前摘要(可为空)。 + new_chat_text: 游标后对话的纯文本拼接。 + + 输出: + 融合后的最终摘要字符串。 + + 系统定位: token 超过 HARD_COMPRESS_RATE 时触发,游标移至末尾。 + + 可扩展性: 可分块摘要再合并,应对超长历史。 + """ prompt = ChatPromptTemplate.from_messages([ ("system", SUMMARY_SYSTEM_PROMPT), ("human", "这是之前的对话摘要:\n{previous_summary}\n\n以及后续对话内容:\n{new_chat}\n\n请生成包含所有信息的最终摘要,保留重要事实和决策。"), @@ -168,16 +283,25 @@ def summarizer( user_content: str, llm: Any ) -> Tuple[List[BaseMessage], bool]: - """ - 根据上下文窗口与上一轮 token 使用量,动态压缩历史消息并返回完整消息列表。 - :param session_id: 会话ID - :param agent_name: Agent 名称 - :param system_prompt: 系统提示词 - :param user_content: 当前用户输入文本(纯文本或已处理的多模态文本) - :param llm: 语言模型实例(用于压缩) - :return: (messages, context_exceeded) - messages: 构建好的消息列表 - context_exceeded: 是否超过 90% 窗口,需要提示新建会话 + """根据 token 占用动态压缩历史并组装本轮 LLM 输入消息列表。 + + 输入: + session_id: 会话 ID。 + agent_name: Agent 名称,用于 session_meta。 + system_prompt: 系统提示词(可追加历史摘要)。 + user_content: 当前用户输入文本。 + llm: 用于轻量/全量压缩的模型实例。 + + 输出: + (messages, context_exceeded): + - messages: SystemMessage + 未压缩近期消息 + 当前 HumanMessage + - context_exceeded: True 表示已超过 90% 窗口,应提示新建会话 + + 系统定位: + execute_agent 在调用 graph 前的上下文管理核心。 + + 可扩展性: + 阈值由 env_utils 配置;可接入 token 精确计数替代 last_total_tokens 估计。 """ meta = get_session_meta(session_id, agent_name) cursor_index = meta["cursor_index"] @@ -260,7 +384,20 @@ def summarizer( # ---------- 辅助函数 ---------- def assistant_text(value) -> str: - """把网关返回的 content、reasoning 等收成一段可见文本。""" + """将 LLM 响应的 content/reasoning 等多形态字段归一为可见纯文本。 + + 输入: + value: str | list | dict | None 等网关返回结构。 + + 输出: + 去首尾空白后的字符串;无法解析时 str(value) 或 JSON。 + + 系统定位: + runtime、llm、nodes 统一提取助手回复与 reasoning。 + + 可扩展性: + 新增厂商字段时在 key 列表中补充即可。 + """ if value is None: return "" if isinstance(value, str): @@ -290,13 +427,30 @@ def assistant_text(value) -> str: class Documents_process: - """文件分类与内容提取工具集""" + """多格式用户附件的分类、解析与多模态内容提取。 + + 系统定位: + core/llm._preprocess_messages 处理用户上传文件时调用 process()。 + + 可扩展性: + 新增格式:扩展 DOC_EXT_MAP 并实现 process_xxx 方法。 + """ @staticmethod def doc_classify(file_path: str) -> str: - """ - 根据扩展名和 MIME 类型返回内部处理类型标签。 - 可能的返回值:'plain_text'、'docx'、'pdf'、'image'、'tabular'。 + """根据扩展名与 MIME 返回内部类型标签。 + + 输入: + file_path: 本地文件路径。 + + 输出: + plain_text | docx | pdf | image | tabular。 + + 系统定位: + process() 分发入口。 + + 可扩展性: + 不支持类型抛出 ValueError,便于上层捕获并提示用户。 """ ext = pathlib.Path(file_path).suffix.lower() # 先检查文档映射表 @@ -320,12 +474,25 @@ class Documents_process: # ---------- 各类文件的具体处理方法 ---------- @staticmethod def process_plain_text(file_path: str) -> tuple[str, list]: + """读取 UTF-8 纯文本文件。 + + 输入: file_path 本地路径。 + 输出: (文本内容, 空图片列表)。 + 系统定位: plain_text 类型处理。 + 可扩展性: 可增加编码探测(chardet)。 + """ with open(file_path, "r", encoding="utf-8") as f: return f.read(), [] @staticmethod def process_docx(file_path: str) -> tuple[str, list]: - """按文档顺序提取文本、图片和表格,并将短标题与图表合并。""" + """按文档顺序提取 Word 文本、内嵌图片与表格。 + + 输入: file_path .docx 路径。 + 输出: (带 N、 标记的文本, 图片字节列表)。 + 系统定位: 用户上传 docx 附件解析。 + 可扩展性: 依赖 python-docx;可扩展页眉页脚、脚注。 + """ try: from docx import Document from docx.oxml.ns import qn @@ -434,6 +601,13 @@ class Documents_process: @staticmethod def process_pdf(file_path: str) -> tuple[str, list]: + """提取 PDF 文本与内嵌图片占位符。 + + 输入: file_path .pdf 路径。 + 输出: (分页拼接文本含 N, 图片字节列表)。 + 系统定位: PDF 附件入模前解析。 + 可扩展性: 依赖 pypdf;扫描件可接 OCR 管线。 + """ try: from pypdf import PdfReader except ImportError: @@ -468,10 +642,12 @@ class Documents_process: @staticmethod def process_tabular(file_path: str) -> tuple[str, list]: - """ - 处理 CSV 和 Excel(.xlsx) 文件。 - 将所有 sheet 的内容转化为
SheetName\n内容
格式的字符串。 - 图片列表固定为空。 + """将 CSV / xlsx 转为 标记的表格文本。 + + 输入: file_path .csv 或 .xlsx。 + 输出: (多 sheet 表格字符串, 空图片列表)。 + 系统定位: 结构化数据附件供 LLM 阅读。 + 可扩展性: 可支持 xls、parquet;大表可采样行数。 """ try: import csv @@ -507,12 +683,26 @@ class Documents_process: @staticmethod def process_image(file_path: str) -> tuple[str, list]: + """读取图片文件为字节,文本部分为空。 + + 输入: file_path 图片路径。 + 输出: ("", [图片字节])。 + 系统定位: 与 llm 中 image_url 分支配合多模态输入。 + 可扩展性: 可在此预调用 resize_image_if_needed。 + """ with open(file_path, "rb") as f: return "", [f.read()] # ---------- 统一处理入口 ---------- @staticmethod def process(file_path: str) -> tuple[str, list]: + """统一附件处理入口:分类后分发到对应 process_* 方法。 + + 输入: file_path 本地文件路径。 + 输出: (文本内容, 图片字节列表)。 + 系统定位: llm._preprocess_messages 文件 part 处理。 + 可扩展性: 新增类型在 doc_classify 与分支中注册。 + """ file_type = Documents_process.doc_classify(file_path) if file_type == "plain_text": @@ -527,13 +717,35 @@ class Documents_process: return Documents_process.process_tabular(file_path) else: raise ValueError(f"未处理的文件类型: {file_type}") -# 文本分块工具 + +###### 文本分块工具(多线程语音生成TTS) ###### SENT_SPLIT_PATTERN = re.compile(r'(?<=[。!?;,、])') REMOVE_PUNCT_PATTERN = re.compile(r'[。!?;,、:;“”()《》【】\-\_\—\~\.\,\!\?\s·]') def effective_length(text: str) -> int: + """计算去除标点空白后的有效字符长度。 + + 输入: text 字符串。 + 输出: 整数长度。 + 系统定位: split_text 合并短句时的阈值判断。 + 可扩展性: 可按语言调整 REMOVE_PUNCT_PATTERN。 + """ return len(REMOVE_PUNCT_PATTERN.sub('', text)) + def split_text(text: str, min_effective_chars: int = 20) -> list[str]: + """按中文标点分句并合并过短片段,供 TTS 分块请求。 + + 输入: + text: 待切分文本。 + min_effective_chars: 每块最少有效字符数。 + + 输出: + 字符串列表,每块适合单次 TTS 调用。 + + 系统定位: core/llm._text_to_audio 并行合成前分块。 + + 可扩展性: 可改为按 token 或时长分块。 + """ # 先按标点粗分 raw_chunks = [c.strip() for c in SENT_SPLIT_PATTERN.split(text) if c.strip()] if not raw_chunks: @@ -555,7 +767,18 @@ def split_text(text: str, min_effective_chars: int = 20) -> list[str]: return merged def merge_wavs(audio_parts: list[bytes]) -> bytes: - """将多个 WAV 字节串按顺序拼接成一个 WAV""" + """将多段 WAV 二进制按顺序拼接为单个 WAV 文件字节。 + + 输入: + audio_parts: WAV 格式字节列表(采样率等参数以首段为准)。 + + 输出: + 合并后的 WAV bytes。 + + 系统定位: llm._text_to_audio 多块 TTS 结果合并。 + + 可扩展性: 参数不一致时可增加重采样;可支持 mp3 拼接。 + """ if len(audio_parts) == 1: return audio_parts[0] import io, wave diff --git a/src/agent/utils/database_utils.py b/src/agent/utils/database_utils.py index 6b45d36..d1197c1 100644 --- a/src/agent/utils/database_utils.py +++ b/src/agent/utils/database_utils.py @@ -1,3 +1,16 @@ +"""PostgreSQL 元数据查询与只读 SQL 执行(供 Agent 工具层使用)。 + +系统定位: + 为数据库相关 Tool 提供安全封装的 schema introspection 与 SELECT 查询, + 结果统一 JSON 序列化便于模型消费。 + +安全策略: + execute_query / validate_query 仅允许 SELECT 与 WITH 语句。 + +可扩展性: + - 可配置 fetch 行数上限、连接池参数 + - 注释掉的 rename_table / update_table_comment 可按需启用并加权限控制 +""" from sqlalchemy import create_engine, inspect, text import json from typing import Optional, List @@ -5,8 +18,15 @@ from uuid import UUID from datetime import datetime class JSONEncoder(json.JSONEncoder): - """自定义JSON编码器,处理非标准序列化类型""" + """扩展 json.JSONEncoder,支持 UUID、datetime 及带 __dict__ 的对象。 + + 系统定位: PostgreSQL_Manager 各方法 dumps 时的默认编码器。 + + 可扩展性: 可增加 Decimal、date、Enum 等类型分支。 + """ + def default(self, obj): + """将 UUID、datetime 及带 __dict__ 的对象转为 JSON 可编码值。""" if isinstance(obj, UUID): return str(obj) elif isinstance(obj, datetime): @@ -16,7 +36,20 @@ class JSONEncoder(json.JSONEncoder): return super().default(obj) def make_json_serializable(obj): - """递归转换对象为JSON序列化格式""" + """递归将嵌套结构转为 JSON 可序列化形式。 + + 输入: + obj: dict | list | UUID | datetime | 任意对象。 + + 输出: + 仅含 JSON 兼容类型的树结构。 + + 系统定位: + get_table_schema / get_table_comments 返回前清洗 SQLAlchemy 反射结果。 + + 可扩展性: + 与 JSONEncoder.default 保持类型处理一致。 + """ if isinstance(obj, dict): return {k: make_json_serializable(v) for k, v in obj.items()} elif isinstance(obj, (list, tuple)): @@ -31,21 +64,46 @@ def make_json_serializable(obj): return obj class PostgreSQL_Manager: + """PostgreSQL 连接池管理与只读元数据/查询 API。 + + 系统定位: + Agent 数据库工具的后端实现类。 + + 可扩展性: + 可注入 engine、增加读写分离、审计日志。 + """ + def __init__(self, connection_params): - """ - PostgreSQL数据库管理类,提供连接和操作数据库的功能 - Args: - connection_params (dict): 连接参数字符串,格式为: - postgresql+psycopg2://用户名:密码@主机:端口/数据库名 - 本机默认为:postgresql+psycopg2://postgres:12345678@localhost:5432/agent_controller + """创建 SQLAlchemy 引擎与连接池。 + + 输入: + connection_params: SQLAlchemy URL,如 + postgresql+psycopg2://user:pass@host:5432/dbname + + 输出: + 无(设置 self.engine)。 + + 系统定位: + 工具初始化时构造单例或按请求构造。 + + 可扩展性: + pool_size、pool_recycle 可改为构造参数或环境变量。 """ self.engine = create_engine(connection_params, pool_size=5, pool_recycle=7200) def get_table_names(self) -> list[str]: - """ - 获取数据库中的所有表名 - Returns: - list: 数据库中的表名列表 + """列出当前库中所有表名。 + + 输入: 无(使用 self.engine)。 + + 输出: + 表名字符串列表;异常时返回空列表并打印错误。 + + 系统定位: + Agent 探索库结构的第一步。 + + 可扩展性: + 可过滤 schema、排除系统表。 """ try: # 使用SQLAlchemy的inspect功能获取数据库表名 @@ -56,12 +114,19 @@ class PostgreSQL_Manager: return [] def get_table_comments(self, table_name: str = None) -> str: - """ - 获取指定表/所有表的注释信息,并整理成JSON格式 - Args: - table_name (str, optional): 表名,如果为None则返回所有表 - Returns: - str: JSON格式的表的注释信息 + """获取表 COMMENT 元数据并返回 JSON 字符串。 + + 输入: + table_name: 指定表名;None 表示全部表。 + + 输出: + JSON 字符串;错误时含 error 字段。 + + 系统定位: + 供 LLM 理解业务表含义。 + + 可扩展性: + 可合并列级 comment。 """ try: inspector = inspect(self.engine) @@ -80,12 +145,19 @@ class PostgreSQL_Manager: return json.dumps(error_msg, ensure_ascii=False, indent=2) def get_table_schema(self, table_names: Optional[List[str]] = None) -> str: - """ - 获取指定表/所有表的schema信息,并整理成JSON格式 - Args: - table_names (List[str], optional): 表名列表,如果为None则返回所有表 - Returns: - str: JSON格式的表的schema信息 + """反射表结构(列、主键、外键、索引)并返回 JSON。 + + 输入: + table_names: 表名列表;None 表示所有表。 + + 输出: + JSON 字符串,每表含 columns/primary_keys/foreign_keys/indexes。 + + 系统定位: + Text-to-SQL 或数据分析前的 schema 上下文。 + + 可扩展性: + 可增加视图、物化视图、枚举类型信息。 """ try: inspector = inspect(self.engine) @@ -111,12 +183,19 @@ class PostgreSQL_Manager: return json.dumps(error_msg, ensure_ascii=False, indent=2) def execute_query(self, query: str) -> str: - """ - 执行任意SQL查询,并返回结果 - Args: - query (str): SQL查询语句 - Returns: - str: JSON格式的查询结果或错误信息 + """执行只读 SQL 并返回最多 100 行结果的 JSON。 + + 输入: + query: 必须以 select 或 with 开头(大小写不敏感)。 + + 输出: + JSON 结果数组或 error/message 对象。 + + 系统定位: + Agent 安全查询数据;禁止 DML/DDL。 + + 可扩展性: + fetch 行数、超时、只读事务隔离级别可配置。 """ # 安全检查,防止执行危险的SQL语句,仅允许select和with查询语句 if not query.strip().lower().startswith(('select', 'with')): @@ -145,12 +224,19 @@ class PostgreSQL_Manager: return json.dumps(error_msg, ensure_ascii=False, indent=2) def validate_query(self, query: str) -> str: - """ - 验证SQL查询语句的合法性 - Args: - query (str): SQL查询语句 - Returns: - bool: 查询语句是否合法 + """用 EXPLAIN(PostgreSQL)或试执行验证 SQL 合法性。 + + 输入: + query: 只读 SQL 字符串。 + + 输出: + JSON:成功含 message;失败含 error。 + + 系统定位: + 执行前的预检,减少无效查询。 + + 可扩展性: + 非 PG 方言可切换验证策略。 """ if not query or not query.strip(): error_msg = {"error": f"Error occurred while validating query: Query is must not be empty."} diff --git a/src/agent/utils/env_utils.py b/src/agent/utils/env_utils.py index fb675f3..6b8dad4 100644 --- a/src/agent/utils/env_utils.py +++ b/src/agent/utils/env_utils.py @@ -1,11 +1,41 @@ -import os -from typing import Dict, Tuple +"""环境变量加载与全局配置常量。 -from dotenv import load_dotenv -load_dotenv(override=True) +系统定位: + 项目配置入口,模块导入时从 ``core/env_config.json`` 加载配置, + 并向 ``core/llm``、``utils/agent_utils``、``utils/image_utils`` 等模块 + 提供 LLM、压缩策略、RAG、浏览器等运行时参数。 + +可扩展性: + - 新增配置项时,在此定义常量并注明默认值与类型转换逻辑。 + - 可将 ``BROWSER_MAP`` 扩展为从环境变量或配置文件动态加载。 +""" +import json +import os +from pathlib import Path +from typing import Dict, Tuple def _get_env(name: str, default: str | None = None, cast: type | None = None, required: bool = False): + """读取并可选类型转换单个环境变量。 + + 功能描述: + 从 ``os.environ`` 取值,支持必填校验与类型转换。 + + 输入: + name: 环境变量名。 + default: 缺省值;与 required=True 互斥使用。 + cast: 转换类型(如 int、float)。 + required: 为 True 且变量缺失时抛出 ``EnvironmentError``。 + + 输出: + 原始字符串或 cast 后的值;缺失且无 default 时为 None。 + + 系统定位: + 本模块内部私有辅助,所有模块级常量均通过此函数初始化。 + + 可扩展性: + 可扩展 bool 解析、路径规范化、密钥脱敏日志等。 + """ value = os.getenv(name, default) if value is None and required: raise EnvironmentError(f"Missing required environment variable: {name}") @@ -17,25 +47,84 @@ def _get_env(name: str, default: str | None = None, cast: type | None = None, re raise ValueError(f"Invalid value for {name}: {value}") from exc -LLM_BASE_URL = _get_env("LLM_BASE_URL", required=True) -LLM_API_KEY = _get_env("LLM_API_KEY", required=True) -LLM_MODEL = _get_env("LLM_MODEL", required=True) -LLM_TIMEOUT = _get_env("LLM_TIMEOUT", default="60", cast=float) -# LLM_REQUESTS_PER_SECOND = _get_env("LLM_REQUESTS_PER_SECOND", default="10", cast=float) -# LLM_MAX_BUCKET_SIZE = _get_env("LLM_MAX_BUCKET_SIZE", default="10", cast=int) -# LLM_CHECK_EVERY_N_SECONDS = _get_env("LLM_CHECK_EVERY_N_SECONDS", default="0.1", cast=float) -# ASR_TTS_BASE_URL = _get_env("ASR_TTS_BASE_URL") -TTS_BASE_URL = _get_env("TTS_BASE_URL") -ASR_BASE_URL = _get_env("ASR_BASE_URL") -RAG_BASE_URL = _get_env("RAG_BASE_URL") -LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 131072)) -MINI_COMPRESS_RATE = float(os.getenv("MINI_COMPRESS_RATE", 0.5)) -HARD_COMPRESS_RATE = float(os.getenv("HARD_COMPRESS_RATE", 0.75)) -CURRENT_TURNS = int(os.getenv("CURRENT_TURNS", 4)) -IMG_SIZE = int(os.getenv("IMG_SIZE", 768)) -RAG_CHUNK_SIZE=int(os.getenv("RAG_CHUNK_SIZE", 500)) -RAG_CHUNK_OVERLAP=int(os.getenv("RAG_CHUNK_OVERLAP", 50)) +def _refresh_globals(): + """刷新模块级常量(热重载用)。""" + global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT + global TTS_BASE_URL, ASR_BASE_URL, RAG_BASE_URL + global LLM_CONTEXT_WINDOW, MINI_COMPRESS_RATE, HARD_COMPRESS_RATE, CURRENT_TURNS + global IMG_SIZE, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, GROUNDING_WIDTH, GROUNDING_HEIGHT + LLM_BASE_URL = _get_env("LLM_BASE_URL", required=True) + LLM_API_KEY = _get_env("LLM_API_KEY", required=True) + LLM_MODEL = _get_env("LLM_MODEL", required=True) + LLM_TIMEOUT = _get_env("LLM_TIMEOUT", default="60", cast=float) + TTS_BASE_URL = _get_env("TTS_BASE_URL") + ASR_BASE_URL = _get_env("ASR_BASE_URL") + RAG_BASE_URL = _get_env("RAG_BASE_URL") + LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 131072)) + MINI_COMPRESS_RATE = float(os.getenv("MINI_COMPRESS_RATE", 0.5)) + HARD_COMPRESS_RATE = float(os.getenv("HARD_COMPRESS_RATE", 0.75)) + CURRENT_TURNS = int(os.getenv("CURRENT_TURNS", 4)) + IMG_SIZE = int(os.getenv("IMG_SIZE", 768)) + RAG_CHUNK_SIZE = int(os.getenv("RAG_CHUNK_SIZE", 500)) + RAG_CHUNK_OVERLAP = int(os.getenv("RAG_CHUNK_OVERLAP", 50)) + GROUNDING_WIDTH = int(os.getenv("GROUNDING_WIDTH", 1000)) + GROUNDING_HEIGHT = int(os.getenv("GROUNDING_HEIGHT", 1000)) + + +def _load_config() -> dict: + """加载 env_config.json,不存在时回退到 .env 文件。 + + 优先级: env_config.json > .env > 默认值 + """ + _config_path = Path(__file__).resolve().parents[1] / "core" / "env_config.json" + + if _config_path.is_file(): + try: + with open(_config_path, "r", encoding="utf-8") as f: + config = json.load(f) + if isinstance(config, dict): + # 将非 models 的值写入 os.environ + for key, value in config.items(): + if key != "models" and value is not None: + os.environ[key] = str(value) + # 从 models 列表中提取第一个模型的 LLM 参数 + models = config.get("models") + if isinstance(models, list) and models: + m = models[0] + if m.get("api_key"): os.environ["LLM_API_KEY"] = str(m["api_key"]) + if m.get("base_url"): os.environ["LLM_BASE_URL"] = str(m["base_url"]) + if m.get("model"): os.environ["LLM_MODEL"] = str(m["model"]) + if m.get("timeout") is not None: os.environ["LLM_TIMEOUT"] = str(m["timeout"]) + _refresh_globals() + return config + except (json.JSONDecodeError, OSError): + pass + + # 回退:读取 .env 文件 + _env_path = Path(__file__).resolve().parents[3] / ".env" + if _env_path.is_file(): + try: + with open(_env_path, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip("'").strip('"') + if key not in os.environ: # 不覆盖已设置的值 + os.environ[key] = value + except OSError: + pass + + return {} + + +_load_config() +_refresh_globals() # 确保无论配置加载成功与否,都有默认值 + +# ---------- 浏览器工具映射(内部名 -> 可执行名, 显示名) ---------- BROWSER_MAP: Dict[str, Tuple[str, str]] = { "edge": ("microsoft-edge", "Microsoft Edge"), "chrome": ("chrome", "Google Chrome"), diff --git a/src/agent/utils/image_utils.py b/src/agent/utils/image_utils.py index 55e92b4..58e0a88 100644 --- a/src/agent/utils/image_utils.py +++ b/src/agent/utils/image_utils.py @@ -1,4 +1,13 @@ -"""图片缩放、体积控制与 OpenAI 风格多模态 image_url 片段(供 LLM / Agent 使用)。""" +"""图片缩放、体积控制与 OpenAI 风格多模态 image_url 片段构建。 + +系统定位: + 多模态输入适配层,被 ``core/llm``、``core/nodes``、``utils/agent_utils`` + 调用,将本地路径或内存字节转为 LLM 可消费的 data URL 内容块。 + +可扩展性: + - 可接入 WebP/AVIF 编码、按模型 token 预算动态调整 ``max_size``。 + - ``SupportsChatCompletion`` 协议可扩展为统一多模态网关接口。 +""" import base64 import io import os @@ -7,17 +16,37 @@ from PIL import Image from agent.utils.env_utils import IMG_SIZE + class SupportsChatCompletion(Protocol): - """具备 ``chat_completion(messages, **kwargs)`` 的 LLM(如 ``ChatQwen35OpenAI``)。""" + """具备 chat_completion 方法的 LLM 协议(如 ChatQwen)。 + + 系统定位: 类型提示用,便于静态检查与依赖注入。 + + 可扩展性: 可补充 stream、embed 等方法签名。 + """ def chat_completion(self, messages: list[Any], **kwargs: Any) -> Any: ... + # 小写扩展名(含点),用于判断路径是否为支持的栅格图 IMAGE_FILE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".bmp", ".gif", ".webp"}) def guess_image_mime(extension_without_dot: str) -> str: - """根据扩展名返回 image/* MIME,未知则回退为 image/jpeg。""" + """根据文件扩展名推断 image/* MIME 类型。 + + 输入: + extension_without_dot: 扩展名,可带或不带点(如 png、.jpg)。 + + 输出: + MIME 字符串;未知扩展名回退为 ``image/jpeg``。 + + 系统定位: + ``image_path_to_openai_image_url_part`` 构造 data URL 时使用。 + + 可扩展性: + 可改用 ``mimetypes`` 或 magic bytes 双重校验。 + """ ext = extension_without_dot.lower().lstrip(".") if ext in ("png", "jpg", "jpeg", "gif", "bmp", "webp"): return "image/jpeg" if ext == "jpg" else f"image/{ext}" @@ -25,10 +54,25 @@ def guess_image_mime(extension_without_dot: str) -> str: def resize_image_if_needed(image_path_or_bytes: str | bytes, *, max_size: int = IMG_SIZE) -> bytes: - """ - 控制图片体积,避免 data-url base64 触发超长 token。 - - 若图片尺寸过大,等比例缩放(默认最大边 max_size) - - 优先压成 JPEG(透明图保底转白底 RGB) + """等比缩放并压缩图片,控制 base64 体积与 token 消耗。 + + 功能描述: + - 超长边超过 max_size 时等比缩放(LANCZOS) + - RGBA/LA 透明图转白底后优先输出 JPEG + - JPEG quality=70,PNG 保留透明通道场景外的优化输出 + + 输入: + image_path_or_bytes: 本地路径或原始图片字节。 + max_size: 最大边长像素,默认来自环境变量 IMG_SIZE。 + + 输出: + 处理后的图片二进制(JPEG 或 PNG)。 + + 系统定位: + 所有图片入模前的统一预处理入口。 + + 可扩展性: + 可按用途(vision / thumbnail)传入不同 quality 与 max_size 配置。 """ if isinstance(image_path_or_bytes, str): img = Image.open(image_path_or_bytes) @@ -77,16 +121,42 @@ def resize_image_if_needed(image_path_or_bytes: str | bytes, *, max_size: int = def image_to_base64(image_content: str | bytes) -> str | None: - """将图片路径或原始字节转为 base64 字符串;失败返回 None。""" + """将图片路径或字节转为 base64 字符串。 + + 输入: + image_content: 文件路径或原始字节。 + + 输出: + base64 编码字符串;处理失败返回 None。 + + 系统定位: + 供路径类 image_url 块与简单编码场景使用。 + + 可扩展性: + 失败时可返回错误码或记录日志,便于排查。 + """ try: processed = resize_image_if_needed(image_content) return base64.b64encode(processed).decode("utf-8") - except Exception as e: + except Exception: return None def _mime_from_magic(data: bytes) -> str: - """根据压缩后图片文件头推断 MIME,用于内存字节构造 data URL。""" + """根据文件头魔数推断图片 MIME。 + + 输入: + data: 压缩后的图片字节。 + + 输出: + ``image/jpeg`` | ``image/png`` | ``image/gif``;默认 jpeg。 + + 系统定位: + 内存字节构造 data URL 时,扩展名不可用时的回退。 + + 可扩展性: + 可补充 WebP、BMP 等魔数识别。 + """ if len(data) >= 2 and data[:2] == b"\xff\xd8": return "image/jpeg" if len(data) >= 8 and data[:8] == b"\x89PNG\r\n\x1a\n": @@ -101,10 +171,26 @@ def image_bytes_to_openai_image_url_part( *, detail: str | None = None, ) -> dict | None: - """将内存中的图片字节(如截屏 PNG)转为 OpenAI 风格的 image_url 块;失败返回 None。""" + """将内存图片字节转为 OpenAI Chat Completions 的 image_url 内容块。 + + 输入: + image_bytes: 原始或已编码图片字节(如工具截屏 PNG)。 + detail: 可选,传给模型的 detail 级别(auto/low/high)。 + + 输出: + ``{"type": "image_url", "image_url": {"url": "data:..."}}``; + 失败返回 None。 + + 系统定位: + ``core/nodes.process_tool_artifact`` 将工具截图注入对话; + ``core/llm`` 处理附件内嵌图片。 + + 可扩展性: + 可支持 URL 引用模式,避免大 base64 内联。 + """ try: processed = resize_image_if_needed(image_bytes) - except Exception as e: + except Exception: return None mime = _mime_from_magic(processed) b64_str = base64.b64encode(processed).decode("utf-8") @@ -120,9 +206,20 @@ def image_path_to_openai_image_url_part( *, detail: str | None = None, ) -> dict | None: - """ - 从本地图片路径构造 OpenAI Chat Completions 的 image_url 内容块。 - 无法编码时返回 None。 + """从本地图片路径构造 OpenAI 风格 image_url 内容块。 + + 输入: + file_path: 本地图片绝对或相对路径。 + detail: 可选 detail 级别。 + + 输出: + 与 ``image_bytes_to_openai_image_url_part`` 相同结构;失败 None。 + + 系统定位: + ``core/llm._process_image_content`` 将用户上传图片路径转为模型输入。 + + 可扩展性: + 可增加文件大小上限校验、病毒扫描钩子。 """ b64_str = image_to_base64(file_path) if not b64_str: diff --git a/src/agent/utils/log_utils.py b/src/agent/utils/log_utils.py index b36cb9e..f9c138e 100644 --- a/src/agent/utils/log_utils.py +++ b/src/agent/utils/log_utils.py @@ -1,3 +1,13 @@ +"""基于 loguru 的全局日志封装。 + +系统定位: + 提供项目级单例 ``log``,供各模块统一输出结构化调试日志; + 默认输出到 stdout,级别 DEBUG。 + +可扩展性: + - 可在 ``MyLogger.__init__`` 中增加文件轮转、按模块过滤等 handler。 + - 可封装 ``log.bind(session_id=...)`` 以支持请求级追踪。 +""" import sys from loguru import logger @@ -5,7 +15,19 @@ from loguru import logger class MyLogger: + """loguru 日志器包装类,统一格式与输出目标。""" + def __init__(self): + """初始化并配置 loguru:移除默认 handler,添加 stdout DEBUG 输出。 + + 输入: 无。 + + 输出: 无(副作用:配置全局 logger)。 + + 系统定位: 模块加载时由 ``log = MyLogger().get_logger()`` 实例化一次。 + + 可扩展性: 可增加环境变量控制日志级别、JSON 格式输出等。 + """ self.logger = logger self.logger.remove() self.logger.add( @@ -21,6 +43,16 @@ class MyLogger: ) def get_logger(self): + """返回已配置的 loguru Logger 实例。 + + 输入: 无。 + + 输出: loguru.Logger。 + + 系统定位: 供模块导出 ``log`` 单例。 + + 可扩展性: 可改为返回带默认 context 的 bound logger。 + """ return self.logger diff --git a/src/agent/utils/tool_call_utils.py b/src/agent/utils/tool_call_utils.py new file mode 100644 index 0000000..21f2dfc --- /dev/null +++ b/src/agent/utils/tool_call_utils.py @@ -0,0 +1,121 @@ +"""工具调用规范化与人工编辑解析工具。 + +系统定位: + 位于 Agent 工具链与人工确认(HITL)之间的适配层,被 ``core/routing``、 + ``core/runtime``、``core/approvals`` 调用,负责将 LangChain 工具调用对象 + 统一为字典格式,并解析用户在 UI 中编辑后的指令。 + +可扩展性: + - 新增工具参数格式时,可在 ``normalize_tool_call`` 中扩展 args 解析逻辑。 + - ``parse_edited_instruction`` 可支持更多用户输入格式(如 YAML、键值对等)。 +""" +from __future__ import annotations + +import json +from typing import Any + + +def json_safe(value: Any) -> Any: + """将任意 Python 对象递归转换为 JSON 可序列化结构。 + + 功能描述: + 尝试直接序列化;若遇到不可序列化类型,则递归处理 dict/list/tuple, + 其余类型转为字符串。 + + 输入: + value: 任意 Python 对象。 + + 输出: + JSON 兼容的 dict / list / str / 基本类型;结构与原值对应。 + + 系统定位: + 供 ``core/approvals.build_approval_meta`` 在展示待确认工具参数时使用, + 避免 UI 层因不可序列化对象而报错。 + + 可扩展性: + 可为 datetime、Decimal、Enum 等类型增加专用分支,而非一律 ``str()``。 + """ + try: + json.dumps(value, ensure_ascii=False) + return value + except TypeError: + if isinstance(value, dict): + return {str(k): json_safe(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return [json_safe(v) for v in value] + return str(value) + + +def normalize_tool_call(tool_call: Any) -> dict[str, Any]: + """将 LangChain ToolCall 或原始 dict 规范化为统一字典。 + + 功能描述: + 兼容 dict 与对象两种来源,解析 args(支持 JSON 字符串), + 输出固定字段 ``id``、``name``、``args``。 + + 输入: + tool_call: LangChain ``ToolCall`` 对象,或含 name/args/id 的 dict。 + + 输出: + ``{"id": str|None, "name": str|None, "args": dict}``。 + + 系统定位: + ``core/routing`` 在判断工具执行策略和提取待确认项时统一调用, + 屏蔽不同 LLM 返回格式的差异。 + + 可扩展性: + 可在此增加参数校验、默认值填充,或对接 JSON Schema 校验。 + """ + if isinstance(tool_call, dict): + name = tool_call.get("name") + args = tool_call.get("args") or {} + call_id = tool_call.get("id") + else: + name = getattr(tool_call, "name", None) + args = getattr(tool_call, "args", {}) or {} + call_id = getattr(tool_call, "id", None) + if isinstance(args, str): + try: + args = json.loads(args) + except Exception: + args = {"raw": args} + return {"id": call_id, "name": name, "args": args or {}} + + +def parse_edited_instruction(instruction: str, fallback: dict[str, Any]) -> dict[str, Any]: + """解析用户在「编辑」确认流程中提交的指令文本。 + + 功能描述: + 优先按 JSON 解析完整工具调用;失败则将原文本写入 ``args.instruction``; + 兼容 ``tool_name`` 别名字段。 + + 输入: + instruction: 用户编辑后的文本(可为 JSON 或纯文本)。 + fallback: 原始工具调用 dict,解析失败时作为回退。 + + 输出: + 规范化后的工具调用 dict,至少含 ``name`` 与 ``args``。 + + 系统定位: + ``core/runtime.continue_after_human_action`` 在 decision=edit 时调用, + 将 UI 编辑结果还原为可执行的工具调用。 + + 可扩展性: + 可增加 schema 校验、参数合并策略(仅覆盖用户修改的字段)。 + """ + raw = (instruction or "").strip() + if not raw: + return fallback + try: + parsed = json.loads(raw) + except json.JSONDecodeError: + updated = dict(fallback) + updated["args"] = {"instruction": raw} + return updated + if isinstance(parsed, dict): + if "name" not in parsed and "tool_name" in parsed: + parsed["name"] = parsed.get("tool_name") + if "args" not in parsed: + parsed["args"] = fallback.get("args") or {} + return parsed + return fallback diff --git a/src/agent/workspace/.todo b/src/agent/workspace/.todo new file mode 100644 index 0000000..9e86f67 --- /dev/null +++ b/src/agent/workspace/.todo @@ -0,0 +1,53 @@ +# Plan TodoList + +路径选择:路径一:使用浏览器控制 + +# 执行计划:读取 Bilibili 首页第一个视频标题并分析 + +**说明**:本计划旨在通过浏览器自动化控制访问 Bilibili 首页,提取首个视频标题,并提供基于当前网络热点和一般认知的理解。由于我无法直接“看到”实时变化的互联网内容,以下流程将指导如何获取最新数据并进行分析。在实际执行中,我会根据获取到的具体标题进行针对性解读。 + +## Todo List + +- [x] **步骤 1:环境准备与导航** · 状态:进行中 · 状态:已完成 + - **依赖**:无 + - **状态**:待开始 + - **任务描述**: + 1. 启动内置浏览器或模拟浏览器会话。 + 2. 导航至 Bilibili 首页 URL (`https://www.bilibili.com`)。 + 3. 等待页面完全加载,确保动态内容(如推荐流)已渲染完毕。 + +- [x] **步骤 2:定位首个视频元素** · 状态:进行中 · 状态:已完成 + - **依赖**:步骤 1 完成 + - **状态**:待开始 + - **任务描述**: + 1. 识别首页的主要视频推荐区域(通常是“推荐”标签页下的第一个卡片)。 + 2. 定位包含视频标题的 DOM 元素(通常位于视频封面下方或悬浮层中)。 + 3. *注意*:需排除广告位或非视频内容(如直播入口、专栏文章),确保选取的是标准的 UP 主投稿视频。 + +- [x] **步骤 3:提取视频标题文本** · 状态:进行中 · 状态:已完成 + - **依赖**:步骤 2 完成 + - **状态**:待开始 + - **任务描述**: + 1. 从定位到的元素中提取纯文本标题。 + 2. 清理多余的空白字符或隐藏的控制符。 + 3. 记录提取结果,若提取失败则重试或尝试备用选择器。 + +- [x] **步骤 4:信息分析与理解生成** · 状态:进行中 · 状态:已完成 + - **依赖**:步骤 3 完成 + - **状态**:待开始 + - **任务描述**: + 1. 基于提取的标题,结合当前的社会热点、B站社区文化及常见视频类型进行分析。 + 2. 判断视频可能的主题(如科技评测、生活 vlog、游戏实况、知识科普等)。 + 3. 评估标题是否具有吸引力(如是否使用悬念、热点词汇、情绪化表达)。 + 4. 给出简短的个性化理解和建议观看理由。 + +- [x] **步骤 5:输出最终报告** · 状态:进行中 · 状态:已完成 + - **依赖**:步骤 4 完成 + - **状态**:待开始 + - **任务描述**: + 1. 整合提取的标题和分析结果。 + 2. 以清晰的结构呈现给用户,包括“原始标题”、“内容推测”和“我的理解”。 + +--- + +**备注**:在执行过程中,我将根据实际抓取到的标题实时更新后续的分析内容。如果首页因登录状态或算法推荐显示不同内容,我将基于当时可见的第一个视频进行处理。 diff --git a/src/web/__pycache__/__init__.cpython-313.pyc b/src/web/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000..1537bed Binary files /dev/null and b/src/web/__pycache__/__init__.cpython-313.pyc differ diff --git a/src/web/__pycache__/server.cpython-312.pyc b/src/web/__pycache__/server.cpython-312.pyc index b0a6dc1..8fd28a5 100644 Binary files a/src/web/__pycache__/server.cpython-312.pyc and b/src/web/__pycache__/server.cpython-312.pyc differ diff --git a/src/web/__pycache__/server.cpython-313.pyc b/src/web/__pycache__/server.cpython-313.pyc new file mode 100644 index 0000000..5706e41 Binary files /dev/null and b/src/web/__pycache__/server.cpython-313.pyc differ diff --git a/src/web/__pycache__/ui_session.cpython-312.pyc b/src/web/__pycache__/ui_session.cpython-312.pyc index 388d1e6..5aa0596 100644 Binary files a/src/web/__pycache__/ui_session.cpython-312.pyc and b/src/web/__pycache__/ui_session.cpython-312.pyc differ diff --git a/src/web/__pycache__/ui_session.cpython-313.pyc b/src/web/__pycache__/ui_session.cpython-313.pyc new file mode 100644 index 0000000..919b49e Binary files /dev/null and b/src/web/__pycache__/ui_session.cpython-313.pyc differ diff --git a/src/web/app.css b/src/web/app.css index d6250d3..dc58921 100644 --- a/src/web/app.css +++ b/src/web/app.css @@ -487,6 +487,85 @@ body { background: var(--bubble-assistant); } +.pending-action-card { + margin-top: 0.65rem; + padding: 0.7rem; + border-radius: 12px; + border: 1px solid rgba(14, 116, 144, 0.22); + background: rgba(236, 254, 255, 0.28); + color: rgba(51, 65, 85, 0.82); + font-size: 0.78rem; +} + +.pending-action-card--resolved { + opacity: 0.58; + pointer-events: none; +} + +.pending-action-title { + font-weight: 700; + color: rgba(15, 23, 42, 0.72); + margin-bottom: 0.35rem; +} + +.pending-action-params { + margin: 0 0 0.45rem; + padding: 0.5rem; + max-height: 160px; + overflow: auto; + border-radius: 10px; + background: rgba(255, 255, 255, 0.32); + color: rgba(30, 41, 59, 0.72); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + white-space: pre-wrap; +} + +.pending-action-editor { + width: 100%; + resize: vertical; + border: 1px solid rgba(148, 163, 184, 0.42); + border-radius: 10px; + padding: 0.45rem 0.5rem; + background: rgba(255, 255, 255, 0.36); + color: rgba(15, 23, 42, 0.78); + font: inherit; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; +} + +.pending-action-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.5rem; +} + +.pending-action-btn { + border: 1px solid rgba(148, 163, 184, 0.45); + border-radius: 999px; + padding: 0.32rem 0.62rem; + background: rgba(255, 255, 255, 0.42); + color: #334155; + cursor: pointer; + font: inherit; + font-size: 0.75rem; +} + +.pending-action-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.7); +} + +.pending-action-btn--approve { + border-color: rgba(20, 184, 166, 0.45); + color: #0f766e; +} + +.pending-action-btn--reject { + border-color: rgba(248, 113, 113, 0.45); + color: #b91c1c; +} + /* 图片在气泡内留出呼吸空间 */ .msg-bubble img { display: block; @@ -1379,4 +1458,1346 @@ body { .chat-messages { -webkit-overflow-scrolling: touch; } -} \ No newline at end of file +} +/* ===== 人工交互 / 工具审批卡片 ===== */ +.pending-action-card { + margin-top: 0.75rem; + padding: 0.75rem; + border: 1px solid rgba(14, 116, 144, 0.22); + border-radius: 12px; + background: rgba(255, 255, 255, 0.42); + box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08); +} + +.pending-action-card--resolved { + opacity: 0.68; +} + +.pending-action-title { + font-weight: 700; + margin-bottom: 0.45rem; + color: #0f172a; +} + +.pending-action-prompt { + margin-bottom: 0.55rem; + color: #334155; + line-height: 1.55; + white-space: pre-wrap; +} + +.pending-action-params { + margin: 0 0 0.55rem; + padding: 0.55rem; + max-height: 220px; + overflow: auto; + border-radius: 10px; + background: rgba(15, 23, 42, 0.88); + color: #e2e8f0; + font-size: 0.78rem; +} + +.pending-action-editor { + width: 100%; + resize: vertical; + border: 1px solid rgba(148, 163, 184, 0.55); + border-radius: 10px; + padding: 0.55rem 0.65rem; + font: inherit; + color: var(--text); + background: rgba(255, 255, 255, 0.65); +} + +.pending-action-editor:focus { + outline: 2px solid rgba(14, 116, 144, 0.28); + outline-offset: 1px; +} + +.pending-action-options { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin: 0.2rem 0 0.55rem; +} + +.pending-action-option, +.pending-action-btn { + border: 1px solid rgba(148, 163, 184, 0.55); + border-radius: 999px; + padding: 0.38rem 0.72rem; + font: inherit; + cursor: pointer; + background: rgba(255, 255, 255, 0.55); + color: #0f172a; +} + +.pending-action-option:hover:not(:disabled), +.pending-action-btn:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.78); +} + +.pending-action-buttons { + display: flex; + flex-wrap: wrap; + gap: 0.45rem; + margin-top: 0.55rem; +} + +.pending-action-btn--approve { + background: linear-gradient(165deg, #14b8a6, #0e7490); + border-color: rgba(14, 116, 144, 0.7); + color: #fff; +} + +.pending-action-btn--reject { + background: rgba(254, 226, 226, 0.86); + border-color: rgba(220, 38, 38, 0.35); + color: #991b1b; +} + +.pending-action-btn--edit { + background: rgba(254, 243, 199, 0.86); + border-color: rgba(217, 119, 6, 0.35); + color: #92400e; +} + +.pending-action-card--information .pending-action-btn--approve, +.pending-action-card--selection .pending-action-btn--approve { + min-width: 2.35rem; + font-size: 1rem; + font-weight: 700; +} + +/* ===== 设置按钮 ===== */ +.settings-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.2rem; + height: 2.2rem; + padding: 0; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.45); + background: rgba(255, 255, 255, 0.35); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + cursor: pointer; + font-size: 1.15rem; + color: #334155; + transition: transform 0.15s, background 0.15s, box-shadow 0.15s; + box-shadow: 0 2px 8px rgba(15, 23, 42, 0.08); +} + +.settings-btn:hover { + background: rgba(255, 255, 255, 0.65); + box-shadow: 0 4px 14px rgba(15, 23, 42, 0.14); + transform: rotate(30deg); +} + +/* ===== 设置模态框 ===== */ +.settings-overlay { + position: fixed; + inset: 0; + z-index: 100; + display: flex; + align-items: center; + justify-content: center; + background: rgba(15, 23, 42, 0.55); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + animation: fadeIn 0.18s ease; +} + +.settings-overlay[hidden] { + display: none; +} + +.settings-dialog { + width: min(920px, 94vw); + max-height: 90vh; + max-height: 90dvh; + background: rgba(248, 250, 252, 0.96); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border-radius: 18px; + border: 1px solid rgba(148, 163, 184, 0.35); + box-shadow: 0 24px 64px rgba(15, 23, 42, 0.22); + display: flex; + flex-direction: column; + overflow: hidden; + animation: slideUp 0.22s ease; +} + +.settings-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.85rem 1.15rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.25); +} + +.settings-header h2 { + margin: 0; + font-size: 1.15rem; + color: #0f172a; +} + +.settings-close-btn { + width: 2rem; + height: 2rem; + padding: 0; + border-radius: 999px; + border: 1px solid rgba(148, 163, 184, 0.4); + background: rgba(255, 255, 255, 0.5); + cursor: pointer; + font-size: 1rem; + color: #475569; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.15s; +} + +.settings-close-btn:hover { + background: rgba(239, 68, 68, 0.15); + color: #991b1b; +} + +.settings-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid rgba(148, 163, 184, 0.25); + padding: 0 1.15rem; + background: rgba(241, 245, 249, 0.6); +} + +.settings-tab { + padding: 0.55rem 1.15rem; + border: none; + border-bottom: 2px solid transparent; + background: none; + font: inherit; + font-size: 0.9rem; + font-weight: 600; + color: #64748b; + cursor: pointer; + transition: color 0.15s, border-color 0.15s; +} + +.settings-tab:hover { + color: #334155; +} + +.settings-tab.active { + color: var(--accent); + border-bottom-color: var(--accent); +} + +.settings-body { + flex: 1; + overflow-y: auto; + padding: 1rem 1.15rem; + min-height: 0; +} + +.settings-panel[hidden] { + display: none; +} + +.settings-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.85rem; + flex-wrap: wrap; + gap: 0.5rem; +} + +.settings-path { + font-size: 0.72rem; + color: #94a3b8; + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.settings-save-btn { + margin-top: 0.85rem; +} + +/* ===== Agent 配置卡片 ===== */ +.agent-config-card { + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 14px; + padding: 1.05rem; + margin-bottom: 0.8rem; + background: rgba(255, 255, 255, 0.65); + box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04); + transition: box-shadow 0.15s; +} + +.agent-config-card:hover { + box-shadow: 0 3px 12px rgba(15, 23, 42, 0.08); +} + +.agent-config-card + .agent-config-card { + margin-top: 0.8rem; +} + +.agent-card-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; + padding-bottom: 0.55rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.15); +} + +.agent-card-header h4 { + margin: 0; + font-size: 0.95rem; + color: #0f172a; + font-weight: 700; +} + +.agent-card-remove { + width: 1.6rem; + height: 1.6rem; + padding: 0; + border-radius: 999px; + border: 1px solid rgba(220, 38, 38, 0.3); + background: rgba(254, 226, 226, 0.5); + cursor: pointer; + font-size: 0.8rem; + color: #991b1b; + display: inline-flex; + align-items: center; + justify-content: center; + transition: background 0.15s, color 0.15s; +} + +.agent-card-remove:hover { + background: rgba(220, 38, 38, 0.15); + color: #7f1d1d; +} + +.setting-field { + margin-bottom: 0.6rem; +} + +.setting-field label { + display: block; + font-size: 0.75rem; + font-weight: 700; + color: #475569; + margin-bottom: 0.25rem; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.setting-field input, +.setting-field select, +.setting-field textarea { + width: 100%; + border: 1px solid rgba(148, 163, 184, 0.45); + border-radius: 8px; + padding: 0.45rem 0.55rem; + font: inherit; + font-size: 0.85rem; + background: rgba(255, 255, 255, 0.8); + color: var(--text); + transition: border-color 0.15s, box-shadow 0.15s; + box-sizing: border-box; +} + +.setting-field input:focus, +.setting-field select:focus, +.setting-field textarea:focus { + border-color: var(--accent, #0e7490); + box-shadow: 0 0 0 3px rgba(14, 116, 144, 0.1); + outline: none; +} + +.setting-field textarea { + resize: vertical; + min-height: 68px; + font-size: 0.82rem; + line-height: 1.45; +} + +.setting-row { + display: flex; + gap: 0.55rem; +} + +.setting-row .setting-field { + flex: 1; + min-width: 0; +} + +/* ===== 工具配置卡片 ===== */ +.tool-config-card { + border: 1px solid rgba(148, 163, 184, 0.25); + border-radius: 12px; + padding: 0.8rem; + margin-bottom: 0.6rem; + background: rgba(255, 255, 255, 0.55); + transition: opacity 0.2s; +} + +.tool-config-card.disabled { + opacity: 0.45; +} + +.tool-config-card h4 { + margin: 0 0 0.4rem; + font-size: 0.9rem; + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: #1e293b; +} + +.tool-card-desc { + font-size: 0.76rem; + color: #64748b; + margin-bottom: 0.5rem; + line-height: 1.4; +} + +.tool-card-row { + display: flex; + align-items: center; + gap: 0.6rem; + flex-wrap: wrap; +} + +.auto-rules-section { + border-top: 1px dashed rgba(148, 163, 184, 0.3); + padding-top: 0.35rem; + margin-top: 0.35rem; +} + +.auto-rules-list { + display: flex; + flex-direction: column; + gap: 0.12rem; +} + +/* ===== 环境变量配置 ===== */ +.env-config-item { + display: flex; + align-items: center; + gap: 0.6rem; + margin-bottom: 0.5rem; + flex-wrap: wrap; +} + +.env-config-item label { + min-width: 150px; + font-size: 0.8rem; + font-weight: 600; + color: #334155; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.env-config-item input { + flex: 1; + min-width: 200px; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 8px; + padding: 0.4rem 0.5rem; + font: inherit; + font-size: 0.84rem; + background: rgba(255, 255, 255, 0.7); + transition: border-color 0.15s, box-shadow 0.15s; +} + +.env-config-item input:focus { + border-color: var(--accent, #0e7490); + box-shadow: 0 0 0 3px rgba(14, 116, 144, 0.08); + outline: none; +} + +/* ===== 设置页脚 ===== */ +.settings-footer { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.7rem 1.15rem; + border-top: 1px solid rgba(148, 163, 184, 0.2); + background: rgba(241, 245, 249, 0.55); +} + +.settings-status { + font-size: 0.8rem; + font-weight: 500; + color: #475569; +} + +.settings-status.success { + color: #16a34a; +} + +.settings-status.error { + color: #dc2626; +} + +/* ===== 设置面板内联按钮 ===== */ +.settings-btn-inline { + padding: 0.25rem 0.75rem; + font-size: 0.78rem; + border-radius: 6px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(255, 255, 255, 0.7); + color: #475569; + cursor: pointer; + font-weight: 500; + transition: all 0.15s; +} + +.settings-btn-inline:hover { + background: #fff; + border-color: var(--accent, #0e7490); + color: var(--accent, #0e7490); +} + +.settings-btn-inline.danger { + color: #991b1b; +} + +.settings-btn-inline.danger:hover { + background: #fef2f2; + border-color: #dc2626; + color: #dc2626; +} + +/* ===== 设置面板标题区 ===== */ +.settings-section-title { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.65rem; +} + +.settings-section-title h4 { + margin: 0; + font-size: 0.92rem; + font-weight: 700; + color: #1e293b; +} + +.settings-section-title .btn { + padding: 0.3rem 0.8rem; + font-size: 0.78rem; +} + +/* ===== 模型编辑弹窗 ===== */ +.model-modal-overlay { + display: none; + position: fixed; + top: 0; left: 0; right: 0; bottom: 0; + background: rgba(0,0,0,0.4); + z-index: 2000; + align-items: center; + justify-content: center; +} +.model-modal-overlay.show { + display: flex; +} +.model-modal-box { + background: #fff; + border-radius: 12px; + padding: 1.5rem; + width: 480px; + max-width: 90vw; + box-shadow: 0 20px 60px rgba(0,0,0,0.2); + animation: fadeIn 0.2s ease; +} +.model-modal-box h3 { + margin: 0 0 1rem; + font-size: 1rem; + font-weight: 600; + color: #1e293b; +} +.model-modal-body .modal-field { + margin-bottom: 0.6rem; +} +.model-modal-body .modal-field label { + display: block; + font-size: 0.78rem; + font-weight: 600; + color: #475569; + margin-bottom: 0.15rem; +} +.model-modal-body .modal-field input { + width: 100%; + border: 1px solid rgba(148,163,184,0.5); + border-radius: 6px; + padding: 0.4rem 0.5rem; + font-size: 0.82rem; + box-sizing: border-box; + transition: border-color 0.15s; +} +.model-modal-body .modal-field input:focus { + outline: none; + border-color: var(--accent, #4f46e5); + box-shadow: 0 0 0 3px rgba(79,70,229,0.08); +} +.model-modal-actions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 1.2rem; +} + +/* ===== 模型表格 ===== */ +.model-table { + border: 1px solid rgba(148,163,184,0.3); + border-radius: 10px; + overflow: hidden; + background: rgba(255,255,255,0.65); +} + +.model-table-head { + display: flex; + background: rgba(241,245,249,0.8); + font-size: 0.73rem; + font-weight: 700; + color: #475569; + padding: 0.5rem 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + gap: 0.5rem; +} + +.model-table-row { + display: flex; + padding: 0.5rem 0.75rem; + border-top: 1px solid rgba(148,163,184,0.12); + align-items: center; + font-size: 0.8rem; + transition: background 0.12s; + gap: 0.5rem; +} + +.model-table-row:hover { + background: rgba(241,245,249,0.45); +} + +/* 模型表格列宽 */ +.model-table .col-name { flex: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 600; color: #1e293b; } +.model-table .col-model { flex: 1.2; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #475569; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.78rem; } +.model-table .col-url { flex: 2.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #94a3b8; font-size: 0.72rem; } +.model-table .col-actions { flex: 0 0 90px; display: flex; gap: 0.35rem; justify-content: flex-end; align-items: center; } + +/* 表格内操作按钮 */ +.model-table .col-actions button { + padding: 0.25rem 0.55rem; + font-size: 0.72rem; + border: 1px solid rgba(148,163,184,0.25); + border-radius: 5px; + cursor: pointer; + background: rgba(255,255,255,0.8); + color: #475569; + font-weight: 500; + transition: all 0.15s; + white-space: nowrap; +} + +.model-table .col-actions button:hover { + background: #fff; + border-color: var(--accent, #0e7490); + color: var(--accent, #0e7490); +} + +.model-table .col-actions button.danger { + color: #991b1b; + border-color: rgba(220,38,38,0.2); + background: rgba(254,226,226,0.3); +} + +.model-table .col-actions button.danger:hover { + background: #fef2f2; + border-color: #dc2626; + color: #dc2626; +} + +/* ===== 开关控件 ===== */ +.toggle-switch { + position: relative; + display: inline-block; + width: 42px; + height: 22px; + flex-shrink: 0; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-slider { + position: absolute; + cursor: pointer; + top: 0; left: 0; right: 0; bottom: 0; + background: #cbd5e1; + border-radius: 22px; + transition: background 0.2s; +} + +.toggle-slider::before { + content: ""; + position: absolute; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; + box-shadow: 0 1px 3px rgba(0,0,0,0.15); +} + +.toggle-switch input:checked + .toggle-slider { + background: var(--accent, #0e7490); +} + +.toggle-switch input:checked + .toggle-slider::before { + transform: translateX(20px); +} + +.toggle-row { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.toggle-row .toggle-label { + font-size: 0.82rem; + font-weight: 600; + color: #475569; + cursor: pointer; + user-select: none; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ===== 图谱切换按钮 ===== */ +.graph-toggle-btn { + margin-left: 0.15rem; +} + +.graph-toggle-btn.graph-active { + background: rgba(14, 116, 144, 0.18); + border-color: rgba(14, 116, 144, 0.5); + color: #0e7490; +} + +/* ===== 主内容区(chat + graph 并排) ===== */ +.main-content-wrap { + flex: 1; + min-height: 0; + display: flex; + gap: 0; + overflow: hidden; +} + +/* 图谱开启时聊天面板缩窄,圆角只保留左侧 */ +.main.has-graph .chat-shell { + flex: 1 1 58%; + min-width: 320px; + border-radius: var(--radius) 0 0 var(--radius); +} + +/* ===== 图谱侧面板 ===== */ +.graph-panel { + display: flex; + flex-direction: column; + width: 42%; + min-width: 340px; + max-width: 520px; + border-left: 1px solid rgba(148, 163, 184, 0.35); + border-radius: 0 var(--radius) var(--radius) 0; + background: rgba(255, 255, 255, 0.25); + backdrop-filter: blur(8px); + -webkit-backdrop-filter: blur(8px); + overflow: hidden; + box-shadow: -4px 0 24px rgba(15, 23, 42, 0.06); + transition: width 0.3s ease, min-width 0.3s ease, max-width 0.3s ease, opacity 0.25s ease; +} + +.graph-panel[hidden] { + display: none; +} + +.graph-toolbar { + flex-shrink: 0; + display: flex; + align-items: center; + gap: 0.4rem; + padding: 0.45rem 0.65rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.22); + background: rgba(255, 255, 255, 0.15); +} + +.graph-toolbar-title { + font-size: 0.82rem; + font-weight: 700; + color: #1e293b; + margin-right: 0.25rem; +} + +.graph-btn { + font-size: 0.7rem; + padding: 0.2rem 0.45rem; + border-radius: 6px; +} + +.graph-legend { + margin-left: auto; + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.65rem; + color: #64748b; +} + +.graph-close-btn { + width: 1.65rem; + height: 1.65rem; + padding: 0; + border: 1px solid rgba(148, 163, 184, 0.4); + border-radius: 6px; + background: rgba(255, 255, 255, 0.4); + color: #64748b; + font-size: 0.75rem; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.graph-close-btn:hover { + background: rgba(255, 255, 255, 0.65); + color: #1e293b; +} + +.legend-dot { + display: inline-block; + width: 10px; + height: 10px; + border-radius: 50%; + margin-right: 0.1rem; +} + +.legend-running { + background: #f59e0b; + box-shadow: 0 0 6px rgba(245, 158, 11, 0.5); + animation: legendPulse 1.2s ease-in-out infinite; +} + +.legend-done { + background: #22c55e; +} + +.legend-error { + background: #ef4444; +} + +.legend-idle { + background: #94a3b8; +} + +@keyframes legendPulse { + 0%, 100% { opacity: 0.7; box-shadow: 0 0 4px rgba(245, 158, 11, 0.3); } + 50% { opacity: 1; box-shadow: 0 0 10px rgba(245, 158, 11, 0.7); } +} + +/* 图谱画布 */ +.graph-canvas-wrap { + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; + cursor: grab; + background: + radial-gradient(circle, rgba(148, 163, 184, 0.06) 1px, transparent 1px); + background-size: 24px 24px; +} + +.graph-canvas-wrap:active { + cursor: grabbing; +} + +.graph-canvas-wrap.panning { + cursor: grabbing; +} + +.graph-canvas { + width: 100%; + height: 100%; + display: block; + user-select: none; +} + +/* Agent 节点 */ +.graph-node { + cursor: pointer; + transition: transform 0.2s ease; +} + +.graph-node:hover .graph-node-rect { + filter: brightness(1.05); +} + +.graph-node-rect { + fill: rgba(255, 255, 255, 0.75); + stroke: rgba(148, 163, 184, 0.5); + stroke-width: 1.5; + rx: 16; + transition: fill 0.35s ease, stroke 0.35s ease, filter 0.35s ease; +} + +/* 状态颜色 */ +.graph-node.status-running .graph-node-rect { + fill: rgba(255, 200, 120, 0.45); + stroke: #f59e0b; + stroke-width: 2.5; + filter: drop-shadow(0 0 8px rgba(245, 158, 11, 0.35)); +} + +.graph-node.status-done .graph-node-rect { + fill: rgba(187, 247, 208, 0.45); + stroke: #22c55e; + stroke-width: 2; +} + +.graph-node.status-error .graph-node-rect { + fill: rgba(254, 202, 202, 0.5); + stroke: #ef4444; + stroke-width: 2.5; + filter: drop-shadow(0 0 6px rgba(239, 68, 68, 0.3)); +} + +.graph-node.status-idle .graph-node-rect { + fill: rgba(255, 255, 255, 0.5); + stroke: rgba(148, 163, 184, 0.4); +} + +/* Agent 节点内部文字 */ +.graph-node-icon { + font-size: 1.4rem; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} + +.graph-node-label { + font-size: 0.75rem; + font-weight: 600; + fill: #1e293b; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} + +.graph-node-role { + font-size: 0.6rem; + fill: #94a3b8; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} + +.graph-node-badge { + font-size: 0.6rem; + font-weight: 700; + text-anchor: middle; + dominant-baseline: central; + pointer-events: none; +} + +/* 连线 */ +.graph-edge { + transition: stroke 0.4s ease, stroke-width 0.4s ease, opacity 0.4s ease; +} + +.graph-edge.highlighted { + stroke: #f59e0b; + stroke-width: 2.5; + opacity: 1; + stroke-dasharray: none; +} + +.graph-edge.active { + stroke: #14b8a6; + stroke-width: 2; + opacity: 1; + stroke-dasharray: 6 3; + animation: edgeFlow 0.6s linear infinite; +} + +.graph-edge.normal { + stroke: rgba(148, 163, 184, 0.4); + stroke-width: 1.5; + opacity: 0.7; +} + +@keyframes edgeFlow { + from { stroke-dashoffset: 18; } + to { stroke-dashoffset: 0; } +} + +/* 工具调用面板 */ +/* 工具调用卡片 */ +.toolcall-card { + padding: 0.4rem 0.55rem; + border-radius: 10px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: rgba(255, 255, 255, 0.5); + font-size: 0.72rem; +} + +.toolcall-card-head { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.2rem; +} + +.toolcall-card-name { + font-weight: 700; + color: #1e293b; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; +} + +.toolcall-card-status { + font-size: 0.62rem; + padding: 0.1rem 0.35rem; + border-radius: 999px; + font-weight: 600; +} + +.toolcall-card-status.success { + background: rgba(34, 197, 94, 0.15); + color: #15803d; +} + +.toolcall-card-status.error { + background: rgba(239, 68, 68, 0.15); + color: #b91c1c; +} + +.toolcall-card-status.pending { + background: rgba(245, 158, 11, 0.15); + color: #b45309; +} + +.toolcall-card-args { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.65rem; + color: #64748b; + white-space: pre-wrap; +} + +.toolcall-card-result { + margin-top: 0.25rem; + padding: 0.3rem 0.4rem; + border-radius: 6px; + background: rgba(241, 245, 249, 0.7); + font-size: 0.65rem; + color: #475569; + max-height: 48px; + overflow: hidden; + text-overflow: ellipsis; + white-space: pre-wrap; + word-break: break-all; +} + +/* 消息传递动画脉冲 */ +@keyframes msgPulse { + 0%, 100% { opacity: 0.4; } + 50% { opacity: 1; } +} + +.graph-edge.active { + animation: edgeFlow 0.6s linear infinite, msgPulse 0.8s ease-in-out infinite; +} + +/* ===== 工具调用气泡(chat 视图) ===== */ +.tool-call-bubble-wrap { + margin-top: 0.5rem; + border: 1px solid rgba(148, 163, 184, 0.3); + border-radius: 10px; + overflow: hidden; + background: rgba(255, 255, 255, 0.18); + transition: background 0.3s; +} + +.tool-call-toggle { + display: block; + width: 100%; + padding: 0.35rem 0.65rem; + border: none; + background: rgba(255, 255, 255, 0.3); + font-size: 0.72rem; + font-weight: 600; + color: #475569; + cursor: pointer; + text-align: left; + transition: background 0.2s; + font-family: inherit; +} + +.tool-call-toggle:hover { + background: rgba(255, 255, 255, 0.5); +} + +.tool-call-collapsed { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + padding: 0.4rem 0.65rem; +} + +.tool-call-bubble-wrap--expanded .tool-call-collapsed { + display: none; +} + +.tool-call-chip { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 8px; + background: rgba(79, 70, 229, 0.1); + font-size: 0.68rem; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + color: #4f46e5; + font-weight: 500; + transition: opacity 0.5s; +} + +.tool-call-more { + display: inline-block; + padding: 0.15rem 0.45rem; + border-radius: 8px; + background: rgba(148, 163, 184, 0.15); + font-size: 0.65rem; + color: #94a3b8; + font-weight: 600; +} + +.tool-call-expanded { + display: none; + padding: 0.4rem 0.65rem 0.65rem; + max-height: 240px; + overflow-y: auto; +} + +.tool-call-bubble-wrap--expanded .tool-call-expanded { + display: block; +} + +.tool-call-detail { + margin-bottom: 0.5rem; + padding: 0.4rem 0.55rem; + border-radius: 8px; + background: rgba(241, 245, 249, 0.6); + border: 1px solid rgba(148, 163, 184, 0.2); +} + +.tool-call-detail-name { + font-weight: 700; + font-size: 0.72rem; + color: #1e293b; + margin-bottom: 0.25rem; +} + +.tool-call-detail-text { + margin: 0.2rem 0; + padding: 0.3rem 0.45rem; + border-radius: 6px; + background: rgba(148, 163, 184, 0.12); + font-size: 0.65rem; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + color: #64748b; + white-space: pre-wrap; + word-break: break-all; + max-height: 80px; + overflow-y: auto; +} + +.tool-call-detail-result { + color: #334155; + background: rgba(148, 163, 184, 0.08); +} + +.tool-call-detail-img { + border-radius: 6px; + margin: 0.2rem; + max-width: 120px; + max-height: 90px; + object-fit: cover; + opacity: 0.6; + transition: opacity 0.3s; +} + +.tool-call-detail-img:hover { + opacity: 0.9; +} + +/* 淡出动画 */ +.tool-call-block--fading { + transition: opacity 0.8s ease-out; + opacity: 0 !important; + pointer-events: none; +} + +/* ===== 实时工具调用(thinking 气泡内) ===== */ +.tool-call-live-wrap { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-top: 0.5rem; + padding: 0.4rem 0.5rem; + border-top: 1px solid rgba(148, 163, 184, 0.2); +} + +.tool-call-live-chip { + display: inline-flex; + align-items: center; + gap: 0.2rem; + padding: 0.15rem 0.5rem; + border-radius: 8px; + background: rgba(99, 102, 241, 0.08); + font-size: 0.68rem; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + color: #6366f1; + font-weight: 500; + transition: all 0.3s; +} + +.tool-call-live-chip--running { + background: rgba(245, 158, 11, 0.1); + color: #d97706; + animation: livePulse 1.2s ease-in-out infinite; +} + +@keyframes livePulse { + 0%, 100% { opacity: 0.7; } + 50% { opacity: 1; } +} + +/* 任务完成提示 */ +.tool-call-done-badge { + display: inline-block; + padding: 0.2rem 0.5rem; + border-radius: 999px; + background: rgba(34, 197, 94, 0.15); + color: #15803d; + font-size: 0.68rem; + font-weight: 600; + margin-bottom: 0.4rem; +} + +/* ===== Graph 节点工具调用缩略 ===== */ +.graph-node-tool-chip { + font-size: 0.55rem; + font-family: ui-monospace, SFMono-Regular, Consolas, monospace; + fill: #6366f1; + font-weight: 600; + text-anchor: start; +} + +/* ===== Agent 详情弹窗 ===== */ +.agent-detail-overlay { + position: fixed; + inset: 0; + z-index: 999; + background: rgba(15, 23, 42, 0.55); + display: flex; + align-items: center; + justify-content: center; +} + +.agent-detail-overlay[hidden] { + display: none; +} + +.agent-detail-dialog { + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border-radius: 16px; + width: min(520px, 92vw); + max-height: 80vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 20px 60px rgba(0,0,0,0.2); +} + +.agent-detail-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + border-bottom: 1px solid rgba(148, 163, 184, 0.25); +} + +.agent-detail-header h2 { + margin: 0; + font-size: 1.1rem; + font-weight: 700; + color: #1e293b; +} + +.agent-detail-close { + background: none; + border: none; + font-size: 1.5rem; + cursor: pointer; + color: #64748b; + line-height: 1; + padding: 0.2rem; +} + +.agent-detail-body { + padding: 1rem 1.25rem; + overflow-y: auto; + flex: 1; +} + +.agent-detail-section { + margin-bottom: 1.2rem; +} + +.agent-detail-section h3 { + margin: 0 0 0.5rem; + font-size: 0.85rem; + font-weight: 700; + color: #475569; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.agent-detail-section code { + padding: 0.1rem 0.3rem; + border-radius: 4px; + background: rgba(79, 70, 229, 0.08); + font-size: 0.75rem; + color: #4f46e5; +} + + diff --git a/src/web/app.js b/src/web/app.js index a17e553..346c69d 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -23,6 +23,7 @@ const drawerBackdrop = $("drawerBackdrop"); const bgImageLayer = $("bgImageLayer"); const outputModeSelect = $("outputModeSelect"); + const agentModeSelect = $("agentModeSelect"); const dofRange = $("dofRange"); const dofValue = $("dofValue"); const vignetteRange = $("vignetteRange"); @@ -31,7 +32,6 @@ const STORAGE_DOF = "minor_web_bg_dof"; const STORAGE_VIG = "minor_web_bg_vignette"; const STORAGE_HEADER_COLLAPSED = "minor_web_header_collapsed"; - /** @type {string} */ let sessionId = ""; /** @type {string[]} */ @@ -43,6 +43,7 @@ let recorderStream = null; let recordedChunks = []; let recordingMimeType = ""; + let sidebarCollapsed = false; // 自动语音输入相关变量 let autoInputEnabled = false; let audioContext = null; @@ -60,7 +61,6 @@ /** 最近一次 renderMessages 的 JSON 可序列化快照 */ let lastRenderedMessages = []; let activeAudioController = null; - async function api(path, opts = {}) { const r = await fetch(path, { ...opts, @@ -720,6 +720,10 @@ }); } + if (msg.meta && msg.meta.pending_action) { + bubble.appendChild(renderPendingActionCard(msg.meta.pending_action)); + } + scrollChatToBottom(); } @@ -748,6 +752,16 @@ return wrap; } + function showPendingActionInThinking(typingEl, msg) { + if (!typingEl || !msg || !msg.meta || !msg.meta.pending_action) return false; + const bubble = typingEl.querySelector(".msg-bubble"); + if (!bubble) return false; + bubble.appendChild(renderPendingActionCard(msg.meta.pending_action)); + lastRenderedMessages = lastRenderedMessages.concat([JSON.parse(JSON.stringify(msg))]); + scrollChatToBottom(); + return true; + } + function renderAudioBubble(part, options = {}) { const { autoplay = false, @@ -952,6 +966,336 @@ return div; } + function renderPendingActionCard(pending) { + const card = document.createElement("div"); + const resolved = Boolean(pending && pending.resolved); + const pendingType = pending && pending.type ? String(pending.type) : "tool_call"; + const interactionType = pending && pending.interaction_type ? String(pending.interaction_type) : ""; + card.className = "pending-action-card" + (resolved ? " pending-action-card--resolved" : "") + ` pending-action-card--${pendingType}` + (interactionType ? ` pending-action-card--${interactionType}` : ""); + const title = document.createElement("div"); + title.className = "pending-action-title"; + title.textContent = pending.title || "待确认执行"; + card.appendChild(title); + + if (pending.prompt) { + const prompt = document.createElement("div"); + prompt.className = "pending-action-prompt"; + prompt.textContent = pending.prompt; + card.appendChild(prompt); + } + + const textarea = document.createElement("textarea"); + textarea.className = "pending-action-editor"; + textarea.value = pending.instruction || ""; + textarea.disabled = resolved; + + const isTextChoiceCard = pendingType === "human_interaction"; + if (isTextChoiceCard) { + if (interactionType === "selection" && Array.isArray(pending.options) && pending.options.length > 0 && pendingType === "human_interaction") { + const optionWrap = document.createElement("div"); + optionWrap.className = "pending-action-options"; + pending.options.forEach((option, idx) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "pending-action-option"; + btn.textContent = String(option); + btn.disabled = resolved; + btn.addEventListener("click", () => { + textarea.value = String(option); + textarea.focus(); + }); + if (idx === 0 && !textarea.value) textarea.value = String(option); + optionWrap.appendChild(btn); + }); + card.appendChild(optionWrap); + } + textarea.placeholder = interactionType === "information" ? "在这里补充信息..." : "可直接编辑选择内容..."; + textarea.rows = Math.min(8, Math.max(2, textarea.value.split("\n").length)); + card.appendChild(textarea); + + const actions = document.createElement("div"); + actions.className = "pending-action-buttons"; + const makeActionBtn = (label, decision, className = decision) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `pending-action-btn pending-action-btn--${className}`; + btn.textContent = label; + btn.disabled = resolved; + btn.addEventListener("click", () => handleHumanAction(pending.id, decision, textarea.value, card).catch((e) => alert(e.message || String(e)))); + return btn; + }; + + if (interactionType === "approval") { + textarea.readOnly = true; + actions.appendChild(makeActionBtn("同意", "approve")); + actions.appendChild(makeActionBtn("拒绝", "reject")); + } else { + const okBtn = makeActionBtn("✓", interactionType === "selection" ? "edit" : "approve", "approve"); + okBtn.title = interactionType === "information" ? "提交补充信息" : "确认"; + actions.appendChild(okBtn); + } + card.appendChild(actions); + return card; + } + + textarea.rows = Math.min(10, Math.max(4, textarea.value.split("\n").length)); + textarea.readOnly = true; + card.appendChild(textarea); + + const actions = document.createElement("div"); + actions.className = "pending-action-buttons"; + + const makeBtn = (label, decision) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = `pending-action-btn pending-action-btn--${decision}`; + btn.textContent = label; + btn.disabled = resolved; + btn.addEventListener("click", () => handleHumanAction(pending.id, decision, textarea.value, card).catch((e) => alert(e.message || String(e)))); + return btn; + }; + actions.appendChild(makeBtn("同意", "approve")); + actions.appendChild(makeBtn("拒绝", "reject")); + card.appendChild(actions); + return card; + } + + async function handleHumanAction(approvalId, decision, instruction, card) { + if (!approvalId || sending) return; + sending = true; + sendBtn.disabled = true; + if (card) card.classList.add("pending-action-card--resolved"); + const fd = new FormData(); + fd.append("session_id", sessionId); + fd.append("approval_id", approvalId); + fd.append("decision", decision); + fd.append("instruction", instruction || ""); + fd.append("output_type", outputModeSelect ? outputModeSelect.value : "text"); + let typingEl = appendThinkingIndicator(); + try { + const done = await api("/api/human-action", { method: "POST", body: fd }); + if (typingEl && typingEl.parentNode) typingEl.remove(); + typingEl = null; + sessionId = done.sessionId; + sessions = done.sessions || []; + renderSessions(); + const messages = done.messages || []; + const last = messages[messages.length - 1]; + if (last && last.role === "assistant") { + renderMessages(messages.slice(0, -1)); + await streamAssistantMessage(last); + } else { + renderMessages(messages); + } + } finally { + if (typingEl && typingEl.parentNode) typingEl.remove(); + sending = false; + sendBtn.disabled = false; + } + } + + // ===== 工具调用展示(chat 视图) ===== + let toolCallDataCache = { recent: [], detail: [], complete: false }; + let toolCallTimer = null; + + async function fetchToolCallData() { + if (!sessionId) return; + try { + const [recentRes, detailRes, pendingRes] = await Promise.all([ + api(`/api/tool-calls/${encodeURIComponent(sessionId)}/recent`), + api(`/api/tool-calls/${encodeURIComponent(sessionId)}/detail`), + api(`/api/tool-calls/${encodeURIComponent(sessionId)}/has-pending`), + ]); + toolCallDataCache = { + recent: recentRes.tool_calls || [], + detail: detailRes.details || [], + complete: pendingRes.is_complete, + }; + } catch { + /* ignore */ + } + } + + function startToolCallPolling() { + fetchToolCallData(); + toolCallTimer = setInterval(fetchToolCallData, 2000); + } + + function stopToolCallPolling(keepDisplay = false) { + if (toolCallTimer) { + clearInterval(toolCallTimer); + toolCallTimer = null; + } + if (!keepDisplay) { + // 清除过期工具调用显示(渐变消失) + document.querySelectorAll(".tool-call-block--fading").forEach((el) => { + el.addEventListener("transitionend", () => el.remove()); + el.style.opacity = "0"; + }); + } + } + + function renderToolCallBlock(call, index, total, isComplete) { + const block = document.createElement("div"); + block.className = "tool-call-block" + (isComplete ? " tool-call-block--fading" : ""); + + const header = document.createElement("div"); + header.className = "tool-call-header"; + const name = document.createElement("span"); + name.className = "tool-call-name"; + name.textContent = `🔧 ${escapeHtml(call.name || "")}`; + const args = document.createElement("span"); + args.className = "tool-call-args"; + args.textContent = call.args_summary || ""; + const status = document.createElement("span"); + status.className = "tool-call-status"; + status.textContent = call.status === "done" ? "✓" : "…"; + header.appendChild(name); + header.appendChild(args); + header.appendChild(status); + block.appendChild(header); + + return block; + } + + function renderToolCallDetail(call) { + const detail = document.createElement("div"); + detail.className = "tool-call-detail"; + + const name = document.createElement("div"); + name.className = "tool-call-detail-name"; + name.textContent = `🔧 ${escapeHtml(call.name || "unknown")}`; + detail.appendChild(name); + + if (call.args && Object.keys(call.args).length > 0) { + const argsEl = document.createElement("pre"); + argsEl.className = "tool-call-detail-text"; + argsEl.textContent = JSON.stringify(call.args, null, 2); + detail.appendChild(argsEl); + } + + if (call.result_text) { + const resultEl = document.createElement("pre"); + resultEl.className = "tool-call-detail-text tool-call-detail-result"; + resultEl.textContent = call.result_text; + detail.appendChild(resultEl); + } + + if (call.result_images && call.result_images.length > 0) { + call.result_images.forEach((imgUrl) => { + const img = document.createElement("img"); + img.src = imgUrl; + img.className = "tool-call-detail-img"; + img.alt = "工具返回图片"; + img.style.maxWidth = "160px"; + img.style.opacity = "0.5"; + detail.appendChild(img); + }); + } + + return detail; + } + + function injectLiveToolCalls(typingEl, liveCalls) { + if (!typingEl || !liveCalls.length) return; + // 查找或创建工具调用容器 + let liveWrap = typingEl.querySelector(".tool-call-live-wrap"); + if (!liveWrap) { + liveWrap = document.createElement("div"); + liveWrap.className = "tool-call-live-wrap"; + typingEl.appendChild(liveWrap); + } + // 更新显示 + liveWrap.innerHTML = ""; + liveCalls.forEach((call, i) => { + const chip = document.createElement("span"); + chip.className = "tool-call-live-chip"; + const statusIcon = call.status === "running" ? "⏳" : "✓"; + chip.textContent = `${statusIcon} ${escapeHtml(call.name || "")}`; + chip.style.opacity = Math.max(0.3, 1 - i * 0.15); + if (call.status === "running") chip.classList.add("tool-call-live-chip--running"); + liveWrap.appendChild(chip); + }); + scrollChatToBottom(); + } + + function buildToolCallBubble() { + const data = toolCallDataCache; + if (!data.recent.length && !data.detail.length) return null; + + const total = data.recent.length; + const visibleCount = Math.min(2, total); + const moreCount = total - visibleCount; + + const wrap = document.createElement("div"); + wrap.className = "tool-call-bubble-wrap"; + + // 折叠按钮 + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = "tool-call-toggle"; + toggleBtn.textContent = `🔧 工具调用 (${total}) ▸`; + toggleBtn.addEventListener("click", () => { + const expanded = wrap.classList.toggle("tool-call-bubble-wrap--expanded"); + toggleBtn.textContent = `🔧 工具调用 (${total}) ${expanded ? "▾" : "▸"}`; + }); + + // 折叠态:只显示最近2个工具名称,渐变透明度动态消失 + const collapsed = document.createElement("div"); + collapsed.className = "tool-call-collapsed"; + data.recent.slice(-visibleCount).forEach((call, i) => { + const chip = document.createElement("span"); + chip.className = "tool-call-chip"; + chip.textContent = call.name || ""; + // 越旧的越透明 + chip.style.opacity = Math.max(0.25, 1 - (visibleCount - 1 - i) * 0.35); + collapsed.appendChild(chip); + }); + if (moreCount > 0) { + const more = document.createElement("span"); + more.className = "tool-call-more"; + more.textContent = `+${moreCount}`; + more.style.cursor = "pointer"; + more.addEventListener("click", () => { + wrap.classList.add("tool-call-bubble-wrap--expanded"); + toggleBtn.textContent = `🔧 工具调用 (${total}) ▾`; + }); + collapsed.appendChild(more); + } + + // 展开态:显示所有详情 + const expanded = document.createElement("div"); + expanded.className = "tool-call-expanded"; + data.detail.forEach((call) => { + expanded.appendChild(renderToolCallDetail(call)); + }); + + wrap.appendChild(toggleBtn); + wrap.appendChild(collapsed); + wrap.appendChild(expanded); + + return wrap; + } + + function injectToolCallDisplay(typingEl) { + if (!typingEl) return; + if (!toolCallDataCache.recent.length && !toolCallDataCache.detail.length) return; + + const existingBlock = typingEl.querySelector(".tool-call-bubble-wrap"); + if (existingBlock) { + // 更新现有显示 + const newBlock = buildToolCallBubble(); + if (newBlock) existingBlock.replaceWith(newBlock); + return; + } + + const block = buildToolCallBubble(); + if (block) { + typingEl.appendChild(block); + scrollChatToBottom(); + } + } + function renderMessage(msg, allowAutoplay = false) { const wrap = document.createElement("div"); wrap.className = "msg " + (msg.role === "user" ? "user" : "assistant") + (isAudioOnlyAssistantMessage(msg) ? " msg--audio-only" : ""); @@ -991,6 +1335,9 @@ } else { bubble.textContent = c == null ? "" : String(c); } + if (msg.meta && msg.meta.pending_action) { + bubble.appendChild(renderPendingActionCard(msg.meta.pending_action)); + } wrap.appendChild(bubble); return wrap; } @@ -1130,12 +1477,33 @@ revokeBlobUrlStack(blobUrls); typingEl = appendThinkingIndicator(); + // 清空上轮工具调用缓存(新 turn) + toolCallDataCache = { recent: [], detail: [], complete: false }; + + // 启动实时工具调用轮询 + let livePollTimer = null; + let livePollActive = true; + const pollLiveToolCalls = async () => { + if (!livePollActive) return; + try { + const liveData = await api(`/api/tool-calls/${encodeURIComponent(sessionId)}/live`); + if (liveData.tool_calls && liveData.tool_calls.length > 0 && typingEl) { + injectLiveToolCalls(typingEl, liveData.tool_calls); + } + } catch { /* ignore */ } + if (livePollActive) livePollTimer = setTimeout(pollLiveToolCalls, 800); + }; + pollLiveToolCalls(); + const fd2 = new FormData(); fd2.append("session_id", sessionId); fd2.append("output_type", outputModeSelect ? outputModeSelect.value : "text"); + fd2.append("agent_mode", agentModeSelect ? agentModeSelect.value : "agent"); const done = await api("/api/chat/complete", { method: "POST", body: fd2 }); - if (typingEl && typingEl.parentNode) typingEl.remove(); - typingEl = null; + + // 停止轮询 + livePollActive = false; + if (livePollTimer) clearTimeout(livePollTimer); sessionId = done.sessionId; sessions = done.sessions || []; @@ -1143,14 +1511,28 @@ const messages = done.messages || []; const last = messages[messages.length - 1]; - if (last && last.role === "assistant") { - const head = messages.slice(0, -1); - renderMessages(head); - revokeBlobUrlStack(blobUrls); - await streamAssistantMessage(last); + if (last && last.role === "assistant" && last.meta && last.meta.pending_action && typingEl) { + showPendingActionInThinking(typingEl, last); + typingEl = null; } else { - renderMessages(messages); - revokeBlobUrlStack(blobUrls); + if (typingEl && typingEl.parentNode) typingEl.remove(); + typingEl = null; + if (last && last.role === "assistant") { + const head = messages.slice(0, -1); + renderMessages(head); + revokeBlobUrlStack(blobUrls); + await streamAssistantMessage(last); + // 获取工具调用数据并注入到最后一条助手消息 + await fetchToolCallData(); + const lastBubble = chatMessages.querySelector(".msg.assistant:last-child .msg-bubble"); + if (lastBubble && toolCallDataCache.recent.length) { + const toolBlock = buildToolCallBubble(); + if (toolBlock) lastBubble.appendChild(toolBlock); + } + } else { + renderMessages(messages); + revokeBlobUrlStack(blobUrls); + } } } catch (e) { revokeBlobUrlStack(blobUrls); @@ -1347,6 +1729,1115 @@ }); loadHeaderPref(); + // ===== 系统配置模态框 ===== + const settingsBtn = $("settingsBtn"); + const settingsOverlay = $("settingsOverlay"); + const settingsCloseBtn = $("settingsCloseBtn"); + const settingsCancelBtn = $("settingsCancelBtn"); + const settingsStatus = $("settingsStatus"); + const agentsListEl = $("agentsList"); + const toolsListEl = $("toolsList"); + const envListEl = $("envList"); + const agentConfigPath = $("agentConfigPath"); + const toolConfigPath = $("toolConfigPath"); + const envConfigPath = $("envConfigPath"); + const addAgentBtn = $("addAgentBtn"); + + let cachedAgentConfigs = []; + let cachedToolConfigs = []; + let cachedEnvConfig = {}; + let cachedModels = []; + let registeredToolNames = []; + + // 模型编辑状态 + let editingModelIdx = -1; + + function setStatus(msg, type) { + if (!settingsStatus) return; + settingsStatus.textContent = msg; + settingsStatus.className = "settings-status" + (type ? " " + type : ""); + if (type === "success" || type === "error") { + setTimeout(() => { if (settingsStatus.textContent === msg) setStatus(""); }, 3000); + } + } + + function openSettings() { + settingsOverlay.hidden = false; + loadAllConfigs().catch((e) => setStatus("加载配置失败: " + e.message, "error")); + } + + function closeSettings() { + settingsOverlay.hidden = true; + setStatus(""); + } + + // Tab 切换 + document.querySelectorAll(".settings-tab").forEach((tab) => { + tab.addEventListener("click", () => { + const target = tab.getAttribute("data-tab"); + document.querySelectorAll(".settings-tab").forEach((t) => t.classList.remove("active")); + tab.classList.add("active"); + document.querySelectorAll(".settings-panel").forEach((p) => p.hidden = p.getAttribute("data-panel") !== target); + // 切换时重新渲染对应面板 + if (target === "agent") renderAgentConfigs(); + else if (target === "tool") renderToolConfigs(); + else if (target === "env") renderEnvConfig(); + }); + }); + + // ===== 加载所有配置 ===== + async function loadAllConfigs() { + const [agentRes, toolRes, envRes] = await Promise.all([ + api("/api/config/agents"), + api("/api/config/tools"), + api("/api/config/env"), + ]); + cachedAgentConfigs = agentRes.agents || []; + cachedToolConfigs = toolRes.tools || []; + cachedEnvConfig = envRes.env || {}; + cachedModels = envRes.models || []; + registeredToolNames = toolRes.registered_tool_names || []; + window._toolParameters = toolRes.tool_parameters || {}; + window._forcedPermissions = toolRes.forced_permissions || {}; + if (agentConfigPath) agentConfigPath.textContent = agentRes.config_path || ""; + if (toolConfigPath) toolConfigPath.textContent = toolRes.config_path || ""; + if (envConfigPath) envConfigPath.textContent = envRes.config_path || ""; + renderAgentConfigs(); + renderToolConfigs(); + renderEnvConfig(); + } + + // ===== Agent 配置渲染 ===== + function renderAgentConfigs() { + agentsListEl.innerHTML = ""; + cachedAgentConfigs.forEach((agent, idx) => { + agentsListEl.appendChild(buildAgentCard(agent, idx)); + }); + } + + function getModelLabel(modelId) { + if (!modelId) return "— 未选择 —"; + const m = cachedModels.find((mod) => mod.id === modelId); + return m ? `${m.name} (${m.model})` : modelId; + } + + function buildAgentCard(agent, idx) { + const card = document.createElement("div"); + card.className = "agent-config-card"; + card.dataset.index = idx; + + // 头部 + const header = document.createElement("div"); + header.className = "agent-card-header"; + const h4 = document.createElement("h4"); + h4.textContent = `Agent: ${agent.name || "(未命名)"}`; + const removeBtn = document.createElement("button"); + removeBtn.className = "agent-card-remove"; + removeBtn.textContent = "✕"; + removeBtn.title = "删除此 Agent"; + removeBtn.addEventListener("click", () => { + const wasMain = agent.role === "main"; + cachedAgentConfigs.splice(idx, 1); + if (wasMain && cachedAgentConfigs.length > 0) { + // 如果删的是主 agent,第一个自动升为主 agent + cachedAgentConfigs[0].role = "main"; + } + renderAgentConfigs(); + }); + header.appendChild(h4); + header.appendChild(removeBtn); + + // Name + const nameField = makeField("名称", "text", agent.name || "", (v) => { agent.name = v; }); + // max_iterations + const maxIterField = makeField("最大迭代次数", "number", agent.max_iterations || 200, (v) => { agent.max_iterations = parseInt(v) || 200; }); + // system_prompt + const spField = makeField("系统提示词 (system_prompt)", "textarea", agent.system_prompt || "", (v) => { agent.system_prompt = v; }); + + // LLM 模型选择 + const llmField = document.createElement("div"); + llmField.className = "setting-field"; + const llmLabel = document.createElement("label"); + llmLabel.textContent = "LLM 模型"; + const llmRow = document.createElement("div"); + llmRow.style.cssText = "display:flex;gap:0.4rem;align-items:center;"; + const llmSelect = document.createElement("select"); + llmSelect.style.cssText = "flex:1;"; + const emptyOpt = document.createElement("option"); + emptyOpt.value = ""; + emptyOpt.textContent = "— 未选择 —"; + llmSelect.appendChild(emptyOpt); + cachedModels.forEach((m) => { + const opt = document.createElement("option"); + opt.value = m.id; + opt.textContent = `${m.name} (${m.model})`; + if (m.id === agent.llm_model_id) opt.selected = true; + llmSelect.appendChild(opt); + }); + if (!cachedModels.some((m) => m.id === agent.llm_model_id) && agent.llm_model_id) { + const opt = document.createElement("option"); + opt.value = agent.llm_model_id; + opt.textContent = agent.llm_model_id + " (已删除)"; + opt.selected = true; + llmSelect.appendChild(opt); + } + llmSelect.addEventListener("change", () => { + agent.llm_model_id = llmSelect.value; + }); + llmRow.appendChild(llmSelect); + llmField.appendChild(llmLabel); + llmField.appendChild(llmRow); + + // 角色与启用 + const metaRow = document.createElement("div"); + metaRow.className = "setting-field"; + metaRow.style.cssText = "display:flex;gap:1rem;align-items:center;flex-wrap:wrap;"; + + // 角色 + const roleWrap = document.createElement("div"); + roleWrap.style.cssText = "display:flex;align-items:center;gap:0.4rem;"; + const roleLabel = document.createElement("label"); + roleLabel.textContent = "角色:"; + roleLabel.style.cssText = "font-size:0.82rem;font-weight:600;"; + const roleSelect = document.createElement("select"); + roleSelect.innerHTML = ''; + roleSelect.value = agent.role || "sub"; + roleSelect.addEventListener("change", () => { + if (roleSelect.value === "main") { + // 将所有其他 agent 设为 sub + cachedAgentConfigs.forEach((a) => { a.role = "sub"; }); + agent.role = "main"; + renderAgentConfigs(); + } else { + agent.role = "sub"; + // 确保至少有一个 main + const hasMain = cachedAgentConfigs.some((a) => a.role === "main"); + if (!hasMain) { + // 找第一个非此 agent 的设为 main + const other = cachedAgentConfigs.find((a, i) => i !== idx); + if (other) other.role = "main"; + renderAgentConfigs(); + } + } + }); + roleWrap.appendChild(roleLabel); + roleWrap.appendChild(roleSelect); + + // 启用(开关) + const enabledWrap = document.createElement("div"); + enabledWrap.className = "toggle-row"; + const toggleLabel = document.createElement("label"); + toggleLabel.className = "toggle-switch"; + const toggleInput = document.createElement("input"); + toggleInput.type = "checkbox"; + toggleInput.checked = agent.enabled !== false; + toggleInput.addEventListener("change", () => { agent.enabled = toggleInput.checked; }); + const toggleSlider = document.createElement("span"); + toggleSlider.className = "toggle-slider"; + toggleLabel.appendChild(toggleInput); + toggleLabel.appendChild(toggleSlider); + const enabledLabel = document.createElement("span"); + enabledLabel.className = "toggle-label"; + enabledLabel.textContent = "激活"; + enabledLabel.addEventListener("click", () => { toggleInput.click(); }); + enabledWrap.appendChild(toggleLabel); + enabledWrap.appendChild(enabledLabel); + + metaRow.appendChild(roleWrap); + metaRow.appendChild(enabledWrap); + + // 工具选择(列表式:下拉 + 添加/删除) + const toolsField = document.createElement("div"); + toolsField.className = "setting-field"; + const toolsLabel = document.createElement("label"); + toolsLabel.textContent = "工具列表"; + toolsField.appendChild(toolsLabel); + + // 已选工具列表 + const selectedToolsList = document.createElement("div"); + selectedToolsList.style.cssText = "display:flex;flex-wrap:wrap;gap:0.25rem;margin-bottom:0.35rem;min-height:1.5rem;"; + + function renderSelectedTools() { + selectedToolsList.innerHTML = ""; + (agent.tools || []).forEach((tname) => { + const chip = document.createElement("span"); + chip.style.cssText = "display:inline-flex;align-items:center;gap:0.2rem;padding:0.15rem 0.4rem;background:rgba(79,70,229,0.12);border-radius:6px;font-size:0.78rem;"; + chip.textContent = tname; + const delBtn = document.createElement("span"); + delBtn.textContent = "×"; + delBtn.style.cssText = "cursor:pointer;color:#991b1b;font-weight:700;margin-left:0.15rem;"; + delBtn.title = "移除此工具"; + delBtn.addEventListener("click", () => { + agent.tools = (agent.tools || []).filter((t) => t !== tname); + renderSelectedTools(); + }); + chip.appendChild(delBtn); + selectedToolsList.appendChild(chip); + }); + } + renderSelectedTools(); + toolsField.appendChild(selectedToolsList); + + // 添加工具行 + const addToolRow = document.createElement("div"); + addToolRow.style.cssText = "display:flex;gap:0.4rem;align-items:center;"; + const toolSelect = document.createElement("select"); + toolSelect.style.cssText = "flex:1;"; + const emptyToolOpt = document.createElement("option"); + emptyToolOpt.value = ""; + emptyToolOpt.textContent = "选择工具..."; + toolSelect.appendChild(emptyToolOpt); + registeredToolNames.forEach((tname) => { + if (!(agent.tools || []).includes(tname)) { + const opt = document.createElement("option"); + opt.value = tname; + opt.textContent = tname; + toolSelect.appendChild(opt); + } + }); + const addToolBtn = document.createElement("button"); + addToolBtn.type = "button"; + addToolBtn.className = "btn btn-ghost"; + addToolBtn.style.cssText = "padding:0.2rem 0.6rem;font-size:0.78rem;white-space:nowrap;"; + addToolBtn.textContent = "+ 添加"; + addToolBtn.addEventListener("click", () => { + const val = toolSelect.value; + if (!val) return; + if (!agent.tools) agent.tools = []; + if (!agent.tools.includes(val)) { + agent.tools.push(val); + renderSelectedTools(); + // 移除已选项 + Array.from(toolSelect.options).forEach((o) => { if (o.value === val) o.remove(); }); + toolSelect.value = ""; + } + }); + addToolRow.appendChild(toolSelect); + addToolRow.appendChild(addToolBtn); + toolsField.appendChild(addToolRow); + + card.appendChild(header); + card.appendChild(nameField); + card.appendChild(maxIterField); + card.appendChild(spField); + card.appendChild(llmField); + card.appendChild(metaRow); + card.appendChild(toolsField); + return card; + } + + function makeField(label, type, value, onChange) { + const div = document.createElement("div"); + div.className = "setting-field"; + const lbl = document.createElement("label"); + lbl.textContent = label; + let input; + if (type === "textarea") { + input = document.createElement("textarea"); + input.rows = 3; + } else { + input = document.createElement("input"); + input.type = type; + } + input.value = value ?? ""; + input.addEventListener("input", () => onChange(input.value)); + if (type === "number") input.step = "any"; + div.appendChild(lbl); + div.appendChild(input); + return div; + } + + addAgentBtn.addEventListener("click", () => { + // 新 agent 默认为 sub,除非是第一个 + const isFirst = cachedAgentConfigs.length === 0; + cachedAgentConfigs.push({ + name: "new_agent", + system_prompt: "", + max_iterations: 200, + llm: { model: "", api_key: "", base_url: "", timeout: 60, max_retries: 0 }, + llm_model_id: cachedModels.length > 0 ? cachedModels[0].id : "", + tools: [], + enabled: true, + role: isFirst ? "main" : "sub", + }); + renderAgentConfigs(); + }); + + // ===== Tool 配置渲染 ===== + function renderToolConfigs() { + toolsListEl.innerHTML = ""; + cachedToolConfigs.forEach((tool, idx) => { + toolsListEl.appendChild(buildToolCard(tool, idx)); + }); + } + + function buildToolCard(tool, idx) { + const card = document.createElement("div"); + card.className = "tool-config-card" + (tool.enabled === false ? " disabled" : ""); + + const title = document.createElement("h4"); + const enabledCheck = document.createElement("input"); + enabledCheck.type = "checkbox"; + enabledCheck.checked = tool.enabled !== false; + enabledCheck.addEventListener("change", () => { + tool.enabled = enabledCheck.checked; + card.classList.toggle("disabled", !tool.enabled); + }); + title.appendChild(enabledCheck); + title.appendChild(document.createTextNode(` ${tool.display_name || tool.name}`)); + + const desc = document.createElement("div"); + desc.className = "tool-card-desc"; + desc.textContent = tool.description || ""; + + const row = document.createElement("div"); + row.className = "tool-card-row"; + + // 权限选择 + const forcedPerm = (window._forcedPermissions || {})[tool.name]; + const permLabel = document.createElement("label"); + permLabel.textContent = "权限: "; + permLabel.style.fontSize = "0.82rem"; + permLabel.style.fontWeight = "600"; + const permSelect = document.createElement("select"); + permSelect.innerHTML = ''; + permSelect.value = tool.permission || "confirm"; + if (forcedPerm) { + permSelect.disabled = true; + permSelect.value = forcedPerm; + tool.permission = forcedPerm; + permLabel.textContent = "权限(锁定): "; + } + row.appendChild(permLabel); + row.appendChild(permSelect); + + card.appendChild(title); + card.appendChild(desc); + card.appendChild(row); + + // ---------- 自动执行规则(非强制 confirm 且含参数的工具) ---------- + const toolParams = (window._toolParameters || {})[tool.name] || []; + const hasParams = toolParams.length > 0; + const rulesWrap = document.createElement("div"); + + function updateRulesVisibility() { + const visible = !forcedPerm && tool.permission === "confirm" && hasParams; + rulesWrap.hidden = !visible; + } + + if (hasParams) { + rulesWrap.className = "auto-rules-section"; + const rulesLabel = document.createElement("div"); + rulesLabel.style.cssText = "font-size:0.78rem;font-weight:600;color:#475569;margin:0.5rem 0 0.3rem;"; + rulesLabel.textContent = "自动执行规则(满足任一条件则跳过确认):"; + rulesWrap.appendChild(rulesLabel); + + const rulesList = document.createElement("div"); + rulesList.className = "auto-rules-list"; + + const renderRules = () => { + rulesList.innerHTML = ""; + (tool.auto_execute_rules || []).forEach((rule, ri) => { + const ruleRow = document.createElement("div"); + ruleRow.className = "auto-rule-row"; + ruleRow.style.cssText = "display:flex;align-items:center;gap:0.3rem;margin-bottom:0.25rem;flex-wrap:wrap;"; + + const paramLabel = document.createElement("span"); + paramLabel.style.cssText = "font-size:0.74rem;font-weight:600;"; + paramLabel.textContent = "当"; + ruleRow.appendChild(paramLabel); + + const paramSelect = document.createElement("select"); + paramSelect.style.cssText = "flex:0 0 auto;min-width:0;"; + toolParams.forEach((p) => { + const opt = document.createElement("option"); + opt.value = p.name; + opt.textContent = p.name; + if (p.type) opt.title = `类型: ${p.type}`; + if (p.name === (rule.parameter || "")) opt.selected = true; + paramSelect.appendChild(opt); + }); + if (rule.parameter && !toolParams.some((p) => p.name === rule.parameter)) { + const opt = document.createElement("option"); + opt.value = rule.parameter; + opt.textContent = rule.parameter; + opt.selected = true; + paramSelect.appendChild(opt); + } + paramSelect.addEventListener("change", () => { rule.parameter = paramSelect.value; }); + ruleRow.appendChild(paramSelect); + + const opLabel = document.createElement("span"); + opLabel.style.cssText = "font-size:0.74rem;"; + opLabel.textContent = "="; + ruleRow.appendChild(opLabel); + + const valInput = document.createElement("input"); + valInput.type = "text"; + valInput.value = rule.value || ""; + valInput.placeholder = "期望值"; + valInput.style.cssText = "flex:1;min-width:80px;border:1px solid rgba(148,163,184,0.5);border-radius:6px;padding:0.2rem 0.35rem;font-size:0.8rem;"; + valInput.addEventListener("input", () => { rule.value = valInput.value; }); + ruleRow.appendChild(valInput); + + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.style.cssText = "border:none;background:none;cursor:pointer;color:#991b1b;font-size:0.9rem;padding:0 0.2rem;"; + delBtn.textContent = "×"; + delBtn.title = "删除此规则"; + delBtn.addEventListener("click", () => { + tool.auto_execute_rules = (tool.auto_execute_rules || []).filter((_, i) => i !== ri); + renderRules(); + }); + ruleRow.appendChild(delBtn); + + rulesList.appendChild(ruleRow); + }); + }; + renderRules(); + + const addRuleRow = document.createElement("div"); + addRuleRow.style.cssText = "margin-top:0.35rem;"; + const addRuleBtn = document.createElement("button"); + addRuleBtn.type = "button"; + addRuleBtn.className = "btn btn-ghost"; + addRuleBtn.style.cssText = "padding:0.15rem 0.5rem;font-size:0.75rem;"; + addRuleBtn.textContent = "+ 添加规则"; + addRuleBtn.addEventListener("click", () => { + const defaultParam = toolParams[0].name; + tool.auto_execute_rules = [...(tool.auto_execute_rules || []), { parameter: defaultParam, operator: "equals", value: "" }]; + renderRules(); + }); + addRuleRow.appendChild(addRuleBtn); + rulesWrap.appendChild(rulesList); + rulesWrap.appendChild(addRuleRow); + } + + card.appendChild(rulesWrap); + updateRulesVisibility(); + + permSelect.addEventListener("change", () => { + tool.permission = permSelect.value; + updateRulesVisibility(); + }); + + return card; + } + + // ===== Env 配置渲染 ===== + function renderEnvConfig() { + envListEl.innerHTML = ""; + + // ---------- 模型管理区 ---------- + const modelsSection = document.createElement("div"); + modelsSection.style.cssText = "margin-bottom:1.5rem;"; + + const modelsHeader = document.createElement("div"); + modelsHeader.className = "settings-section-title"; + const modelsTitle = document.createElement("h4"); + modelsTitle.textContent = "LLM 模型列表"; + const addModelBtn = document.createElement("button"); + addModelBtn.type = "button"; + addModelBtn.className = "btn btn-primary"; + addModelBtn.textContent = "+ 添加模型"; + addModelBtn.addEventListener("click", () => openModelModal(-1)); + modelsHeader.appendChild(modelsTitle); + modelsHeader.appendChild(addModelBtn); + modelsSection.appendChild(modelsHeader); + + if (cachedModels.length === 0) { + const emptyHint = document.createElement("p"); + emptyHint.style.cssText = "color:#94a3b8;font-size:0.8rem;"; + emptyHint.textContent = "暂无模型,请添加至少一个 LLM 模型。"; + modelsSection.appendChild(emptyHint); + } else { + const table = document.createElement("div"); + table.className = "model-table"; + const thead = document.createElement("div"); + thead.className = "model-table-head"; + thead.innerHTML = '名称ModelBase URL操作'; + table.appendChild(thead); + cachedModels.forEach((m, mi) => { + const row = document.createElement("div"); + row.className = "model-table-row"; + row.innerHTML = `${escapeHtml(m.name)} + ${escapeHtml(m.model)} + ${escapeHtml(m.base_url)}`; + const actions = document.createElement("span"); + actions.className = "col-actions"; + const editBtn = document.createElement("button"); + editBtn.type = "button"; + editBtn.textContent = "编辑"; + editBtn.addEventListener("click", () => openModelModal(mi)); + const delBtn = document.createElement("button"); + delBtn.type = "button"; + delBtn.className = "danger"; + delBtn.textContent = "删除"; + delBtn.addEventListener("click", () => { + if (!confirm(`确定删除模型 "${m.name}"?`)) return; + cachedModels.splice(mi, 1); + // 清除引用该模型的 agent + cachedAgentConfigs.forEach((a) => { if (a.llm_model_id === m.id) a.llm_model_id = ""; }); + // 同时保存模型变更 + api("/api/config/models", { method: "POST", body: JSON.stringify({ models: cachedModels }) }).catch(() => {}); + api("/api/config/reload", { method: "POST", body: JSON.stringify({}) }).catch(() => {}); + renderEnvConfig(); + renderAgentConfigs(); + }); + actions.appendChild(editBtn); + actions.appendChild(delBtn); + row.appendChild(actions); + table.appendChild(row); + }); + modelsSection.appendChild(table); + } + envListEl.appendChild(modelsSection); + + // ---------- 模型编辑弹窗(内嵌在 env 面板中) ---------- + const modalOverlay = document.createElement("div"); + modalOverlay.className = "model-modal-overlay"; + modalOverlay.addEventListener("click", (e) => { if (e.target === modalOverlay) closeModelModal(); }); + const modalBox = document.createElement("div"); + modalBox.className = "model-modal-box"; + modalBox.innerHTML = '

模型配置

'; + const modalBody = document.createElement("div"); + modalBody.className = "model-modal-body"; + const modalFields = {}; + ["name", "model", "api_key", "base_url", "timeout"].forEach((key) => { + const div = document.createElement("div"); + div.className = "modal-field"; + const lbl = document.createElement("label"); + lbl.textContent = { name: "显示名称", model: "模型名 (model)", api_key: "API Key", base_url: "Base URL", timeout: "Timeout (秒)" }[key]; + const inp = document.createElement("input"); + inp.type = key === "timeout" ? "number" : key === "api_key" ? "password" : "text"; + if (key === "timeout") inp.step = "any"; + div.appendChild(lbl); + div.appendChild(inp); + modalFields[key] = inp; + modalBody.appendChild(div); + }); + modalBox.appendChild(modalBody); + const modalActions = document.createElement("div"); + modalActions.className = "model-modal-actions"; + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "btn btn-secondary"; + cancelBtn.textContent = "取消"; + cancelBtn.addEventListener("click", closeModelModal); + const saveBtn = document.createElement("button"); + saveBtn.type = "button"; + saveBtn.className = "btn btn-primary"; + saveBtn.textContent = "保存"; + saveBtn.addEventListener("click", async () => { + const data = { + id: editingModelIdx >= 0 && cachedModels[editingModelIdx] ? cachedModels[editingModelIdx].id : makeId(), + name: modalFields.name.value.trim(), + model: modalFields.model.value.trim(), + api_key: modalFields.api_key.value.trim(), + base_url: modalFields.base_url.value.trim(), + timeout: parseFloat(modalFields.timeout.value) || 60, + max_retries: 0, + }; + if (!data.name || !data.model) { alert("名称和模型名不能为空"); return; } + if (editingModelIdx >= 0) { + cachedModels[editingModelIdx] = data; + } else { + cachedModels.push(data); + } + await api("/api/config/models", { method: "POST", body: JSON.stringify({ models: cachedModels }) }); + closeModelModal(); + renderEnvConfig(); + renderAgentConfigs(); + setStatus("模型已保存", "success"); + }); + modalActions.appendChild(cancelBtn); + modalActions.appendChild(saveBtn); + modalBox.appendChild(modalActions); + modalOverlay.appendChild(modalBox); + envListEl.appendChild(modalOverlay); + + window._modelModalOverlay = modalOverlay; + window._modelModalFields = modalFields; + + // ---------- 分隔线 ---------- + const divider = document.createElement("div"); + divider.style.cssText = "border-top:1px solid rgba(148,163,184,0.25);margin:1rem 0;"; + envListEl.appendChild(divider); + + // ---------- 其他环境变量 ---------- + const otherTitle = document.createElement("h4"); + otherTitle.textContent = "其他环境变量"; + otherTitle.style.cssText = "margin:0 0 0.65rem;font-size:0.92rem;font-weight:700;color:#1e293b;"; + envListEl.appendChild(otherTitle); + + const entries = Object.entries(cachedEnvConfig); + entries.forEach(([key, val]) => { + const row = document.createElement("div"); + row.className = "env-config-item"; + const lbl = document.createElement("label"); + lbl.textContent = key; + const inp = document.createElement("input"); + inp.type = "text"; + inp.value = val ?? ""; + inp.addEventListener("input", () => { cachedEnvConfig[key] = inp.value; }); + row.appendChild(lbl); + row.appendChild(inp); + envListEl.appendChild(row); + }); + } + + function openModelModal(idx) { + editingModelIdx = idx; + const overlay = window._modelModalOverlay; + const fields = window._modelModalFields; + if (!overlay || !fields) return; + if (idx >= 0 && cachedModels[idx]) { + const m = cachedModels[idx]; + fields.name.value = m.name || ""; + fields.model.value = m.model || ""; + fields.api_key.value = m.api_key || ""; + fields.base_url.value = m.base_url || ""; + fields.timeout.value = m.timeout || 60; + } else { + fields.name.value = ""; + fields.model.value = ""; + fields.api_key.value = ""; + fields.base_url.value = ""; + fields.timeout.value = 60; + } + overlay.classList.add("show"); + } + + function closeModelModal() { + const overlay = window._modelModalOverlay; + if (overlay) overlay.classList.remove("show"); + } + + function makeId() { + return Math.random().toString(36).substring(2, 10); + } + + // ===== 保存回调 ===== + document.querySelectorAll(".settings-save-btn").forEach((btn) => { + btn.addEventListener("click", async () => { + const saveType = btn.getAttribute("data-save"); + setStatus("正在保存...", ""); + try { + if (saveType === "agents") { + const agents = cachedAgentConfigs.map((a) => ({ + name: a.name, + system_prompt: a.system_prompt, + max_iterations: a.max_iterations, + llm: { + model: (a.llm || {}).model || "", + api_key: (a.llm || {}).api_key || "", + base_url: (a.llm || {}).base_url || "", + timeout: (a.llm || {}).timeout || 60, + max_retries: (a.llm || {}).max_retries || 0, + }, + llm_model_id: a.llm_model_id || "", + tools: a.tools || [], + enabled: a.enabled !== false, + role: a.role || "sub", + })); + await api("/api/config/agents", { method: "POST", body: JSON.stringify({ agents }) }); + await api("/api/config/reload", { method: "POST", body: JSON.stringify({}) }); + } else if (saveType === "tools") { + const tools = cachedToolConfigs.map((t) => ({ + name: t.name, + display_name: t.display_name, + description: t.description, + permission: t.permission, + auto_execute_rules: t.auto_execute_rules || [], + enabled: t.enabled, + })); + await api("/api/config/tools", { method: "POST", body: JSON.stringify({ tools }) }); + await api("/api/config/reload", { method: "POST", body: JSON.stringify({}) }); + } else if (saveType === "env") { + // 保存 env + models + await api("/api/config/models", { method: "POST", body: JSON.stringify({ models: cachedModels }) }); + await api("/api/config/env", { method: "POST", body: JSON.stringify({ env: cachedEnvConfig }) }); + await api("/api/config/reload", { method: "POST", body: JSON.stringify({}) }); + } + await loadAllConfigs(); + setStatus("保存成功!配置已实时生效。", "success"); + } catch (e) { + setStatus("保存失败: " + e.message, "error"); + } + }); + }); + + // 事件绑定 + settingsBtn.addEventListener("click", openSettings); + settingsCloseBtn.addEventListener("click", closeSettings); + settingsCancelBtn.addEventListener("click", closeSettings); + settingsOverlay.addEventListener("click", (e) => { + if (e.target === settingsOverlay) closeSettings(); + }); + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && !settingsOverlay.hidden) closeSettings(); + if (e.key === "Escape" && agentDetailOverlay && !agentDetailOverlay.hidden) closeAgentDetailModal(); + }); + + // ========== 图谱侧面板 ========== + const graphToggleBtn = $("graphToggleBtn"); + const graphPanel = $("graphPanel"); + const graphCloseBtn = $("graphCloseBtn"); + const graphCanvas = $("graphCanvas"); + const graphCanvasWrap = $("graphCanvasWrap"); + const graphEdgesLayer = $("graphEdgesLayer"); + const graphNodesLayer = $("graphNodesLayer"); + const graphRefreshBtn = $("graphRefreshBtn"); + const graphFitBtn = $("graphFitBtn"); + + // 图谱数据与状态 + let graphData = { agents: [], edges: [], events: [], tool_calls: {} }; + let graphOpen = false; + // 可拖拽偏移 + let panOffset = { x: 80, y: 60 }; + let isPanning = false; + let panStart = { x: 0, y: 0 }; + let panStartOffset = { x: 0, y: 0 }; + // 节点布局参数 + const NODE_W = 160; + const NODE_H = 100; + + // 图谱开关(始终可聊天) + function toggleGraph(open) { + graphOpen = open; + graphPanel.hidden = !open; + graphToggleBtn.classList.toggle("graph-active", open); + appRoot.classList.toggle("has-graph", open); + if (open) { + loadGraphData(); + } + } + + graphToggleBtn.addEventListener("click", () => toggleGraph(!graphOpen)); + graphCloseBtn.addEventListener("click", () => toggleGraph(false)); + + // Agent 详情弹窗事件 + const agentDetailOverlay = document.getElementById("agentDetailOverlay"); + const agentDetailClose = document.getElementById("agentDetailClose"); + if (agentDetailClose) agentDetailClose.addEventListener("click", closeAgentDetailModal); + if (agentDetailOverlay) agentDetailOverlay.addEventListener("click", (e) => { + if (e.target === agentDetailOverlay) closeAgentDetailModal(); + }); + + // 加载图谱数据 + async function loadGraphData() { + try { + const snap = await api(`/api/graph/snapshot?session_id=${encodeURIComponent(sessionId)}`); + graphData = snap; + renderGraph(); + } catch (e) { + console.error("加载图谱数据失败:", e); + } + } + + // 自动布局:主 agent 在中心,子 agent 环绕 + function computeLayout() { + const agents = graphData.agents || []; + if (agents.length === 0) return {}; + + const positions = {}; + const wrapW = graphCanvasWrap.clientWidth || 600; + const wrapH = graphCanvasWrap.clientHeight || 400; + const cx = wrapW / 2; + const cy = wrapH / 2; + + // 主 agent 在中心 + const mainAgent = agents.find(a => a.role === "main"); + if (mainAgent) { + positions[mainAgent.name] = { x: cx - NODE_W / 2, y: cy - NODE_H / 2 }; + } + + // 子 agent 均匀环绕 + const subs = agents.filter(a => a.role !== "main"); + const radius = Math.min(wrapW, wrapH) * 0.32; + subs.forEach((agent, i) => { + const angle = (2 * Math.PI * i) / subs.length - Math.PI / 2; + positions[agent.name] = { + x: cx + radius * Math.cos(angle) - NODE_W / 2, + y: cy + radius * Math.sin(angle) - NODE_H / 2, + }; + }); + + return positions; + } + + // 状态到 CSS 类 + 文字 + function statusInfo(status) { + switch (status) { + case "running": return { cls: "status-running", badge: "执行中" }; + case "done": return { cls: "status-done", badge: "已完成" }; + case "error": return { cls: "status-error", badge: "出错" }; + default: return { cls: "status-idle", badge: "等待" }; + } + } + + // 渲染图谱 + function renderGraph() { + const agents = graphData.agents || []; + const edges = graphData.edges || []; + const positions = computeLayout(); + + // 计算 SVG viewBox + const wrapW = graphCanvasWrap.clientWidth || 600; + const wrapH = graphCanvasWrap.clientHeight || 400; + graphCanvas.setAttribute("viewBox", `0 0 ${wrapW} ${wrapH}`); + + // --- 渲染连线 --- + graphEdgesLayer.innerHTML = ""; + edges.forEach((edge, i) => { + const srcPos = positions[edge.source]; + const tgtPos = positions[edge.target]; + if (!srcPos || !tgtPos) return; + + const x1 = srcPos.x + NODE_W / 2; + const y1 = srcPos.y + NODE_H / 2; + const x2 = tgtPos.x + NODE_W / 2; + const y2 = tgtPos.y + NODE_H / 2; + + // 计算从边缘出发(而非中心) + const dx = x2 - x1; + const dy = y2 - y1; + const dist = Math.sqrt(dx * dx + dy * dy) || 1; + const nx = dx / dist; + const ny = dy / dist; + + const sx = x1 + nx * (NODE_W / 2); + const sy = y1 + ny * (NODE_H / 2); + const ex = x2 - nx * (NODE_W / 2); + const ey = y2 - ny * (NODE_H / 2); + + let edgeClass = "normal"; + let markerEnd = "url(#arrowNormal)"; + if (edge.highlighted) { + edgeClass = "highlighted"; + markerEnd = "url(#arrowHighlight)"; + } + + const line = document.createElementNS("http://www.w3.org/2000/svg", "line"); + line.setAttribute("x1", sx + panOffset.x); + line.setAttribute("y1", sy + panOffset.y); + line.setAttribute("x2", ex + panOffset.x); + line.setAttribute("y2", ey + panOffset.y); + line.setAttribute("class", `graph-edge ${edgeClass}`); + line.setAttribute("marker-end", markerEnd); + line.setAttribute("data-source", edge.source); + line.setAttribute("data-target", edge.target); + graphEdgesLayer.appendChild(line); + }); + + // --- 渲染节点 --- + graphNodesLayer.innerHTML = ""; + agents.forEach((agent) => { + const pos = positions[agent.name]; + if (!pos) return; + + const sinfo = statusInfo(agent.status || "idle"); + const g = document.createElementNS("http://www.w3.org/2000/svg", "g"); + g.setAttribute("class", `graph-node ${sinfo.cls}`); + g.setAttribute("transform", `translate(${pos.x + panOffset.x}, ${pos.y + panOffset.y})`); + g.setAttribute("data-agent", agent.name); + + // 圆角矩形 + const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect"); + rect.setAttribute("width", NODE_W); + rect.setAttribute("height", NODE_H); + rect.setAttribute("class", "graph-node-rect"); + g.appendChild(rect); + + // 图标 + const iconText = document.createElementNS("http://www.w3.org/2000/svg", "text"); + iconText.setAttribute("x", 28); + iconText.setAttribute("y", NODE_H / 2); + iconText.setAttribute("class", "graph-node-icon"); + iconText.textContent = agent.icon || "🧠"; + g.appendChild(iconText); + + // 名称 + const labelText = document.createElementNS("http://www.w3.org/2000/svg", "text"); + labelText.setAttribute("x", 90); + labelText.setAttribute("y", NODE_H / 2 - 8); + labelText.setAttribute("class", "graph-node-label"); + labelText.textContent = agent.name; + g.appendChild(labelText); + + // 角色 + const roleText = document.createElementNS("http://www.w3.org/2000/svg", "text"); + roleText.setAttribute("x", 90); + roleText.setAttribute("y", NODE_H / 2 + 12); + roleText.setAttribute("class", "graph-node-role"); + roleText.textContent = agent.role === "main" ? "主 Agent" : "子 Agent"; + g.appendChild(roleText); + + // 状态标签(右上角) + const badgeText = document.createElementNS("http://www.w3.org/2000/svg", "text"); + badgeText.setAttribute("x", NODE_W - 8); + badgeText.setAttribute("y", 14); + badgeText.setAttribute("class", "graph-node-badge"); + const badgeColor = agent.status === "running" ? "#f59e0b" : agent.status === "done" ? "#22c55e" : agent.status === "error" ? "#ef4444" : "#94a3b8"; + badgeText.setAttribute("fill", badgeColor); + badgeText.textContent = sinfo.badge; + g.appendChild(badgeText); + + // 工具调用缩略(仅最近2轮,渐变透明) + const agentToolCalls = (graphData.tool_calls || {})[agent.name] || []; + const recentCalls = agentToolCalls.slice(0, 4); + recentCalls.forEach((tc, idx) => { + const chip = document.createElementNS("http://www.w3.org/2000/svg", "text"); + chip.setAttribute("x", 12 + idx * 38); + chip.setAttribute("y", NODE_H - 10); + chip.setAttribute("class", "graph-node-tool-chip"); + chip.setAttribute("opacity", Math.max(0.2, 1 - idx * 0.35)); + chip.textContent = (tc.tool_name || "").substring(0, 5); + g.appendChild(chip); + }); + + // 点击事件 → 弹出 Agent 详情 modal + g.style.cursor = "pointer"; + g.addEventListener("click", () => openAgentDetailModal(agent)); + + graphNodesLayer.appendChild(g); + }); + } + + // 渲染工具调用列表 → 已移至 Agent 点击弹窗 + function openAgentDetailModal(agent) { + const overlay = document.getElementById("agentDetailOverlay"); + const stateDiv = document.getElementById("agentDetailState"); + const configDiv = document.getElementById("agentDetailConfig"); + const toolCallsDiv = document.getElementById("agentDetailToolCalls"); + const titleEl = document.getElementById("agentDetailTitle"); + if (!overlay || !stateDiv || !configDiv || !toolCallsDiv) return; + + titleEl.textContent = `Agent: ${agent.name}`; + const sinfo = statusInfo(agent.status || "idle"); + + // 状态 + stateDiv.innerHTML = ` +

名称: ${escapeHtml(agent.name)}

+

角色: ${agent.role === "main" ? "主 Agent" : "子 Agent"}

+

状态: ${sinfo.badge}

+ ${agent.error_message ? `

错误: ${escapeHtml(agent.error_message)}

` : ""} + `; + + // 配置(只读) + const tools = agent.tools || []; + configDiv.innerHTML = ` +

工具列表: ${tools.length ? tools.map(t => `${escapeHtml(t)}`).join(", ") : "(无)"}

+ `; + + // 工具调用记录 + const calls = (graphData.tool_calls || {})[agent.name] || []; + if (calls.length === 0) { + toolCallsDiv.innerHTML = `

暂无工具调用记录

`; + } else { + toolCallsDiv.innerHTML = [...calls].reverse().slice(0, 6).map(c => ` +
+
+ ${escapeHtml(c.tool_name)} + ${c.status || 'success'} +
+ ${c.tool_args && Object.keys(c.tool_args).length > 0 ? `
${escapeHtml(JSON.stringify(c.tool_args, null, 1))}
` : ""} + ${c.result_preview ? `
${escapeHtml(c.result_preview)}
` : ""} +
+ `).join(""); + } + + overlay.hidden = false; + } + + // 关闭 Agent 详情弹窗 + function closeAgentDetailModal() { + const overlay = document.getElementById("agentDetailOverlay"); + if (overlay) overlay.hidden = true; + } + + + // 画布拖拽 + graphCanvasWrap.addEventListener("mousedown", (e) => { + if (e.target === graphCanvas || e.target === graphCanvasWrap || e.target.closest("svg")) { + isPanning = true; + panStart = { x: e.clientX, y: e.clientY }; + panStartOffset = { ...panOffset }; + graphCanvasWrap.classList.add("panning"); + e.preventDefault(); + } + }); + + window.addEventListener("mousemove", (e) => { + if (!isPanning) return; + const dx = e.clientX - panStart.x; + const dy = e.clientY - panStart.y; + panOffset = { + x: panStartOffset.x + dx, + y: panStartOffset.y + dy, + }; + renderGraph(); + }); + + window.addEventListener("mouseup", () => { + if (isPanning) { + isPanning = false; + graphCanvasWrap.classList.remove("panning"); + } + }); + + // 触摸拖拽 + graphCanvasWrap.addEventListener("touchstart", (e) => { + if (e.touches.length === 1) { + isPanning = true; + panStart = { x: e.touches[0].clientX, y: e.touches[0].clientY }; + panStartOffset = { ...panOffset }; + graphCanvasWrap.classList.add("panning"); + } + }, { passive: false }); + + graphCanvasWrap.addEventListener("touchmove", (e) => { + if (!isPanning || e.touches.length !== 1) return; + const dx = e.touches[0].clientX - panStart.x; + const dy = e.touches[0].clientY - panStart.y; + panOffset = { + x: panStartOffset.x + dx, + y: panStartOffset.y + dy, + }; + renderGraph(); + e.preventDefault(); + }, { passive: false }); + + graphCanvasWrap.addEventListener("touchend", () => { + if (isPanning) { + isPanning = false; + graphCanvasWrap.classList.remove("panning"); + } + }); + + // 鼠标滚轮缩放(可选) + + // 按钮事件 + graphRefreshBtn.addEventListener("click", () => { + loadGraphData(); + }); + + graphFitBtn.addEventListener("click", () => { + panOffset = { x: 80, y: 60 }; + renderGraph(); + }); + + // 窗口大小变化时重新渲染 + let resizeGraphTimer = null; + window.addEventListener("resize", () => { + clearTimeout(resizeGraphTimer); + resizeGraphTimer = setTimeout(() => { + if (graphOpen) { + renderGraph(); + } + }, 200); + }); + bootstrap().catch((e) => { chatPlaceholder.hidden = false; chatPlaceholder.innerHTML = `

无法连接服务

${escapeHtml(e.message)}

请从 Agent 目录执行:uvicorn web.server:app --reload --host 127.0.0.1 --port 8765,并保证 PYTHONPATH 包含 src。

`; diff --git a/src/web/index.html b/src/web/index.html index cd18cf1..ac59600 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -77,15 +77,69 @@ > ▴ + + -
-
-

开始对话

-

输入文字,或上传附件 后点击发送。

-

支持用户音频输入、助手文本/语音/双模输出、会话本地媒体持久化。

-
- -
+
+ +
+
+

开始对话

+

输入文字,或上传附件 后点击发送。

+

支持用户音频输入、助手文本/语音/双模输出、会话本地媒体持久化。

+
+ +
+ + +
@@ -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)