diff --git a/README.html b/README.html index 0b63a26..0d63c4b 100644 --- a/README.html +++ b/README.html @@ -728,11 +728,11 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
@@ -766,16 +766,16 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas- 工具 文件 能力 - 🖱️ gui tools/gui.py桌面 GUI 自动化:截图、视觉批量定位、点击/输入/快捷键/滚动 - 🌐 browser_control tools/browser_control.py浏览器打开/关闭、地址导航、标签页交互 + 💻 terminal_execute tools/terminal_execute.py执行任意终端命令,支持工作目录与超时 + 🖱️ gui tools/gui.py桌面 GUI 自动化:截图、视觉批量定位、点击/输入/快捷键/滚动(桌面/任意应用轨) + 🌐 browser_control tools/browser_control.py浏览器打开/关闭、地址导航、标签页交互(Playwright + CDP 分离进程,Profile 默认一次性临时目录,可配置持久) + 🧭 web_page tools/web_page.py浏览器页面 DOM 级精准操作(浏览器轨):snapshot 元素清单、click/fill/scroll/press/extract/screenshot/eval_js 等,毫秒级、优于 GUI 视觉定位;典型流程 browser_control 打开 → web_page 操作 💻 terminal_execute tools/terminal_execute.py执行任意终端命令(含目录/文件操作,可替代 directory_listing),支持工作目录与超时 🪟 software_control tools/software_control.pyWin32 API 启停软件、注册表、 os.startfiletools/email.pyIMAP 收邮件 + SMTP 发邮件(含附件) - 📄 doc tools/doc.py创建/读取/编辑 docx / xlsx / pptx / pdf / txt 📂 directory_listing tools/directory_listing.py递归列目录、文件元信息 📨 send_file tools/send_file.py以邮件附件形式发送本地文件 - 🔍 web_search tools/web_search.py联网搜索(Tavily / Firecrawl / 火山引擎) + 🗄️ text2sql tools/text2sql.py自然语言/SQL 操作数据库:查表结构、查询、增删改 🗄️ text2sql tools/text2sql.py自然语言/SQL 操作数据库(MySQL / SQLite,工具数据库配置):查表结构、查询、增删改、建库建表 📚 rag tools/rag.pyChromaDB 向量检索 + Reranker 重排序 🎨 image_gen tools/image_gen.py调用 Z-Image-Turbo 生成图片 @@ -785,11 +785,13 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas ✅ todo_list tools/todo_list.py多步任务规划与进度跟踪 📅 date_query tools/date_query.py当前日期时间查询 ⏰ cron_manager tools/cron_manager.py定时任务管理:查看/新增/修改/删除定时任务,支持 cron/间隔/一次性触发 - 📊 hard_excel_read tools/hard_excel_read.py复杂 Excel 表结构读取:前 N 行原始数据 + 维度/合并区/数据区范围建议,供大模型解析结构 + 📥 excel2sql tools/excel2sql.py按大模型给定的数据行列范围,将 Excel 数据区流式批量入库 MySQL(自动建库建表、表注释) + 📥 excel2sql tools/excel2sql.py按大模型给定的数据行列范围,将 Excel 数据区流式批量入库(MySQL / SQLite,自动建库建表) 🔒 main Agent 默认挂载(代码强制注入,不可通过配置移除,前端显示为锁定项):
+call_subagent(子 Agent 委派)、doc_tool(文档读写)、human_interaction(人机交互审查)、todo_list(任务规划)、terminal_execute(终端命令)。其余工具按config.json中 main agent 的 tools 列表自行增删;子 Agent 无此限制。🎯 区别于其他 Agent 项目的核心设计
@@ -799,13 +801,16 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas@@ -825,7 +830,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas ./git_image/运行-todolist.png -+ GUI 操作粒度 一步一调用,N 个动作 = 2N+1 次 LLM 调用 动作序列合并:同页多操作打包为一次 gui调用,N 个动作仅 2 次 LLM 调用UI 自动化双轨 单轨(截图 + 坐标模拟) Playwright + GUI 双轨兼顾容错与效率:浏览器内走 DOM 级精准操作( web_page毫秒级、定位可靠);桌面/任意应用走 GUI 视觉定位(gui_tool截图视觉闭环、可处理任意界面)——浏览器能 DOM 就不截图,桌面无 DOM 就走视觉视觉反馈 仅返回坐标文本 截图作为视觉闭环注入上下文,多模态 LLM 看到真实屏幕状态 坐标定位 逐元素请求 批量定位 _batch_ground_elements:N 个元素 1 次子模型调用循环失控 无防护,token 烧穿 两级循环检测:连续 3 次同工具警告反思 → 连续 5 次强制终止 + 上下文膨胀 简单截断 图内分级压缩:token 超 窗口×COMPRESS_RATE触发,压缩历史工具调用为累积摘要,每步 LLM 调用后检查上下文连续性 思考链跨轮丢失 历史思考/反思注入:压缩游标后的思考、反思、工具调用按因果顺序交错注入(稳定 id、确定性构建,前缀缓存友好);深度思考与反思分离存储 部署形态 仅 Web / 仅 API 同一套源码双模式: uvicornWeb 服务 ↔ Electron 桌面应用文件操作 HTTP 上传下载 桌面端走 IPC 直读磁盘,零延迟;Web 端走 /api/fs/*+ 提示词 单层 system prompt 四层提示词架构:系统级 / 工具描述 / 运行时注入 / 内部子模型 插件化 注册式插件表(适配新插件需改框架主流程) 前后端单文件插件化:后端按 BaseTool协议单文件实现、前端按setXxxDeps依赖注入协议接入,监听事件 / 回调驱动而非注册式——新增或替换一个插件只需保持协议签名,宿主装配零改动人机交互确认 — 敏感操作(终端执行、文件写入等)与信息收集以浮窗形式实时弹出,用户可同意、拒绝、跳过或补充信息;拒绝也只是一次工具返回值,Agent 自行调整方案继续,不会结束本轮 | 任务规划 TodoList — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表
+人机交互确认 — 敏感操作(终端执行、文件写入等)与信息收集以「输入区上方通知条」形式实时弹出:只显示标题与数量角标,点击展开为完整卡片(标题/内容/选项胶囊/补充输入框),选择或补充后作为工具返回值,Agent 继续执行;拒绝也只是一次工具返回值,Agent 自行调整方案继续,不会结束本轮 | 任务规划 TodoList — 多步任务自动拆解与进度跟踪,同样以通知条展示(最新步骤 + 数量角标,可展开全部步骤),Todo 状态会话级存储(chat 与 cron 各自独立,切换会话自动恢复)
- @@ -839,7 +844,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas ./git_image/运行-工具调用1.png文件编辑模式 — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织 | 工具调用记录 — 实时展示每轮 ReAct 的工具调用链、参数及返回值
+文件编辑模式 — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织 | 工具调用记录 — 实时展示每轮 ReAct 的工具调用链、参数及返回值;思考(reasoning_content)与反思(content)分离显示为独立的 Thought / Reflection 行
- @@ -862,7 +867,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas ./git_image/运行-edit模式.png定时任务管理 — 支持 cron / 间隔 / 一次性三种触发方式,状态实时可见 | Edit 编辑模式 — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent
+定时任务管理 — 支持 cron / 间隔 / 一次性三种触发方式;任务按工作区分组展示;任务会话与聊天同构(点击会话式查看历史、多轮对话、流式渲染),任务在独立子进程中执行;模式切换锁:chat 运行时可查看 cron、cron 运行时可查看 chat,双容器渲染不打断流式输出 | Edit 编辑模式 — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent
@@ -941,7 +946,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas-
- +
call_model(core/nodes.py):追加动态尾部(TodoList 状态 + 循环提醒;固定反思引导已并入系统提示词前缀)→ LLM 调用 → 每步检查压缩(token 超阈值标记)→ 提取 thinkingcall_model(core/nodes.py):本轮回插思考/反思([思考]/[反思]合成消息)→ 追加动态尾部(TodoList 状态 + 循环提醒;固定反思引导已并入系统提示词前缀)→ LLM 调用 → 每步检查压缩(token 超阈值标记)→ 提取 thinking / reflection 独立记录should_continue(core/routing.py):含 tool_calls → 循环检测 → 路由到tools或END(需人工确认的工具不在此暂停,而是在工具执行钳点阻塞征求用户意见,见「阻塞式人机交互」)process_tool_artifact:gui 截图作为 synthetic HumanMessage 注入,实现视觉闭环- @@ -951,7 +956,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
compress(core/nodes.py):token 超窗口×COMPRESS_RATE时,将历史工具调用压缩为累积摘要(见「实现细节」)多 Agent 模式
- 多 Agent 并非独立图,而是同一张 react 图内的工具级并行:
tools节点由make_parallel_tool_node(线程池)取代 ToolNode,一轮内的多个工具调用并行执行;其中agent_call工具会以主 Agent 当前消息快照启动子 Agent 的独立图实例执行子任务,结果以工具返回值注入主 Agent 上下文。子 Agent 内的人机交互同样为阻塞式——在子图线程中等待浮窗应答,完成后自然回到主图: + 多 Agent 并非独立图,而是同一张 react 图内的工具级并行:tools节点由make_parallel_tool_node(线程池)取代 ToolNode,一轮内的多个工具调用并行执行;其中agent_call工具会以主 Agent 当前消息快照启动子 Agent 的独立图实例执行子任务,结果以工具返回值注入主 Agent 上下文。子 Agent 内的人机交互同样为阻塞式——在子图线程中等待通知条应答,完成后自然回到主图:@@ -960,7 +965,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas ├─ 普通工具:直接并行执行 └─ agent_call:spawn 子 Agent Runtime → 子图 invoke │(子图内 human_interaction 阻塞等待 - │ 浮窗应答,答案作为工具返回值) + │ 通知条应答,答案作为工具返回值) └─ 结果作为 ToolMessage 注入主 Agent ◄┐ ▲ │ └──────────────── 有 agent_call → 继续循环;无 → END ──────────────────────┘ @@ -987,10 +992,12 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas ├── src/ │ ├── agent/ # 🧠 Agent 核心 │ │ ├── agents/ -│ │ │ └── agent_runtime.py # 编排层入口:execute_agent(动态 Agent 工厂) +│ │ │ ├── agent_runtime.py # 编排层入口:execute_agent(动态 Agent 工厂,main 默认挂载工具强制注入) +│ │ │ ├── config.json # ★ 合并配置(agents/tools/env/gui/workspace/theme 分区,含密钥) +│ │ │ └── config_dist.json # 配置模板(不含密钥) │ │ ├── core/ │ │ │ ├── graph.py # LangGraph 构建(单 Agent + 多 Agent) -│ │ │ ├── nodes.py # ReAct 节点:call_model / process_tool_artifact / 压缩 +│ │ │ ├── nodes.py # ReAct 节点:call_model(思考/反思提取与本轮回插)/ process_tool_artifact / 压缩 │ │ │ ├── runtime.py # 图执行器:消息拼装、历史加载、轨迹落盘 │ │ │ ├── routing.py # should_continue 路由 + 强制终止 │ │ │ ├── state.py # Graph 状态定义(messages / agent_mode) @@ -999,17 +1006,18 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas │ │ │ ├── turn_runner.py # ★ 后台线程轮次执行器(解耦 HTTP 与图执行生命周期) │ │ │ ├── llm.py # ChatQwen 多模态封装(图像/附件/非标准 tool_call) │ │ │ ├── tool_policy.py # 工具执行策略(直执 / 需确认) -│ │ │ ├── storage.py # ★ 统一存储层:接口 + JSON/MySQL 双实现 + 工厂 + scope -│ │ │ └── config_manager.py# 配置读写:agent/tool/env/gui/model/theme +│ │ │ ├── db.py # ★ 数据库基础设施:配置加载(存储库/工具库隔离)、连接、幂等建库建表(MySQL/SQLite) +│ │ │ ├── storage.py # ★ 统一存储层:接口 + JSON/MySQL/SQLite 三实现 + 工厂 + scope +│ │ │ └── config_manager.py# 配置读写:agent/tool/env/gui/model/theme + MAIN_DEFAULT_TOOLS │ │ ├── memory/ │ │ │ └── system_prompt.py # ★ 四层提示词:MAIN / PLAN / REFLECTION / VISION... │ │ ├── tools/ # 20 项工具(见上表) │ │ │ ├── ...(gui / browser_control / terminal_execute / email / doc / web_search / text2sql / rag / cron_manager 等) │ │ ├── cron/ # ⏰ 定时任务引擎 -│ │ │ ├── models.py # 数据模型(CronTask / Trigger) +│ │ │ ├── models.py # 数据模型(CronTask / Trigger,含 workspace 字段) │ │ │ ├── storage.py # 转发层(实现统一在 core/storage.py) │ │ │ ├── scheduler.py # 调度计算与触发扫描 -│ │ │ ├── runner.py # 任务执行器(子进程,切换存储 cron 作用域) +│ │ │ ├── runner.py # 任务执行器(独立子进程,切换存储 cron 作用域) │ │ │ └── live.py # 常驻调度循环 │ │ ├── skills/ # 5 项技能(skill.json + skill.md) │ │ │ ├── netease_mail_read/ @@ -1021,16 +1029,10 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas │ │ │ └── streaming_client.py # 流式 TTS 客户端(SSE 收音频块) │ │ ├── history/ │ │ │ ├── session_storage.py # 转发层(实现统一在 core/storage.py) -│ │ │ └── tool_call_recorder.py # 每轮工具调用轨迹记录 (tool_{turn_id}.json) +│ │ │ ├── tool_call_recorder.py # 每轮工具调用轨迹记录(含思考/反思双字段,tool_{turn_id}.json) +│ │ │ └── sqlite/ # SQLite 存储默认目录(storage.sqlite / tool/ 每库一文件) │ │ ├── utils/ # env_utils / agent_utils / image_utils / ppt_utils / tool_call_utils -│ │ └── config/ # ★ 配置文件目录 -│ │ ├── env_config.json # 运行时配置(首次向导生成,勿手动改) -│ │ ├── env_config_dist.json # 配置模板(默认值) -│ │ ├── agent_config.json # Agent 行为配置 + 兜底 system_prompt -│ │ ├── tool_config.json # 各工具启用/参数 -│ │ ├── gui_config.json # GUI 工具参数 -│ │ ├── theme_config.json # 主题 -│ │ └── workspace_config.json # 工作空间列表与当前选择 +│ │ └── data/ # 运行时数据(浏览器 Profile 等,不入库) │ │ │ └── web/ # 🎨 前端 UI │ ├── server.py # FastAPI 后端(75+ 路由) @@ -1080,7 +1082,7 @@ npm install- 首次运行会弹出配置向导(3 步),写入
src/agent/config/env_config.json后自动启动: + 首次运行会弹出配置向导(3 步),写入src/agent/agents/config.json后自动启动:@@ -1094,7 +1096,7 @@ npm install-💡 之后启动直接进入主界面,不再弹向导。如需重配,删除+src/agent/config/env_config.json即可重触首次流程。💡 之后启动直接进入主界面,不再弹向导。如需重配,删除src/agent/agents/config.json(或复制config_dist.json为新配置)即可重触首次流程。3. 打包为安装程序
@@ -1113,10 +1115,10 @@ npm run build📦 打包原理(点击展开) @@ -1142,8 +1144,8 @@ pip install -r requirements.txt # 3. 配置环境(首次) # 复制模板并填写 LLM 凭证 -Copy-Item src\agent\config\env_config_dist.json src\agent\config\env_config.json -# 编辑 env_config.json,至少填写 models[].base_url / api_key / model +Copy-Item src\agent\agents\config_dist.json src\agent\agents\config.json +# 编辑 config.json,至少填写 env.models[].base_url / api_key / model # 4. 启动 Web 服务 $env:PYTHONPATH="src;$env:PYTHONPATH" @@ -1172,10 +1174,10 @@ cloudflared tunnel --url http://localhost:8765-
- +
package.json的build.files仅打包electron/、src/、requirements.txt,排除env_config.json(每个用户独立生成)package.json的build.files仅打包electron/、src/、requirements.txt,排除config.json(每个用户独立生成,模板config_dist.json随包分发)asar: false:源码不加密,便于审计与二次开发- NSIS 安装器:允许改安装目录、创建桌面/开始菜单快捷方式、卸载时清理 AppData
-- 首次启动由
+setup.html向导引导:检测 Python → 配置 LLM → 写env_config.json→ 启动 FastAPI → 加载 UI- 首次启动由
setup.html向导引导:检测 Python → 配置 LLM → 写config.json→ 启动 FastAPI → 加载 UI- 主进程
findSystemPython()依次检查:用户配置路径 → 常见 Python 安装位置 → PATH 中的python⚙️-配置说明
-所有运行时配置集中在
+src/agent/config/env_config.json,首次向导自动生成。所有运行时配置集中在
src/agent/agents/config.json(合并单文件,含agents / tools / env / gui / workspace / theme分区),模板见config_dist.json,首次向导自动生成。关键字段:
+env 分区关键字段:
-@@ -1183,12 +1185,9 @@ cloudflared tunnel --url http://localhost:8765
models[]SETUP_REQUIREDLLM 列表: {id, name, model, api_key, base_url, timeout, max_retries};ASR / TTS / RAG / ImageGen 四个本地服务也在此注册(按model字段匹配,api_key留空)- gui_model_id""GUI 视觉定位专用模型(留空则回退主模型) - WORKING_DIR项目根目录 工作根目录(桌面端自动设为安装目录) WORKSPACE_DIR""工作空间目录(相对 WORKING_DIR),留空则使用 WORKING_DIR USER_PYTHON_PATH""指定 Python 解释器路径 LLM_CONTEXT_WINDOW262144上下文窗口大小(token) - COMPRESS_RATE0.6上下文压缩阈值比例(0~1),token 用量超过 窗口×该值时触发压缩THINKING_LEVEL"low"深度思考档位:low / high / xhigh / max / ultra(xhigh 及以上启用深度思考) IMG_SIZE768截图短边尺寸 GROUNDING_WIDTH/HEIGHT1000视觉定位输入分辨率 @@ -1197,8 +1196,11 @@ cloudflared tunnel --url http://localhost:8765 LOOP_DETECT_REPEATED_TOOL_WARN3连续同工具同参多少次警告 SEND_FILE_SIZE_LIMIT30发送文件大小上限(MB) WEB_SEARCH_API_KEY""联网搜索 API Key - WEB_SEARCH_ENGINE"tavily"搜索引擎选择(tavily / firecrawl / volcengine) - STORAGE_BACKEND"json"存储后端(json / mysql) + MYSQL_HOST/MYSQL_PORT/MYSQL_USER/MYSQL_PASSWORD/MYSQL_DATABASE""MySQL 连接参数(STORAGE_BACKEND=mysql 时生效) + STORAGE_BACKEND"json"存储后端:json(本地文件)/ mysql / sqlite + STORAGE_DB_TYPE+STORAGE_DB_HOST/PORT/USER/PASSWORD/DATABASE/PATH""存储数据库配置(mysql 用 host/port/user/password/database;sqlite 用 path,留空默认 history/sqlite/storage.sqlite);缺省回退旧MYSQL_*(零迁移)+ TOOL_DB_TYPE+TOOL_DB_HOST/PORT/USER/PASSWORD/DATABASE/PATH"mysql"工具数据库配置——数据库操作工具(text2sql / excel2sql)专用,与存储后端完全隔离;sqlite path 留空默认 history/sqlite/tool目录,每个库名一个.sqlite文件+ MYSQL_*""旧键:存储数据库 MySQL 参数(已被 STORAGE_DB_*取代,仅作回退兼容)BROWSER_PROFILE_DIR""浏览器控制工具持久 Profile 目录;留空 = 每次启动一次性临时 Profile(登录态不持久、不留盘) TOOL_TIMEOUT300工具执行超时时间(秒) CRON_TIME_PERIOD_MINUTES30定时任务时段长度(分钟),同一时段全局并发=1 @@ -1206,9 +1208,9 @@ cloudflared tunnel --url http://localhost:8765 EMAIL_ADDRESS / EMAIL_AUTH_CODE""邮箱凭证(IMAP/SMTP) ⚙️ 以上参数均可在主界面「设置」面板热修改,或通过+/api/config/*接口程序化调整。⚙️ 思考档位(low / high / xhigh / max / ultra)为会话级配置(存于各会话-session_meta.json);以上参数均可在主界面「设置」面板热修改,或通过/api/config/*接口程序化调整。设置面板包含五个标签页,覆盖 Agent 运行的全部可调参数:
+设置面板包含 11 个分组(常规 / 外观 / 模型 / Agent / 工具 / Skills / GUI / 网络搜索 / 邮件 / 存储与数据库 / 运行时参数),覆盖 Agent 运行的全部可调参数:
- @@ -1222,7 +1224,7 @@ cloudflared tunnel --url http://localhost:8765 ./git_image/1.pngAgent 配置 — 管理多个 AI 角色,各自绑定独立模型与工具集 | 工具配置 — 启用/禁用工具、调整参数及执行策略
+Agent 配置 — 管理多个 AI 角色,各自绑定独立模型与工具集(main Agent 默认挂载 5 个工具为锁定项,不可移除) | 工具配置 — 启用/禁用工具、调整参数及执行策略
- @@ -1245,7 +1247,7 @@ cloudflared tunnel --url http://localhost:8765 ./git_image/4.png主题配置 — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体
+主题配置 — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体;另含 常规 / 模型 / Skills / 网络搜索 / 邮件 / 存储与数据库 / 运行时参数 等分组
@@ -1280,11 +1282,15 @@ cloudflared tunnel --url http://localhost:8765上下文按「前缀稳定性」布局,最大化利用 LLM 服务端的前缀缓存:
-[系统提示词(含固定反思引导,稳定)] + [压缩摘要] + [对话历史] + [用户消息] + [动态尾部(每轮变化)] +[系统提示词(含固定反思引导,稳定)] + [压缩摘要] + [对话历史] + [本轮回插的思考/反思] ++ [历史思考/反思/工具调用(压缩游标后)] + [用户消息] + [动态尾部(每轮变化)]@@ -1338,16 +1344,17 @@ cloudflared tunnel --url http://localhost:8765
- 固定部分放前缀:反思引导等不变提示文本并入系统提示词、位于上下文最前 —— 前缀稳定即可命中缓存;避免把固定文本逐轮塞在消息中间导致整段缓存失效。
+- 历史思考/反思/工具调用注入(
+build_tool_history_messages):压缩游标之后的所有轮次,按因果顺序交错注入[历史思考] → [历史反思] → [历史工具调用];思考/反思独立注入(工具无结果文本时仍保留),尾部未被工具消费的思考/反思去重后追加在轮末。所有消息从磁盘确定性构建、id 稳定(think_ctx_{idx}/reflect_ctx_{idx}/tool_ctx_{idx}),只追加在末尾 —— 满足前缀缓存命中条件;压缩节点按 id 精确移除。- 思考与反思分离:深度思考(
+reasoning_content)与反思(content)独立存储(工具记录的thinking/reflection字段 + 轮末reflections列表)并独立注入上下文,二者可同时存在,确保注入内容与真实消息一一对应、前缀缓存准确命中。- 本轮回插(
_inject_turn_thinking):带工具调用的 AIMessage 前,将其携带的思考/反思以[思考]/[反思]合成消息插回本次调用的上下文(reasoning_content不被 LLM API 回传),使模型在后续步骤中仍能看到自己的思考链;仅注入messages_for_llm、不进图状态、id 稳定,不破坏前缀缓存。- 变化部分放末尾:TodoList 状态、循环警告等每轮变化的内容追加在消息列表末尾,变化只影响尾部、不破坏前缀缓存;无 TodoList 且无循环提醒时不注入任何尾部提示(省 token)。
- 动态尾部作为长期记忆:每轮尾部文本持久化到
session_meta的dynamic_tail_history(连续相同去重),后续轮次把全部历史尾部注入上下文,Agent 能看到之前所有轮的 TodoList 状态与循环提醒。阻塞式人机交互(交互类 / 展示类工具的统一模式)
- 人机交互不再「暂停图 + 存快照 + 续跑」,而是阻塞式普通工具:工具在图上线程中等待前端浮窗应答,答案(同意 / 拒绝 / 跳过 / 补充信息)只是普通工具返回值,图自然继续——拒绝不会结束整轮,Agent 自行调整方案。 + 人机交互不再「暂停图 + 存快照 + 续跑」,而是阻塞式普通工具:工具在图上线程中等待前端应答,答案(同意 / 拒绝 / 跳过 / 补充信息)只是普通工具返回值,图自然继续——拒绝不会结束整轮,Agent 自行调整方案。
+LLM 发出 human_interaction / 需确认工具调用 → tools 节点执行工具 → human_request.ask_human() 阻塞等待(注册到请求表,随 live snapshot 推送前端) - → 前端浮窗出现,工具调用行显示"运行中" - → 用户点击 → POST /api/human-action → 写入答案并唤醒 + → 前端"通知条"出现(输入区上方,标题 + 数量角标),工具调用行显示"运行中" + → 用户点击展开 → 选项胶囊 / 补充输入框 → 提交(POST /api/human-action)→ 写入答案并唤醒 → 工具返回答案文本 → ToolMessage → 图继续(无快照重放、无续跑状态机)通知条(notify-strip):todo 与人机交互不再使用浮窗,改为输入区上方的水平通知条——收起态只显示标题/最新步骤 + 数量角标,点击上下展开完整卡片;通知条左侧按钮可收起为输入区按钮,再次点击恢复;todo 与人机交互的挂起状态会话级存储(chat 与 cron 各自独立,切换会话自动保存/恢复)。
@@ -1366,7 +1373,7 @@ cloudflared tunnel --url http://localhost:8765 设计点 传统做法(本项目旧版) 阻塞式(当前) @@ -1386,6 +1393,28 @@ cloudflared tunnel --url http://localhost:8765
- 交互类工具(如
-human_interaction):工具内调一次ask_human(meta)阻塞,自己格式化返回值- 展示类工具(如
+todo_list):纯文本返回、不维护全局 store,前端从通用 live 记录(工具调用的 args/结果)按工具名写一个小 adapter 渲染浮窗(如js/todo.js的updateTodoOverlayFromRecords)- 展示类工具(如
todo_list):纯文本返回、不维护全局 store,前端从通用 live 记录(工具调用的 args/结果)按工具名写一个小 adapter 渲染通知条(如js/notify-strip.js)通过系统提示词前缀中的固定反思引导(
+ +REFLECTION_PROMPT)+MAIN_SYSTEM_PROMPT双重强调「同页操作必须合并为一次gui调用」实现。+ 5.1 ++UI 自动化双轨(Playwright + GUI,兼顾容错与效率)
+UI 操作按目标介质分两条轨道,由系统提示词引导 Agent 按「能 DOM 不截图」的原则选择:
++++
++ + 轨道 工具 机制 适用场景 特点 + 浏览器轨 browser_control+web_pagePlaywright 会话管理器(CDP 分离进程,Agent 退出后浏览器保留、重启自动重连)+ DOM 级操作 浏览器内的网页操作 毫秒级、定位可靠(snapshot 元素清单 → ref/selector 精准操作,不依赖视觉模型) + + 桌面轨 gui截图 + 视觉批量定位( _batch_ground_elements)+ 键鼠模拟桌面应用 / 任意界面(含浏览器无 DOM 场景) 通用容错:截图视觉闭环,多模态 LLM 看到真实屏幕状态,可处理任何界面 +++
+- 典型流程:
+browser_control打开 URL →web_pagesnapshot 获取元素清单 → 按 ref/selector 批量 click/fill/scroll → 页面跳转后重新 snapshot;浏览器不适用(如系统对话框、桌面软件)时回退gui视觉定位- 两轨均遵循「连续操作合并为一次调用」(actions 列表批量传入)的性能原则
+6 @@ -1405,56 +1434,81 @@ cloudflared tunnel --url http://localhost:8765前端通过
- +window.electronAPI?.isElectron自动适配。7 -存储双后端(JSON 可视化 / MySQL,统一接口隔离)
+存储多后端(JSON / MySQL / SQLite,统一接口隔离)
- 会话历史与定时任务存储统一在
agent/core/storage.py:接口(Protocol)驱动、双后端实现、消费方零感知后端选择(不判断STORAGE_BACKEND)。 + 会话历史与定时任务存储统一在agent/core/storage.py:接口(Protocol)驱动、多后端实现、消费方零感知后端选择(不判断STORAGE_BACKEND)。接口与实现
- -
ConversationRecordRepository(对话消息 / 工具调用 / 轮次元数据 / 会话生命周期,16 方法)与TaskConfigRepository(cron 任务配置,6 方法)两个 Protocol- 每后端一个实现类:
+JsonRecordRepository/MysqlRecordRepository(含会话与 cron 双作用域)、JsonTaskConfigRepository/MysqlTaskConfigRepository- 每后端一个实现类:
JsonRecordRepository/MysqlRecordRepository/SqliteRecordRepository(含会话与 cron 双作用域)、JsonTaskConfigRepository/MysqlTaskConfigRepository/SqliteTaskConfigRepository- 新增存储后端只需实现上述接口并在工厂注册一行;消费方新增功能/字段无需改动存储层(消息原样 JSON 存储、工具记录 meta 全量透传)
作用域(scope)
-
- -
session(默认):主进程普通会话,JSON 落盘history/sessions/{session_id}/,MySQL 用sessions / session_messages / session_tool_calls三表- +
cron:定时任务,JSON 落盘history/cron/{task_id}/,MySQL 用cron_messages / cron_tool_calls两表;cron 子进程切换存储作用域后直写记录,主进程经 cron 作用域仓库读取- +
session(默认):主进程普通会话,JSON 落盘history/sessions/{session_id}/,MySQL/SQLite 用sessions / session_messages / session_tool_calls三表cron:定时任务,JSON 落盘history/cron/{task_id}/,MySQL/SQLite 用cron_messages / cron_tool_calls两表;cron 子进程切换存储作用域后直写记录,主进程经 cron 作用域仓库读取
- 后端 存储位置 说明 - json(默认) history/sessions/{session_id}/消息与工具调用以人类可读 JSON 文件落盘( turn_{turn_id}.json/tool_{turn_id}.json/session_meta.json/_session_tokens.json),可直接打开查看/修改,便于审计与调试+ mysql session 三表 / cron 两表 消息与工具调用记录原样 JSON 入库、读取只走数据库;二进制产物(图片/附件等)两个后端均落盘 + json(默认) history/sessions/{session_id}/消息与工具调用以人类可读 JSON 文件落盘( turn_{turn_id}.json/tool_{turn_id}.json/session_meta.json),可直接打开查看/修改,便于审计与调试+ mysql session 三表 / cron 两表 消息与工具调用记录原样 JSON 入库、读取只走数据库;二进制产物(图片/附件等)所有后端均落盘 sqlite history/sqlite/storage.sqlite(单文件,session+cron 共用)内置 sqlite3零依赖,JSON 存 TEXT 列;cron_tool_calls含 meta 列,尾部思考可完整落库;WAL 模式支持多进程并发--
- 由
+STORAGE_BACKEND+MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE配置,agent/core/db.py提供统一连接与幂等建表- 由
+STORAGE_BACKEND(json / mysql / sqlite)+ 存储数据库配置驱动;存储数据库与工具数据库完全隔离:存储用STORAGE_DB_*(缺省回退旧MYSQL_*,零迁移),数据库操作工具(text2sql / excel2sql)用TOOL_DB_*(不回退存储配置);SQLite 工具库目录模式下每个库名 = 一个.sqlite文件- 两类数据库均在设置 → 存储与数据库页配置(类型可选择 MySQL / SQLite);
agent/core/db.py提供统一连接(connect_db)与幂等建库建表(ensure_database/ensure_sqlite_database—— 库不存在时先建库再按表结构建表)- 不双写:选哪种后端,读写就只走该后端;写入幂等(同一
-(session_id, turn_id)先 DELETE 再 INSERT);DB 异常仅打印日志,不阻断主流程- 分支(
+/api/sessions/branch)与回滚等会话功能均基于存储接口实现,两个后端一致可用- 分支(
/api/sessions/branch)与回滚等会话功能均基于存储接口实现,各后端一致可用双后端行为差异(设计内)
+后端行为差异(设计内)
+ + +-
+ 场景 json mysql 场景 json mysql / sqlite 轮次中实时工具记录 每次工具结果后增量写盘 tool_*.json,进程崩溃后重启可回退恢复最近记录不增量入库,由轮次结束 save_tool_calls统一落库(无额外磁盘 IO,但崩溃时该轮记录丢失)- 服务重启后运行中轮次 get_live_tool_calls回退读取增量文件,可继续展示内存记录丢失、数据库无该轮记录,实时面板为空 + cron 历史读取 直接读 history/cron/下文件子进程直写 cron 表,历史可正常读取 cron 历史读取 直接读 history/cron/下文件子进程直写数据库表,历史可正常读取 + 8 ++前后端单文件插件化(协议驱动,非注册式)
+核心思想:一个插件 = 后端一个文件 + 前端一个文件(或 adapter),通过协议接入;框架与插件之间靠事件 / 回调监听通信,而不是集中式注册表 + 修改主流程——新增或替换插件只需保持协议签名,宿主装配零改动。
+后端:
+BaseTool协议(src/agent/tools/*.py)+++
+- 每个工具是独立单文件,继承 LangChain
+BaseTool,实现name/description/args_schema/_run四要素即完成协议;tools/__init__.py注册表一行挂载- 前端零改动:工具配置面板(工具列表、参数表单、权限/自动执行开关、main 默认挂载)全部由
+/api/config/tools的registered_tool_names/tool_parameters/forced_permissions自动生成——新增工具自动出现在设置面板与 Agent 工具选择器- 展示类工具(如
+todo_list)只需保持「纯文本返回」协议,前端按工具名写一个小 adapter 即可渲染通知条前端:
+setXxxDeps / setXxxFn依赖注入协议(src/web/js/*.js)++
+- 每个功能模块独立单文件,只导出 setter(协议接口)与自身功能函数,模块间零直接引用;宿主
+app.js作为装配中心做一次性连线(依赖注入):setSendDeps({...})/setLiveUiDeps({...})/setRenderPersistentToolCalls(fn)/setRenderSkills(fn)- 事件监听式数据流:
+toolcalls.js收到 live snapshot(SSE/轮询)后,通过setLiveUiDeps注入的回调分发给各展示组件——组件是被回调通知(监听事件),而非向全局注册表登记- 替换/新增模块零宿主改动:旧模块(如
+pending-overlay.js)被新模块(notify-strip.js)替换时,只需保持协议导出签名(setLiveUiDeps/updateTodoOverlayFromRecords等),宿主连线无需修改
@@ -1500,11 +1554,11 @@ cloudflared tunnel --url http://localhost:8765api.js封装所有后端 API 请求 state.js全局状态管理 - send.js消息发送逻辑 - chat-render.js聊天消息渲染 - cron.js定时任务管理面板 - sessions.js会话列表管理 - toolcalls.js工具调用实时面板 + todo.jsTodoList 面板 + chat-render.js聊天消息渲染(双容器:chat / cron 各自独立,运行时增量渲染、空闲全量重渲染) + cron.js定时任务面板(按工作区分组列表、类 chat 会话式历史、模式切换锁、实时运行) + sessions.js会话列表管理(工作区分组,切换会话恢复会话级通知条状态) + toolcalls.js工具调用实时面板(思考 Thought / 反思 Reflection 分离显示,rAF 合并高频快照) notify-strip.js★ 通知条系统:todo 与人机交互的输入区上方长条(收起/展开、按钮切换、会话级存储) edit-mode.js编辑模式(Monaco) doc-tree.js/doc-mod-panel.js文档树与修改面板 @@ -1520,7 +1574,7 @@ cloudflared tunnel --url http://localhost:8765 action-bar.js底部操作栏 token-ring.js上下文 Token 用量环形指示器(含压缩阈值刻度与三类占比) access-mode.js工作空间访问模式切换(限制访问 / 权限审查 / 完全访问) - think-level.js思考模式档位选择(low / high / xhigh / max / ultra) + pending.js/pending-overlay.js人工请求浮窗与决策提交(数据源为 live snapshot 的 human_requests)pending.js人工请求决策提交(数据源为 live snapshot 的 human_requests,配合 notify-strip 渲染)rollback.js消息回滚 workspace.js工作空间切换 @@ -1530,15 +1584,15 @@ cloudflared tunnel --url http://localhost:8765 dialog.js对话框 CSS 架构(
-src/web/css/)+
variables.css定义主题变量,其余按模块拆分:base / layout / chat / composer / cron / dialog / settings / skills / todo / toolcalls / edit-mode / canvas-editor / audio / animations / pending / pending-overlay / toast / responsive。
variables.css定义主题变量,其余按模块拆分:base / layout / chat / composer / cron / dialog / settings / skills / toolcalls / notify-strip / edit-mode / canvas-editor / audio / animations / toast / responsive。添加自定义工具
@@ -1567,7 +1621,7 @@ Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force-
- 在
-src/agent/tools/新建my_tool.py,继承 LangChainBaseTool,实现_run,填写name/description/args_schema- 在
-tool_config.json注册并配置启用状态与参数(权限设为confirm时,工具执行前会自动弹出确认浮窗,同意后执行、拒绝/跳过作为返回值继续)- 交互类工具(需要向用户提问)在
-_run中调用core/human_request.ask_human(meta)阻塞等待答案;展示类工具(如进度面板)返回纯文本即可,前端从 live 工具记录按工具名写 adapter 渲染浮窗- 重启后端,工具自动绑定到 LLM
+- 在
+src/agent/tools/新建my_tool.py,继承 LangChainBaseTool,实现_run,填写name/description/args_schema(协议四要素)- 在
+config.json的tools分区注册并配置启用状态与参数(权限设为confirm时,工具执行前会自动弹出确认通知条,同意后执行、拒绝/跳过作为返回值继续);前端无需改动——设置面板工具列表 / 参数表单 / Agent 工具选择器由 API 自动生成- 交互类工具(需要向用户提问)在
+_run中调用core/human_request.ask_human(meta)阻塞等待答案;展示类工具(如进度面板)返回纯文本即可,前端从 live 工具记录按工具名写 adapter 渲染通知条- 重启后端,工具自动绑定到 LLM;若希望 main Agent 默认挂载,加入
config_manager.MAIN_DEFAULT_TOOLS@@ -1589,7 +1643,7 @@ Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force
- 后端日志:桌面端
npm start会在终端打印[FastAPI]日志- 端口占用:
-main.js的killPortProcess(8765)启动前自动清理- 重置配置:删除
+src/agent/config/env_config.json重触首次向导- 重置配置:删除
src/agent/agents/config.json重触首次向导(或复制config_dist.json为新配置)- 工具调用轨迹:
history/sessions/{session_id}/tool_{turn_id}.json🐍 Python 版本 建议 Python 3.11 / 3.12 / 3.13;首次向导会自动检测 🔐 未签名安装包 .exe可能被 Windows Defender 拦截,点「更多信息 → 仍要运行」- 🌐 首次启动联网 加载 Google Fonts 需联网;离线环境字体选择器有 HTML 硬编码兜底 + 🔑 API Key 安全 env_config.json含明文凭证,勿提交到公开仓库(已在.gitignore排除env_config.json,仅保留env_config_dist.json模板)🔑 API Key 安全 src/agent/agents/config.json含明文凭证,勿提交到公开仓库(已在.gitignore排除,仅保留config_dist.json模板)🎨 图标 当前为 SVG,NSIS 打包后若显示默认图标,需准备 .ico文件替换src/web/image/icon.ico💾 历史存储 默认会话历史与工具轨迹以 JSON 落盘在 history/(可切换 MySQL 后端),长期使用注意清理diff --git a/README.md b/README.md index 9987b2e..9808a28 100644 --- a/README.md +++ b/README.md @@ -22,11 +22,11 @@ > 🎓 **项目初衷**:这是一个 LangChain + LangGraph 的学习项目。基于两者构建 Agent 工作流,结构清晰、上手容易、扩展方便,适合作为 Agent 开发的参考实现。 - 🖥️ **真能操作桌面**:截图 → 视觉定位 → 键鼠模拟,闭环完成 ERP 录入、表单填写、软件操控 -- 🧩 **20 项内置工具**:从终端命令到邮件收发,从 PPT 制作到图像生成,覆盖真实办公场景 +- 🧩 **20 项内置工具 + 单文件插件化**:从终端命令到邮件收发,从 PPT 制作到图像生成;新增工具 = 一个文件 + 协议接入,前端面板自动生成,无需大面积改动 - 🏠 **完全本地运行**:Electron 打包,数据不出本机;后端 FastAPI + 前端零延迟直读磁盘 - 🎨 **可深度定制**:主题 / 字体 / 字号 / 背景 / 工作空间 / 提示词分层架构,全部可调 - 🔁 **工程级稳定性**:循环检测、上下文压缩、阻塞式人工确认、轨迹回放,对抗 LLM 的"幻觉操作" -- 💾 **存储双后端(接口隔离)**:会话与定时任务存储统一接口驱动,**JSON 可视化 / MySQL** 一键切换、不双写;新增存储方式只需实现接口,消费方无感知后端 +- 💾 **存储多后端(接口隔离)**:会话与定时任务存储统一接口驱动,**JSON / MySQL / SQLite** 一键切换、不双写;存储数据库与工具数据库配置隔离;新增存储方式只需实现接口,消费方无感知后端 > 💡 项目演进:`Web 在线应用` → `Electron 桌面应用`。源码同一套 `src/`,既可由 `uvicorn` 作 Web 服务启动,也可由 Electron 主进程拉起,**双模式共用**。 @@ -43,16 +43,18 @@ | 工具 | 文件 | 能力 | |------|------|------| -| 🖱️ **gui** | `tools/gui.py` | 桌面 GUI 自动化:截图、视觉批量定位、点击/输入/快捷键/滚动 | -| 🌐 **browser_control** | `tools/browser_control.py` | 浏览器打开/关闭、地址导航、标签页交互 | -| 💻 **terminal_execute** | `tools/terminal_execute.py` | 执行任意终端命令,支持工作目录与超时 | +| 🖱️ **gui** | `tools/gui.py` | 桌面 GUI 自动化:截图、视觉批量定位、点击/输入/快捷键/滚动(桌面/任意应用轨) | +| 🌐 **browser_control** | `tools/browser_control.py` | 浏览器打开/关闭、地址导航、标签页交互(Playwright + CDP 分离进程,Profile 默认一次性临时目录,可配置持久) | +| 🧭 **web_page** | `tools/web_page.py` | 浏览器页面 **DOM 级精准操作**(浏览器轨):snapshot 元素清单、click/fill/scroll/press/extract/screenshot/eval_js 等,毫秒级、优于 GUI 视觉定位;典型流程 browser_control 打开 → web_page 操作 | +| 💻 **terminal_execute** | `tools/terminal_execute.py` | 执行任意终端命令(含目录/文件操作,可替代 directory_listing),支持工作目录与超时;`background=True` 挂起长驻任务(立即返回任务ID/PID,不阻塞;不支持交互式输入,密码/采集输入类命令应放弃并建议用户手动执行) | +| 📟 **terminal_info** | `tools/terminal_info.py` | 查看挂起的后台终端任务状态(运行中/已完成/异常退出/已终止)与最新输出(截断 1000 字符) | +| ⏳ **wait** | `tools/wait.py` | 通用等待工具:阻塞等待指定后台任务结束并取回最终输出(超时返回运行中摘要) | | 🪟 **software_control** | `tools/software_control.py` | Win32 API 启停软件、注册表、`os.startfile` | | 📧 **email** | `tools/email.py` | IMAP 收邮件 + SMTP 发邮件(含附件) | | 📄 **doc** | `tools/doc.py` | 创建/读取/编辑 docx / xlsx / pptx / pdf / txt | -| 📂 **directory_listing** | `tools/directory_listing.py` | 递归列目录、文件元信息 | | 📨 **send_file** | `tools/send_file.py` | 以邮件附件形式发送本地文件 | | 🔍 **web_search** | `tools/web_search.py` | 联网搜索(Tavily / Firecrawl / 火山引擎) | -| 🗄️ **text2sql** | `tools/text2sql.py` | 自然语言/SQL 操作数据库:查表结构、查询、增删改 | +| 🗄️ **text2sql** | `tools/text2sql.py` | 自然语言/SQL 操作数据库(MySQL / SQLite,工具数据库配置):查表结构、查询、增删改、建库建表 | | 📚 **rag** | `tools/rag.py` | ChromaDB 向量检索 + Reranker 重排序 | | 🎨 **image_gen** | `tools/image_gen.py` | 调用 Z-Image-Turbo 生成图片 | | ✅ **todo_list** | `tools/todo_list.py` | 多步任务规划与进度跟踪 | @@ -62,20 +64,25 @@ | 📅 **date_query** | `tools/date_query.py` | 当前日期时间查询 | | ⏰ **cron_manager** | `tools/cron_manager.py` | 定时任务管理:查看/新增/修改/删除定时任务,支持 cron/间隔/一次性触发 | | 📊 **hard_excel_read** | `tools/hard_excel_read.py` | 复杂 Excel 表结构读取:前 N 行原始数据 + 维度/合并区/数据区范围建议,供大模型解析结构 | -| 📥 **excel2sql** | `tools/excel2sql.py` | 按大模型给定的数据行列范围,将 Excel 数据区流式批量入库 MySQL(自动建库建表、表注释) | +| 📥 **excel2sql** | `tools/excel2sql.py` | 按大模型给定的数据行列范围,将 Excel 数据区流式批量入库(MySQL / SQLite,自动建库建表) | + +> 🔒 **main Agent 默认挂载**(代码强制注入,不可通过配置移除,前端显示为锁定项):`call_subagent`(子 Agent 委派)、`doc_tool`(文档读写)、`human_interaction`(人机交互审查)、`todo_list`(任务规划)、`terminal_execute`(终端命令,含挂起)、`terminal_info`(终端信息)、`wait`(等待)。其余工具按 `config.json` 中 main agent 的 tools 列表自行增删;子 Agent 无此限制。 ### 🎯 区别于其他 Agent 项目的核心设计 | 设计点 | 多数 Agent 项目 | minor Agent | |--------|----------------|-------------| | **GUI 操作粒度** | 一步一调用,N 个动作 = 2N+1 次 LLM 调用 | **动作序列合并**:同页多操作打包为一次 `gui` 调用,N 个动作仅 2 次 LLM 调用 | +| **UI 自动化双轨** | 单轨(截图 + 坐标模拟) | **Playwright + GUI 双轨兼顾容错与效率**:浏览器内走 DOM 级精准操作(`web_page` 毫秒级、定位可靠);桌面/任意应用走 GUI 视觉定位(`gui_tool` 截图视觉闭环、可处理任意界面)——浏览器能 DOM 就不截图,桌面无 DOM 就走视觉 | | **视觉反馈** | 仅返回坐标文本 | **截图作为视觉闭环**注入上下文,多模态 LLM 看到真实屏幕状态 | | **坐标定位** | 逐元素请求 | **批量定位** `_batch_ground_elements`:N 个元素 1 次子模型调用 | | **循环失控** | 无防护,token 烧穿 | **循环检测**:连续同工具同参(默认 3 次)注入反思警告,引导模型自我纠偏 | | **上下文膨胀** | 简单截断 | **图内分级压缩**:token 超 `窗口×COMPRESS_RATE` 触发,压缩历史工具调用为累积摘要,每步 LLM 调用后检查 | +| **上下文连续性** | 思考链跨轮丢失 | **历史思考/反思注入**:压缩游标后的思考、反思、工具调用按因果顺序交错注入(稳定 id、确定性构建,前缀缓存友好);深度思考与反思分离存储 | | **部署形态** | 仅 Web / 仅 API | **同一套源码双模式**:`uvicorn` Web 服务 ↔ Electron 桌面应用 | | **文件操作** | HTTP 上传下载 | 桌面端走 **IPC 直读磁盘**,零延迟;Web 端走 `/api/fs/*` | | **提示词** | 单层 system prompt | **四层提示词架构**:系统级 / 工具描述 / 运行时注入 / 内部子模型 | +| **插件化** | 注册式插件表(适配新插件需改框架主流程) | **前后端单文件插件化**:后端按 `BaseTool` 协议单文件实现、前端按 `setXxxDeps` 依赖注入协议接入,**监听事件 / 回调驱动而非注册式**——新增或替换一个插件只需保持协议签名,宿主装配零改动 | ### 🖼️ 运行界面 @@ -86,14 +93,14 @@ Agent 运行过程中的关键交互界面,支持文件从任意位置**拖拽 ⚡ GUI 性能 同页多操作务必合并为一次 gui调用,否则延迟显著(见「实现细节」)![]()
![]()
-人机交互确认 — 敏感操作(终端执行、文件写入等)与信息收集以浮窗形式实时弹出,用户可同意、拒绝、跳过或补充信息;**拒绝也只是一次工具返回值**,Agent 自行调整方案继续,不会结束本轮 | 任务规划 TodoList — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表 +人机交互确认 — 敏感操作(终端执行、文件写入等)与信息收集以「输入区上方通知条」形式实时弹出:只显示标题与数量角标,点击展开为完整卡片(标题/内容/选项胶囊/补充输入框),选择或补充后作为工具返回值,Agent 继续执行;**拒绝也只是一次工具返回值**,Agent 自行调整方案继续,不会结束本轮 | 任务规划 TodoList — 多步任务自动拆解与进度跟踪,同样以通知条展示(最新步骤 + 数量角标,可展开全部步骤),Todo 状态**会话级存储**(chat 与 cron 各自独立,切换会话自动恢复) | 后台终端任务 — `terminal_execute(background=True)` 挂起的长驻任务以第三条通知条展示("x 后台运行中 - y 已完成",展开可终止/查看状态);点击顶栏终端按钮展开底部终端栏,Agent 挂起任务以 "任务#PID" 标签与用户交互式 PowerShell 终端并列且视觉区分,实时滚动输出
![]()
![]()
-文件编辑模式 — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织 | 工具调用记录 — 实时展示每轮 ReAct 的工具调用链、参数及返回值 +文件编辑模式 — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织 | 工具调用记录 — 实时展示每轮 ReAct 的工具调用链、参数及返回值;思考(reasoning_content)与反思(content)分离显示为独立的 Thought / Reflection 行@@ -106,7 +113,7 @@ Agent 运行过程中的关键交互界面,支持文件从任意位置**拖拽
![]()
![]()
-定时任务管理 — 支持 cron / 间隔 / 一次性三种触发方式,状态实时可见 | Edit 编辑模式 — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent +定时任务管理 — 支持 cron / 间隔 / 一次性三种触发方式;任务按**工作区分组**展示;任务会话与聊天同构(点击会话式查看历史、多轮对话、流式渲染),任务在**独立子进程**中执行;模式切换锁:chat 运行时可查看 cron、cron 运行时可查看 chat,双容器渲染不打断流式输出 | Edit 编辑模式 — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent@@ -178,7 +185,7 @@ START → [agent: call_model] ──有 tool_calls──▶ [tools: 并行工具 ### 多 Agent 模式 -多 Agent 并非独立图,而是**同一张 react 图内的工具级并行**:`tools` 节点由 `make_parallel_tool_node`(线程池)取代 ToolNode,一轮内的多个工具调用并行执行;其中 `agent_call` 工具会以主 Agent 当前消息快照启动**子 Agent 的独立图实例**执行子任务,结果以工具返回值注入主 Agent 上下文。子 Agent 内的人机交互同样为阻塞式——在子图线程中等待浮窗应答,完成后自然回到主图: +多 Agent 并非独立图,而是**同一张 react 图内的工具级并行**:`tools` 节点由 `make_parallel_tool_node`(线程池)取代 ToolNode,一轮内的多个工具调用并行执行;其中 `agent_call` 工具会以主 Agent 当前消息快照启动**子 Agent 的独立图实例**执行子任务,结果以工具返回值注入主 Agent 上下文。子 Agent 内的人机交互同样为阻塞式——在子图线程中等待通知条应答,完成后自然回到主图: ``` [agent] ──有 agent_call──▶ [tools: parallel_tool_node(线程池)] @@ -186,7 +193,7 @@ START → [agent: call_model] ──有 tool_calls──▶ [tools: 并行工具 ├─ 普通工具:直接并行执行 └─ agent_call:spawn 子 Agent Runtime → 子图 invoke │(子图内 human_interaction 阻塞等待 - │ 浮窗应答,答案作为工具返回值) + │ 通知条应答,答案作为工具返回值) └─ 结果作为 ToolMessage 注入主 Agent ◄┐ ▲ │ └──────────────── 有 agent_call → 继续循环;无 → END ──────────────────────┘ @@ -206,10 +213,12 @@ Agent/ ├── src/ │ ├── agent/ # 🧠 Agent 核心 │ │ ├── agents/ -│ │ │ └── agent_runtime.py # 编排层入口:execute_agent(动态 Agent 工厂) +│ │ │ ├── agent_runtime.py # 编排层入口:execute_agent(动态 Agent 工厂,main 默认挂载工具强制注入) +│ │ │ ├── config.json # ★ 合并配置(agents/tools/env/gui/workspace/theme 分区,含密钥) +│ │ │ └── config_dist.json # 配置模板(不含密钥) │ │ ├── core/ │ │ │ ├── graph.py # LangGraph 构建(单 Agent + 多 Agent) -│ │ │ ├── nodes.py # ReAct 节点:call_model / process_tool_artifact / 压缩 +│ │ │ ├── nodes.py # ReAct 节点:call_model(思考/反思提取与本轮回插)/ process_tool_artifact / 压缩 │ │ │ ├── runtime.py # 图执行器:消息拼装、历史加载、轨迹落盘 │ │ │ ├── routing.py # should_continue 路由 │ │ │ ├── state.py # Graph 状态定义(messages / agent_mode) @@ -218,17 +227,18 @@ Agent/ │ │ │ ├── turn_runner.py # ★ 后台线程轮次执行器(解耦 HTTP 与图执行生命周期) │ │ │ ├── llm.py # ChatQwen 多模态封装(图像/附件/非标准 tool_call) │ │ │ ├── tool_policy.py # 工具执行策略(直执 / 需确认) -│ │ │ ├── storage.py # ★ 统一存储层:接口 + JSON/MySQL 双实现 + 工厂 + scope -│ │ │ └── config_manager.py# 配置读写:agent/tool/env/gui/model/theme +│ │ │ ├── db.py # ★ 数据库基础设施:配置加载(存储库/工具库隔离)、连接、幂等建库建表(MySQL/SQLite) +│ │ │ ├── storage.py # ★ 统一存储层:接口 + JSON/MySQL/SQLite 三实现 + 工厂 + scope +│ │ │ └── config_manager.py# 配置读写:agent/tool/env/gui/model/theme + MAIN_DEFAULT_TOOLS │ │ ├── memory/ │ │ │ └── system_prompt.py # ★ 四层提示词:MAIN / PLAN / REFLECTION / VISION... │ │ ├── tools/ # 20 项工具(见上表) │ │ │ ├── ...(gui / browser_control / terminal_execute / email / doc / web_search / text2sql / rag / cron_manager 等) │ │ ├── cron/ # ⏰ 定时任务引擎 -│ │ │ ├── models.py # 数据模型(CronTask / Trigger) +│ │ │ ├── models.py # 数据模型(CronTask / Trigger,含 workspace 字段) │ │ │ ├── storage.py # 转发层(实现统一在 core/storage.py) │ │ │ ├── scheduler.py # 调度计算与触发扫描 -│ │ │ ├── runner.py # 任务执行器(子进程,切换存储 cron 作用域) +│ │ │ ├── runner.py # 任务执行器(独立子进程,切换存储 cron 作用域) │ │ │ └── live.py # 常驻调度循环 │ │ ├── skills/ # 5 项技能(skill.json + skill.md) │ │ │ ├── netease_mail_read/ @@ -240,16 +250,11 @@ Agent/ │ │ │ └── streaming_client.py # 流式 TTS 客户端(SSE 收音频块) │ │ ├── history/ │ │ │ ├── session_storage.py # 转发层(实现统一在 core/storage.py) -│ │ │ └── tool_call_recorder.py # 每轮工具调用轨迹记录 (tool_{turn_id}.json) +│ │ │ ├── tool_call_recorder.py # 每轮工具调用轨迹记录(含思考/反思双字段,tool_{turn_id}.json) +│ │ │ └── sqlite/ # SQLite 存储默认目录(storage.sqlite / tool/ 每库一文件) │ │ ├── utils/ # env_utils / agent_utils / image_utils / ppt_utils / tool_call_utils -│ │ └── config/ # ★ 配置文件目录 -│ │ ├── env_config.json # 运行时配置(首次向导生成,勿手动改) -│ │ ├── env_config_dist.json # 配置模板(默认值) -│ │ ├── agent_config.json # Agent 行为配置 + 兜底 system_prompt -│ │ ├── tool_config.json # 各工具启用/参数 -│ │ ├── gui_config.json # GUI 工具参数 -│ │ ├── theme_config.json # 主题 -│ │ └── workspace_config.json # 工作空间列表与当前选择 +│ │ ├── memory/ # 系统提示词 / 会话历史 +│ │ └── data/ # 运行时数据(浏览器 Profile 等,不入库) │ │ │ └── web/ # 🎨 前端 UI │ ├── server.py # FastAPI 后端(75+ 路由) @@ -293,7 +298,7 @@ npm install npm start ``` -首次运行会弹出**配置向导**(3 步),写入 `src/agent/config/env_config.json` 后自动启动: +首次运行会弹出**配置向导**(3 步),写入 `src/agent/agents/config.json` 后自动启动: | 步骤 | 配置内容 | 说明 | |------|----------|------| @@ -301,7 +306,7 @@ npm start | **Step 2** LLM 设置 | `base_url` / `api_key` / `model` | 必填;可填任何 OpenAI 兼容端点 | | **Step 3** 高级参数 | TTS / ASR / RAG / 搜索 / 压缩 / 循环阈值 | 可选;不填则对应能力禁用 | -> 💡 之后启动直接进入主界面,不再弹向导。如需重配,删除 `src/agent/config/env_config.json` 即可重触首次流程。 +> 💡 之后启动直接进入主界面,不再弹向导。如需重配,删除 `src/agent/agents/config.json`(或复制 `config_dist.json` 为新配置)即可重触首次流程。 #### 3. 打包为安装程序 @@ -317,10 +322,10 @@ npm run build
@@ -345,8 +350,8 @@ pip install -r requirements.txt # 3. 配置环境(首次) # 复制模板并填写 LLM 凭证 -Copy-Item src\agent\config\env_config_dist.json src\agent\config\env_config.json -# 编辑 env_config.json,至少填写 models[].base_url / api_key / model +Copy-Item src\agent\agents\config_dist.json src\agent\agents\config.json +# 编辑 config.json,至少填写 env.models[].base_url / api_key / model # 4. 启动 Web 服务 $env:PYTHONPATH="src;$env:PYTHONPATH" @@ -370,18 +375,17 @@ cloudflared tunnel --url http://localhost:8765 ## ⚙️ 配置说明 -所有运行时配置集中在 `src/agent/config/env_config.json`,首次向导自动生成。关键字段: +所有运行时配置集中在 **`src/agent/agents/config.json`**(合并单文件,含 `agents / tools / env / gui / workspace / theme` 分区),模板见 `config_dist.json`,首次向导自动生成。env 分区的关键字段: | 字段 | 默认值 | 说明 | |------|--------|------| | `models[]` | `SETUP_REQUIRED` | LLM 列表:`{id, name, model, api_key, base_url, timeout, max_retries}`;ASR / TTS / RAG / ImageGen 四个本地服务也在此注册(按 `model` 字段匹配,`api_key` 留空) | | `gui_model_id` | `""` | GUI 视觉定位专用模型(留空则回退主模型) | -| `WORKING_DIR` | 项目根目录 | 工作根目录(桌面端自动设为安装目录) | -| `WORKSPACE_DIR` | `""` | 工作空间目录(相对 WORKING_DIR),留空则使用 WORKING_DIR | | `USER_PYTHON_PATH` | `""` | 指定 Python 解释器路径 | +| `LLM_TIMEOUT` | `60` | LLM 单次请求超时(秒) | +| `LLM_MAX_RETRIES` | `3` | LLM 调用超时自动重试次数(仅 ReAct 节点调用;重试期间前端提示"超时正在重试") | | `LLM_CONTEXT_WINDOW` | `262144` | 上下文窗口大小(token) | | `COMPRESS_RATE` | `0.6` | 上下文压缩阈值比例(0~1),token 用量超过 `窗口×该值` 时触发压缩 | -| `THINKING_LEVEL` | `"low"` | 深度思考档位:low / high / xhigh / max / ultra(xhigh 及以上启用深度思考) | | `IMG_SIZE` | `768` | 截图短边尺寸 | | `GROUNDING_WIDTH/HEIGHT` | `1000` | 视觉定位输入分辨率 | | `LOOP_DETECT_REPEATED_TOOL_WARN` | `3` | 连续同工具同参多少次注入反思警告 | @@ -389,22 +393,29 @@ cloudflared tunnel --url http://localhost:8765 | `SEND_FILE_SIZE_LIMIT` | `30` | 发送文件大小上限(MB) | | `WEB_SEARCH_API_KEY` | `""` | 联网搜索 API Key | | `WEB_SEARCH_ENGINE` | `"tavily"` | 搜索引擎选择(tavily / firecrawl / volcengine) | -| `STORAGE_BACKEND` | `"json"` | 存储后端(json / mysql) | -| `MYSQL_HOST` / `MYSQL_PORT` / `MYSQL_USER` / `MYSQL_PASSWORD` / `MYSQL_DATABASE` | `""` | MySQL 连接参数(STORAGE_BACKEND=mysql 时生效) | +| `STORAGE_BACKEND` | `"json"` | 存储后端:json(本地文件)/ mysql / sqlite | +| `STORAGE_DB_TYPE` | `""` | 存储数据库类型(mysql / sqlite;留空随 STORAGE_BACKEND 推导) | +| `STORAGE_DB_HOST / PORT / USER / PASSWORD / DATABASE / PATH` | `""` | 存储数据库连接参数(mysql 用 host/port/user/password/database;sqlite 用 path,留空默认 `history/sqlite/storage.sqlite`);缺省回退旧 `MYSQL_*` | +| `TOOL_DB_TYPE` | `"mysql"` | **工具数据库**类型(mysql / sqlite)——数据库操作工具(text2sql / excel2sql)专用,**与存储后端完全隔离** | +| `TOOL_DB_HOST / PORT / USER / PASSWORD / DATABASE / PATH` | `""` | 工具数据库连接参数(sqlite path 留空默认 `history/sqlite/tool` 目录,每个库名一个 `.sqlite` 文件) | +| `MYSQL_*` | `""` | **旧键**:存储数据库 MySQL 参数(已被 `STORAGE_DB_*` 取代,仅作回退兼容) | +| `BROWSER_PROFILE_DIR` | `""` | 浏览器控制工具持久 Profile 目录;留空 = 每次启动一次性临时 Profile(登录态不持久、不留盘) | | `TOOL_TIMEOUT` | `300` | 工具执行超时时间(秒) | | `CRON_TIME_PERIOD_MINUTES` | `30` | 定时任务时段长度(分钟),同一时段全局并发=1 | | `EMAIL_ADDRESS / EMAIL_AUTH_CODE` | `""` | 邮箱凭证(IMAP/SMTP) | -> ⚙️ 以上参数均可在主界面「设置」面板热修改,或通过 `/api/config/*` 接口程序化调整。 +> ⚙️ 思考档位(low / high / xhigh / max / ultra)为**会话级**配置(存于各会话 `session_meta.json`);以上参数均可在主界面「设置」面板热修改,或通过 `/api/config/*` 接口程序化调整。 +> +> ⚠️ 首次向导中的「轻度压缩率 / 深度压缩率」(写入 `MINI_COMPRESS_RATE` / `HARD_COMPRESS_RATE`)目前**不参与运行时逻辑**——运行时仅使用单一 `COMPRESS_RATE`(默认 0.6,见「实现细节 · 上下文分级压缩」),两键为兼容性预留。 -设置面板包含五个标签页,覆盖 Agent 运行的全部可调参数: +设置面板包含 11 个分组,覆盖 Agent 运行的全部可调参数:📦 打包原理(点击展开)
-- `package.json` 的 `build.files` 仅打包 `electron/`、`src/`、`requirements.txt`,**排除** `env_config.json`(每个用户独立生成) +- `package.json` 的 `build.files` 仅打包 `electron/`、`src/`、`requirements.txt`,**排除** `config.json`(每个用户独立生成,模板 `config_dist.json` 随包分发) - `asar: false`:源码不加密,便于审计与二次开发 - NSIS 安装器:允许改安装目录、创建桌面/开始菜单快捷方式、卸载时清理 AppData -- 首次启动由 `setup.html` 向导引导:检测 Python → 配置 LLM → 写 `env_config.json` → 启动 FastAPI → 加载 UI +- 首次启动由 `setup.html` 向导引导:检测 Python → 配置 LLM → 写 `config.json` → 启动 FastAPI → 加载 UI - 主进程 `findSystemPython()` 依次检查:用户配置路径 → 常见 Python 安装位置 → PATH 中的 `python`
![]()
![]()
-Agent 配置 — 管理多个 AI 角色,各自绑定独立模型与工具集 | 工具配置 — 启用/禁用工具、调整参数及执行策略 +Agent 配置 — 管理多个 AI 角色,各自绑定独立模型与工具集(main Agent 默认挂载 5 个工具为锁定项,不可移除) | 工具配置 — 启用/禁用工具、调整参数及执行策略@@ -417,7 +428,7 @@ cloudflared tunnel --url http://localhost:8765
--- @@ -438,10 +449,15 @@ cloudflared tunnel --url http://localhost:8765 上下文按「前缀稳定性」布局,最大化利用 LLM 服务端的前缀缓存: ``` -[系统提示词(含固定反思引导,稳定)] + [压缩摘要] + [对话历史] + [用户消息] + [动态尾部(每轮变化)] +[系统提示词(含固定反思引导,稳定) + 链式压缩块 #1..#N(只增不删)] ++ [压缩游标后的原生历史(user → AI(思考/反思/工具调用) → Tool* → 尾部反思 → 最终回复)] ++ [本轮用户消息] + [动态尾部(每轮变化)] ``` -- **固定部分放前缀**:反思引导等不变提示文本并入系统提示词、位于上下文最前 —— 前缀稳定即可命中缓存;避免把固定文本逐轮塞在消息中间导致整段缓存失效。 +- **固定部分放前缀**:反思引导等不变提示文本并入系统提示词、链式压缩块按序位于其后(字节不变、只增不删)——前缀稳定即可命中缓存;避免把固定文本逐轮塞在消息中间导致整段缓存失效。 +- **原生历史重建**(`build_native_history_messages`,`utils/agent_utils.py`):压缩游标之后的所有轮次,按 turn 文件**确定性重建为原生 LangChain 消息**——`HumanMessage`(文本/图片/附件多模态部分)、`AIMessage`(思考→`additional_kwargs.reasoning_content`、反思→`content`、工具调用→`tool_calls`,同一模型响应的并行调用**合并为一条**)、`ToolMessage`(`tool_call_id`/`name` 严格匹配);单轮顺序:user → (AI + Tool)* → 未被消费的尾部反思(文本去重)→ 最终无工具回复。消息 id 稳定(`hist_user_{tid}` / `hist_assist_{tid}_{g}` / `hist_tool_{tid}_{g}_{i}` / `hist_tail_{tid}_{i}` / `hist_final_{tid}`),注入只追加在末尾 —— 满足前缀缓存命中条件;压缩节点按 id 精确移除。 +- **思考/反思原生透传**:`llm.py` 的 `Multimodel_LLM._get_request_payload` 在 LangChain 消息转换后补回 assistant 消息的 `reasoning_content` 字段(langchain-openai 默认丢弃该附加字段),使深度思考以原生 AIMessage 字段进入请求体(前缀缓存格式);图中当前轮的 `[思考]`/`[反思]` 合成为兜底(原生历史不再合成,消息带 `reasoning_content` 时跳过)。 +- **思考与反思分离**:深度思考(`reasoning_content`)与反思(`content`)**独立存储**(工具记录的 `thinking` / `reflection` 字段 + 轮末 `reflections` 列表),重建时分别落入 AIMessage 的 `reasoning_content` 与 `content`,二者可同时存在。 - **变化部分放末尾**:TodoList 状态、循环警告等每轮变化的内容追加在消息列表**末尾**,变化只影响尾部、不破坏前缀缓存;无 TodoList 且无循环提醒时**不注入任何尾部提示**(省 token)。 - **动态尾部作为长期记忆**:每轮尾部文本持久化到 `session_meta` 的 `dynamic_tail_history`(连续相同去重),后续轮次把全部历史尾部注入上下文,Agent 能看到之前所有轮的 TodoList 状态与循环提醒。 @@ -456,13 +472,23 @@ cloudflared tunnel --url http://localhost:8765 每次 LLM 调用后读取 response.usage_metadata.total_tokens ├─ 未超阈值 → 不处理,继续下一轮 ├─ 主 Agent 超阈值 → 标记压缩,由 compress 节点统一执行: - │ 将注入的历史工具调用与返回压缩为累积摘要(RemoveMessage 移除已覆盖消息) + │ 将「上次游标后 → 当前用户消息前」的全部原生历史压缩为摘要块, + │ 以链式压缩块追加存储(只增不删),RemoveMessage 移除已覆盖消息 └─ 子 Agent 超阈值 → 注入整理提示词,整理进度后直接返回主 Agent(子 Agent 不压缩) ``` -- **触发**:`call_model` 内每步检查 `total_tokens`,超过 `LLM_CONTEXT_WINDOW × COMPRESS_RATE`(默认 0.6)即标记压缩 -- **执行**:压缩发生在图内 `compress` 节点(`process_tool_artifact` 之后),只压缩注入的历史工具上下文,系统提示词与对话历史保留,摘要按压缩游标累积 -- **子 Agent**:不做压缩,超限时整理任务进度直接返回主 Agent,避免上下文继续膨胀 +- **触发**:`call_model` 内每步检查 `total_tokens`,超过 `LLM_CONTEXT_WINDOW × COMPRESS_RATE`(默认 0.6,即 262144 × 0.6 ≈ 157k)即标记压缩 +- **压缩范围**:上次压缩游标之后 → **当前轮用户消息之前**的**全部原生历史**——思考/反思、对话(user/AI)、工具调用与返回值均压缩;固定提示词(系统提示词 + 工具描述,后者为 API 级 `bind_tools` 非消息)**永不压缩**;当前进行轮不压缩(其消息保持原样,下一轮起随重建进入压缩范围) +- **合并**:相邻同角色消息(多条 HumanMessage / 多条 AIMessage)预合并为一条大单元再送摘要模型(工具结果不合并),合并后的单元数计入统计 +- **链式存储**:摘要追加为压缩块写入 `session_meta.main.compressed_context`(`blocks` 数组,`seq` 递增),**不覆盖旧块**;`cursor_msg` 推进到压缩末尾的全局单元序号(每条消息 = 1 个单元,1 起) +- **替换语义**:以 `RemoveMessage` 精确移除被覆盖的 `hist_*` 原生消息,图内追加**固定 id**(`compressed_block_{seq}`)的摘要 `SystemMessage`;下一轮由 `runtime._inject_summary_context` 将全部块放回系统提示词之后的前缀位置(图内只能尾部追加) + +补充细节(与 `nodes.py` 一致): + +- **触发位置**:`execute_agent` 节点每次 LLM 响应后调用 `_maybe_trigger_compress`(`nodes.py`),子 Agent 超限返回 `sub_oom`(注入整理提示词引导其总结任务进度后返回),主 Agent 超限置 `_need_compress=True` 交给图内压缩节点;同时将 token 用量与「消息 / 工具 / 系统提示词」三类占比估算存入 `tool_call_recorder` 并聚合 `usage_stats`(前端环形指示器 + 欢迎区活跃度矩阵的数据源) +- **摘要生成**:`summarize_chat_text`(默认 LLM)对合并后的文本生成摘要;统计项:`msg_from`/`msg_to`(消息区间)、`units`/`merged_units`(原始/合并单元数)、`turns`(按 `YYYYMMDD_HHMMSS` 轮次去重)、`tokens_before`/`tokens_after`(CJK≈1 字符/token、ASCII≈4 字符/token 的估算口径) +- **流式反馈**:压缩以「summarizer」虚拟工具记录经 live SSE 推送(压缩中附统计 / 完成附统计 / 失败),前端工具面板可见 +- **异常路径**:压缩 LLM 超时 → 本轮中止并由 `execute_agent` 回复「压缩节点超时」(消息不删、完整保留落盘);其他异常 → 跳过压缩直接继续本轮 ### 3. 循环检测(反思警告) @@ -474,17 +500,19 @@ cloudflared tunnel --url http://localhost:8765 ### 4. 阻塞式人机交互(交互类 / 展示类工具的统一模式) -人机交互不再"暂停图 + 存快照 + 续跑",而是**阻塞式普通工具**:工具在图上线程中等待前端浮窗应答,答案(同意 / 拒绝 / 跳过 / 补充信息)只是普通工具返回值,图自然继续——**拒绝不会结束整轮**,Agent 自行调整方案。 +人机交互不再"暂停图 + 存快照 + 续跑",而是**阻塞式普通工具**:工具在图上线程中等待前端应答,答案(同意 / 拒绝 / 跳过 / 补充信息)只是普通工具返回值,图自然继续——**拒绝不会结束整轮**,Agent 自行调整方案。 ``` LLM 发出 human_interaction / 需确认工具调用 → tools 节点执行工具 → human_request.ask_human() 阻塞等待(注册到请求表,随 live snapshot 推送前端) - → 前端浮窗出现,工具调用行显示"运行中" - → 用户点击 → POST /api/human-action → 写入答案并唤醒 + → 前端"通知条"出现(输入区上方,标题 + 数量角标),工具调用行显示"运行中" + → 用户点击展开 → 选项胶囊 / 补充输入框 → 提交(POST /api/human-action)→ 写入答案并唤醒 → 工具返回答案文本 → ToolMessage → 图继续(无快照重放、无续跑状态机) ``` +**通知条(notify-strip)**:todo 与人机交互不再使用浮窗,改为输入区上方的水平通知条——收起态只显示标题/最新步骤 + 数量角标,点击上下展开完整卡片;通知条左侧按钮可收起为输入区按钮,再次点击恢复;todo 与人机交互的挂起状态**会话级存储**(chat 与 cron 各自独立,切换会话自动保存/恢复)。 + | 设计点 | 传统做法(本项目旧版) | 阻塞式(当前) | |--------|----------------------|----------------| | 图执行 | 请求线程内同步 invoke | 后台线程(`core/turn_runner.py`),断连/关页不影响执行,双发请求挂同一轮 | @@ -498,7 +526,7 @@ LLM 发出 human_interaction / 需确认工具调用 **两类工具的扩展模式**(新增能力零框架改动): - **交互类工具**(如 `human_interaction`):工具内调一次 `ask_human(meta)` 阻塞,自己格式化返回值 -- **展示类工具**(如 `todo_list`):纯文本返回、不维护全局 store,前端从通用 live 记录(工具调用的 args/结果)按工具名写一个小 adapter 渲染浮窗(如 `js/todo.js` 的 `updateTodoOverlayFromRecords`) +- **展示类工具**(如 `todo_list`):纯文本返回、不维护全局 store,前端从通用 live 记录(工具调用的 args/结果)按工具名写一个小 adapter 渲染通知条(如 `js/notify-strip.js`) ### 5. GUI 动作序列化(性能关键) @@ -509,6 +537,18 @@ LLM 发出 human_interaction / 需确认工具调用 通过系统提示词前缀中的固定反思引导(`REFLECTION_PROMPT`)+ `MAIN_SYSTEM_PROMPT` 双重强调"同页操作必须合并为一次 `gui` 调用"实现。 +### 5.1 UI 自动化双轨(Playwright + GUI,兼顾容错与效率) + +UI 操作按目标介质分两条轨道,由系统提示词引导 Agent 按"能 DOM 不截图"的原则选择: + +| 轨道 | 工具 | 机制 | 适用场景 | 特点 | +|------|------|------|----------|------| +| **浏览器轨** | `browser_control` + `web_page` | Playwright 会话管理器(CDP 分离进程,Agent 退出后浏览器保留、重启自动重连)+ DOM 级操作 | 浏览器内的网页操作 | **毫秒级、定位可靠**(snapshot 元素清单 → ref/selector 精准操作,不依赖视觉模型) | +| **桌面轨** | `gui` | 截图 + 视觉批量定位(`_batch_ground_elements`)+ 键鼠模拟 | 桌面应用 / 任意界面(含浏览器无 DOM 场景) | **通用容错**:截图视觉闭环,多模态 LLM 看到真实屏幕状态,可处理任何界面 | + +- 典型流程:`browser_control` 打开 URL → `web_page` snapshot 获取元素清单 → 按 ref/selector 批量 click/fill/scroll → 页面跳转后重新 snapshot;浏览器不适用(如系统对话框、桌面软件)时回退 `gui` 视觉定位 +- 两轨均遵循"连续操作合并为一次调用"(actions 列表批量传入)的性能原则 + ### 6. 双模式源码共用 `src/web/` 同一套代码两种部署: @@ -522,35 +562,96 @@ LLM 发出 human_interaction / 需确认工具调用 前端通过 `window.electronAPI?.isElectron` 自动适配。 -### 7. 存储双后端(JSON 可视化 / MySQL,统一接口隔离) +### 7. 存储多后端(JSON / MySQL / SQLite,统一接口隔离) -会话历史与定时任务存储统一在 **`agent/core/storage.py`**:接口(Protocol)驱动、双后端实现、**消费方零感知后端选择**(不判断 `STORAGE_BACKEND`)。 +会话历史与定时任务存储统一在 **`agent/core/storage.py`**:接口(Protocol)驱动、多后端实现、**消费方零感知后端选择**(不判断 `STORAGE_BACKEND`)。 **接口与实现** - `ConversationRecordRepository`(对话消息 / 工具调用 / 轮次元数据 / 会话生命周期,16 方法)与 `TaskConfigRepository`(cron 任务配置,6 方法)两个 Protocol -- 每后端一个实现类:`JsonRecordRepository` / `MysqlRecordRepository`(含会话与 cron 双作用域)、`JsonTaskConfigRepository` / `MysqlTaskConfigRepository` +- 每后端一个实现类:`JsonRecordRepository` / `MysqlRecordRepository` / `SqliteRecordRepository`(含会话与 cron 双作用域)、`JsonTaskConfigRepository` / `MysqlTaskConfigRepository` / `SqliteTaskConfigRepository` - **新增存储后端只需实现上述接口并在工厂注册一行**;消费方新增功能/字段无需改动存储层(消息原样 JSON 存储、工具记录 meta 全量透传) **作用域(scope)** -- `session`(默认):主进程普通会话,JSON 落盘 `history/sessions/{session_id}/`,MySQL 用 `sessions / session_messages / session_tool_calls` 三表 -- `cron`:定时任务,JSON 落盘 `history/cron/{task_id}/`,MySQL 用 `cron_messages / cron_tool_calls` 两表;cron 子进程切换存储作用域后**直写**记录,主进程经 cron 作用域仓库读取 +- `session`(默认):主进程普通会话,JSON 落盘 `history/sessions/{session_id}/`,MySQL/SQLite 用 `sessions / session_messages / session_tool_calls` 三表 +- `cron`:定时任务,JSON 落盘 `history/cron/{task_id}/`,MySQL/SQLite 用 `cron_messages / cron_tool_calls` 两表;cron 子进程切换存储作用域后**直写**记录,主进程经 cron 作用域仓库读取 | 后端 | 存储位置 | 说明 | |------|----------|------| -| **json**(默认) | `history/sessions/{session_id}/` | 消息与工具调用以人类可读 JSON 文件落盘(`turn_{turn_id}.json` / `tool_{turn_id}.json` / `session_meta.json` / `_session_tokens.json`),可直接打开查看/修改,便于审计与调试 | -| **mysql** | session 三表 / cron 两表 | 消息与工具调用记录原样 JSON 入库、读取只走数据库;二进制产物(图片/附件等)两个后端均落盘 | +| **json**(默认) | `history/sessions/{session_id}/` | 消息与工具调用以人类可读 JSON 文件落盘(`turn_{turn_id}.json` / `tool_{turn_id}.json` / `session_meta.json`),可直接打开查看/修改,便于审计与调试 | +| **mysql** | session 三表 / cron 两表 | 消息与工具调用记录原样 JSON 入库、读取只走数据库;二进制产物(图片/附件等)所有后端均落盘 | +| **sqlite** | `history/sqlite/storage.sqlite`(单文件,session+cron 共用) | 内置 `sqlite3` 零依赖,JSON 存 TEXT 列;`cron_tool_calls` 含 meta 列,尾部思考可完整落库;WAL 模式支持多进程并发 | -- 由 `STORAGE_BACKEND` + `MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE` 配置,`agent/core/db.py` 提供统一连接与幂等建表 +- 由 `STORAGE_BACKEND`(json / mysql / sqlite)+ 存储数据库配置驱动;**存储数据库与工具数据库完全隔离**: + - **存储数据库配置**(`STORAGE_DB_TYPE` + `STORAGE_DB_HOST/PORT/USER/PASSWORD/DATABASE/PATH`):会话/定时任务存储用,`STORAGE_DB_*` 缺省回退旧 `MYSQL_*`(零迁移) + - **工具数据库配置**(`TOOL_DB_TYPE` + `TOOL_DB_HOST/PORT/USER/PASSWORD/DATABASE/PATH`):数据库操作工具(text2sql / excel2sql)用,**不回退存储配置**;SQLite 目录模式下每个库名 = 一个 `.sqlite` 文件 + - 两者均在设置 → 存储与数据库页配置,类型可选择 MySQL / SQLite;`agent/core/db.py` 提供统一连接(`connect_db`)与幂等建库建表(`ensure_database` / `ensure_sqlite_database` —— 库不存在时先建库再按表结构建表) - **不双写**:选哪种后端,读写就只走该后端;写入幂等(同一 `(session_id, turn_id)` 先 DELETE 再 INSERT);DB 异常仅打印日志,不阻断主流程 -- 分支(`/api/sessions/branch`)与回滚等会话功能均基于存储接口实现,两个后端一致可用 +- 分支(`/api/sessions/branch`)与回滚等会话功能均基于存储接口实现,各后端一致可用 -**双后端行为差异(设计内)** +**后端行为差异(设计内)** -| 场景 | json | mysql | -|------|------|-------| +| 场景 | json | mysql / sqlite | +|------|------|----------------| | 轮次中实时工具记录 | 每次工具结果后**增量写盘** `tool_*.json`,进程崩溃后重启可回退恢复最近记录 | 不增量入库,由轮次结束 `save_tool_calls` 统一落库(无额外磁盘 IO,但崩溃时该轮记录丢失) | | 服务重启后运行中轮次 | `get_live_tool_calls` 回退读取增量文件,可继续展示 | 内存记录丢失、数据库无该轮记录,实时面板为空 | -| cron 历史读取 | 直接读 `history/cron/` 下文件 | 子进程直写 cron 表,历史可正常读取 | +| cron 历史读取 | 直接读 `history/cron/` 下文件 | 子进程直写数据库表,历史可正常读取 | + +### 8. 前后端单文件插件化(协议驱动,非注册式) + +**核心思想**:一个插件 = 后端一个文件 + 前端一个文件(或 adapter),通过**协议**接入;框架与插件之间靠**事件 / 回调监听**通信,而不是集中式注册表 + 修改主流程——新增或替换插件只需保持协议签名,**宿主装配零改动**。 + +**后端:`BaseTool` 协议(`src/agent/tools/*.py`)** + +- 每个工具是独立单文件,继承 LangChain `BaseTool`,实现 `name` / `description` / `args_schema` / `_run` 四要素即完成协议 +- `tools/__init__.py` 注册表一行挂载;`_ALL_AVAILABLE_TOOL_CLASSES` 从实例自动推导 +- **前端零改动**:工具配置面板(工具列表、参数表单、权限/自动执行开关、main 默认挂载)全部由 `/api/config/tools` 的 `registered_tool_names` / `tool_parameters` / `forced_permissions` **自动生成**——新增工具自动出现在设置面板与 Agent 工具选择器 +- 展示类工具(如 `todo_list`)只需保持"纯文本返回"协议,前端按工具名写一个小 adapter 即可渲染通知条 + +**前端:`setXxxDeps / setXxxFn` 依赖注入协议(`src/web/js/*.js`)** + +- 每个功能模块独立单文件,只导出 setter(协议接口)与自身功能函数,**模块间零直接引用** +- 宿主 `app.js` 作为装配中心做一次性连线(依赖注入): + +```js +setSendDeps({ renderSessions, renderMessages, streamAssistantMessage, injectLiveToolCalls, ... }); +setLiveUiDeps({ updatePendingOverlay, updateTodoOverlayFromRecords }); // live 快照事件分发 +setRenderPersistentToolCalls(renderPersistentToolCalls); +setRenderSkills(renderSkills); +``` + +- **事件监听式数据流**:`toolcalls.js` 收到 live snapshot(SSE/轮询)后,通过 `setLiveUiDeps` 注入的回调分发给各展示组件——组件是被回调通知(监听事件),而非向全局注册表登记 +- **替换/新增模块零宿主改动**:旧模块(如 `pending-overlay.js`)被新模块(`notify-strip.js`)替换时,只需保持协议导出签名(`setLiveUiDeps` / `updateTodoOverlayFromRecords` 等),宿主连线无需修改 + +### 9. 前端渲染性能与消息 API 增量 + +针对长会话 / 工具密集运行 / 高频文件变更场景的两轮优化,目标:**运行期主线程低占用、点击后 hover 不失效、消息响应按增量传输**。 + +#### 运行期渲染(`src/web/js/` + `tool_call_recorder.py`) + +| 优化点 | 说明 | +|--------|------| +| SSE 推送防抖合并 | 工具每次状态变更即时推帧 → 按会话 120ms 防抖合并为一批增量(`tool_call_recorder.py`,与 0.8s 落盘防抖同模式),前端 rAF + 120ms 注入节流同步对齐 | +| 思考伪流降频 | 30fps → 12fps(`toolcalls.js`),长思考文本不再持续占用主线程 | +| 回复伪流 O(n) | 伪打字从每帧全量重解析整段 markdown(O(n²))改为预拆分块、仅重建最后一个未完成块(`chat-render.js`) | +| 乐观渲染增量 | 发送消息的乐观渲染改走增量 append,不再 `innerHTML=""` 重建全历史 | +| 会话删除原地移除 | 删除会话仅移除对应列表节点(空工作区组连组移除),不再全量重建 | +| 文件树增量着色 | git 状态仅切换既有节点类名(`edit-mode.js`),展开右栏不再全量重建;右栏关闭期间文件变更只标记 dirty、打开时补刷 | +| 其他 | 背景层移除常驻 `will-change`;`.composer-row` `transition: all` 收窄为 `max-width`;侧边栏拖拽改 Pointer Events + `setPointerCapture`(消除状态泄漏) | + +#### 消息 API 增量与附件 URL 化(`server.py` + `chat-render.js` + `send.js`) + +| 改动 | 说明 | +|------|------| +| 附件不再 base64 内联 | `_serialize_content` 输出 `/api/media?session={sid}&file={relpath}` 服务端 URL;cron 附件(目录隔离)回退内联 | +| `/api/media` 安全路由 | 会话 ID 格式校验 + 相对路径 + `commonpath` 根目录守卫(防穿越);拒绝 `turn_/tool_/session_` 元数据文件;媒体类 inline / 其余 attachment;`Cache-Control` + stat 弱 ETag(手动 304) | +| 三条主链路增量 | `POST /api/chat/start` → `new_message` + `base_count` + `total`;`POST /api/chat/complete` 与 SSE `text` 事件 → `new_messages` + `base_count` + `total`(服务端仅序列化增量);bootstrap / abort / rollback / clear / delete / branch / cron 保持全量 | +| 会话 ETag | `_messages_etag`(turn 文件数 + 最大 mtime 轻量指纹)+ `If-None-Match` → 304,未变化时跳过整表序列化 | +| 前端会话缓存 | `state._msgCache`(浅拷贝快照,LRU 20):切换会话先渲染缓存(秒开)再带 ETag 后台校验;`appendNewMessages` 原语负责乐观用户消息按 `turn_id` 就地替换、计数校验(不一致回退全量 GET)与 `_renderedCount` / 簿记同步(含 `streamAssistantMessage` 此前缺失的计数簿记) | +| 传输压缩 | 全局 `GZipMiddleware`(≥1KB 响应);删除接口按 `current_session_id` 条件化(删除非当前会话不返回全量历史) | + +#### 安全加固(顺带修复) + +- `/api/tool-image` 与 `/api/tool-file` 此前为**任意文件读取**(路径无根目录校验)→ 限定 `SESSIONS_ROOT` / `CRON_ROOT` 白名单;注册路径 URL 拼接补 `quote` 转义 + 缓存头。——本地 API 无鉴权且 CORS 全开,**勿将端口 8765 暴露到公网**。 --- @@ -579,14 +680,14 @@ LLM 发出 human_interaction / 需确认工具调用 | 模块 | 职责 | |------|------| | `app.js` | 应用入口,初始化与事件绑定 | -| `api.js` | 封装所有后端 API 请求 | -| `state.js` | 全局状态管理 | -| `send.js` | 消息发送逻辑 | -| `chat-render.js` | 聊天消息渲染 | -| `cron.js` | 定时任务管理面板 | -| `sessions.js` | 会话列表管理 | -| `toolcalls.js` | 工具调用实时面板 | -| `todo.js` | TodoList 面板 | +| `api.js` | 封装所有后端 API 请求(`api` / `apiWithHeaders`(读取 ETag 等响应头)/ `streamApi` SSE) | +| `state.js` | 全局状态管理(含按会话消息缓存 `_msgCache`) | +| `send.js` | 消息发送逻辑(增量契约:start / complete / SSE text 只消费 `new_message` / `new_messages`) | +| `chat-render.js` | 聊天消息渲染(双容器:chat / cron 各自独立,运行时增量渲染、空闲全量重渲染;`appendNewMessages` 增量追加原语) | +| `cron.js` | 定时任务面板(按工作区分组列表、类 chat 会话式历史、模式切换锁、实时运行) | +| `sessions.js` | 会话列表管理(工作区分组,切换会话恢复会话级通知条状态) | +| `toolcalls.js` | 工具调用实时面板(思考 Thought / 反思 Reflection 分离显示,rAF 合并高频快照) | +| `notify-strip.js` | ★ 通知条系统:todo 与人机交互的输入区上方长条(收起/展开、按钮切换、会话级存储) | | `edit-mode.js` | 编辑模式(Monaco) | | `doc-tree.js` / `doc-mod-panel.js` | 文档树与修改面板 | | `action-bar.js` | 底部操作栏 | @@ -602,7 +703,7 @@ LLM 发出 human_interaction / 需确认工具调用 | `token-ring.js` | 上下文 Token 用量环形指示器(含压缩阈值刻度与三类占比) | | `access-mode.js` | 工作空间访问模式切换(限制访问 / 权限审查 / 完全访问) | | `think-level.js` | 思考模式档位选择(low / high / xhigh / max / ultra) | -| `pending.js` / `pending-overlay.js` | 人工请求浮窗与决策提交(数据源为 live snapshot 的 `human_requests`) | +| `pending.js` | 人工请求决策提交(数据源为 live snapshot 的 `human_requests`,配合 notify-strip 渲染) | | `rollback.js` | 消息回滚 | | `workspace.js` | 工作空间切换 | | `dialog.js` | 对话框 | @@ -610,14 +711,14 @@ LLM 发出 human_interaction / 需确认工具调用 ### CSS 架构(`src/web/css/`) -`variables.css` 定义主题变量,其余按模块拆分:`base / layout / chat / composer / cron / dialog / settings / skills / todo / toolcalls / edit-mode / canvas-editor / audio / animations / pending / pending-overlay / toast / responsive`。 +`variables.css` 定义主题变量,其余按模块拆分:`base / layout / chat / composer / cron / dialog / settings / skills / toolcalls / notify-strip / edit-mode / canvas-editor / audio / animations / toast / responsive`。 ### 添加自定义工具 -1. 在 `src/agent/tools/` 新建 `my_tool.py`,继承 LangChain `BaseTool`,实现 `_run`,填写 `name` / `description` / `args_schema` -2. 在 `tool_config.json` 注册并配置启用状态与参数(权限设为 `confirm` 时,工具执行前会自动弹出确认浮窗,同意后执行、拒绝/跳过作为返回值继续) -3. 交互类工具(需要向用户提问)在 `_run` 中调用 `core/human_request.ask_human(meta)` 阻塞等待答案;展示类工具(如进度面板)返回纯文本即可,前端从 live 工具记录按工具名写 adapter 渲染浮窗 -4. 重启后端,工具自动绑定到 LLM +1. 在 `src/agent/tools/` 新建 `my_tool.py`,继承 LangChain `BaseTool`,实现 `_run`,填写 `name` / `description` / `args_schema`(协议四要素) +2. 在 `config.json` 的 `tools` 分区注册并配置启用状态与参数(权限设为 `confirm` 时,工具执行前会自动弹出确认通知条,同意后执行、拒绝/跳过作为返回值继续);**前端无需改动**——设置面板工具列表 / 参数表单 / Agent 工具选择器由 API 自动生成 +3. 交互类工具(需要向用户提问)在 `_run` 中调用 `core/human_request.ask_human(meta)` 阻塞等待答案;展示类工具(如进度面板)返回纯文本即可,前端从 live 工具记录按工具名写 adapter 渲染通知条 +4. 重启后端,工具自动绑定到 LLM;若希望 main Agent 默认挂载,加入 `config_manager.MAIN_DEFAULT_TOOLS` ### 添加自定义技能 @@ -641,7 +742,7 @@ Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force - **后端日志**:桌面端 `npm start` 会在终端打印 `[FastAPI]` 日志 - **端口占用**:`main.js` 的 `killPortProcess(8765)` 启动前自动清理 -- **重置配置**:删除 `src/agent/config/env_config.json` 重触首次向导 +- **重置配置**:删除 `src/agent/agents/config.json` 重触首次向导(或复制 `config_dist.json` 为新配置) - **工具调用轨迹**:`history/sessions/{session_id}/tool_{turn_id}.json` --- @@ -654,10 +755,13 @@ Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force | 🐍 **Python 版本** | 建议 Python 3.11 / 3.12 / 3.13;首次向导会自动检测 | | 🔐 **未签名安装包** | `.exe` 可能被 Windows Defender 拦截,点「更多信息 → 仍要运行」 | | 🌐 **首次启动联网** | 加载 Google Fonts 需联网;离线环境字体选择器有 HTML 硬编码兜底 | -| 🔑 **API Key 安全** | `env_config.json` 含明文凭证,**勿提交到公开仓库**(已在 `.gitignore` 排除 `env_config.json`,仅保留 `env_config_dist.json` 模板) | +| 🔑 **API Key 安全** | `src/agent/agents/config.json` 含明文凭证,**勿提交到公开仓库**(已在 `.gitignore` 排除,仅保留 `config_dist.json` 模板) | | 🎨 **图标** | 当前为 SVG,NSIS 打包后若显示默认图标,需准备 `.ico` 文件替换 `src/web/image/icon.ico` | -| 💾 **历史存储** | 默认会话历史与工具轨迹以 JSON 落盘在 `history/`(可切换 MySQL 后端),长期使用注意清理 | +| 💾 **历史存储** | 默认会话历史与工具轨迹以 JSON 落盘在 `history/`(可切换 MySQL / SQLite 后端),长期使用注意清理 | | ⚡ **GUI 性能** | 同页多操作务必合并为一次 `gui` 调用,否则延迟显著(见「实现细节」) | +| 🌐 **浏览器 Profile** | `browser_control` 默认使用一次性临时 Profile(登录态不持久);需要持久登录态时在设置 → 运行时参数配置 `BROWSER_PROFILE_DIR` | +| 🔌 **附件传输** | 会话附件经 `/api/media` URL 加载(不再内联 base64);媒体路由带根目录守卫与 1 天缓存,旧历史消息中的 base64 附件仍正常展示 | +| 🛡️ **媒体接口安全** | `/api/tool-image`、`/api/tool-file` 已限定白名单目录(`history/sessions`、`history/cron`),白名单外路径返回 404;本地 API 无鉴权,**不要对外暴露 8765 端口** | --- diff --git a/electron/main.js b/electron/main.js index 7af2cd0..fc19da5 100644 --- a/electron/main.js +++ b/electron/main.js @@ -60,6 +60,25 @@ let mainWindow = null; let pythonProcess = null; let isQuitting = false; let isLaunching = false; // 防止 setup 窗口关闭时触发 app.quit +let _closeConfirmed = false; // 后台任务确认后放行关闭 + +/** 查询后端运行中的后台终端任务数(退出确认用;后端不可用时视为 0) */ +function checkRunningTasks() { + return new Promise((resolve) => { + const req = http.get(`${BASE_URL}/api/terminal/running-count`, { timeout: 2000 }, (res) => { + let body = ''; + res.on('data', (d) => (body += d)); + res.on('end', () => { + try { + const n = Number(JSON.parse(body).running) || 0; + resolve(n); + } catch { resolve(0); } + }); + }); + req.on('error', () => resolve(0)); + req.on('timeout', () => { req.destroy(); resolve(0); }); + }); +} // ==================== 端口清理 ==================== function killPortProcess(port) { @@ -343,6 +362,28 @@ async function createMainWindow(themeMode) { mainWindow.on('closed', () => { mainWindow = null; }); + // 关闭拦截:若仍有后台终端任务在运行,先弹确认("终止并关闭 / 取消") + mainWindow.on('close', (e) => { + if (isQuitting || _closeConfirmed) return; + e.preventDefault(); + checkRunningTasks().then(async (n) => { + if (n > 0) { + const { response } = await dialog.showMessageBox(mainWindow, { + type: 'warning', + buttons: ['终止并关闭', '取消'], + defaultId: 1, + cancelId: 1, + title: '后台终端仍在运行', + message: `仍有 ${n} 个后台终端任务在运行`, + detail: '终止这些任务并关闭应用?(若不终止,进程将与应用一并被清理)', + }); + if (response !== 0) return; + } + _closeConfirmed = true; + mainWindow.close(); + }); + }); + mainWindow.webContents.setWindowOpenHandler(({ url }) => { require('electron').shell.openExternal(url).catch(() => {}); return { action: 'deny' }; @@ -971,11 +1012,29 @@ app.on('before-quit', () => { stopFastAPI(); }); -// ==================== 内置 PowerShell 终端 ==================== -// 每个标签对应一个 powershell 子进程;面板折叠不 kill,仅标签 x / 右上角 x / 退出时清理。 +// ==================== 内置 PowerShell 终端(ConPTY 真终端) ==================== +// 每个标签对应一个经 node-pty (Windows ConPTY) 启动的真实 PowerShell 会话, +// PSReadLine / Tab 补全 / 历史 / 全屏应用由真实控制台提供,前端 xterm.js 渲染。 +// 面板折叠不 kill(仅隐藏);标签 x 单个 kill;右上角 x 确认后 kill 全部。 const _terminals = new Map(); // id -> { proc, sender } let _terminalSeq = 0; +// 优先 PowerShell 7(truecolor / PSReadLine 更强),回退 Windows PowerShell 5.1 +let _shellPathCache = ''; +function resolveShellPath() { + if (_shellPathCache) return _shellPathCache; + try { + execSync('where pwsh.exe', { stdio: 'ignore', shell: true }); + _shellPathCache = 'pwsh.exe'; + } catch (e) { + _shellPathCache = path.join( + process.env.SystemRoot || 'C:\\Windows', + 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe' + ); + } + return _shellPathCache; +} + function forceKill(proc) { try { proc.kill(); } catch (e) { /* ignore */ } // Windows 兜底:taskkill 结束整个进程树(处理 Ctrl 信号不响应/子进程残留) @@ -991,30 +1050,28 @@ function killAllTerminals() { _terminals.clear(); } -ipcMain.handle('terminal:spawn', (event, { cwd } = {}) => { +ipcMain.handle('terminal:spawn', (event, { cwd, cols, rows } = {}) => { const id = ++_terminalSeq; let proc; try { - proc = spawn('powershell.exe', ['-NoLogo'], { + // ConPTY 需真实控制台;懒加载,模块缺失时不影响应用其余功能 + const pty = require('@homebridge/node-pty-prebuilt-multiarch'); + proc = pty.spawn(resolveShellPath(), ['-NoLogo'], { cwd: (cwd && fs.existsSync(cwd)) ? cwd : undefined, - windowsHide: true, + cols: (cols | 0) || 80, + rows: (rows | 0) || 24, + env: { ...process.env, TERM: 'xterm-256color' }, }); } catch (e) { - return { id, ok: false, error: String(e && e.message || e) }; + return { id, ok: false, error: String((e && e.message) || e) }; } _terminals.set(id, { proc, sender: event.sender }); - proc.stdout.on('data', (chunk) => { - if (!event.sender.isDestroyed()) event.sender.send('terminal:data', { id, chunk: chunk.toString('utf8') }); + proc.onData((chunk) => { + if (!event.sender.isDestroyed()) event.sender.send('terminal:data', { id, chunk }); }); - proc.stderr.on('data', (chunk) => { - if (!event.sender.isDestroyed()) event.sender.send('terminal:data', { id, chunk: chunk.toString('utf8') }); - }); - proc.on('exit', (code) => { + proc.onExit(({ exitCode }) => { _terminals.delete(id); - if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { id, code }); - }); - proc.on('error', (err) => { - if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { id, code: -1, error: String(err.message || err) }); + if (!event.sender.isDestroyed()) event.sender.send('terminal:exit', { id, code: exitCode }); }); return { id, ok: true }; }); @@ -1022,7 +1079,13 @@ ipcMain.handle('terminal:spawn', (event, { cwd } = {}) => { ipcMain.on('terminal:input', (_event, { id, data } = {}) => { const t = _terminals.get(id); if (!t || !data) return; - try { t.proc.stdin.write(data); } catch (e) { /* ignore */ } + try { t.proc.write(data); } catch (e) { /* ignore */ } +}); + +ipcMain.on('terminal:resize', (_event, { id, cols, rows } = {}) => { + const t = _terminals.get(id); + if (!t) return; + try { t.proc.resize((cols | 0) || 80, (rows | 0) || 24); } catch (e) { /* ignore */ } }); ipcMain.on('terminal:kill', (_event, { id } = {}) => { diff --git a/electron/preload.js b/electron/preload.js index cc7ab09..4042291 100644 --- a/electron/preload.js +++ b/electron/preload.js @@ -29,8 +29,9 @@ contextBridge.exposeInMainWorld('electronAPI', { openInExplorer: (filePath) => ipcRenderer.invoke('shell:openInExplorer', filePath), // ===== 内置 PowerShell 终端 ===== - spawnTerminal: (cwd) => ipcRenderer.invoke('terminal:spawn', { cwd }), + spawnTerminal: (cwd, cols, rows) => ipcRenderer.invoke('terminal:spawn', { cwd, cols, rows }), sendTerminalInput: (id, data) => ipcRenderer.send('terminal:input', { id, data }), + resizeTerminal: (id, cols, rows) => ipcRenderer.send('terminal:resize', { id, cols, rows }), killTerminal: (id) => ipcRenderer.send('terminal:kill', { id }), killAllTerminals: () => ipcRenderer.send('terminal:killAll'), onTerminalData: (cb) => { diff --git a/package.json b/package.json index 5d1db67..041dffb 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ } }, "dependencies": { + "@homebridge/node-pty-prebuilt-multiarch": "^0.14.1", "bluebird": "^3.7.2" } } diff --git a/src/agent/agents/agent_runtime.py b/src/agent/agents/agent_runtime.py index 18c1755..03dbf54 100644 --- a/src/agent/agents/agent_runtime.py +++ b/src/agent/agents/agent_runtime.py @@ -13,7 +13,7 @@ from __future__ import annotations from typing import Any, Literal -from agent.core.config_manager import load_agent_configs, load_models, AgentConfig +from agent.core.config_manager import load_agent_configs, load_models, AgentConfig, MAIN_DEFAULT_TOOLS from agent.core.graph import build_agent_graph from agent.core.runtime import execute_agent as execute_runtime_agent from agent.core.runtime import AgentRuntime @@ -79,10 +79,12 @@ def _build_agent_runtime(cfg: AgentConfig) -> AgentRuntime: else: agent_tools = all_tools - # 2.1 强制为主 Agent 加载 doc_tool 与 todo_list(不可通过配置移除) - # doc_tool: 文档读写必需;todo_list: Plan 模式第一步规划必需 + # 2.1 强制为主 Agent 加载默认挂载工具(MAIN_DEFAULT_TOOLS,不可通过配置移除): + # call_subagent(子 Agent 委派)/ doc_tool(文档读写)/ + # human_interaction(人机交互审查)/ todo_list(任务规划)/ + # terminal_execute(终端命令) if cfg.role == "main": - for force_name in ("doc_tool", "todo_list"): + for force_name in MAIN_DEFAULT_TOOLS: if not any(t.name == force_name for t in agent_tools): for t in all_tools: if t.name == force_name: diff --git a/src/agent/agents/config_dist.json b/src/agent/agents/config_dist.json index 10b51b7..71af9fb 100644 --- a/src/agent/agents/config_dist.json +++ b/src/agent/agents/config_dist.json @@ -1,6 +1,92 @@ { - "agents": [], - "tools": [], + "agents": [ + { + "name": "main_agent", + "description": "", + "max_iterations": 200, + "llm_model_id": "affiaa7r", + "tools": [ + "gui_tool", + "browser_control", + "web_page" + ], + "enabled": true, + "role": "main", + "trajectory_rounds": 3 + } + ], + "tools": [ + { + "name": "browser_control", + "display_name": "BrowserControl", + "description": "控制浏览器(Playwright 独立进程,跨会话存活、自动重连):打开 URL、刷新、关闭浏览器/标签页、列出与切换标签页。页面元素级操作请用 web_page 工具。", + "permission": "confirm", + "auto_execute_rules": [ + { + "parameter": "action", + "operator": "equals", + "value": "open" + }, + { + "parameter": "action", + "operator": "equals", + "value": "list_tabs" + }, + { + "parameter": "action", + "operator": "equals", + "value": "switch_tab" + } + ], + "enabled": true, + "timeout": 300 + }, + { + "name": "web_page", + "display_name": "WebPage", + "description": "对浏览器当前页面执行 DOM 级精准批量操作(Playwright,毫秒级,优于视觉定位):snapshot 获取可交互元素清单,按 ref 序号或选择器 click/fill/hover/select/scroll/press/extract/eval_js 等。典型流程:browser_control 打开 URL → web_page snapshot → 批量操作。", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true, + "timeout": 300 + }, + { + "name": "human_interaction", + "display_name": "HumanInteraction", + "description": "当任务缺少关键信息、需要人类选择路径、或需要人类审批后才能继续时调用。三种类型分别是:information=请求人类补充缺失信息;selection=让人类在多个选项中选择且可编辑;approval=请求人类审批高影响决策或继续执行。工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。", + "permission": "confirm", + "auto_execute_rules": [], + "enabled": true, + "timeout": 300 + }, + { + "name": "todo_list", + "display_name": "TodoList", + "description": "任务管理工具。用于将复杂任务拆分为多个步骤并跟踪执行进度。", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true, + "timeout": 300 + }, + { + "name": "gui_tool", + "display_name": "GUITool", + "description": "GUI 自动化工具。通过截图视觉定位桌面元素,将坐标映射到真实屏幕,并执行 pyautogui 动作。接收一个有序的动作列表,按顺序依次执行。每个动作项支持的操作:click(点击)、type(输入文本,可通过 element_description 指定目标输入框自动聚焦)、drag_and_drop(拖拽)、scroll(滚动)、hotkey(快捷键)、hold_and_press(按住组合键", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true, + "timeout": 300 + }, + { + "name": "doc_tool", + "display_name": "DocTool", + "description": "文档操作工具。支持纯文本(plain_text)、表格(tabular: CSV/XLSX)、Word(docx)、PowerPoint(pptx)、PDF 文档的增删改查,以及图片(image)、音频(audio)的读取。", + "permission": "direct", + "auto_execute_rules": [], + "enabled": true, + "timeout": 300 + } + ], "env": { "USER_NICKNAME": "", "USER_PYTHON_PATH": "", @@ -24,7 +110,8 @@ "MYSQL_DATABASE": "agent_history", "EMAIL_ADDRESS": "", "EMAIL_AUTH_CODE": "", - "models": [] + "models": [], + "BROWSER_PROFILE_DIR": "" }, "gui": { "gui_monitor_name": "", diff --git a/src/agent/core/config_manager.py b/src/agent/core/config_manager.py index 1e9a34c..f7d9499 100644 --- a/src/agent/core/config_manager.py +++ b/src/agent/core/config_manager.py @@ -71,6 +71,19 @@ class ToolConfig: timeout: float = 300.0 # 工具执行超时(秒),超时后返回"工具执行超时" +# main Agent 默认挂载工具(构建运行时强制注入,不可通过配置移除; +# 前端对 role="main" 的 Agent 显示为锁定项——不能删除、无需手动添加) +MAIN_DEFAULT_TOOLS: tuple[str, ...] = ( + "call_subagent", # agent_call.py:子 Agent 委派路由 + "doc_tool", # doc.py:文档读写 + "human_interaction", # human_interaction.py:人机交互/审查 + "todo_list", # todo_list.py:任务规划/进度标记 + "terminal_execute", # terminal_execute.py:终端命令执行(含挂起 background) + "terminal_info", # terminal_info.py:查看后台终端任务状态/最新输出 + "wait", # wait.py:等待后台任务结束(普通通用工具) +) + + # ---------- 通用 JSON 读写 ---------- def _read_json(path: Path) -> dict | list | None: """读取 JSON 文件,不存在或格式错误返回 None。""" @@ -442,6 +455,8 @@ def _default_env_config() -> dict: "GROUNDING_HEIGHT": "1000", "LOOP_DETECT_REPEATED_TOOL_WARN": "3", "SEND_FILE_SIZE_LIMIT": "30", + # 浏览器控制工具持久 Profile 目录(空 = 每次启动一次性临时 Profile) + "BROWSER_PROFILE_DIR": "", "STORAGE_BACKEND": "json", "MYSQL_HOST": "localhost", "MYSQL_PORT": "3306", diff --git a/src/agent/core/llm.py b/src/agent/core/llm.py index cc566d9..d90a40f 100644 --- a/src/agent/core/llm.py +++ b/src/agent/core/llm.py @@ -29,6 +29,67 @@ from agent.utils.image_utils import image_path_to_openai_image_url_part, image_b from agent.utils.tool_call_utils import split_inline_thinking from agent.memory.system_prompt import VISION_SYSTEM_PROMPT + +# ---------- LLM 错误域:超时判定 / 重试耗尽异常 / 用户可读文案 ---------- +# 系统定位: +# 节点级重试(core.nodes._invoke_llm_with_retry)与轮次级错误分类 +# (core.runtime._classify_error)均复用本处的 LLM 域知识, +# 保证"重试日志、异常类型、用户文案"三者在同一处维护、无重复定义。 +LLM_TIMEOUT_RETRY_MSG_TEMPLATE = ( + "LLM 服务调用超时,已重试 {n} 次仍失败,本轮执行已中止。" + "本轮已执行的工具调用与消息已保留,请检查模型服务是否正常运行后重试。" +) +LLM_COMPRESS_TIMEOUT_MSG = ( + "上下文压缩节点调用超时,本轮执行已中止。" + "本轮消息列表与工具调用已完整保留,请检查模型服务后重试。" +) +LLM_TIMEOUT_GENERIC_MSG = "LLM 服务调用超时,请检查模型服务是否正常运行后重试。" + + +def is_timeout_error(e: BaseException) -> bool: + """判断异常是否为 LLM 服务超时/连接类错误(重试候选)。 + + 识别范围: + - httpx.TimeoutException 及 openai.APITimeoutError(底层网络/UDP 超时) + - 异常消息含 "timed out" / "timeout" / "read timeout" / + "connect" / "connection error" 等网络症状(兼容非标准封装) + + 输出: + True 表示应触发重试。 + """ + try: + import httpx + if isinstance(e, httpx.TimeoutException): + return True + except Exception: + pass + try: + import openai + if isinstance(e, openai.APITimeoutError): + return True + except Exception: + pass + msg = str(e).lower() + return any(k in msg for k in ("timed out", "timeout", "read timeout", "connect error", "connection error", "connection aborted", "remote end closed connection")) + + +class LLMRetryExhausted(Exception): + """节点级重试耗尽后抛出的终端异常。 + + 属性: + retries: 已执行的重试次数(不含首次尝试)。 + original: 最后一次失败的原始异常(作为 cause 保留)。 + + __str__ 返回用户可读文案(与 runtime._classify_error 输出一致), + 因此经 call_subagent 等工具边界捕获后展示给模型/用户的也是友好文案。 + """ + + def __init__(self, retries: int = 0, original: BaseException | None = None) -> None: + super().__init__(LLM_TIMEOUT_RETRY_MSG_TEMPLATE.format(n=retries)) + self.retries = retries + self.original = original + + class Multimodel_LLM(ChatOpenAI): """基于 OpenAI 兼容 API 的多模态模型封装,扩展深度思考归一化能力。 @@ -242,6 +303,26 @@ class Multimodel_LLM(ChatOpenAI): except Exception: pass + def _get_request_payload(self, input_, *, stop=None, **kwargs) -> dict: + """在 LangChain 消息转换后补回 ``reasoning_content``。 + + langchain-openai 的 ``_convert_message_to_dict`` 只透传 content/tool_calls + 等固定字段,``additional_kwargs["reasoning_content"]``(DeepSeek/Qwen 深度 + 思考约定)会被丢弃;这里按索引回写为 provider 消息的 ``reasoning_content`` + 字段,供历史上下文中的 AIMessage 携带深度思考。 + """ + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + src = input_ if isinstance(input_, list) else (getattr(input_, "messages", None) or []) + messages = payload.get("messages") or [] + if len(messages) != len(src): + return payload + for pdict, msg in zip(messages, src): + if isinstance(msg, AIMessage) and pdict.get("role") == "assistant": + rc = (msg.additional_kwargs or {}).get("reasoning_content") + if isinstance(rc, str) and rc.strip() and not pdict.get("reasoning_content"): + pdict["reasoning_content"] = rc + return payload + def _create_chat_result(self, response, generation_info=None) -> ChatResult: """解析 API 响应:深度思考归一化、XML/JSON tool_call 提取。 diff --git a/src/agent/core/nodes.py b/src/agent/core/nodes.py index 3b35ffe..649ea8b 100644 --- a/src/agent/core/nodes.py +++ b/src/agent/core/nodes.py @@ -15,6 +15,7 @@ """ from __future__ import annotations +import json from typing import Any, Callable from langchain_core.messages import AIMessage, HumanMessage, ToolMessage @@ -22,6 +23,7 @@ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from agent.core.loop_detector import build_loop_reflection_prompt from agent.core.state import AgentState from agent.memory.system_prompt import SUB_AGENT_OOM_PROMPT +from agent.core.llm import LLMRetryExhausted, is_timeout_error from agent.utils.image_utils import image_bytes_to_openai_image_url_part from agent.utils.tool_call_utils import normalize_tool_call, extract_thought, extract_reflection from langchain_core.messages import SystemMessage @@ -106,7 +108,7 @@ def _filter_short_term_memory(messages: list, trajectory_rounds: int) -> list: # 剥离历史模型思考文本:上一/几轮的思考不进入本轮上下文。 # 带 tool_calls 的 AIMessage,其 content 仅是思维链,保留 tool_calls 即可 # (思考/反思已由 extract_thought/extract_reflection 单独记录到 think 面板, - # 并由 build_tool_history_messages 以 [历史思考]/[历史反思] 形式注入)。 + # 并由 build_native_history_messages 以原生 AIMessage 形式注入)。 cleaned: list = [] for m in result: if isinstance(m, AIMessage) and getattr(m, "tool_calls", None) and getattr(m, "content", None): @@ -248,19 +250,97 @@ def _estimate_token_breakdown(messages: list[Any], usage: dict) -> dict[str, flo } -def _compute_next_cursor(session_id: str) -> int: - """计算下一次压缩游标:当前轮次起点在全局工具调用序列中的索引。 +# ---------- 链式压缩块:单元渲染 / 合并 / token 估算 ---------- +# 重建历史消息 id 前缀(agent_utils.build_native_history_messages), +# 压缩节点按此前缀圈定压缩范围(块 SystemMessage 用 compressed_block_ 前缀,不冲突)。 +HISTORY_MSG_PREFIX = "hist_" +COMPRESSED_BLOCK_ID_PREFIX = "compressed_block_" - 触发压缩时当前 turn 尚未持久化(save_turn 在图执行结束后才调用), - 因此游标 = 当前 turn 之前所有已完成轮次的工具调用总数。 - 游标之前的工具调用历史已包含在压缩摘要中,注入上下文时跳过。 + +def _msg_id(m: Any) -> str: + """取消息稳定 id;无 id 返回空串(当前轮消息无 id,天然被排除在压缩范围外)。""" + mid = getattr(m, "id", None) + return mid if isinstance(mid, str) else "" + + +def _estimate_text_tokens(text: str) -> int: + """字符/token 估算口径:CJK 约 1 字符/token,ASCII 约 4 字符/token。""" + if not text: + return 0 + cjk = sum(0x4E00 <= ord(c) <= 0x9FFF or 0x3000 <= ord(c) <= 0x303F for c in text) + return cjk + (len(text) - cjk + 3) // 4 + + +def _unit_to_text(m: Any) -> str: + """将单个重建单元渲染为压缩输入文本(多模态内容转文本占位,思考/工具调用附注)。""" + lines: list[str] = [] + content = getattr(m, "content", None) + if isinstance(content, str): + if content.strip(): + lines.append(content.strip()) + elif isinstance(content, list): + for part in content: + if not isinstance(part, dict): + continue + if part.get("type") == "text": + t = str(part.get("text") or "").strip() + if t: + lines.append(t) + elif part.get("type") == "image_url": + lines.append(f"[图片: {part.get('name') or '截图'}]") + elif part.get("type") == "file": + lines.append(f"[文件: {part.get('name') or '附件'}]") + if isinstance(m, AIMessage): + rc = (getattr(m, "additional_kwargs", None) or {}).get("reasoning_content") + if isinstance(rc, str) and rc.strip(): + lines.append(f"[思考] {rc.strip()}") + for tc in getattr(m, "tool_calls") or []: + tc = normalize_tool_call(tc) + args = tc.get("args") or {} + arg_text = json.dumps(args, ensure_ascii=False) + if len(arg_text) > 200: + arg_text = arg_text[:200] + "…" + lines.append(f"[工具调用] {tc.get('name', 'unknown')}: {arg_text}") + if isinstance(m, ToolMessage): + name = getattr(m, "name", "") or "" + if name and lines: + lines[0] = f"({name}) {lines[0]}" + elif name: + lines.append(f"({name})") + return "\n".join(x for x in lines if x).strip() + + +def _merge_units(units: list[Any]) -> list[str]: + """合并相邻同角色消息(Human|AI,工具结果不合并)为压缩输入单元。 + + 注意: + langchain-core 1.x 无原生 role,按类型判定;连续多条 HumanMessage 或 + 连续多条 AIMessage 合并为一条大单元(相邻异角色之间保持原顺序)。 """ - try: - from agent.history.tool_call_recorder import list_turns - turns = list_turns(session_id, limit=100000) - return sum(len(t.get("tool_calls") or []) for t in turns) - except Exception: - return -1 + merged: list[str] = [] + prev_parts: list[str] | None = None # 当前合并单元的累积文本 + prev_role: str = "" # "human" | "ai" | "tool" + for m in units: + text = _unit_to_text(m) + if not text: + continue + if isinstance(m, ToolMessage): + role = "tool" + elif isinstance(m, AIMessage): + role = "ai" + else: + role = "human" + # 仅相邻同角色 Human/AI 合并成一条大单元;工具结果不合并 + if prev_parts is not None and role == prev_role and role in ("human", "ai"): + prev_parts.append(text) + continue + if prev_parts is not None: + merged.append("\n".join(prev_parts)) + prev_parts = [text] + prev_role = role + if prev_parts is not None: + merged.append("\n".join(prev_parts)) + return merged def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None, model_name: str = "") -> tuple[str, int, int]: @@ -307,22 +387,35 @@ def _maybe_trigger_compress(response: Any, messages: list[Any] | None = None, mo return ("compress", total, threshold) -COMPRESSED_SUMMARY_PREFIX = "【历史工具调用摘要】" -COMPRESSED_SUMMARY_MSG_ID = "compressed_summary" - - def maybe_compress_context(state: AgentState) -> dict: """上下文压缩节点:位于 process_tool_artifact 之后、agent 之前。 当 agent 节点检测到 token 超阈值(state["_need_compress"]=True)时, - 将上下文中注入的"历史工具调用与返回值"压缩为摘要(系统提示词、对话历史 - 均不压缩),更新压缩游标,并通过 RemoveMessage 移除已被摘要覆盖的历史工具 - 调用消息,替换为累积摘要 SystemMessage。 + 将「上次压缩游标之后 → 当前轮用户消息之前」的原生历史(思考/反思/对话/ + 工具调用与返回值,id 以 hist_ 开头)压缩为摘要块,**追加**为链式压缩块 + (新块不覆盖旧块),推进 ``cursor_msg``,并通过 RemoveMessage 移除已被 + 摘要覆盖的原生历史消息。压缩块属于固定前缀的一部分,压缩时不重压、 + 压缩后按原字节进入下一轮上下文 → 前缀缓存命中。 + + 统计口径(每条消息 = 1 个重建单元,同现有组装视图): + msg_from/msg_to —— 全局单元区间(1 起,游标为已压缩单元数); + turns —— 覆盖的轮次数(按 hist_* 消息 id 中的时间戳轮次去重); + merged_units —— 相邻同角色消息合并后送给摘要模型的单元数; + tokens_before/tokens_after —— 压缩前后文本 token 估算。 输出: - {"messages": [RemoveMessage..., 摘要 SystemMessage], "_need_compress": False} + {"messages": [RemoveMessage..., 新块 SystemMessage], "_need_compress": False} """ if not state.get("_need_compress"): + # 图外注入排水点(压缩未触发时也取走注入消息,追加进本轮消息) + try: + from agent.core.turn_injector import drain as _drain_injected + from agent.history.tool_call_recorder import get_current_session + injected = _drain_injected(get_current_session() or "") + if injected: + return {"messages": injected} + except Exception: + pass return {} # 子 Agent 不做压缩(超限时已在 agent 节点直接报错) @@ -336,67 +429,96 @@ def maybe_compress_context(state: AgentState) -> dict: return {"_need_compress": False} messages = list(state["messages"]) - ctx_msgs = [ - m for m in messages - if isinstance(m, HumanMessage) and (m.additional_kwargs or {}).get("tool_ctx") - ] - if not ctx_msgs: + # 压缩范围:链式块之后、当前轮用户消息之前的原生历史(稳定 id 前缀 hist_)。 + # 当前轮消息(无稳定 id 的 user/AI/Tool)天然排除在外,不被压缩。 + units = [m for m in messages if _msg_id(m).startswith(HISTORY_MSG_PREFIX)] + if not units: return {"_need_compress": False} - compress_text = "\n".join( - str(getattr(m, "content", "") or "").strip() for m in ctx_msgs - ).strip() + merged_units = _merge_units(units) + compress_text = "\n\n".join(merged_units).strip() if not compress_text: return {"_need_compress": False} - before_len = len(messages) + # 统计:覆盖轮次(turn_id 形如 YYYYMMDD_HHMMSS,含一个下划线) + import re as _re + import time as _time + turn_ids: set[str] = set() + for m in units: + mt = _re.search(r"\d{8}_\d{6}", _msg_id(m)) + if mt: + turn_ids.add(mt.group(0)) - # SSE 通知前端:压缩开始 + # 读取当前压缩上下文(游标 + 块链),并计算新块区间 + from agent.utils.agent_utils import load_compressed_context, save_compressed_context, summarize_chat_text + ctx = load_compressed_context(sid) if sid else {"version": 2, "cursor_msg": 0, "blocks": []} + cursor = int(ctx.get("cursor_msg") or 0) + msg_from = cursor + 1 + msg_to = cursor + len(units) + seq = max((int(b.get("seq") or 0) for b in (ctx.get("blocks") or [])), default=0) + 1 + tokens_before = _estimate_text_tokens(compress_text) + + # SSE 通知前端:压缩开始(附统计) try: from agent.history.tool_call_recorder import record_tool_call_live if sid: record_tool_call_live(sid, "summarizer", { "action": "上下文压缩中", - "before_messages": before_len, + "msg_from": msg_from, + "msg_to": msg_to, + "turns": len(turn_ids), + "units": len(units), + "merged_units": sum(1 for x in merged_units if x.strip()), + "tokens_before": tokens_before, }) except Exception: pass try: from agent.core.llm import get_default_llm - from agent.utils.agent_utils import get_session_meta, summarize_chat_text, update_session_meta - summary_text = summarize_chat_text(get_default_llm(), compress_text) - # 累积压缩摘要并更新压缩游标(游标前历史已被摘要覆盖) + tokens_after = _estimate_text_tokens(summary_text) + block = { + "seq": seq, + "content": summary_text, + "msg_from": msg_from, + "msg_to": msg_to, + "units": len(units), + "merged_units": sum(1 for x in merged_units if x.strip()), + "turns": len(turn_ids), + "tokens_before": tokens_before, + "tokens_after": tokens_after, + "compressed_at": int(_time.time()), + } + # 链式存储:新块追加到块链末尾,不覆盖旧块;游标推进到本轮压缩的末尾单元 + ctx["cursor_msg"] = msg_to + (ctx.setdefault("blocks", [])).append(block) if sid: - meta = get_session_meta(sid, "main") - old_content = meta.get("compressed_content") or "" - new_content = (old_content + "\n\n" + summary_text).strip() if old_content else summary_text - cursor = _compute_next_cursor(sid) - update_session_meta(sid, "main", cursor, new_content) - else: - new_content = summary_text + save_compressed_context(sid, ctx) - # 通过 RemoveMessage 移除已被摘要覆盖的历史工具调用消息, - # 并以固定 id 插入最新累积摘要(同 id 消息会被替换而非追加) + # RemoveMessage 移除已被摘要覆盖的原生历史消息;新块以固定 id 追加 + # (LangGraph 仅支持尾部追加,下一轮装配时由 _inject_summary_context + # 将全部块放回系统提示词之后的前缀位置)。 from langgraph.graph.message import RemoveMessage updates: list = [ RemoveMessage(id=m.id) - for m in ctx_msgs + for m in units if getattr(m, "id", None) ] updates.append(SystemMessage( - content=f"{COMPRESSED_SUMMARY_PREFIX}\n{new_content}", - id=COMPRESSED_SUMMARY_MSG_ID, + content=f"【历史对话摘要 #{seq}】\n{summary_text}", + id=f"{COMPRESSED_BLOCK_ID_PREFIX}{seq}", )) - # SSE 通知前端:压缩完成 + # SSE 通知前端:压缩完成(附统计) try: from agent.history.tool_call_recorder import record_tool_result_live if sid: record_tool_result_live(sid, "summarizer", - f"压缩完成: 压缩 {len(ctx_msgs)} 条历史工具调用消息") + f"压缩完成: 第 {msg_from}-{msg_to} 条消息({len(turn_ids)} 轮, " + f"{len(units)} 条合并为 {block['merged_units']} 单元)" + f"{tokens_before} → {tokens_after} token") except Exception: pass return {"messages": updates, "_need_compress": False} @@ -406,9 +528,14 @@ def maybe_compress_context(state: AgentState) -> dict: from agent.history.tool_call_recorder import record_tool_result_live if sid: record_tool_result_live(sid, "summarizer", - f"压缩失败: {str(e)}") + "压缩失败: LLM 调用超时" if is_timeout_error(e) else f"压缩失败: {str(e)}") except Exception: pass + if is_timeout_error(e): + # 压缩节点超时:中止本轮并交由 execute_agent 回复"压缩节点超时"。 + # 消息列表不删任何消息(RemoveMessage 仅成功时执行), + # 完整保留并由 execute_agent 走 save_failed_turn 落盘。 + raise RuntimeError("COMPRESS_TIMEOUT") from e return {"_need_compress": False} @@ -434,29 +561,88 @@ def _inject_turn_thinking(messages: list[Any]) -> list[Any]: reflect_seq = 0 for m in messages: if isinstance(m, AIMessage) and getattr(m, "tool_calls", None): - try: - thought = extract_thought(m) - reflection = extract_reflection(m) - except Exception: - thought = reflection = "" - if thought: - out.append(HumanMessage( - content=f"[思考] {thought}", - additional_kwargs={"synthetic": True}, - id=f"turn_think_{think_seq}", - )) - think_seq += 1 - if reflection: - out.append(HumanMessage( - content=f"[反思] {reflection}", - additional_kwargs={"synthetic": True}, - id=f"turn_reflect_{reflect_seq}", - )) - reflect_seq += 1 + # 原生重建历史(hist_*)已是 AIMessage 格式:思考在 + # additional_kwargs.reasoning_content、反思在 content, + # 由 llm._get_request_payload 以原生字段透传 → 不在此合成文本。 + # 图中当前轮响应同理:reasoning_content 随之透传后 + # [思考] 合成为重复内容,跳过;[反思] 保持原行为兜底。 + if not _msg_id(m).startswith(HISTORY_MSG_PREFIX): + try: + thought = extract_thought(m) + reflection = extract_reflection(m) + except Exception: + thought = reflection = "" + has_reasoning = bool((getattr(m, "additional_kwargs", None) or {}).get("reasoning_content")) + if thought and not has_reasoning: + out.append(HumanMessage( + content=f"[思考] {thought}", + additional_kwargs={"synthetic": True}, + id=f"turn_think_{think_seq}", + )) + think_seq += 1 + if reflection: + out.append(HumanMessage( + content=f"[反思] {reflection}", + additional_kwargs={"synthetic": True}, + id=f"turn_reflect_{reflect_seq}", + )) + reflect_seq += 1 out.append(m) return out +def _invoke_llm_with_retry(model: Any, messages: list[Any], session_id: str = "") -> Any: + """调用已绑定工具的模型,LLM 服务超时时自动重试(默认 3 次,LLM_MAX_RETRIES 配置)。 + + 输入: + model: 已绑定工具的 ChatOpenAI 实例。 + messages: 待发送消息列表。 + session_id: 会话 ID,用于前端实时事件通知与 abort 检查。 + + 输出: + 模型响应;重试耗尽时抛出 LLMRetryExhausted(最终异常为 cause)。 + + 系统定位: + 仅包装 ReAct 循环的 agent 节点调用——工具内部 LLM 调用(describe_image、 + 文档摘要在工具内自行处理)与压缩节点不经过此处:前者失败由工具返回 + 错误给主 Agent 不影响主图,后者在 maybe_compress_context 走独立超时路径。 + 重试期间前端消息等待状态不变(轮次仍在后台线程执行),仅通过 + record_live_event 推送"超时正在重试"提示浮窗。 + """ + import time as _time + from agent.utils.env_utils import LLM_MAX_RETRIES + max_retries = max(0, int(LLM_MAX_RETRIES or 0)) + attempt = 0 + while True: + try: + return model.invoke(messages) + except Exception as e: # noqa: BLE001 — 重试候选异常需全量捕获再按类型判定 + if not is_timeout_error(e): + raise + if attempt >= max_retries: + # 已重试过但最终仍失败:抛带次数的终端异常(保留原始异常作为 cause) + if attempt: + raise LLMRetryExhausted(retries=attempt, original=e) from e + raise + attempt += 1 + if session_id: + try: + from agent.history.tool_call_recorder import record_live_event + record_live_event(session_id, f"LLM 服务调用超时,正在重试(第 {attempt}/{max_retries} 次)") + except Exception: + pass + # 重试等待期间检查用户是否手动暂停 + try: + from agent.core.runtime import check_abort + if check_abort(session_id): + raise RuntimeError("ABORTED_BY_USER") + except RuntimeError: + raise + except Exception: + pass + _time.sleep(attempt) # 递增退避:1s / 2s / 3s + + def make_call_model_node( model_with_tools: Any, trajectory_rounds: int = 3, @@ -515,7 +701,8 @@ def make_call_model_node( additional_kwargs={"synthetic": True, "reflection": True}, )) - response = model_with_tools.invoke(messages_for_llm) + # 超时自动重试:仅 ReAct 调用的模型调用(工具内部 LLM 与压缩节点不受影响) + response = _invoke_llm_with_retry(model_with_tools, messages_for_llm, _sid) # 模型名(用于全局用量统计按模型分账) model_name = "" @@ -534,7 +721,7 @@ def make_call_model_node( content=SUB_AGENT_OOM_PROMPT.format(total_tokens=_total, threshold=_threshold), additional_kwargs={"synthetic": True, "reflection": True}, ) - response = model_with_tools.invoke(list(messages_for_llm) + [oom_msg]) + response = _invoke_llm_with_retry(model_with_tools, list(messages_for_llm) + [oom_msg], _sid) # 剥离工具调用:确保子 Agent 以文字整理结果结束,不再进入工具循环 if getattr(response, "tool_calls", None): response = AIMessage(content=getattr(response, "content", "") or "(子Agent上下文超限,已完成任务整理)") @@ -581,6 +768,15 @@ def make_call_model_node( def process_tool_artifact(state: AgentState) -> dict: last_msg = state["messages"][-1] + # ---- 图外注入排水点:后台任务完成结果等外部消息追加进本轮消息 ---- + injected = [] + try: + from agent.core.turn_injector import drain as _drain_injected + from agent.history.tool_call_recorder import get_current_session + injected = _drain_injected(get_current_session() or "") + except Exception: + injected = [] + # ---- 实时记录工具结果到前端 ---- # 处理尾部所有连续的 ToolMessage(并行工具调用时会有多条), # record_tool_result_live 对已 done 的记录是幂等的,不会重复标记 @@ -606,7 +802,7 @@ def process_tool_artifact(state: AgentState) -> dict: pass if not isinstance(last_msg, ToolMessage) or not last_msg.artifact: - return {} + return {"messages": injected} if injected else {} artifact = last_msg.artifact img_bytes_list: list[bytes] = [] @@ -632,7 +828,7 @@ def process_tool_artifact(state: AgentState) -> dict: img_bytes_list.append(artifact) if not img_bytes_list: - return {} + return {"messages": injected} if injected else {} content_parts = [] failed_count = 0 @@ -655,4 +851,4 @@ def process_tool_artifact(state: AgentState) -> dict: content_parts[-1]["text"] += f"(其中 {failed_count} 张图片处理失败,请忽略)" human_msg = HumanMessage(content=content_parts, additional_kwargs={"synthetic": True}) - return {"messages": [human_msg]} + return {"messages": [human_msg] + injected} diff --git a/src/agent/core/runtime.py b/src/agent/core/runtime.py index 3a3bf75..9b4d555 100644 --- a/src/agent/core/runtime.py +++ b/src/agent/core/runtime.py @@ -23,9 +23,10 @@ from typing import Any, Literal from langchain_core.messages import AIMessage, HumanMessage, SystemMessage from langgraph.errors import GraphRecursionError -from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, save_aborted_turn, set_current_turn, set_current_session +from agent.history.tool_call_recorder import save_turn, start_live_turn, end_live_turn, save_aborted_turn, save_failed_turn, set_current_turn, set_current_session from agent.memory.system_prompt import get_plan_system_prompt, get_cron_system_prompt -from agent.utils.agent_utils import assistant_text, get_history +from agent.core.llm import LLMRetryExhausted, LLM_TIMEOUT_RETRY_MSG_TEMPLATE, LLM_TIMEOUT_GENERIC_MSG, LLM_COMPRESS_TIMEOUT_MSG +from agent.utils.agent_utils import assistant_text from web.ui_session import rewrite_user_message_files_to_session_dir, sync_turn_from_chat_history AgentMode = Literal["agent", "plan", "cron"] @@ -66,35 +67,38 @@ _OOM_MESSAGE = "OOM: 已达到最大迭代次数限制,任务未能完成。 def _inject_summary_context(session_id: str, messages: list) -> None: - """注入压缩摘要(游标之前的工具调用历史已压缩进摘要,仅主 Agent)。""" + """注入链式压缩块(固定提示词之后的稳定前缀,仅主 Agent)。 + + 每块独立 SystemMessage(固定 id compressed_block_{seq}),块间按 seq 追加。 + 压缩只追加新块、不覆盖旧块 → 前缀(固定提示词 + 块链)字节不变, + 压缩时与压缩后前缀缓存均命中。 + """ if not session_id: return try: - from agent.utils.agent_utils import get_session_meta - meta = get_session_meta(session_id, "main") - summary = meta.get("compressed_content") or "" - if summary: - # 固定 id:图内 compress 节点更新摘要时按同 id 替换,避免重复累积 + from agent.utils.agent_utils import load_compressed_context + ctx = load_compressed_context(session_id) + for block in ctx.get("blocks") or []: + seq = block.get("seq", 0) + content = block.get("content") or "" + if not content: + continue messages.append(SystemMessage( - content=f"【历史工具调用摘要】\n{summary}", - id="compressed_summary", + content=f"【历史对话摘要 #{seq}】\n{content}", + id=f"compressed_block_{seq}", )) except Exception: pass def _inject_tool_history_context(session_id: str, messages: list) -> None: - """注入压缩游标之后的全部轮次历史工具调用上下文(替代原"最近5轮"注入)。 - - 系统定位: - 上下文组成(用户方案): - 系统提示词 + 压缩摘要 + 对话历史 + 压缩游标后的工具调用历史 + 本轮反思。 - """ + """注入压缩游标之后的全部轮次原生历史(user → AI/工具 → 最终回复)。""" if not session_id: return try: - from agent.utils.agent_utils import build_tool_history_messages - messages.extend(build_tool_history_messages(session_id, "main")) + from agent.utils.agent_utils import build_native_history_messages, load_compressed_context + msg_cursor = (load_compressed_context(session_id).get("cursor_msg") or 0) + messages.extend(build_native_history_messages(session_id, msg_cursor)) except Exception: pass @@ -139,9 +143,15 @@ def _invoke_with_recursion_limit( input_data: dict, config: dict | None = None, ) -> dict: - """包装 graph.invoke,注入 recursion_limit 并捕获超限错误返回 OOM 消息。""" + """包装 graph.invoke,注入 recursion_limit 并捕获超限错误返回 OOM 消息。 + + LangGraph 的 recursion_limit 按"节点执行次数"计数,而本图每轮 agent 循环 + 消耗 4 个节点(agent → tools → process_tool_artifact → compress → agent), + max_iterations 配置的语义是"最大 agent 轮数",故换算为节点数并留余量。 + """ cfg = dict(config or {}) - cfg["recursion_limit"] = runtime.max_iterations + # 初始 agent 节点 1 个 + 每轮 4 节点 + 收尾余量 + cfg["recursion_limit"] = runtime.max_iterations * 4 + 16 try: return graph.invoke(input_data, config=cfg) except GraphRecursionError: @@ -180,14 +190,23 @@ def _finalize_turn( def _classify_error(e: Exception) -> str: """将异常归类为用户可读的错误提示。""" msg = str(e) + # 节点级重试耗尽(LLM 超时已重试 N 次):文案含重试次数,与 llm 常量一致 + if isinstance(e, LLMRetryExhausted): + return LLM_TIMEOUT_RETRY_MSG_TEMPLATE.format(n=e.retries) + # 压缩节点超时(maybe_compress_context 抛出 COMPRESS_TIMEOUT) + if msg == "COMPRESS_TIMEOUT": + return LLM_COMPRESS_TIMEOUT_MSG # LLM API 认证/鉴权错误 if any(k in msg.lower() for k in ("401", "403", "unauthorized", "forbidden", "invalid api key", "authentication", "incorrect api key")): return "API Key 无效或无权访问,请检查模型配置中的 API Key 和 Base URL。" # 速率限制 if any(k in msg.lower() for k in ("429", "rate limit", "too many requests", "quota")): return "API 调用频率过高或额度不足,请稍后重试或检查 API 配额。" + # LLM 调用超时(未走重试路径,如工具内部、子图边界) + if any(k in msg.lower() for k in ("timed out", "timeout")): + return LLM_TIMEOUT_GENERIC_MSG # 网络/连接错误 - if any(k in msg.lower() for k in ("connection", "timeout", "connect", "network", "refused", "unreachable", "econnrefused", "etimedout", "socket", "dns")): + if any(k in msg.lower() for k in ("connection", "connect", "network", "refused", "unreachable", "econnrefused", "etimedout", "socket", "dns")): return f"无法连接到模型服务,请检查网络连接和 Base URL 是否正确。" # 上下文超长 if any(k in msg.lower() for k in ("context length", "too long", "maximum context", "token", "reduce", "truncat")): @@ -253,9 +272,8 @@ def execute_agent( messages = [SystemMessage(content=system_prompt)] if session_id: _inject_summary_context(session_id, messages) - history = get_history(session_id) - messages.extend(history.messages) - # 注入压缩游标之后的所有轮次工具调用上下文 + # 对话/工具历史统一由 build_native_history_messages 按原生格式重建 + # (游标前已由压缩块承载),不再单独注入 get_history 以避免重复 _inject_tool_history_context(session_id, messages) messages.append(HumanMessage(content=user_content)) @@ -310,19 +328,31 @@ def execute_agent( sync_turn_from_chat_history(session_id, chat_history, "") return chat_history import traceback + err_msg = _classify_error(e) + # 必须在清上下文/end_live_turn 之前落盘:失败轮的已执行工具调用不得丢失 + if session_id and turn_id: + try: + save_failed_turn(session_id, turn_id, err_msg) + except Exception: + pass set_current_turn("") set_current_session("") end_live_turn(session_id) - err_msg = _classify_error(e) print(f"[ERROR] 调用模型时出错: {err_msg}") traceback.print_exc() chat_history.append({"role": "assistant", "content": err_msg}) except Exception as e: import traceback + err_msg = _classify_error(e) + # 与上方 RuntimeError 分支相同:失败轮工具调用先落盘再清理上下文 + if session_id and turn_id: + try: + save_failed_turn(session_id, turn_id, err_msg) + except Exception: + pass set_current_turn("") set_current_session("") end_live_turn(session_id) - err_msg = _classify_error(e) print(f"[ERROR] 调用模型时出错: {err_msg}") traceback.print_exc() chat_history.append({"role": "assistant", "content": err_msg}) diff --git a/src/agent/core/storage.py b/src/agent/core/storage.py index 90e161b..1c86330 100644 --- a/src/agent/core/storage.py +++ b/src/agent/core/storage.py @@ -369,7 +369,7 @@ class JsonRecordRepository: record.update(meta) # meta 全量透传,消费方新增字段无需改动存储层 if reflections: # 尾部思考记录(未被任何工具调用消费的反思),随 turn 一并持久化, - # 供后续轮次 build_tool_history_messages 注入上下文 + # 供后续轮次 build_native_history_messages 重建原生消息注入上下文 record["reflections"] = reflections with open(self._tool_path(session_id, turn_id), "w", encoding="utf-8") as f: json.dump(record, f, ensure_ascii=False, indent=2) diff --git a/src/agent/core/turn_injector.py b/src/agent/core/turn_injector.py new file mode 100644 index 0000000..f09e8de --- /dev/null +++ b/src/agent/core/turn_injector.py @@ -0,0 +1,64 @@ +"""图外注入机制:允许图执行线程之外(后台任务完成、未来人类手动插消息)向会话注入消息。 + +系统定位: + 后台任务 watcher 线程在任务结束时,若该会话轮次仍活跃,把结果作为 + ToolMessage 注入当前轮次的工具返回值(等同一次返回两个工具结果); + 注入消息经 ``drain`` 在 process_tool_artifact / compress 节点入口取走并 + 追加进 state.messages(add_messages 合并),agent 下一轮可见,不触发新一轮。 + + 通用接口 ``inject_message`` 接受任意 BaseMessage,为后续"人类手动插入 + 消息"(UI 侧编辑/外部消息)预留同一排水通道。 + + 轮次结束后不注入(后台任务数据由终端浮窗与 wait/terminal_info 承载, + agent 下次需要时自行查询)。 +""" +from __future__ import annotations + +import threading +from collections import deque + +from langchain_core.messages import BaseMessage, ToolMessage + +_queues: dict[str, deque[BaseMessage]] = {} +_lock = threading.Lock() + + +def inject_message(session_id: str, message: BaseMessage) -> None: + """向会话注入任意消息(图执行线程之外调用是安全的)。""" + if not session_id or message is None: + return + with _lock: + if session_id not in _queues: + _queues[session_id] = deque() + _queues[session_id].append(message) + + +def inject_tool_message(session_id: str, tool_call_id: str, name: str, content: str) -> None: + """注入工具结果消息(后台任务完成通知)。""" + inject_message(session_id, ToolMessage( + content=str(content), + tool_call_id=tool_call_id or "bg_task", + name=name or "background_task", + )) + + +def drain(session_id: str) -> list[BaseMessage]: + """取出并清空该会话的待注入消息(图节点入口调用,消息追加进 state)。""" + if not session_id: + return [] + with _lock: + q = _queues.pop(session_id, None) + if not q: + return [] + # 非空队列先取走;可能存在 drain 期间又被注入的竞态——保留队列待下一轮 + if q: + result = list(q) + _queues[session_id] = deque() # 清空已取走的 + return result + return [] + + +def clear_session(session_id: str) -> None: + """会话清理(删除会话/重启时调用)。""" + with _lock: + _queues.pop(session_id, None) diff --git a/src/agent/core/turn_runner.py b/src/agent/core/turn_runner.py index 3795d43..cff9856 100644 --- a/src/agent/core/turn_runner.py +++ b/src/agent/core/turn_runner.py @@ -53,6 +53,14 @@ def start_turn(session_id: str, chat_history: list[dict[str, Any]], agent_mode: traceback.print_exc() handle["result"] = {"error": str(e)} finally: + # 轮次结束清理:杀掉该会话残留的*前台*进程树(框架级工具超时后 + # daemon 线程仍在后台跑的进程无处兜底,在此按会话清理); + # 后台挂起任务不受影响(跨轮存活,由终端浮窗管理)。 + try: + from agent.tools.terminal_tasks import cleanup_session + cleanup_session(session_id) + except Exception: + pass handle["done"].set() thread = threading.Thread(target=_worker, name=f"turn-{session_id}", daemon=True) diff --git a/src/agent/core/workspace_policy.py b/src/agent/core/workspace_policy.py index 3a3f80c..2d69f57 100644 --- a/src/agent/core/workspace_policy.py +++ b/src/agent/core/workspace_policy.py @@ -15,11 +15,12 @@ 无人在线审批,退化为拦截避免静默失败。 注意: shell 命令无法完全沙箱(任意代码可执行),terminal_execute 的命令扫描为 - 保守启发式,仅拦截明显的越界文件操作;强保证来自 doc_tool 路径校验与 - working_dir 校验。 + 保守启发式,仅拦截明显的越界文件操作与危险指令(rm -rf、磁盘格式化、关机 + 重启、注册表删除等);强保证来自 doc_tool 路径校验与 working_dir 校验。 可扩展性: - 新增需审查的工具时,扩展 ``check_violation`` 分支与 ``_POLICY_TOOLS``。 + - 扩充危险指令清单时向 ``_DANGEROUS_PATTERNS`` 追加 (正则, 描述) 元组。 - 可将访问模式改为按工作空间独立存储(当前为全局单值)。 """ from __future__ import annotations @@ -304,7 +305,7 @@ _POLICY_TOOLS = ("doc_tool", "terminal_execute") def check_violation(tool_name: str | None, args: dict[str, Any]) -> str | None: - """返回工具调用的越界原因;无越界或无需审查返回 None。 + """返回工具调用的违反复审原因(越界 & 危险指令);无违规或无需审查返回 None。 输入: tool_name: 工具名。 @@ -313,15 +314,20 @@ def check_violation(tool_name: str | None, args: dict[str, Any]) -> str | None: if tool_name == "doc_tool": return check_doc_tool(args) if tool_name == "terminal_execute": - return check_terminal_execute(args) + reason = check_terminal_execute(args) + command = args.get("command") + danger = detect_dangerous_command(command) if isinstance(command, str) else None + if reason and danger: + return f"{reason}\n{danger}" + return danger or reason return None def decision(tool_name: str | None, args: dict[str, Any]) -> Literal["allow", "block", "approve"]: - """根据访问模式与越界情况决定处理方式。 + """根据访问模式与违反复审(越界 / 危险指令)决定处理方式。 输出: - - "allow": 放行(full 模式或未越界)。 + - "allow": 放行(full 模式或未违规)。 - "block": 拦截(restricted 模式,或 approval 模式且无人值守)。 - "approve": 暂停待人工审批(approval 模式、交互会话)。 """ @@ -336,6 +342,54 @@ def decision(tool_name: str | None, args: dict[str, Any]) -> Literal["allow", "b return "approve" +# ============================================================ +# 危险指令启发式扫描(静态字符串匹配,保守) +# ============================================================ + +# (正则, 描述);参数匹配规则:-r/-f 开头参数(-r -f -rf -Recurse -Force 等)。 +# 注意: 仅静态扫描命令字符串,.bat/.ps1 文件内嵌命令无法检出; +# 为保证不误伤 -WhatIf(dry-run)等,参数以 r/f 开头的才命中。 +_DANGEROUS_PATTERNS: tuple[tuple[re.Pattern[str], str], ...] = ( + # ---- 递归/强制删除 ---- + (re.compile(r"\brm\s+(?:-[rf][\w-]*\s+)+", re.IGNORECASE), "rm 递归/强制删除"), + (re.compile(r"\bremove-item\b[^\r\n;|&]*\s+-[rf][\w-]*", re.IGNORECASE), "Remove-Item 递归/强制删除"), + (re.compile(r"\b(?:del|erase)\s+/[sq][a-zA-Z]*(?:\s|$)", re.IGNORECASE), "del 静默删除(/s 或 /q)"), + (re.compile(r"\b(?:rd|rmdir)\s+/[sq][a-zA-Z]*(?:\s|$)", re.IGNORECASE), "rd 递归/静默删除(/s 或 /q)"), + (re.compile(r"\bdeltree\b", re.IGNORECASE), "deltree 递归删除"), + # ---- 磁盘格式化/分区 ---- + (re.compile(r"\bformat\s+[a-zA-Z]:", re.IGNORECASE), "format 磁盘格式化"), + (re.compile(r"\bformat-volume\b", re.IGNORECASE), "Format-Volume 磁盘格式化"), + (re.compile(r"\bclear-disk\b", re.IGNORECASE), "Clear-Disk 磁盘清理"), + (re.compile(r"\bdiskpart\b", re.IGNORECASE), "diskpart 磁盘分区"), + # ---- 关机/重启(仅匹配命令位置,避免误伤 Get-Process shutdown 等参数) ---- + (re.compile(r"(?:^|[;&|]\s*)\b(?:shutdown|stop-computer|restart-computer)\b", re.IGNORECASE), "关机/重启系统"), + # ---- 注册表破坏性删除 ---- + (re.compile(r"\breg\s+delete\b", re.IGNORECASE), "reg delete 注册表删除"), + (re.compile(r"\bremove-itemproperty\b", re.IGNORECASE), "Remove-ItemProperty 注册表属性删除"), +) + + +def detect_dangerous_command(command: str) -> str | None: + """扫描命令字符串中的危险指令(rm -rf / 磁盘格式化 / 关机重启 / 注册表删除等)。 + + 输入: + command: terminal_execute 的命令字符串。 + + 输出: + 首次命中的危险指令描述;无命中返回 None。 + + 系统定位: + 供 ``check_violation`` 在越界检测之外追加"危险指令审查"——restricted 拦截、 + approval 审批(审批卡提示原因)、full 放行,与越界语义同构。 + """ + if not command or not isinstance(command, str): + return None + for pattern, desc in _DANGEROUS_PATTERNS: + if pattern.search(command): + return f"危险指令: {desc}" + return None + + # ============================================================ # 拦截文案 # ============================================================ @@ -349,7 +403,7 @@ _MODE_NAMES = { def build_block_message(tool_name: str | None, args: dict[str, Any], reason: str | None = None) -> str: """构建拦截时返回给 Agent 的错误文案(供其自行纠正)。""" - reason = reason or check_violation(tool_name, args) or "检测到超出工作空间范围的操作" + reason = reason or check_violation(tool_name, args) or "未通过安全策略" mode = get_access_mode() mode_name = _MODE_NAMES.get(mode, mode) root = get_workspace_root() @@ -359,8 +413,8 @@ def build_block_message(tool_name: str | None, args: dict[str, Any], reason: str elif tool_name == "terminal_execute" and args.get("command"): detail = f"\n命令: {(args.get('command') or '')[:200]}" return ( - f"[ERROR] 该操作已按访问模式「{mode_name}」拦截:超出工作空间范围。\n" + f"[ERROR] 该操作已按访问模式「{mode_name}」拦截:未通过安全策略。\n" f"原因: {reason}\n" f"工作空间: {root}{detail}\n" - f"请将目标路径调整到工作空间目录下后重试。" + f"请调整命令或目标路径后重试。" ) diff --git a/src/agent/history/terminal_tasks_pids.json b/src/agent/history/terminal_tasks_pids.json new file mode 100644 index 0000000..7dde081 --- /dev/null +++ b/src/agent/history/terminal_tasks_pids.json @@ -0,0 +1,3 @@ +{ + "pids": [] +} \ No newline at end of file diff --git a/src/agent/history/tool_call_recorder.py b/src/agent/history/tool_call_recorder.py index 49d53ba..ce51f25 100644 --- a/src/agent/history/tool_call_recorder.py +++ b/src/agent/history/tool_call_recorder.py @@ -7,11 +7,11 @@ """ from __future__ import annotations -import base64 import json import time import threading import queue as _queue_module +import urllib.parse from contextlib import contextmanager from contextvars import ContextVar from datetime import datetime @@ -105,6 +105,13 @@ def sub_agent_context(tool_call_id: str | None = None): _live_lock = threading.Lock() _live_records: dict[str, list[dict[str, Any]]] = {} +# ---------- 增量推送状态(每条快照只推变化,避免长运行 O(N) 全量 JSON) ---------- +# 记录为追加 + 原地更新:dirty 集合记录本轮 notify 之间发生变更/新增的记录索引; +# pushed_count / pushed_refs 记录增量协议已覆盖的记录数/反思数(新增部分按切片取)。 +_live_dirty: dict[str, set[int]] = {} +_live_pushed_count: dict[str, int] = {} +_live_pushed_refs: dict[str, int] = {} + # 子 Agent 实时工具调用记录((session_id, tool_call_id) → list),独立存储, # 在 get_live_tool_calls 中按 tool_call_id 合并到对应 call_subagent 条目的 # sub_tool_calls 中供前端嵌套展开。多个子 Agent 并行时按 (session, tc_id) 隔离。 @@ -124,6 +131,13 @@ _turn_start_times: dict[str, float] = {} # key = session_id # 活跃 session 集合,用于区分 turn 已结束 vs 服务重启 _active_sessions: set[str] = set() +# 一次性实时事件(前端 toast,如"LLM 超时正在重试"),随全量快照/增量推送。 +# 事件为追加式(每条 {id, text, ts}),按 pushed 计数切片避免重复推送。 +_live_events: dict[str, list[dict[str, Any]]] = {} # session_id → 事件列表 +_live_events_pushed: dict[str, int] = {} +_events_lock = threading.Lock() +_events_seq = 0 + # ---------- SSE 订阅机制(pub/sub) ---------- # 每个 session 可有多个订阅者,工具调用状态变更时推送快照 _live_subscribers: dict[str, list[_queue_module.Queue]] = {} @@ -261,10 +275,15 @@ def start_live_turn(session_id: str) -> str: def init_live_turn_with_id(session_id: str, turn_id: str) -> str: """使用已有 turn_id 初始化 live 记录(不清除旧 turn 持久化数据)。""" set_current_turn(turn_id) + # 清理上一轮可能残留的落盘防抖定时器(abort 等未走 end_live_turn 的路径) + _cancel_live_persist(session_id) with _live_lock: _live_records[session_id] = [] _turn_start_times[session_id] = time.time() # 记录轮次开始时间 _active_sessions.add(session_id) + with _events_lock: + _live_events[session_id] = [] + _live_events_pushed[session_id] = 0 with _pending_thinking_lock: _pending_thinking.pop(session_id, None) with _pending_reflection_lock: @@ -309,6 +328,8 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool if reflection: entry["reflection"] = reflection _sub_live_records[key].append(entry) + # 子记录变更 → 父级 call_subagent 条目(sub_tool_calls)变脏 + _mark_parent_dirty(session_id, _sub_key(session_id)[1]) _notify_live(session_id) return with _pending_thinking_lock: @@ -331,6 +352,7 @@ def record_tool_call_live(session_id: str, name: str, args: dict[str, Any], tool if reflection: entry["reflection"] = reflection _live_records[session_id].append(entry) + _live_dirty.setdefault(session_id, set()).add(len(_live_records[session_id]) - 1) _notify_live(session_id) @@ -349,10 +371,10 @@ def record_tool_result_live( for rec in reversed(records): if rec["name"] == name and rec["status"] == "running": rec["status"] = "done" - rec["result_text"] = (result_text or "")[:500] - images = _artifact_image_data_urls(artifact) - if images: - rec["result_images"] = images + rec["result_text"] = (result_text or "")[:4000] + # 子 Agent 轨迹不保存图片字节:前端不渲染子级图片, + # 且轨迹会注入 call_subagent 条目持久化 → 避免 base64 膨胀 + rec.pop("result_images", None) audio_info = _artifact_audio_info(artifact) if audio_info: rec["result_audio"] = audio_info @@ -360,6 +382,8 @@ def record_tool_result_live( if file_infos: rec["result_file_info"] = file_infos break + # 子记录变更 → 父级 call_subagent 条目(sub_tool_calls)变脏 + _mark_parent_dirty(session_id, key[1]) _notify_live(session_id) return with _live_lock: @@ -367,22 +391,129 @@ def record_tool_result_live( for rec in reversed(records): if rec["name"] == name and rec["status"] == "running": rec["status"] = "done" - rec["result_text"] = (result_text or "")[:500] - images = _artifact_image_data_urls(artifact) - if images: - rec["result_images"] = images + rec["result_text"] = (result_text or "")[:4000] + # 图片/文件字节立即落盘为会话目录下的文件,记录只存文件名, + # 不内嵌 base64(实时推送由 get_live_tool_calls 转成 + # /api/tool-image URL;aborted 轮次经 save_aborted_turn 落盘时 + # 文件已存在,历史回放按 result_files 即可还原) + _persist_artifact_files(rec, artifact, records, session_id) audio_info = _artifact_audio_info(artifact) if audio_info: rec["result_audio"] = audio_info file_infos = _artifact_file_infos(artifact) if file_infos: rec["result_file_info"] = file_infos - # 增量持久化到磁盘,防止进程崩溃时记录丢失 - _safe_persist_live_records(session_id, list(records)) + # 标记变更(增量推送用),并增量持久化到磁盘防止崩溃丢失 + _live_dirty.setdefault(session_id, set()).add(records.index(rec)) + _schedule_live_persist(session_id) break _notify_live(session_id) +def record_tool_result_by_call_id(session_id: str, tool_call_id: str, result_text: str) -> bool: + """按 tool_call_id 更新指定工具调用状态为 done(幂等)。 + + 后台任务结束时由 terminal_tasks watcher 调用:把挂起的 terminal_execute + 调用从 "running" 更新为 "done" 并写入最终输出(截断 4000 字符)。 + 仅更新仍处于 running 的条目;子 Agent 嵌套记录不在此处理(后台任务按会话归属)。 + """ + if not tool_call_id: + return False + with _live_lock: + records = _live_records.get(session_id, []) + for rec in records: + if rec.get("id") == tool_call_id and rec.get("status") == "running": + rec["status"] = "done" + rec["result_text"] = (result_text or "")[:4000] + _live_dirty.setdefault(session_id, set()).add(records.index(rec)) + _schedule_live_persist(session_id) + break + else: + return False + _notify_live(session_id) + return True + + +def is_live_turn_active(session_id: str) -> bool: + """该会话当前是否有活跃轮次(start_live_turn 之后、end_live_turn 之前)。""" + with _live_lock: + return session_id in _active_sessions + + +def _mark_parent_dirty(session_id: str, parent_id: str) -> None: + """子 Agent 记录变更时标记父级 call_subagent 条目为脏(其 sub_tool_calls 变化)。""" + if not parent_id: + return + with _live_lock: + records = _live_records.get(session_id) + if not records: + return + for i, rec in enumerate(records): + if rec.get("id") == parent_id: + _live_dirty.setdefault(session_id, set()).add(i) + return + + +def record_live_event(session_id: str, text: str) -> None: + """记录一次性实时事件(前端 toast),随下一个快照/增量推送。 + + 系统定位: + LLM 超时重试等"非工具调用"的运行状态提示。事件为追加式, + 每条自带唯一 id,前端按 id 去重;turn 开始/结束时随 live 记录一起清空。 + + 输入: + session_id: 会话 ID。 + text: 前端展示文案(如 "LLM 服务调用超时,正在重试(第 1/3 次)")。 + + 输出: + 无。 + """ + global _events_seq + if not session_id: + return + with _events_lock: + _events_seq += 1 + _live_events.setdefault(session_id, []).append({ + "id": f"evt_{_events_seq}_{time.time():.0f}", + "text": text, + "ts": time.time(), + }) + _notify_live(session_id) + + +def _persist_artifact_files( + rec: dict[str, Any], + artifact: Any | None, + records: list[dict[str, Any]], + session_id: str, +) -> None: + """把工具返回的图片/文件字节落盘到会话目录,记录中仅保存文件名。 + + 命名与 extract_tool_calls_from_messages 对齐(``_artifact_{name}_{idx}.png`` / + ``_file_{name}_{idx}{ext}``),save_turn 再次写盘时幂等覆盖,不会产生重复文件; + result_images 只含 _artifact_* 图片文件名,实时推送时由 + get_live_tool_calls 转为 /api/tool-image URL。 + """ + if not artifact: + return + try: + match_index = records.index(rec) + except ValueError: + return + name = rec.get("name", "tool") + idx = sum(1 for r in records[:match_index] if r.get("name") == name) + base_name = f"{name}_{idx}" + session_dir = session_tool_dir(session_id) + _ensure_dir(session_dir) + result_files, bytes_map = _save_artifact_files(artifact, base_name) + if not bytes_map: + return + for fname, data_bytes in bytes_map.items(): + (session_dir / fname).write_bytes(data_bytes) + rec["result_files"] = result_files + rec["result_images"] = [f for f in result_files if f.startswith("_artifact_")] + + def _safe_persist_live_records(session_id: str, records: list[dict[str, Any]]) -> None: """将实时记录增量写入存储,防止进程崩溃丢失数据。 @@ -402,53 +533,44 @@ def _safe_persist_live_records(session_id: str, records: list[dict[str, Any]]) - pass # 持久化失败不应影响主流程 -def _artifact_image_data_urls(artifact: Any | None) -> list[str]: - if not artifact: - return [] +# ---------- live 记录落盘防抖(每次结果全量写盘 O(N²) → 批量 ~0.8s 一次) ---------- +_persist_timers: dict[str, threading.Timer] = {} +_persist_timers_lock = threading.Lock() +_PERSIST_DEBOUNCE_S = 0.8 - images: list[str] = [] - image_bytes_list: list[bytes] = [] - def collect(item: Any) -> None: - if isinstance(item, bytes): - image_bytes_list.append(item) +def _schedule_live_persist(session_id: str) -> None: + """防抖调度 live 记录落盘:连续工具结果合并为一次全量写盘。 + + record_tool_result_live 高频触发时,逐次全量写盘是 O(N²) 磁盘 IO, + 与前端进程同机运行时会争抢 CPU/IO;改为 0.8s 防抖后运行期写入次数 + 与记录数同阶,轮次结束(end_live_turn)时再强制冲刷一次兜底。 + """ + with _persist_timers_lock: + if session_id in _persist_timers: return - if not isinstance(item, dict): - return - if isinstance(item.get("image_bytes"), bytes): - image_bytes_list.append(item["image_bytes"]) - # 支持 doc_tool 的 "images" 键(多图片列表) - if isinstance(item.get("images"), list): - for img in item["images"]: - if isinstance(img, bytes): - image_bytes_list.append(img) - elif isinstance(img, dict) and isinstance(img.get("image_bytes"), bytes): - image_bytes_list.append(img["image_bytes"]) - for key in ("image_url", "url", "data_url"): - value = item.get(key) - if isinstance(value, str) and (value.startswith("data:image/") or value.startswith("http")): - images.append(value) - image_url = item.get("image_url") - if isinstance(image_url, dict): - value = image_url.get("url") - if isinstance(value, str) and (value.startswith("data:image/") or value.startswith("http")): - images.append(value) + timer = threading.Timer(_PERSIST_DEBOUNCE_S, _flush_live_persist, args=(session_id,)) + timer.daemon = True + _persist_timers[session_id] = timer + timer.start() - # 处理 batch 类型 artifact - if isinstance(artifact, dict) and artifact.get("file_type") == "batch": - for item in artifact.get("items", []): - collect(item) - elif isinstance(artifact, list): - for item in artifact: - collect(item) - else: - collect(artifact) - images.extend( - "data:image/png;base64," + base64.standard_b64encode(img).decode("ascii") - for img in image_bytes_list - ) - return images +def _flush_live_persist(session_id: str) -> None: + """定时器回调:取当前 live 记录做一次全量落盘(线程安全)。""" + with _persist_timers_lock: + _persist_timers.pop(session_id, None) + with _live_lock: + records = list(_live_records.get(session_id, [])) + if records: + _safe_persist_live_records(session_id, records) + + +def _cancel_live_persist(session_id: str) -> None: + """取消待执行的落盘定时器(轮次结束时调用)。""" + with _persist_timers_lock: + timer = _persist_timers.pop(session_id, None) + if timer is not None: + timer.cancel() def _artifact_audio_info(artifact: Any | None) -> dict[str, Any] | None: @@ -536,6 +658,15 @@ def end_live_turn(session_id: str) -> list[dict[str, Any]]: 供随后 save_turn 持久化为 turn 记录的 reflections 字段(尾部思考注入用)。 """ _stash_turn_reflections(session_id) + # 轮次结束:取消防抖定时器并立即冲刷一次(此时 turn_id 仍有效), + # 确保防抖窗口内最后一批记录不因进程退出/崩溃丢失 + _cancel_live_persist(session_id) + # 同时取消 pending 的推送防抖:live 数据即将清空,延迟推送只会构建空增量 + _cancel_live_notify(session_id) + with _live_lock: + final_records = list(_live_records.get(session_id, [])) + if final_records: + _safe_persist_live_records(session_id, final_records) set_current_turn("") clear_live_reflections(session_id) with _pending_thinking_lock: @@ -552,6 +683,9 @@ def end_live_turn(session_id: str) -> list[dict[str, Any]]: with _sub_live_lock: for k in [k for k in _sub_live_records if k[0] == session_id]: _sub_live_records.pop(k, None) + with _events_lock: + _live_events.pop(session_id, None) + _live_events_pushed.pop(session_id, None) with _live_lock: _active_sessions.discard(session_id) return _live_records.pop(session_id, []) @@ -559,8 +693,18 @@ def end_live_turn(session_id: str) -> list[dict[str, Any]]: # ---------- SSE 订阅 API ---------- def subscribe_live(session_id: str) -> _queue_module.Queue: - """订阅指定 session 的实时工具调用更新,返回一个 Queue。""" + """订阅指定 session 的实时工具调用更新,返回一个 Queue。 + + 订阅时把增量推送计数对齐到当前状态:连接建立后服务端先发一次全量快照, + 后续增量以此为基准(否则首条增量会与全量快照重复)。 + """ q: _queue_module.Queue = _queue_module.Queue(maxsize=64) + with _live_lock: + _live_pushed_count[session_id] = len(_live_records.get(session_id, [])) + with _think_lock: + _live_pushed_refs[session_id] = len(_think_store.get(session_id, [])) + with _events_lock: + _live_events_pushed[session_id] = len(_live_events.get(session_id, [])) with _subscribers_lock: if session_id not in _live_subscribers: _live_subscribers[session_id] = [] @@ -579,15 +723,17 @@ def unsubscribe_live(session_id: str, q: _queue_module.Queue) -> None: def _build_live_snapshot(session_id: str) -> dict[str, Any]: - """构建当前实时状态的快照(不加锁,调用者需确保数据一致性或在锁外读取)。 + """构建当前实时状态的完整快照(连接建立时推送一次,后续推送增量 delta)。 除工具调用/思考/轮次开始时间外,还附带当前等待中的人工请求 (human_requests),前端据此渲染确认浮窗。 """ snapshot = { + "type": "full", "tool_calls": get_live_tool_calls(session_id), "reflections": get_live_reflections(session_id), "started_at": get_turn_started_at(session_id), + "events": _get_live_events(session_id), } try: # 惰性导入避免与 human_request(其模块级导入本模块) 循环依赖 @@ -598,27 +744,138 @@ def _build_live_snapshot(session_id: str) -> dict[str, Any]: return snapshot +# ---------- live 推送防抖(高频率工具事件 → 合并为 ~0.12s 一批 SSE 帧) ---------- +# record_*_live 在状态每次变更时即时调用 _notify_live;工具结果/反思高频变更时 +# 逐条推送会让前端 EventSource 在同一帧内收到大量小增量(每条含 4KB 结果文本), +# JSON 解析 + DOM diff 反复占用主线程。改为按 session 防抖合并: +# 脏标记(_live_dirty)持续累积,防抖窗口到期后一次性构建增量并推送, +# 语义与逐条推送等价(delta 按索引切片,合并无信息损失)。 +_notify_timers: dict[str, threading.Timer] = {} +_notify_timers_lock = threading.Lock() +_NOTIFY_DEBOUNCE_S = 0.12 + + def _notify_live(session_id: str) -> None: - """通知所有订阅者:推送当前快照。在工具调用状态变更后调用。""" + """调度合并推送:防抖窗口内多次变更合并为一次(无订阅者时直接跳过)。""" + with _subscribers_lock: + subs = _live_subscribers.get(session_id) + has_subs = bool(subs) + if not has_subs: + return + with _notify_timers_lock: + if session_id in _notify_timers: + return + timer = threading.Timer(_NOTIFY_DEBOUNCE_S, _flush_live_notify, args=(session_id,)) + timer.daemon = True + _notify_timers[session_id] = timer + timer.start() + + +def _flush_live_notify(session_id: str) -> None: + """定时器回调:通知所有订阅者,推送增量快照(只含变更部分)。 + + 无订阅者时直接返回、不构建增量:订阅建立时 subscribe_live 会把推送 + 计数对齐到当前状态,脏标记累积到下一次构建时一并处理(updated 取 + prev_count 之前的全部脏索引),增量语义不变。 + """ + with _notify_timers_lock: + _notify_timers.pop(session_id, None) with _subscribers_lock: subs = list(_live_subscribers.get(session_id, [])) if not subs: return - snapshot = _build_live_snapshot(session_id) + delta = _build_live_delta(session_id) + if delta is None: + return for q in subs: try: - q.put_nowait(snapshot) + q.put_nowait(delta) except _queue_module.Full: # 队列满时丢弃最旧的消息,放入最新的 try: q.get_nowait() - q.put_nowait(snapshot) + q.put_nowait(delta) except Exception: pass -def save_aborted_turn(session_id: str, turn_id: str) -> None: - """Abort 时将当前内存中的 live 记录落盘为 turn JSON,避免工具调用历史丢失。""" +def _cancel_live_notify(session_id: str) -> None: + """取消待执行的推送定时器(轮次结束时调用)。""" + with _notify_timers_lock: + timer = _notify_timers.pop(session_id, None) + if timer is not None: + timer.cancel() + + +def _build_live_delta(session_id: str) -> dict[str, Any] | None: + """构建相对上一次推送的增量快照(新增记录 + 变更记录 + 新增反思)。 + + 记录为追加 + 原地更新:新增部分按 ``pushed_count`` 切片,变更部分取 + ``_live_dirty`` 中的索引;反思同样按 ``pushed_refs`` 切片。 + ``total_calls / total_reflections`` 供前端校验合并是否对齐(丢包时触发全量重同步)。 + """ + with _live_lock: + records = list(_live_records.get(session_id, [])) + dirty = _live_dirty.pop(session_id, set()) + prev_count = _live_pushed_count.get(session_id, 0) + _live_pushed_count[session_id] = len(records) + with _think_lock: + reflections = list(_think_store.get(session_id, [])) + prev_refs = _live_pushed_refs.get(session_id, 0) + _live_pushed_refs[session_id] = len(reflections) + with _events_lock: + events = list(_live_events.get(session_id, [])) + prev_events = _live_events_pushed.get(session_id, 0) + _live_events_pushed[session_id] = len(events) + if prev_events > len(events): + new_events = list(events) + else: + new_events = events[prev_events:] + # 轮次重置(记录数回退)时按全量处理 + if prev_count > len(records): + added, updated = list(records), [] + else: + added = records[prev_count:] + # 变更索引中"已推送过"的部分走 updated(>= prev_count 的新记录已含在 added 中) + updated = [{"index": i, "call": records[i]} for i in sorted(dirty) if i < prev_count] + if prev_refs > len(reflections): + new_reflections = list(reflections) + else: + new_reflections = reflections[prev_refs:] + # 与全量快照同构:子 Agent 轨迹注入 + 图片文件名转 /api/tool-image URL + # (增量与全量两个通道必须一致,否则前端合并后子轨迹/图片丢失) + _inject_sub_traces_into_records(session_id, added) + _inject_sub_traces_into_records(session_id, [u["call"] for u in updated]) + added = _records_with_image_urls(session_id, added) + updated = [{"index": u["index"], "call": c} + for u, c in zip(updated, _records_with_image_urls(session_id, [u["call"] for u in updated]))] + try: + from agent.core.human_request import get_human_requests_for_session + human_requests = get_human_requests_for_session(session_id) + except Exception: + human_requests = [] + # 空增量(无记录/反思/变更,仅人工请求变化时)也照常推送:人工请求浮窗依赖此通道 + return { + "type": "delta", + "added": added, + "updated": updated, + "reflections": new_reflections, + "human_requests": human_requests, + "events": new_events, + "total_calls": len(records), + "total_reflections": len(reflections), + "started_at": _turn_start_times.get(session_id), + } + + +def _persist_live_records_as_turn(session_id: str, turn_id: str, meta: dict[str, Any]) -> None: + """将当前内存 live 记录落盘为 turn JSON(abort/失败共用,须在 end_live_turn 前调用)。 + + 输入: + session_id: 会话 ID。 + turn_id: 轮次 ID。 + meta: 附加元信息,如 {"aborted": True} 或 {"error": "..."}。 + """ with _live_lock: records = list(_live_records.get(session_id, [])) if not records: @@ -636,6 +893,7 @@ def save_aborted_turn(session_id: str, turn_id: str) -> None: "thinking": r.get("thinking", ""), "reflection": r.get("reflection", ""), "result_images": r.get("result_images", []), + "result_files": r.get("result_files", []), "result_audio": r.get("result_audio"), "result_file_info": r.get("result_file_info"), } @@ -651,11 +909,25 @@ def save_aborted_turn(session_id: str, turn_id: str) -> None: "started_at": started_at, "ended_at": ended_at, "duration": max(0, ended_at - started_at), - "aborted": True, + **meta, } session_storage.save_tool_calls(session_id, turn_id, normalized, meta=_meta) +def save_aborted_turn(session_id: str, turn_id: str) -> None: + """Abort 时将当前内存中的 live 记录落盘为 turn JSON,避免工具调用历史丢失。""" + _persist_live_records_as_turn(session_id, turn_id, {"aborted": True}) + + +def save_failed_turn(session_id: str, turn_id: str, reason: str) -> None: + """轮次失败(LLM 超时重试耗尽/压缩超时等)时将 live 记录落盘,便于恢复对话。 + + 与 save_aborted_turn 的区别仅是 meta 标记(error 信息 vs aborted), + 前端据此区分"失败中止"与"用户手动暂停"。 + """ + _persist_live_records_as_turn(session_id, turn_id, {"error": reason}) + + def get_live_tool_calls(session_id: str) -> list[dict[str, Any]]: """轮询接口:当前执行中 turn 的实时工具调用列表。 @@ -687,32 +959,75 @@ def get_live_tool_calls(session_id: str) -> list[dict[str, Any]]: return [] # ---- 注入子 Agent 实时轨迹到 call_subagent 条目(按 tool_call_id 精确匹配) ---- - # 并行子 Agent(含同名)按 (session, tool_call_id) 分槽,注入到各自对应条目; - # 无 id 的旧数据回退到"最后一条 running"(旧行为兼容)。 + _inject_sub_traces_into_records(session_id, records) + + return _records_with_image_urls(session_id, records) + + +def _inject_sub_traces_into_records(session_id: str, records: list[dict[str, Any]]) -> None: + """把子 Agent 实时轨迹注入到 call_subagent / agent_call 条目(就地修改)。 + + 并行子 Agent(含同名)按 (session, tool_call_id) 分槽,注入到各自对应条目; + 无 id 的旧数据回退到"最后一条 running"(旧行为兼容)。 + 全量快照与增量 delta 推送共用本函数,保证子轨迹在两个通道中一致。 + """ with _sub_live_lock: sub_map = {k: list(v) for k, v in _sub_live_records.items() if k[0] == session_id} - if sub_map: - matched_ids: set[str] = set() - fallback_rec: dict[str, Any] | None = None - for rec in records: - if rec.get("name") not in ("call_subagent", "agent_call"): - continue - tc_id = rec.get("id") or "" - sub_calls = sub_map.get((session_id, tc_id)) - if sub_calls: - rec["sub_tool_calls"] = sub_calls - matched_ids.add(tc_id) - elif rec.get("status") == "running" and fallback_rec is None: - fallback_rec = rec - if fallback_rec is not None and not fallback_rec.get("sub_tool_calls"): - leftover: list[dict[str, Any]] = [] - for k, v in sub_map.items(): - if k[1] not in matched_ids: - leftover.extend(v) - if leftover: - fallback_rec["sub_tool_calls"] = leftover + if not sub_map: + return + matched_ids: set[str] = set() + fallback_rec: dict[str, Any] | None = None + for rec in records: + if rec.get("name") not in ("call_subagent", "agent_call"): + continue + tc_id = rec.get("id") or "" + sub_calls = sub_map.get((session_id, tc_id)) + if sub_calls: + rec["sub_tool_calls"] = sub_calls + matched_ids.add(tc_id) + elif rec.get("status") == "running" and fallback_rec is None: + fallback_rec = rec + if fallback_rec is not None and not fallback_rec.get("sub_tool_calls"): + leftover: list[dict[str, Any]] = [] + for k, v in sub_map.items(): + if k[1] not in matched_ids: + leftover.extend(v) + if leftover: + fallback_rec["sub_tool_calls"] = leftover - return records + +def _records_with_image_urls(session_id: str, records: list[dict[str, Any]]) -> list[dict[str, Any]]: + """把 live 记录中的图片文件名转为 /api/tool-image URL(前端可直接
![]()
-主题配置 — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体 +主题配置 — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体;另含 常规 / 模型 / Skills / 网络搜索 / 邮件 / 存储与数据库 / 运行时参数 等分组展示)。 + + record_tool_result_live 落盘时 result_images 只存相对文件名 + (_artifact_*.png),此处转为可访问的 URL;旧记录中的 base64 data URL + 原样保留(历史兼容)。返回浅拷贝,不修改共享内存中的 live 记录, + 避免后续 persist_live_records 把 URL 写回磁盘。 + """ + out: list[dict[str, Any]] = [] + for rec in records: + images = rec.get("result_images") + needs_convert = isinstance(images, list) and any( + isinstance(i, str) and i + and not (i.startswith("data:") or i.startswith("/") or i.startswith("http")) + for i in images + ) + if not needs_convert: + out.append(rec) + continue + copy = dict(rec) + copy["result_images"] = [ + i + if not isinstance(i, str) or not i + or i.startswith("data:") or i.startswith("/") or i.startswith("http") + else "/api/tool-image?path=" + urllib.parse.quote(str(session_tool_dir(session_id) / i)) + for i in images + ] + subs = rec.get("sub_tool_calls") + if isinstance(subs, list): + copy["sub_tool_calls"] = _records_with_image_urls(session_id, subs) + out.append(copy) + return out def get_turn_started_at(session_id: str) -> float | None: @@ -819,6 +1134,12 @@ def get_live_reflections(session_id: str) -> list[dict[str, Any]]: return list(_think_store.get(session_id, [])) +def _get_live_events(session_id: str) -> list[dict[str, Any]]: + """获取当前会话的实时事件列表(随全量快照推送,前端按 id 去重)。""" + with _events_lock: + return list(_live_events.get(session_id, [])) + + def clear_live_reflections(session_id: str) -> None: """清空指定会话的反思记录。""" with _think_lock: @@ -840,6 +1161,9 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s pending: list[dict[str, Any]] = [] # 同名工具调用计数器,避免多张截图文件名冲突 name_counter: dict[str, int] = {} + # 分组计数器:同一 AIMessage(同一模型响应)的并行 tool_calls 共享 group_index, + # 供历史原生重建时合并为一条 AIMessage(思考/反思/tool_calls 同 group) + group_idx = -1 for msg in messages: if isinstance(msg, AIMessage) and msg.tool_calls: # 提取该 AIMessage 的思考与反思,仅附加到第一个 tool_call: @@ -849,6 +1173,7 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s thinking = extract_thought(msg) reflection = extract_reflection(msg) first = True + group_idx += 1 for tc in msg.tool_calls: d = normalize_tool_call(tc) entry = { @@ -860,6 +1185,7 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s "result_text": "", "result_files": [], "result_images": [], + "group_index": group_idx, } if first: if thinking: @@ -884,7 +1210,8 @@ def extract_tool_calls_from_messages(messages: list[BaseMessage]) -> list[dict[s # 图片不内嵌 base64(避免 tool json 膨胀数 MB): # PNG 统一落盘为 _artifact_*.png,回放时由 # _tool_calls_from_turn_record 按 result_files 生成 /api/tool-image URL - # 实时展示仍走 live 记录中的 data URL(record_tool_result_live) + # 实时展示由 get_live_tool_calls 将 result_images 文件名转为 + # /api/tool-image URL(live 落盘与 turn 落盘同构) # 提取音频信息(不含字节,仅路径元数据) audio_info = _artifact_audio_info(artifact) if audio_info: @@ -1021,7 +1348,7 @@ def save_turn(session_id: str, turn_id: str, user_message: dict[str, Any], "duration": (ended_at - started_at) if started_at else None, } # 尾部思考(未被任何工具调用消费的反思):end_live_turn 暂存 → 此处持久化, - # 供后续轮次 build_tool_history_messages 注入上下文(历史思考)。 + # 供后续轮次 build_native_history_messages 重建原生消息注入上下文。 _reflections = _take_stashed_reflections(session_id) session_storage.save_tool_calls(session_id, turn_id, tool_calls, meta=_meta, reflections=_reflections) return None @@ -1191,9 +1518,9 @@ def _tool_calls_from_turn_record(record: dict[str, Any], session_id: str) -> lis import os as _os fname = _os.path.basename(fp) if fname.startswith("_artifact_"): - image_files.append(f"/api/tool-image?path={fp}&session={turn_id}") + image_files.append(f"/api/tool-image?path={urllib.parse.quote(str(fp))}&session={urllib.parse.quote(str(turn_id or ''))}") else: - other_files.append(f"/api/tool-file?path={fp}&session={turn_id}") + other_files.append(f"/api/tool-file?path={urllib.parse.quote(str(fp))}&session={urllib.parse.quote(str(turn_id or ''))}") # result_images:落盘记录不再内嵌 base64(体积原因), # 统一按 result_files 生成 /api/tool-image URL; # 若历史数据仍带 data URL(旧版记录)则保留以兼容 diff --git a/src/agent/history/usage_stats.json b/src/agent/history/usage_stats.json index c6b625c..8b75bdc 100644 --- a/src/agent/history/usage_stats.json +++ b/src/agent/history/usage_stats.json @@ -1 +1 @@ -{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}, "2026-08-16T19": {"total": 558037, "by_model": {"Qwen3.5-122B-A10B": 558037}}, "2026-08-16T20": {"total": 212587, "by_model": {"Qwen3.5-122B-A10B": 212587}}, "2026-08-16T22": {"total": 5894, "by_model": {"Qwen3.5-122B-A10B": 5894}}, "2026-08-17T07": {"total": 100546, "by_model": {"Qwen3.5-122B-A10B": 100546}}, "2026-08-17T08": {"total": 135785, "by_model": {"Qwen3.5-122B-A10B": 135785}}, "2026-08-17T17": {"total": 5753, "by_model": {"Qwen3.5-122B-A10B": 5753}}, "2026-08-18T14": {"total": 27472, "by_model": {"Qwen3.5-122B-A10B": 27472}}, "2026-08-18T15": {"total": 45565, "by_model": {"Qwen3.5-122B-A10B": 45565}}, "2026-08-19T08": {"total": 6141, "by_model": {"Qwen3.5-122B-A10B": 6141}}, "2026-08-19T11": {"total": 13137, "by_model": {"Qwen3.5-122B-A10B": 13137}}, "2026-08-19T12": {"total": 53109, "by_model": {"Qwen3.5-122B-A10B": 53109}}}} \ No newline at end of file +{"hours": {"2026-08-12T22": {"total": 41613, "by_model": {"Qwen3.5-122B-A10B": 41613}}, "2026-08-14T09": {"total": 535476, "by_model": {"Qwen3.5-122B-A10B": 535476}}, "2026-08-14T10": {"total": 91339, "by_model": {"Qwen3.5-122B-A10B": 91339}}, "2026-08-14T11": {"total": 67389, "by_model": {"Qwen3.5-122B-A10B": 67389}}, "2026-08-16T19": {"total": 558037, "by_model": {"Qwen3.5-122B-A10B": 558037}}, "2026-08-16T20": {"total": 212587, "by_model": {"Qwen3.5-122B-A10B": 212587}}, "2026-08-16T22": {"total": 5894, "by_model": {"Qwen3.5-122B-A10B": 5894}}, "2026-08-17T07": {"total": 100546, "by_model": {"Qwen3.5-122B-A10B": 100546}}, "2026-08-17T08": {"total": 135785, "by_model": {"Qwen3.5-122B-A10B": 135785}}, "2026-08-17T17": {"total": 5753, "by_model": {"Qwen3.5-122B-A10B": 5753}}, "2026-08-18T14": {"total": 27472, "by_model": {"Qwen3.5-122B-A10B": 27472}}, "2026-08-18T15": {"total": 45565, "by_model": {"Qwen3.5-122B-A10B": 45565}}, "2026-08-19T08": {"total": 6141, "by_model": {"Qwen3.5-122B-A10B": 6141}}, "2026-08-19T11": {"total": 13137, "by_model": {"Qwen3.5-122B-A10B": 13137}}, "2026-08-19T12": {"total": 53109, "by_model": {"Qwen3.5-122B-A10B": 53109}}, "2026-08-19T16": {"total": 630061, "by_model": {"Qwen3.5-122B-A10B": 630061}}, "2026-08-19T17": {"total": 567154, "by_model": {"Qwen3.5-122B-A10B": 567154}}, "2026-08-19T18": {"total": 154588, "by_model": {"Qwen3.5-122B-A10B": 154588}}, "2026-08-19T19": {"total": 661541, "by_model": {"Qwen3.5-122B-A10B": 661541}}, "2026-08-20T08": {"total": 320021, "by_model": {"Qwen3.5-122B-A10B": 320021}}, "2026-08-20T10": {"total": 747256, "by_model": {"Qwen3.5-122B-A10B": 747256}}, "2026-08-20T11": {"total": 2172925, "by_model": {"Qwen3.5-122B-A10B": 2172925}}, "2026-08-20T17": {"total": 1628101, "by_model": {"Qwen3.5-122B-A10B": 1628101}}, "2026-08-20T18": {"total": 1290913, "by_model": {"Qwen3.5-122B-A10B": 1290913}}}} \ No newline at end of file diff --git a/src/agent/memory/system_prompt.py b/src/agent/memory/system_prompt.py index abcca57..66a20b4 100644 --- a/src/agent/memory/system_prompt.py +++ b/src/agent/memory/system_prompt.py @@ -5,6 +5,7 @@ _BASE_PROMPT = """你是一个具备多模态理解能力的桌面操作助手 - 只有在信息确实无法从当前对话或上传内容中获取,且必须通过外部工具才能获得时,才调用工具。 - 当你调用工具收到截图或返回结果后,必须仔细观察返回内容,基于实际结果继续推进任务,严禁脱离实际结果凭空编造。 - 对于同一个页面下的GUI操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。 +- 【浏览器/网页操作 - web_page 与 gui_tool 合理配合,无固定优先级】涉及浏览器/网页上的操作(点击、输入、选择、滚动、截图、提取内容等),在 web_page(DOM 级:毫秒级、定位可靠)与 gui_tool(视觉级:截图+坐标,可操作 DOM 覆盖不到的区域)之间按场景合理选择、灵活配合:先用 web_page snapshot 获取页面元素清单(已覆盖弹窗/浮层[弹窗]/[浮层]标注、iframe(帧k)、shadow DOM、下拉选项、图标按钮、readonly 日期控件,字段名与[禁用]/[已选中]已标注),能 DOM 定位就优先用 web_page 的 ref/selector/type/date/select 等动作(状态变更操作后自动附最新快照与截图,无需再手动 snapshot);点选下拉选项/菜单项按文字找元素时用 selector `text="完整文字"`(text= 默认精确匹配);日期控件一律用 `date` 动作(value 传 YYYY-MM-DD,自动操作日历面板),不要 fill/eval_js 直接赋值;canvas、原生弹窗、系统对话框、坐标级操作等 DOM 无法覆盖的场景用 gui_tool 视觉定位;复杂控件(自定义下拉/日期/树/搜索框)两者可交叉配合确认。无论用哪个工具,同页连续操作都合并为一次调用。 - 【任务规划】收到任务后,先判断任务复杂度:仅当任务为复杂任务(如多步骤、多阶段、流程较长或产出物较多)时,才调用 todo_list 工具将任务拆解为清晰的执行步骤列表,然后按步骤逐步执行;每完成一步,用 todo_list 标记进度(done_step),任务完成后将所有步骤标记为已完成。简单任务可直接执行,无需调用 todo_list。 - 当任务完成时,向用户总结执行过程和结果。 """.strip() @@ -180,6 +181,14 @@ CRON_MODE_PROMPT = """ - 对于同一个页面下的 GUI 操作,必须将连续操作合并为一次 gui_tool 调用(actions 列表传入多个动作),严禁逐个单步调用。仅当页面发生跳转/加载后需要重新观察时,才允许分步调用。 - 你可以使用 cron_manager 工具查看、新增、修改、删除定时任务(包括查看任务状态),这样无需用户手动操作即可管理定时任务。 +【浏览器/网页操作 - web_page 与 gui_tool 合理配合,无固定优先级】 +涉及浏览器/网页上的操作(点击、输入、选择、滚动、截图、提取内容等),在 web_page(DOM 级,毫秒级定位可靠)与 gui_tool(视觉级,截图+坐标)之间按场景合理选择并灵活配合,不设固定优先级: +1. web_page 的 snapshot 获取页面元素清单(覆盖弹窗/浮层(标注[弹窗]/[浮层]并排在最前)、iframe(标注(帧k),可用 frame=N >> 前缀)、shadow DOM、下拉选项、图标按钮、readonly 日期控件;字段名与[禁用]/[已选中]已标注;原生 select 附选项列表);**状态变更操作(click/fill/type/select/date/scroll/press/goto 等)执行后工具会自动附最新快照与截图**,直接继续下一步即可,无需再手动调 snapshot;仅当需要查看超过 120 个元素(max_elements 传更大值)或页面跳转后确认状态时才显式 snapshot。 +2. 定位习惯:点选下拉选项、菜单项、弹窗按钮等"按文字找元素"的场景,优先用 selector `text="完整文字"`(text= 已默认精确匹配,避免误中同名前缀元素);输入框/文本域按 placeholder 或字段名用 ref 或 `placeholder="..."`;按钮也常用 ref(snapshot 清单序号)。 +3. 日期控件(readonly 输入框、日历面板)一律用 `date` 动作(ref/selector + value 传 YYYY-MM-DD,自动完成年/月/日选择),严禁 fill 直接输入或 eval_js 赋值(Angular 绑定不生效);日期选择失败时先 snapshot/screenshot 核对面板结构再重试。 +4. canvas、浏览器原生弹窗、系统对话框、页面外桌面软件、坐标级操作等 DOM 无法覆盖的场景,用 gui_tool 视觉定位操作;复杂控件(自定义下拉/日期/树/搜索框、定位失败的字段)可 web_page 与 gui_tool 交叉配合确认(如 gui_tool 截图核对、web_page 精确执行)。 +5. 无论使用哪个工具,同一页面的连续操作必须合并为一次调用(actions 列表批量传入),严禁逐条单步调用。 + 【工作空间配置】 - 工作空间 (WORKSPACE_DIR): {workspace_dir} - 你产生的代码、脚本、图片、文档等文件,默认保存到工作空间目录下。 @@ -203,6 +212,9 @@ REFLECTION_PROMPT = """ 【gui_tool 批量调用规则 - 务必遵守】 如果你计划在同一页面/界面执行多个连续的 GUI 操作(例如:依次点击多个按钮、填写多个表单字段、连续输入多项内容),必须将所有这些操作合并为一次 gui_tool 调用,在 actions 列表中一次性传入所有动作。严禁将同一页面的连续操作拆分多次 gui_tool 调用——这样会导致严重的延迟。仅当需要等页面跳转/加载后才能确定后续操作时,才分步调用。 + +【浏览器/网页操作 - web_page 与 gui_tool 合理配合,无固定优先级】 +涉及浏览器/网页上的操作,在 web_page(DOM 级)与 gui_tool(视觉级)之间按场景合理选择并灵活配合:先 web_page snapshot 获取元素清单(已覆盖弹窗/浮层、iframe、shadow DOM、下拉选项、图标按钮与 readonly 日期控件),能 DOM 定位就用 web_page 的 ref/selector/type/date/select 批量操作(状态变更后自动附最新快照与截图,无需再手动 snapshot);点选下拉选项/菜单项按文字找元素时用 selector `text="完整文字"`(text= 默认精确匹配);日期控件一律用 `date` 动作(value 传 YYYY-MM-DD,自动操作日历面板);canvas、浏览器原生弹窗、系统对话框、页面外软件、坐标级操作等 DOM 无法覆盖的场景用 gui_tool 视觉定位;复杂控件可交叉配合确认。无论用哪个工具,同页连续操作都要合并为一次调用。 """.strip() diff --git a/src/agent/tools/__init__.py b/src/agent/tools/__init__.py index e1b3940..f7b8f1a 100644 --- a/src/agent/tools/__init__.py +++ b/src/agent/tools/__init__.py @@ -3,7 +3,6 @@ from .date_query import DateQuery from .browser_control import BrowserControl from .web_page import WebPage from .human_interaction import HumanInteraction -from .directory_listing import DirectoryListing from .todo_list import TodoList from .software_control import SoftwareControl from .gui import GUITool @@ -13,6 +12,8 @@ from .doc import DocTool from .skill_router import SkillRouter from .send_file import SendFileTool from .terminal_execute import TerminalExecute +from .terminal_info import TerminalInfo +from .wait import WaitTool from .email import MailTool from .image_gen import ImageGen from .cron_manager import CronManagerTool @@ -28,7 +29,6 @@ _all_tools = [ HumanInteraction(), SoftwareControl(), RAGTool(), - DirectoryListing(), TodoList(), GUITool(), CallSubAgentTool(), @@ -36,6 +36,8 @@ _all_tools = [ SkillRouter(), SendFileTool(), TerminalExecute(), + TerminalInfo(), + WaitTool(), MailTool(), ImageGen(), CronManagerTool(), diff --git a/src/agent/tools/agent_call.py b/src/agent/tools/agent_call.py index ce022d8..b35e1fe 100644 --- a/src/agent/tools/agent_call.py +++ b/src/agent/tools/agent_call.py @@ -148,7 +148,8 @@ class CallSubAgentTool(BaseTool): HumanMessage(content=task_description), ] - config = {"recursion_limit": runtime.max_iterations} + # recursion_limit 按节点计:本图每轮循环 4 节点,max_iterations 语义为轮数 + config = {"recursion_limit": runtime.max_iterations * 4 + 16} # 设置子 Agent 上下文:agent 名称供前端浮窗按 agent 分组 from agent.core.human_request import set_current_agent_name diff --git a/src/agent/tools/browser_control.py b/src/agent/tools/browser_control.py index b3f5aa5..d0111bb 100644 --- a/src/agent/tools/browser_control.py +++ b/src/agent/tools/browser_control.py @@ -6,12 +6,14 @@ 会话架构(分离进程 + CDP 连接): - 浏览器以独立进程启动(--remote-debugging-port),Agent 退出后窗口保留 - - Agent 重启后通过 CDP 自动重连同一浏览器(登录态、标签页不丢) + - Agent 重启后通过 CDP 自动重连同一浏览器(标签页不丢) + - 默认每次启动使用一次性临时 profile(登录态不持久,不留盘); + 显式设置 BROWSER_PROFILE_DIR 环境变量时使用持久 profile(登录态保留) - 专用后台线程运行 asyncio loop,所有 Playwright 异步操作在该线程串行执行, 规避 sync API 的线程亲和问题 可扩展性: - - 端口与 profile 目录取环境变量 BROWSER_DEBUG_PORT / BROWSER_PROFILE_DIR 覆盖 + - 端口与持久 profile 目录取环境变量 BROWSER_DEBUG_PORT / BROWSER_PROFILE_DIR 覆盖 - 新的页面级操作经 BrowserSession.call_async 提交协程即可,无需改动线程模型 """ from __future__ import annotations @@ -22,8 +24,10 @@ import glob import json import os import re +import shutil import subprocess import sys +import tempfile import threading import time import urllib.request @@ -40,7 +44,10 @@ from pydantic import BaseModel, Field, create_model # ---------- 配置常量 ---------- _AGENT_DIR = Path(__file__).resolve().parents[1] BROWSER_DEBUG_PORT = int(os.getenv("BROWSER_DEBUG_PORT", "9222")) +# 显式设置 BROWSER_PROFILE_DIR 时使用持久 profile 目录(登录态保留); +# 未设置时每次启动用一次性临时 profile(不落盘 data/,登录态不持久)。 BROWSER_PROFILE_DIR = str(Path(os.getenv("BROWSER_PROFILE_DIR", str(_AGENT_DIR / "data" / "browser_profile")))) +_TEMP_PROFILE_PREFIX = "agent_browser_profile_" _CDP_URL = f"http://127.0.0.1:{BROWSER_DEBUG_PORT}" _LAUNCH_TIMEOUT = 15.0 # 分离启动后等待 CDP 就绪的秒数 _DEFAULT_OP_TIMEOUT = 45.0 # 单次操作的默认外层超时 @@ -105,6 +112,27 @@ def _resolve_executable(browser: Optional[str]) -> str: ) +def _resolve_profile_dir() -> str: + """解析浏览器 profile 目录。 + + - 显式设置 BROWSER_PROFILE_DIR 环境变量 → 使用持久目录(登录态跨重启保留); + - 未设置 → 每次启动新建一次性临时目录(不落盘 data/,登录态不持久), + 并顺带清理上一次会话遗留的临时 profile。 + 浏览器为分离进程(Agent 退出后可能仍存活),无法在退出时精确清理, + 故改为下次启动时统一清理。 + """ + if os.getenv("BROWSER_PROFILE_DIR"): + # 实时读环境变量(BROWSER_PROFILE_DIR 常量仅为导入期默认值快照) + return str(Path(os.getenv("BROWSER_PROFILE_DIR"))) + base = tempfile.gettempdir() + try: + for old in glob.glob(os.path.join(base, _TEMP_PROFILE_PREFIX + "*")): + shutil.rmtree(old, ignore_errors=True) + except Exception: + pass + return tempfile.mkdtemp(prefix=_TEMP_PROFILE_PREFIX) + + def _cdp_alive() -> Optional[dict]: """探测 CDP 调试端口是否存活,存活返回 /json/version 内容。""" try: @@ -129,8 +157,8 @@ class BrowserSession: self._page = None # 当前活动标签页 self._proc: Optional[subprocess.Popen] = None self._watched: set[int] = set() # 已注册 close 监听的 page id - # ref 注册表(snapshot 序号 -> CSS 选择器) - self._refs: dict[int, str] = {} + # ref 注册表(snapshot 序号 -> {"frame": frame 索引, "sel": CSS 选择器}) + self._refs: dict[int, dict] = {} self._refs_page = None self._launch_browser_pref: Optional[str] = None atexit.register(self._atexit_shutdown) @@ -190,7 +218,7 @@ class BrowserSession: contexts = self._browser.contexts self._context = contexts[0] if contexts else await self._browser.new_context() try: - self._context.set_default_timeout(10_000) # 动作超时 10s + self._context.set_default_timeout(3_000) # 动作超时 3s(_loc_op 统一重试一次,最坏 ≈6.6s) self._context.set_default_navigation_timeout(30_000) # 导航超时 30s except Exception: pass @@ -233,11 +261,12 @@ class BrowserSession: async def _launch_detached(self) -> None: """以分离进程启动浏览器并等待 CDP 就绪。""" exe = _resolve_executable(self._launch_browser_pref) - Path(BROWSER_PROFILE_DIR).mkdir(parents=True, exist_ok=True) + profile_dir = _resolve_profile_dir() + Path(profile_dir).mkdir(parents=True, exist_ok=True) cmd = [ exe, f"--remote-debugging-port={BROWSER_DEBUG_PORT}", - f"--user-data-dir={BROWSER_PROFILE_DIR}", + f"--user-data-dir={profile_dir}", "--start-maximized", "--no-first-run", "--no-default-browser-check", @@ -404,24 +433,36 @@ class BrowserSession: }) return tabs - async def _settle_current(self) -> None: - """等待当前页稳定(截图前调用),避免截到加载动画而非动作结果。 + async def _settle_current(self, force: bool = False) -> None: + """等待当前页稳定(截图/观察前调用),避免截到加载动画而非动作结果。 - 三段式:主文档加载完成(4s 上限)→ 网络空闲(3s 上限,覆盖 AJAX; - 长轮询页面会超时直接放行)→ 0.3s 渲染缓冲。 + 三段式:主文档加载完成 → 网络空闲(覆盖 AJAX;长轮询页面会超时直接放行) + → 短渲染缓冲(覆盖弹窗/浮层入场动画)。预算压缩后最长约 3.9s, + 页面已就绪时毫秒级返回。 + + 性能:2s 内重复调用直接跳过(连续 snapshot/extract 只等待一次), + 状态变更动作后由 web_page 调 _mark_dirty() 重置,保证变更后必等。 """ + now = time.monotonic() + if not force and (now - getattr(self, "_last_settle_ts", 0.0)) < 2.0: + return page = self._page if page is None: return try: - await page.wait_for_load_state("domcontentloaded", timeout=4000) + await page.wait_for_load_state("domcontentloaded", timeout=2000) except Exception: pass try: - await page.wait_for_load_state("networkidle", timeout=3000) + await page.wait_for_load_state("networkidle", timeout=1500) except Exception: pass - await asyncio.sleep(0.3) + await asyncio.sleep(0.3) # 覆盖弹窗/浮层入场动画后再观察 + self._last_settle_ts = time.monotonic() + + def _mark_dirty(self) -> None: + """标记页面状态已变更(点击/输入等动作后调用),强制下次 _settle_current 完整等待。""" + self._last_settle_ts = 0.0 async def screenshot(self, full_page: bool = False) -> bytes: """当前标签页截图(先等待页面稳定),返回 PNG 字节。""" @@ -430,13 +471,35 @@ class BrowserSession: return await self._page.screenshot(full_page=full_page) # ---------- ref 注册表(供 web_page 在会话线程内使用) ---------- - def set_refs(self, refs: dict[int, str], page) -> None: + def set_refs(self, refs: dict[int, dict], page) -> None: + """注册 snapshot 序号 -> {"frame": frame 索引, "sel": CSS 选择器}。 + + frame 索引对应 snapshot 时 page.frames 的顺序(0 为主 frame), + 使 ref 可以跨 iframe 定位元素。同时记录快照时的 URL—— + 页面 reload/SPA 跳转(同 Page 对象)后旧 ref 会指向错误元素, + 需重新 snapshot。 + """ self._refs = refs self._refs_page = page + try: + self._refs_url = page.url + except Exception: + self._refs_url = "" - def get_ref_selector(self, ref: int) -> Optional[str]: + def get_ref(self, ref: int) -> Optional[dict]: return self._refs.get(ref) + def refs_stale(self, page) -> bool: + """refs 是否已失效:页面被替换(换标签/关闭)或 URL 变化(reload/跳转)。""" + if self._refs_page is not page or not self._refs: + return True + try: + if page.url != self._refs_url: + return True + except Exception: + return True + return False + # ---------- 同步门面(工具线程/主线程调用,内部转投会话线程) ---------- def open(self, url: Optional[str] = None, browser: Optional[str] = None) -> dict[str, Any]: return self.call_async(lambda s: s.ensure(browser) if url is None else s.open_tab(url, browser), @@ -527,7 +590,8 @@ class BrowserControl(BaseTool): "- action='close', url='任意非空字符(例如current)': 关闭当前标签页\n" "- action='list_tabs': 列出全部标签页(索引、标题、URL)\n" "- action='switch_tab', tab_index=N: 切换到第 N 个标签页\n" - "操作完成后返回当前页面截图。打开页面后的元素级操作请使用 web_page 工具。\n" + "操作完成后返回操作描述。浏览器为独立可见窗口(detached CDP)," + "不随结果返回截图;打开页面后的元素级操作与页面状态请使用 web_page 工具。\n" "仅用于进行浏览器操作时调用,其余任何情况严禁调用。" ) response_format: Literal["content", "content_and_artifact"] = "content_and_artifact" @@ -573,14 +637,10 @@ class BrowserControl(BaseTool): except Exception as e: return f"执行浏览器操作时出错: {e}", {} - # 关闭整个浏览器后无页面可截,其余情况返回当前页截图 - if action == "close" and url is None: - return msg, {} - try: - png = session.screenshot_sync() - return msg, {"image_bytes": png} - except Exception as e: - return f"{msg}\n(截屏失败: {e})", {} + # 浏览器为独立可见窗口(detached CDP):截图模型不可见、且每次操作 + # 数百 KB,纯属浪费(打开浏览器不需要图片返回)。页面状态由 web_page + # 工具按需获取(snapshot/screenshot 动作),此处仅返回文本描述。 + return msg, {} # ---------- 异步入口 ---------- async def _arun(self, action: str = "open", url: Optional[str] = None, diff --git a/src/agent/tools/directory_listing.py b/src/agent/tools/directory_listing.py deleted file mode 100644 index 3d7f84d..0000000 --- a/src/agent/tools/directory_listing.py +++ /dev/null @@ -1,61 +0,0 @@ -from __future__ import annotations - -import os -import asyncio -from pathlib import Path -from typing import Optional - -from langchain.tools import BaseTool -from pydantic import BaseModel, Field, create_model - - -class DirectoryListing(BaseTool): - """列出指定目录下的文件和文件夹,帮助 Agent 了解本地文件结构。""" - - args_schema: type[BaseModel] = create_model( - "DirectoryListingInput", - path=( - Optional[str], - Field( - default=None, - description="要查看的目录路径,默认为当前工作目录。" - ), - ), - show_hidden=( - bool, - Field( - default=False, - description="是否显示隐藏文件(以点开头的文件/文件夹),默认不显示。" - ), - ), - ) - name: str = "directory_listing" - description: str = ( - "列出指定目录下的所有文件和子目录。可用于探索文件系统、查找文件位置。" - "仅当需要获取目录内容或确认文件结构时调用,其余情况严禁调用。" - ) - - def _run(self, path: Optional[str] = None, show_hidden: bool = False) -> str: - try: - target = Path(path).resolve() if path else Path.cwd() - if not target.exists(): - return f"路径不存在: {target}" - if not target.is_dir(): - return f"路径不是目录: {target}" - - items = [] - with os.scandir(target) as entries: - for entry in entries: - if not show_hidden and entry.name.startswith('.'): - continue - item_type = "DIR" if entry.is_dir() else "FILE" - items.append(f"[{item_type}] {entry.name}") - - if not items: - return f"目录 {target} 为空" - return f"目录 {target} 的内容:\n" + "\n".join(sorted(items)) - except Exception as e: - return f"列出目录时出错: {str(e)}" - - async def _arun(self, path: Optional[str] = None, show_hidden: bool = False) -> str: - return await asyncio.to_thread(self._run, path, show_hidden) diff --git a/src/agent/tools/human_interaction.py b/src/agent/tools/human_interaction.py index 2041e6c..2b5e9e3 100644 --- a/src/agent/tools/human_interaction.py +++ b/src/agent/tools/human_interaction.py @@ -10,20 +10,37 @@ from pydantic import BaseModel, Field class HumanInteractionInput(BaseModel): title: str = Field(default="需要人工处理", description="展示给人类的交互标题。") - prompt: str = Field(default="请处理后继续。", description="需要人类理解并响应的具体问题。") - options: Optional[list[str] | str] = Field( + prompt: str | list[str] = Field( + default="请处理后继续。", + description="需要人类理解并响应的具体问题。传字符串为单个问题;" + "传字符串数组为一次交互的多个问题(人类依次作答,统一返回结果)。", + ) + options: Optional[list | str] = Field( default=None, - description="可选项。优先传字符串数组;JSON 字符串或逗号分隔会被自动解析。" - "可空——人类也可直接在补充输入框中输入任意信息。", + description="可选项,每个问题最多 3 项。单问题时优先传字符串数组;" + "多问题时传与 prompt 一一对应的二维数组(每个子问题子数组最多 3 项)。" + "支持 JSON 字符串或逗号分隔;可空——人类也可直接在补充输入框中输入任意信息。", ) def normalize_human_interaction_args(args: dict) -> dict: - """规范化 human_interaction 工具参数为固定 schema(title / prompt / options)。""" + """规范化 human_interaction 工具参数为固定 schema(title / questions / prompt / options)。 + + questions: [{prompt, options}],多问题时由 prompt 列表 + options 二维数组拼出; + 每个问题 options 最多 3 项(超出截断)。 + """ normalized = dict(args or {}) normalized["title"] = str(normalized.get("title") or "需要人工处理") - normalized["prompt"] = str(normalized.get("prompt") or "请处理后继续。") - normalized["options"] = HumanInteraction.normalize_options(normalized.get("options")) + prompts = normalized.get("prompt") + if isinstance(prompts, list): + prompt_list = [str(p or "请处理后继续。") for p in prompts] + else: + prompt_list = [str(prompts or "请处理后继续。")] + questions = HumanInteraction.build_questions(prompt_list, normalized.get("options")) + normalized["questions"] = questions + # 兼容旧字段:prompt / options 保留第一个问题(历史消费者与摘要渲染使用) + normalized["prompt"] = questions[0]["prompt"] + normalized["options"] = questions[0]["options"] return normalized @@ -34,9 +51,12 @@ class HumanInteraction(BaseTool): name: str = "human_interaction" description: str = ( "当任务缺少关键信息或需要人类选择路径时调用。" - "通过 options 提供可选项(可空),人类可以从选项中选择," - "也可在补充输入框中输入任意信息——输入内容将直接作为本工具的返回值。" - "工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。" + "prompt 可为单个问题或问题数组(一次交互多个问题,人类依次作答);" + "每个问题的 options 最多 3 项,是/否类问题传 [\"是\", \"否\"];可空。" + "多问题作答结果以编号列表统一返回,如:" + "“1、用户选择B选项;2、用户选择忽略此问题;3、用户选择A选项;”。" + "人类也可在补充输入框中输入任意信息——输入内容将作为对应问题的答案。" + "本工具返回必须被视为用户原始意图的纯字符串反馈。其余情况严禁调用。" ) @staticmethod @@ -57,6 +77,36 @@ class HumanInteraction(BaseTool): return [str(parsed)] return [str(item) for item in options] + @staticmethod + def build_questions(prompts: list[str], options) -> list[dict]: + """prompts 与 options 对齐构建问题列表;每个问题最多 3 个选项。 + + options 支持:二维数组(与问题一一对应,子元素可以是数组/JSON串/逗号串/None)、 + 扁平数组(仅用于第一个问题)、JSON 字符串 / 逗号分隔串(同扁平数组)。 + """ + if isinstance(options, str): + raw = options.strip() + try: + parsed = json.loads(raw) if raw else [] + except json.JSONDecodeError: + parsed = [item.strip() for item in raw.replace(",", ",").split(",") if item.strip()] + options = parsed + if isinstance(options, list) and any(isinstance(o, list) for o in options): + # 二维矩阵:每个子项独立规范化(数组 / JSON/逗号串 / None 均可) + matrix = options + elif isinstance(options, list): + matrix = [options] + [[] for _ in range(len(prompts) - 1)] + else: + matrix = [[] for _ in prompts] + questions = [] + for i, prompt in enumerate(prompts): + raw_opts = matrix[i] if i < len(matrix) else None + questions.append({ + "prompt": prompt, + "options": HumanInteraction.normalize_options(raw_opts)[:3], + }) + return questions + @staticmethod def format_result(response: str) -> str: """将用户输入(选中的选项或补充输入框内容)直接作为工具返回值。""" @@ -74,16 +124,25 @@ class HumanInteraction(BaseTool): agent_name = "主Agent" if _ag in ("", "__main__") else _ag except Exception: pass + questions = args.get("questions") or [ + {"prompt": args.get("prompt") or "请处理后继续。", "options": args.get("options") or []} + ] return { "type": "human_interaction", "title": args["title"] or "需要人工处理", - "prompt": args["prompt"] or "请处理后继续。", + "questions": questions, + # 兼容旧前端:按第一个问题给出 prompt / options 摘要 + "prompt": questions[0]["prompt"], "tool_name": "human_interaction", "args": { - "说明": args["prompt"] or "请处理后继续。", - "可选项": args["options"] or [], + "问题数": len(questions), + "说明": questions[0]["prompt"] or "请处理后继续。", + "可选项": questions[0]["options"] or [], + } if len(questions) > 1 else { + "说明": questions[0]["prompt"] or "请处理后继续。", + "可选项": questions[0]["options"] or [], }, - "options": args["options"] or [], + "options": questions[0]["options"] or [], "agent_name": agent_name, "is_sub_agent": agent_name != "主Agent", "resolved": False, diff --git a/src/agent/tools/terminal_execute.py b/src/agent/tools/terminal_execute.py index 6d77df4..cb5266d 100644 --- a/src/agent/tools/terminal_execute.py +++ b/src/agent/tools/terminal_execute.py @@ -30,6 +30,12 @@ class TerminalExecuteInput(BaseModel): True, description="是否在虚拟环境中执行命令。默认 True,会自动激活配置的虚拟环境。设为 False 则使用系统级环境。", ) + background: bool = Field( + False, + description="是否挂起后台执行(长驻/长时任务专用)。默认 False。设为 True 时立即返回任务ID(PID)," + "进程在后台继续运行,可用 terminal_info 查看状态、wait 等待结束;" + "本工具【不支持】交互式命令(密码/采集输入等),此类命令应放弃执行并建议用户手动操作。", + ) class TerminalExecute(BaseTool): @@ -46,7 +52,18 @@ class TerminalExecute(BaseTool): "【参数说明】\n" "- command: 必填,要执行的命令字符串\n" "- working_dir: 可选,命令执行的工作目录\n" - "- timeout: 超时时间(秒),默认 120 秒\n\n" + "- timeout: 超时时间(秒),默认 120 秒\n" + "- background: 是否挂起后台执行(长驻服务、长时任务专用),默认 False\n\n" + "【挂起执行(background=True)】\n" + "- 立即返回任务ID(PID);进程在后台继续运行,在会话终端浮窗实时可见\n" + "- 用 terminal_info(任务ID) 查看当前状态与最新输出;用 wait(任务ID, 超时秒数) 原地等待结束并获取最终输出\n" + "- 终止任务:用本工具(background=False)执行 `taskkill /F /T /PID <任务ID>`,或让用户在终端浮窗点击终止按钮\n\n" + "【重要限制:不支持交互式命令】\n" + "- 本工具(含挂起模式)无法向终端输入任何内容。命令运行中途需要用户输入\n" + " (SSH/凭据密码、`input()` 算法题采集、sudo 等)时,应【放弃执行】该命令,\n" + " 改用 human_interaction 提示用户:请用户在桌面终端(底部终端栏)手动执行;\n" + " 严禁发起此类无法推进的交互式命令。\n" + "- 已挂起的交互式命令若卡住:用 background=False 执行 taskkill /F /T /PID <任务ID> 终止。\n\n" "【数据作图标准流程】(本工具替代 data_plot,用于基于已入库数据库数据作图)\n" "当用户要求对已入库的数据画图/出图表时,按以下流程用本工具执行一个 Python 脚本:\n" "1. 查库:脚本内连接数据库(MySQL 或 SQLite)。连接参数从 env_config.json 的\n" @@ -74,6 +91,7 @@ class TerminalExecute(BaseTool): working_dir: Optional[str] = None, timeout: int = 120, use_venv: bool = True, + background: bool = False, ) -> str: try: # 如果未指定 working_dir,优先使用运行时选中的工作空间,回退到 env_config 默认值 @@ -93,6 +111,32 @@ class TerminalExecute(BaseTool): if not os.path.isdir(cwd): return f"[ERROR] 工作目录不存在: {cwd}" + # 挂起模式:交给统一进程注册表管理(不阻塞、不接管 stdin) + if background: + try: + from agent.tools.terminal_tasks import start_background_task + from agent.history.tool_call_recorder import get_current_session, get_current_tool_call_id + pid = start_background_task( + get_current_session() or "", command, cwd, use_venv=use_venv, + tool_call_id=get_current_tool_call_id(), + ) + return ( + f"=== 终端命令已挂起 ===\n" + f"命令: {command}\n" + f"工作目录: {cwd}\n" + f"任务ID(PID): {pid}\n" + f"状态: 后台运行中(已注册管理,可在终端浮窗实时查看)\n\n" + f"后续操作:\n" + f"- 查看状态: terminal_info(task_id={pid})\n" + f"- 等待结束: wait(task_id={pid}, timeout=秒数)\n" + f"- 手动终止: taskkill /F /T /PID {pid}(或让用户在终端浮窗点击终止)\n" + f"注意: 本命令无法接收输入;若需要交互输入请放弃并让用户手动执行。" + ) + except FileNotFoundError: + return f"[ERROR] 找不到 PowerShell,请确认系统环境正常" + except Exception as e: + return f"[ERROR] 挂起后台任务失败: {str(e)}" + # 构建完整命令:如果启用 venv,将虚拟环境 Scripts 目录注入 PATH 最前 full_command = command if use_venv: @@ -112,19 +156,27 @@ class TerminalExecute(BaseTool): errors="replace", ) + # 登记到统一进程注册表:超时/轮次结束时按会话杀整个进程树(资源边界) + from agent.tools.terminal_tasks import register_foreground, unregister_foreground, kill_tree + from agent.history.tool_call_recorder import get_current_session, get_current_tool_call_id + register_foreground(proc, get_current_session() or "", command, cwd, + tool_call_id=get_current_tool_call_id()) + try: stdout, stderr = proc.communicate(timeout=timeout) except subprocess.TimeoutExpired: - proc.kill() + kill_tree(proc.pid) # 杀整个进程树(含子进程),杜绝孤儿 stdout, stderr = proc.communicate() return ( - f"[ERROR] 命令执行超时({timeout}秒),已强制终止\n" + f"[ERROR] 命令执行超时({timeout}秒),已强制终止(含子进程树)\n" f"命令: {command}\n" f"工作目录: {cwd}\n" f"--- 超时前已获取的部分输出 ---\n" f"stdout: {(stdout or '').strip()[-500:]}\n" f"stderr: {(stderr or '').strip()[-500:]}" ) + finally: + unregister_foreground(proc.pid) returncode = proc.returncode stdout = stdout.strip() diff --git a/src/agent/tools/terminal_info.py b/src/agent/tools/terminal_info.py new file mode 100644 index 0000000..495438e --- /dev/null +++ b/src/agent/tools/terminal_info.py @@ -0,0 +1,44 @@ +"""terminal_info 工具:查看后台终端任务信息(终端工具族,图标同 terminal_execute)。 + +返回任务当前状态(运行中/已完成/异常退出/已终止)、退出码、异常标志、 +运行时长、输出字符数,以及**截断返回的最新 1000 字符**输出——用于 agent +判断任务是否健康推进(长时间无输出且持续运行可能是交互式命令卡死, +此时应 taskkill 终止并建议用户手动执行)。 +""" +from __future__ import annotations + +from langchain.tools import BaseTool +from pydantic import BaseModel, Field + + +class TerminalInfoInput(BaseModel): + task_id: int = Field( + ..., + description="后台终端任务ID(即 terminal_execute(background=True) 返回的任务ID,也就是进程 PID)。", + ) + + +class TerminalInfo(BaseTool): + args_schema: type[BaseModel] = TerminalInfoInput + name: str = "terminal_info" + description: str = ( + "查看后台终端任务信息。查询由 terminal_execute(background=True) 挂起的任务当前状态," + "并返回最新输出(截断至最近 1000 字符)。\n\n" + "返回内容:\n" + "- 任务ID(PID) / 命令 / 工作目录\n" + "- 状态: 运行中 | 已完成(退出码)| 异常退出(退出码)| 已终止\n" + "- 运行时长 / 输出总计(超出缓冲上限会截断并提示)\n" + "- 最新 1000 字符输出(stdout/stderr 分开)\n\n" + "判断技巧:\n" + "- 任务长时间“运行中”且输出不再增长 → 可能卡在交互输入(密码/采集输入)," + "本工具不支持输入,应执行 taskkill /F /T /PID <任务ID> 终止,并提示用户手动完成该操作。\n" + "- 任务结束 → 用 wait(task_id=任务ID) 获取完整最终输出。" + ) + + def _run(self, task_id: int) -> str: + from agent.tools.terminal_tasks import task_info_text + return task_info_text(int(task_id), max_chars=1000) + + async def _arun(self, **kwargs) -> str: + import asyncio + return await asyncio.to_thread(self._run, **kwargs) diff --git a/src/agent/tools/terminal_tasks.py b/src/agent/tools/terminal_tasks.py new file mode 100644 index 0000000..50fa8cf --- /dev/null +++ b/src/agent/tools/terminal_tasks.py @@ -0,0 +1,621 @@ +"""统一进程注册表:管理由 terminal_execute 启动的进程(前台 + 后台挂起)。 + +职责: +- 前台命令:登记/注销,超时或轮次结束时按会话清理并杀整个进程树 + (资源边界:杜绝"单次命令超时后其子进程/孤儿进程继续存活"——单点 + ``proc.kill()`` 只杀 PowerShell 父进程,其子进程会变孤儿;注册表统一 + 用 ``taskkill /F /T /PID`` 按树清理,覆盖工具内超时、框架级超时线程 + 残留与服务退出/崩溃残留三个通道)。 +- 后台挂起任务:持续管理(stdout/stderr 环形缓冲、状态、退出码、异常标志), + 供 ``wait``/``terminal_info`` 工具与前端终端浮窗实时查看;**不接管 stdin** + ——交互式命令(密码、算法题采集输入等)由工具描述明确禁止 agent 执行。 +- 各任务按 session_id 隔离(子 Agent 经 contextvar 传播归属同一会话), + 父级会话浮窗可见全部任务,互不串扰。 +- 进程树终止统一使用 Windows 现成 ``taskkill /F /T /PID``(零新依赖; + 同 electron/main.js forceKill 与 cron runner 的先例)。 + +系统定位: + 被 ``tools/terminal_execute``(前台登记/后台挂起)、``tools/wait``、 + ``tools/terminal_info``、``web/server.py``(SSE/终止/生命周期钩子)、 + ``core/turn_runner``(轮次结束清理前台残留)使用。 +""" +from __future__ import annotations + +import json +import os +import queue as _queue_module +import subprocess +import threading +import time +from collections import deque +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +# ---------- 常量 ---------- +MAX_BUFFER_CHARS = 256 * 1024 # 每任务输出环形缓冲上限(字符),超出丢最旧 +MAX_TASKS_PER_SESSION = 50 # 每会话最多保留的任务条目(超出淘汰最旧已完成) +READ_CHUNK = 4096 # reader 每次读取字符数 +KILL_TREE_TIMEOUT = 5 # taskkill 等待秒数 +INITIAL_OUTPUT_LIMIT = 64 * 1024 # SSE 连接/轮询时随任务下发的最多输出字符 + +# 崩溃恢复用的 pid 文件(服务退出时遗留进程,下次启动据此清理) +try: + from agent.utils.agent_utils import HISTORY_ROOT + _PID_FILE = Path(os.path.join(HISTORY_ROOT, "terminal_tasks_pids.json")) +except Exception: + _PID_FILE = Path(os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", "..", + "history", "terminal_tasks_pids.json", + )) + + +# ---------- 任务信息 ---------- +@dataclass +class TaskInfo: + pid: int + session_id: str + command: str # 展示用原始命令(未注入 venv PATH) + cwd: str + scope: str # "foreground" | "background" + tool_call_id: str = "" + status: str = "running" # running | done | killed + exit_code: int | None = None + abnormal: bool = False # 异常退出(exit_code 非 0 且非手动终止) + started_at: float = field(default_factory=time.time) + finished_at: float | None = None + proc: Any | None = None + # 输出环形缓冲:按 (seq, source, text) 追加;seq 单调递增(丢弃只丢最旧) + chunks: deque[tuple[int, str, str]] = field(default_factory=deque) + total_chars: int = 0 + truncated: bool = False # 缓冲是否因超限丢弃过最旧输出 + _chunk_seq: int = 0 + _readers: list[threading.Thread] = field(default_factory=list) + lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + +# ---------- 全局注册表(session_id → {pid: TaskInfo}) ---------- +_tasks: dict[str, dict[int, TaskInfo]] = {} +_tasks_lock = threading.Lock() + +# pid 文件内存镜像(含已登记但未结束的前台命令) +_pid_entries: set[int] = set() +_pid_lock = threading.Lock() + +# ---------- SSE 订阅(同 recorder 的 pub/sub 模式) ---------- +_subscribers: dict[str, list[_queue_module.Queue]] = {} +_subscribers_lock = threading.Lock() + + +def _now() -> float: + return time.time() + + +def _all_tasks() -> list[TaskInfo]: + with _tasks_lock: + return [t for d in _tasks.values() for t in d.values()] + + +def get_task(pid: int) -> TaskInfo | None: + """按 PID 全局查找任务(PID 唯一)。""" + with _tasks_lock: + for d in _tasks.values(): + t = d.get(pid) + if t: + return t + return None + + +def list_tasks(session_id: str) -> list[TaskInfo]: + """列出某会话的全部任务(新→旧)。""" + with _tasks_lock: + d = _tasks.get(session_id, {}) + return sorted(d.values(), key=lambda t: t.started_at, reverse=True) + + +# ---------- 启动/登记 ---------- +def _build_powershell_argv(command: str, cwd: str, use_venv: bool) -> list[str]: + """构建 PowerShell argv(venv PATH 注入逻辑与 terminal_execute 保持一致)。 + + 末尾追加 ``exit $LASTEXITCODE``:powershell -Command 默认不传递原生命令的 + 退出码(失败统一返回 1),后台任务按退出码判定"异常退出"需要真实值。 + """ + full_command = command + if use_venv: + try: + from agent.tools.terminal_execute import TerminalExecute + scripts_dir = TerminalExecute._get_venv_scripts_dir() + if scripts_dir: + full_command = f'$env:PATH = "{scripts_dir};" + $env:PATH; {command}' + except Exception: + pass + return ["powershell", "-Command", f'{full_command}; exit $LASTEXITCODE'] + + +def _spawn(command: str, cwd: str, use_venv: bool): + """Popen PowerShell,stdout/stderr 二进制管道(os.read 分块读取,解码 gbk), + 不接管 stdin(交互式命令由工具描述禁止 agent 执行)。 + + 说明: 文本模式 TextIOWrapper 无 read1 方法,逐块接收会阻塞或异常关闭 + 管道读端(导致子进程 BrokenPipe 退出);用二进制 + os.read 可即时、 + 无损地流式读取。 + """ + return subprocess.Popen( + _build_powershell_argv(command, cwd, use_venv), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + cwd=cwd, + ) + + +def _read_stream(task: TaskInfo, source: str, stream) -> None: + """os.read 分块读取管道(阻塞至至少 1 字节或 EOF),按 gbk 解码追加缓冲。""" + fd = stream.fileno() + while True: + try: + chunk = os.read(fd, READ_CHUNK) + except Exception: + return # 读取失败不再关闭管道(关闭读端会向子进程发破碎管道信号) + if not chunk: + break + _append_output(task, source, chunk.decode("gbk", errors="replace")) + try: + stream.close() + except Exception: + pass + + +def register_foreground(proc, session_id: str, command: str, cwd: str, tool_call_id: str = "") -> int: + """前台命令登记(启动后、执行前调用;正常返回前由 unregister_foreground 注销)。""" + return _register(proc, session_id, command, cwd, "foreground", tool_call_id) + + +def unregister_foreground(pid: int) -> None: + """前台命令注销(正常返回时调用,进程树已随命令结束)。""" + task = get_task(pid) + if not task or task.scope != "foreground": + return + with _tasks_lock: + bucket = _tasks.get(task.session_id) + if bucket: + bucket.pop(pid, None) + if not bucket: + _tasks.pop(task.session_id, None) + _pid_remove(pid) + + +def start_background_task( + session_id: str, + command: str, + cwd: str, + use_venv: bool = True, + tool_call_id: str = "", +) -> int: + """启动后台挂起任务,立即返回(task_id = pid)。进程结束后自动: + 更新该工具调用的 live 记录为 done,并在会话轮次仍活跃时经图外注入 + 把结果作为 ToolMessage 加入本轮工具返回值。不接管 stdin。""" + proc = _spawn(command, cwd, use_venv) + task = _register(proc, session_id, command, cwd, "background", tool_call_id) + + def _watcher(): + try: + proc.wait() + except Exception: + pass + # 进程退出后短暂等待 reader 把管道尾部读完,再定稿状态 + for r in task._readers: + r.join(timeout=2) + _finalize_task(task) + + for src, stream in (("out", proc.stdout), ("err", proc.stderr)): + t = threading.Thread(target=_read_stream, args=(task, src, stream), daemon=True, name=f"term-reader-{proc.pid}-{src}") + task._readers.append(t) + t.start() + watcher = threading.Thread(target=_watcher, daemon=True, name=f"term-watch-{proc.pid}") + watcher.start() + return proc.pid + + +def _register(proc, session_id: str, command: str, cwd: str, scope: str, tool_call_id: str) -> TaskInfo: + task = TaskInfo( + pid=proc.pid, + session_id=session_id or "", + command=command, + cwd=cwd, + scope=scope, + tool_call_id=tool_call_id or "", + proc=proc, + ) + with _tasks_lock: + bucket = _tasks.setdefault(session_id or "", {}) + bucket[proc.pid] = task + # 每会话最多保留 N 条:淘汰最旧的已完成任务 + running = [t for t in bucket.values() if t.status == "running"] + done = sorted((t for t in bucket.values() if t.status != "running"), key=lambda t: t.started_at) + surplus = len(running) + len(done) - MAX_TASKS_PER_SESSION + for old in done[: max(0, surplus)]: + bucket.pop(old.pid, None) + _pid_add(proc.pid) + _notify(task.session_id, {"type": "task_update", "task": task_to_dict(task)}) + return task + + +# ---------- 输出缓冲 ---------- +def _append_output(task: TaskInfo, source: str, text: str) -> None: + if not text: + return + with task.lock: + task._chunk_seq += 1 + task.chunks.append((task._chunk_seq, source, text)) + task.total_chars += len(text) + # 环形裁剪:超过上限丢最旧 chunk + while task.total_chars > MAX_BUFFER_CHARS and task.chunks: + _, _, old = task.chunks.popleft() + task.total_chars -= len(old) + task.truncated = True + _notify(task.session_id, { + "type": "output", + "task_id": task.pid, + "chunks": [{"seq": task._chunk_seq, "source": source, "text": text}], + }) + + +def get_output_chunks(task: TaskInfo, max_chars: int = INITIAL_OUTPUT_LIMIT) -> tuple[list[dict], int]: + """返回最近 max_chars 的输出 chunk 列表(含 seq)及下一 seq。""" + with task.lock: + chunks = list(task.chunks) + out: list[dict] = [] + total = 0 + for seq, source, text in reversed(chunks): + if total + len(text) > max_chars and out: + break + out.append({"seq": seq, "source": source, "text": text}) + total += len(text) + out.reverse() + next_seq = (chunks[-1][0] + 1) if chunks else 0 + return out, next_seq + + +def _tail(task: TaskInfo, max_chars: int) -> tuple[str, str]: + """返回 (stdout 尾部, stderr 尾部),各自不超过 max_chars 字符。""" + with task.lock: + chunks = list(task.chunks) + out_parts: list[str] = [] + err_parts: list[str] = [] + out_len = err_len = 0 + for _, source, text in reversed(chunks): + if source == "err": + if err_len + len(text) > max_chars: + text = text[-max(0, max_chars - err_len):] + err_parts.append(text) if text else None + err_len = max_chars + else: + err_parts.append(text) + err_len += len(text) + else: + if out_len + len(text) > max_chars: + text = text[-max(0, max_chars - out_len):] + out_parts.append(text) if text else None + out_len = max_chars + else: + out_parts.append(text) + out_len += len(text) + if out_len >= max_chars and err_len >= max_chars: + break + return "".join(reversed(out_parts)), "".join(reversed(err_parts)) + + +def _status_label(task: TaskInfo) -> str: + if task.status == "running": + return "运行中" + if task.status == "killed": + return "已终止" + if task.abnormal: + return f"异常退出(退出码: {task.exit_code})" + return f"已完成(退出码: {task.exit_code})" + + +def task_to_dict(task: TaskInfo) -> dict[str, Any]: + """SSE/API 用任务快照(不含输出)。""" + with task.lock: + return { + "id": task.pid, + "pid": task.pid, + "command": task.command, + "cwd": task.cwd, + "status": task.status, + "exit_code": task.exit_code, + "abnormal": task.abnormal, + "started_at": task.started_at, + "finished_at": task.finished_at, + "total_chars": task.total_chars, + "truncated": task.truncated, + "scope": task.scope, + "tool_call_id": task.tool_call_id, + } + + +def _format_task_final(task: TaskInfo, max_chars: int = 4000) -> str: + out_tail, err_tail = _tail(task, max_chars) + elapsed = (task.finished_at or _now()) - task.started_at + lines = [ + "=== 后台任务结束 ===", + f"任务ID(PID): {task.pid}", + f"命令: {task.command}", + f"工作目录: {task.cwd}", + f"状态: {_status_label(task)}", + f"运行时长: {elapsed:.1f} 秒", + f"输出总计: {task.total_chars} 字符" + ("(已截断,仅保留最新部分)" if task.truncated else ""), + ] + if task.abnormal and task.total_chars == 0: + lines.append("说明: 无任何输出且非零退出,疑似启动即失败(命令错误/端口占用/依赖缺失等)") + if out_tail: + lines.append(f"\n--- stdout(最新)---\n{out_tail}") + if err_tail: + lines.append(f"\n--- stderr(最新)---\n{err_tail}") + return "\n".join(lines) + + +def _format_running(task: TaskInfo, max_chars: int = 1000) -> str: + out_tail, err_tail = _tail(task, max_chars) + elapsed = _now() - task.started_at + lines = [ + "=== 后台任务仍在运行 ===", + f"任务ID(PID): {task.pid}", + f"命令: {task.command}", + f"状态: 运行中(已运行 {elapsed:.0f} 秒)", + f"输出总计: {task.total_chars} 字符" + ("(较早输出已截断)" if task.truncated else ""), + f"等待剩余时间: 超时后返回摘要(可用 terminal_info 查看最新状态,任务结束后用 wait 获取最终输出)", + ] + if out_tail: + lines.append(f"\n--- stdout(最新)---\n{out_tail}") + if err_tail: + lines.append(f"\n--- stderr(最新)---\n{err_tail}") + return "\n".join(lines) + + +def task_info_text(pid: int, session_id: str = "", max_chars: int = 1000) -> str: + """terminal_info 工具:查看任务当前状态 + 最新输出(截断 max_chars)。""" + task = get_task(pid) + if not task: + return f"[ERROR] 未找到后台任务(PID: {pid})。任务可能已从注册表清理,请检查命令是否正确挂起。" + if task.status == "running": + return _format_running(task, max_chars) + return _format_task_final(task, max_chars) + + +def wait_task(pid: int, timeout: float = 60, session_id: str = "") -> str: + """wait 工具:原地等待指定后台任务结束(线程阻塞轮询,响应 abort)。 + + 任务已结束 → 立即返回最终输出;未结束 → 阻塞至结束或超时; + 超时 → 返回"仍在运行中"摘要(最新 1000 字符)。 + """ + task = get_task(pid) + if not task: + return f"[ERROR] 未找到后台任务(PID: {pid})。任务可能已从注册表清理,请检查命令是否正确挂起。" + + deadline = _now() + max(0.0, float(timeout)) + while True: + with task.lock: + status = task.status + if status != "running": + break + if _now() >= deadline: + break + try: + from agent.core.runtime import check_abort + if task.session_id and check_abort(task.session_id): + return f"[提示] 等待后台任务(PID: {task.pid})时收到用户中止,任务仍在后台运行,不会被自动终止。" + except Exception: + pass + time.sleep(0.2) + + if task.status == "running": + return _format_running(task, 1000) + return _format_task_final(task, 4000) + + +# ---------- 终止/清理(进程树) ---------- +def kill_tree(pid: int) -> bool: + """杀整个进程树(taskkill /F /T);进程已死也返回 True。""" + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(pid)], + timeout=KILL_TREE_TIMEOUT, + capture_output=True, + check=False, + ) + return True + except Exception: + return False + + +def kill_task(pid: int, session_id: str = "") -> bool: + """终止任务(标记 killed + 杀进程树)。""" + task = get_task(pid) + if not task: + return False + if session_id and task.session_id != session_id: + return False + with task.lock: + if task.status == "running": + task.status = "killed" + task.finished_at = _now() + kill_tree(pid) + # watcher 随后会 finalize(不再覆盖 killed 状态) + _notify(task.session_id, {"type": "task_update", "task": task_to_dict(task)}) + return True + + +def kill_all_session(session_id: str) -> int: + """终止某会话的全部运行中任务(含前台残留),返回终止数量。""" + count = 0 + for task in list_tasks(session_id): + with task.lock: + if task.status == "running": + task.status = "killed" + task.finished_at = _now() + count += 1 + else: + continue + kill_tree(task.pid) + _notify(session_id, {"type": "task_update", "task": task_to_dict(task)}) + return count + + +def cleanup_session(session_id: str) -> None: + """轮次结束清理:杀掉该会话残留的*前台*进程(框架超时后线程仍在跑的场景), + 并注销它们;后台挂起任务不受影响(跨轮存活,由浮窗管理)。""" + for task in list_tasks(session_id): + if task.scope != "foreground": + continue + with task.lock: + if task.status != "running": + continue + task.status = "killed" + task.finished_at = _now() + kill_tree(task.pid) + unregister_foreground(task.pid) + _notify(session_id, {"type": "task_update", "task": task_to_dict(task)}) + + +def kill_all_running() -> int: + """杀全部运行中任务(服务退出/崩溃恢复),返回数量。""" + count = 0 + for task in _all_tasks(): + with task.lock: + if task.status == "running": + task.status = "killed" + task.finished_at = _now() + count += 1 + else: + continue + kill_tree(task.pid) + _pid_remove(task.pid) + _notify(task.session_id, {"type": "task_update", "task": task_to_dict(task)}) + _save_pid_file() + return count + + +def get_running_count() -> int: + """全局运行中任务数(Electron 退出确认用)。""" + with _tasks_lock: + return sum(1 for d in _tasks.values() for t in d.values() if t.status == "running") + + +# ---------- 进程收尾 ---------- +def _finalize_task(task: TaskInfo) -> None: + with task.lock: + if task.status == "running": + task.status = "done" + task.exit_code = task.proc.returncode if task.proc is not None else None + task.abnormal = task.exit_code != 0 + task.finished_at = _now() + task.proc = None + _pid_remove(task.pid) + _notify(task.session_id, {"type": "task_update", "task": task_to_dict(task)}) + + # 1) 更新挂起调用的 live 记录:running → done + 最终输出(轮次仍活跃时) + # 2) 图外注入:把结果作为 ToolMessage 加入本轮工具返回值(仅轮次活跃时; + # 轮次已结束不再注入,避免残留消息污染下一轮的上下文) + try: + from agent.history.tool_call_recorder import record_tool_result_by_call_id, is_live_turn_active + if is_live_turn_active(task.session_id): + final_text = _format_task_final(task) + if task.tool_call_id: + record_tool_result_by_call_id(task.session_id, task.tool_call_id, final_text) + from agent.core.turn_injector import inject_tool_message + inject_tool_message(task.session_id, f"bg_{task.pid}", "background_task", final_text) + except Exception: + pass + + +# ---------- pid 文件(崩溃恢复) ---------- +def _pid_add(pid: int) -> None: + with _pid_lock: + _pid_entries.add(pid) + _save_pid_file() + + +def _pid_remove(pid: int) -> None: + with _pid_lock: + _pid_entries.discard(pid) + _save_pid_file() + + +def _save_pid_file() -> None: + try: + with _pid_lock: + pids = sorted(_pid_entries) + _PID_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(_PID_FILE, "w", encoding="utf-8") as f: + json.dump({"pids": pids}, f, ensure_ascii=False, indent=1) + except Exception: + pass + + +def startup_cleanup() -> None: + """服务启动清理:按 pid 文件杀掉上次崩溃/异常退出遗留的进程树。""" + try: + if _PID_FILE.is_file(): + with open(_PID_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + for pid in data.get("pids", []): + kill_tree(int(pid)) + except Exception: + pass + try: + with _pid_lock: + _pid_entries.clear() + _save_pid_file() + except Exception: + pass + + +# ---------- SSE 订阅 ---------- +def subscribe_tasks(session_id: str) -> _queue_module.Queue: + """订阅某会话的终端任务更新(队列满丢最旧)。""" + q: _queue_module.Queue = _queue_module.Queue(maxsize=256) + with _subscribers_lock: + if session_id not in _subscribers: + _subscribers[session_id] = [] + _subscribers[session_id].append(q) + return q + + +def unsubscribe_tasks(session_id: str, q: _queue_module.Queue) -> None: + with _subscribers_lock: + subs = _subscribers.get(session_id, []) + if q in subs: + subs.remove(q) + if not subs: + _subscribers.pop(session_id, None) + + +def _notify(session_id: str, payload: dict[str, Any]) -> None: + with _subscribers_lock: + subs = list(_subscribers.get(session_id, [])) + if not subs: + return + for q in subs: + try: + q.put_nowait(payload) + except _queue_module.Full: + try: + q.get_nowait() + q.put_nowait(payload) + except Exception: + pass + + +def live_snapshot(session_id: str) -> dict[str, Any]: + """全量快照(SSE 连接建立 / 轮询兜底用):任务列表 + 各任务最近输出 chunk。""" + tasks = list_tasks(session_id) + return { + "type": "full", + "tasks": [task_to_dict(t) for t in tasks], + "outputs": [ + {"id": t.pid, "chunks": get_output_chunks(t)[0], "truncated": t.truncated} + for t in tasks + ], + } diff --git a/src/agent/tools/wait.py b/src/agent/tools/wait.py new file mode 100644 index 0000000..ab95691 --- /dev/null +++ b/src/agent/tools/wait.py @@ -0,0 +1,46 @@ +"""wait 工具:通用"原地等待"工具(普通工具,非终端工具族)。 + +让 agent 暂停当前操作、阻塞等待指定后台任务结束(线程轮询实现,响应 +用户中止);任务已结束立即返回最终输出,超时返回"仍在运行中"摘要。 +查询实时状态请用 terminal_info;本工具的核心语义是"等待"。 +""" +from __future__ import annotations + +from langchain.tools import BaseTool +from pydantic import BaseModel, Field + + +class WaitInput(BaseModel): + task_id: int = Field( + ..., + description="要等待的后台任务ID(即 terminal_execute(background=True) 返回的任务ID,也就是进程 PID)。", + ) + timeout: int = Field( + 60, + description="最长等待秒数(默认 60)。任务未结束且未超时时持续阻塞等待;" + "超时后返回当前运行状态+最新输出摘要(可用 terminal_info 再查状态,稍后再 wait)。", + ) + + +class WaitTool(BaseTool): + args_schema: type[BaseModel] = WaitInput + name: str = "wait" + description: str = ( + "通用等待工具。阻塞等待指定的后台任务结束,期间不执行其他操作。\n\n" + "- 任务已结束(含异常退出/已被终止)→ 立即返回最终输出、退出码与结果状态,不会继续等待\n" + "- 任务未结束 → 原地等待,直到任务结束或到达 timeout\n" + "- timeout 超时 → 返回\"仍在运行中\"摘要(运行时长/输出字符数/最新输出),agent 自行决定继续等还是先做别的\n\n" + "典型用法:\n" + "1. terminal_execute(background=True) 挂起长任务,返回任务ID\n" + "2. wait(task_id=任务ID, timeout=120) 原地等待它结束并取回结果\n" + "3. 若任务中途卡住(如误进了交互式命令),用 terminal_execute(background=False) 执行\n" + " `taskkill /F /T /PID <任务ID>` 终止。" + ) + + def _run(self, task_id: int, timeout: int = 60) -> str: + from agent.tools.terminal_tasks import wait_task + return wait_task(int(task_id), float(max(0, int(timeout)))) + + async def _arun(self, **kwargs) -> str: + import asyncio + return await asyncio.to_thread(self._run, **kwargs) diff --git a/src/agent/tools/web_page.py b/src/agent/tools/web_page.py index eebfd78..1757b6b 100644 --- a/src/agent/tools/web_page.py +++ b/src/agent/tools/web_page.py @@ -1,9 +1,21 @@ """web_page 工具:对当前浏览器页面执行 DOM 级批量操作。 定位方式(无需视觉 grounding,毫秒级命中): - - ref: snapshot 返回的元素序号 + - ref: snapshot 返回的元素序号(内部携带 frame 信息,跨 iframe 可用) - selector: Playwright 选择器(CSS / text=登录 / role=button / xpath= 等, - 纯中文或含空格的字符串会自动按 text= 处理) + 纯中文或含空格的字符串会自动按 text= 处理); + 操作 iframe 内元素可用 frame=N >> 前缀(如 frame=1 >> text=确定) + +snapshot 能力(针对真实页面常见盲区): + - 遍历全部 frame(iframe)与 open shadow root,弹窗/组件内容不会漏 + - 元素按 弹窗[弹窗]/浮层[浮层] → 视口内 → 页面其余 排序, + 弹窗内控件不会被背景页大量元素挤出上限 + - 图标按钮按 aria-label/title/svg/alt 命名;原生 select 附选项列表 + - [禁用]/[已选中] 标注;aria-hidden/inert/opacity:0 的元素被排除 + - 字段名按"几何最近 label"识别(antd 嵌套 fieldset/多列布局), + select/日期等无文本控件也可区分 + - ref 的 CSS 路径生成时校验唯一性并自动扩展(同构表单区块不歧义) + - 超过 max_elements 上限的元素计数提示 批量语义: actions 列表顺序执行,首错即停并报告失败步骤;每次调用结束返回页面截图 @@ -21,52 +33,302 @@ from pydantic import BaseModel, Field from .browser_control import get_browser_session, normalize_url # ---------- snapshot / extract 注入页面的 JS ---------- -_SNAPSHOT_JS = """() => { - const SELECTOR = 'a[href], button, input, select, textarea, [role="button"], [role="link"], [role="tab"], [role="menuitem"], [onclick], [contenteditable="true"]'; - const els = Array.from(document.querySelectorAll(SELECTOR)); - const out = []; +_SNAPSHOT_JS = """(arg) => { + // maxElements>0 时限制数量;0 或未传则全部保留(默认不截断,上下文可容纳) + const MAX = (arg && arg.maxElements && arg.maxElements > 0) ? arg.maxElements : Infinity; + // 交互元素白名单:表单控件 + ARIA 控件 + tabindex 可聚焦元素 + 图标按钮载体。 + // 覆盖原生 checkbox/radio/下拉(select 另附选项)、自定义下拉(role=option)、 + // 纯 div 事件控件([onclick]/[tabindex])、图标按钮(button/a + svg)、 + // SPA 无 href 菜单项(a:not([href]))、antd select 容器与树节点(真实系统实测盲区)。 + const SELECTOR = [ + 'a[href]', 'a:not([href])', 'button', 'input', 'select', 'textarea', + 'label[for]', 'label:has(input, select, textarea)', 'summary', + '[contenteditable]:not([contenteditable="false"])', '[onclick]', + '[tabindex]:not([tabindex="-1"])', '[draggable="true"]', + 'nz-select', '.ant-select-selection', '[class*="tree-node"]', + '.ant-tree-switcher', '[class*="switcher"]', + '.ant-select-dropdown-menu-item', '.ant-dropdown-menu-item', + '[role="button"]', '[role="link"]', '[role="tab"]', '[role="menuitem"]', + '[role="menuitemcheckbox"]', '[role="menuitemradio"]', '[role="option"]', + '[role="checkbox"]', '[role="radio"]', '[role="switch"]', + '[role="combobox"]', '[role="searchbox"]', '[role="slider"]', + '[role="spinbutton"]', '[role="treeitem"]', '[role="gridcell"]', + '[role="textbox"]', 'audio[controls]', 'video[controls]' + ].join(','); + const OVERLAY_CLS_RE = /modal|dialog|popup|drawer|overlay|toast/i; + + // 祖先链可见性:rect 有尺寸,且自身及全部祖先无 display:none / + // visibility:hidden / opacity:0 / content-visibility:hidden / + // aria-hidden / inert(穿过 shadow root 逐级上溯) const isVisible = (el) => { const r = el.getBoundingClientRect(); if (r.width < 1 || r.height < 1) return false; - const s = getComputedStyle(el); - return s.visibility !== 'hidden' && s.display !== 'none'; - }; - const cssPath = (el) => { - if (el.id) return '#' + CSS.escape(el.id); - const parts = []; let cur = el, depth = 0; - while (cur && cur.nodeType === 1 && depth < 6) { - let seg = cur.tagName.toLowerCase(); - if (seg === 'html' || seg === 'body') break; - const parent = cur.parentElement; - if (parent) { - const same = Array.from(parent.children).filter(c => c.tagName === cur.tagName); - if (same.length > 1) seg += ':nth-of-type(' + (same.indexOf(cur) + 1) + ')'; - } - parts.unshift(seg); - cur = parent; + while (cur && cur.nodeType === 1 && depth < 30) { + if (cur.hasAttribute && (cur.getAttribute('aria-hidden') === 'true' || cur.hasAttribute('inert'))) return false; + const s = getComputedStyle(cur); + if (s.display === 'none' || s.visibility === 'hidden' || s.contentVisibility === 'hidden') return false; + const op = parseFloat(s.opacity); + if (!isNaN(op) && op <= 0) return false; + const root = cur.getRootNode && cur.getRootNode(); + cur = cur.parentElement || (root && root.nodeType === 11 ? root.host : null); depth += 1; - if (cur && cur.id) { parts.unshift('#' + CSS.escape(cur.id)); break; } } - return parts.join(' > '); + return true; }; - for (const el of els) { - try { - if (!isVisible(el)) continue; - const text = (el.innerText || '').trim().replace(/\\s+/g, ' ').slice(0, 40); - out.push({ - sel: cssPath(el), - tag: el.tagName.toLowerCase(), - text: text, - aria: (el.getAttribute('aria-label') || '').slice(0, 40), - ph: (el.getAttribute('placeholder') || '').slice(0, 40), - href: (el.getAttribute('href') || '').slice(0, 80), - type: el.getAttribute('type') || '' - }); - if (out.length >= 80) break; - } catch (e) {} + + // 浮层层级:3=语义弹窗(dialog[open] / :modal / aria-modal / 可见 role=dialog), + // 2=浮层(下拉 menu/listbox、tooltip、alert、常见弹窗类名 + fixed/absolute 定位)。 + // 沿整条祖先链取最大值:弹窗内的下拉/内层浮窗(嵌套浮层)同样标为弹窗层级, + // 保证嵌套场景下元素仍按"最外层弹窗 → 内层浮窗"的正确顺序优先列出。 + const layerOf = (el) => { + let cur = el, depth = 0, best = 0; + while (cur && cur.nodeType === 1 && depth < 30) { + if (cur.tagName === 'DIALOG' && cur.open) best = Math.max(best, 3); + let modal = false; + try { modal = cur.matches(':modal'); } catch (e) {} + if (modal || (cur.getAttribute && cur.getAttribute('aria-modal') === 'true')) best = Math.max(best, 3); + const role = cur.getAttribute && cur.getAttribute('role'); + if (role === 'dialog' || role === 'alertdialog' || role === 'menu' || role === 'listbox' || role === 'tooltip' || role === 'alert') { + const r = cur.getBoundingClientRect(); + if (r.width > 0 && r.height > 0) best = Math.max(best, (role === 'dialog' || role === 'alertdialog') ? 3 : 2); + } + const cls = (cur.getAttribute && cur.getAttribute('class')) || ''; + if (cls && OVERLAY_CLS_RE.test(cls)) { + try { + const s = getComputedStyle(cur); + if (s.position === 'fixed' || s.position === 'absolute') { + const r = cur.getBoundingClientRect(); + if (r.width * r.height >= innerWidth * innerHeight * 0.05) best = Math.max(best, 2); + } + } catch (e) {} + } + const root = cur.getRootNode && cur.getRootNode(); + cur = cur.parentElement || (root && root.nodeType === 11 ? root.host : null); + depth += 1; + } + return best; + }; + + const nthOfType = (el) => { + // 同 tag 兄弟序号:普通元素用 parentElement.children; + // shadow root 顶层元素 parentElement 为 null,回退到 shadowRoot.children + const parent = el.parentElement; + let list; + if (parent) { + list = Array.from(parent.children); + } else { + const root = el.getRootNode && el.getRootNode(); + list = root && root.children ? Array.from(root.children) : []; + } + const same = list.filter((c) => c.tagName === el.tagName); + return same.length > 1 ? same.indexOf(el) + 1 : 0; + }; + + const attrSeg = (el, attr) => { + const v = el.getAttribute && el.getAttribute(attr); + if (!v) return ''; + return el.tagName.toLowerCase() + '[' + attr + '="' + CSS.escape(v) + '"]'; + }; + + // 生成可回填 Playwright 的 CSS 路径: + // 1. 收集祖先链段(优先 id / aria-label / data-testid / ng-reflect-name / title / placeholder 锚点) + // 2. 从最内层开始逐段扩展,直到 document 范围内唯一—— + // 避免投保人/被保人等重复区块(同构 form-item)生成的路径歧义导致 ref 点击 strict mode violation + const cssPath = (el) => { + const segs = []; + let cur = el, depth = 0; + while (cur && cur.nodeType === 1 && depth < 12) { + const tag = cur.tagName.toLowerCase(); + if (tag === 'html' || tag === 'body') break; + let seg; + if (cur.id) { + seg = '#' + CSS.escape(cur.id); + } else { + seg = attrSeg(cur, 'aria-label') || attrSeg(cur, 'data-testid') + || attrSeg(cur, 'ng-reflect-name') || attrSeg(cur, 'title') || attrSeg(cur, 'placeholder'); + if (!seg) { + const n = nthOfType(cur); + seg = n ? tag + ':nth-of-type(' + n + ')' : tag; + } + } + segs.unshift(seg); + const root = cur.getRootNode && cur.getRootNode(); + cur = cur.parentElement || (root && root.nodeType === 11 ? root.host : null); + depth += 1; + // 遇带 id 的祖先提前收尾(锚定更稳) + if (cur && cur.id && depth < 12) { + segs.unshift('#' + CSS.escape(cur.id)); + break; + } + } + let last = segs.join(' > '); + for (let n = 1; n <= segs.length; n++) { + const sel = segs.slice(segs.length - n).join(' > '); + try { + if (document.querySelectorAll(sel).length === 1) return sel; + } catch (e) { return last; } + last = sel; + } + return last; + }; + + const resolveIds = (root, ids) => { + const parts = []; + for (const id of ids.split(/\\s+/)) { + try { + const ref = root.querySelector('#' + CSS.escape(id)); + if (ref) parts.push((ref.innerText || ref.textContent || '').trim()); + } catch (e) {} + } + return parts.join(' ').replace(/\\s+/g, ' ').trim(); + }; + + // 图标按钮命名:svg 的 aria-label/aria-labelledby/
+ const svgName = (el) => { + if (!el.querySelector) return ''; + const svg = el.querySelector('svg'); + if (!svg) return ''; + const aria = (svg.getAttribute('aria-label') || '').trim(); + if (aria) return aria; + const by = resolveIds(el.getRootNode(), svg.getAttribute('aria-labelledby') || ''); + if (by) return by; + const title = svg.querySelector('title'); + return title ? (title.textContent || '').trim() : ''; + }; + + const imgName = (el) => { + if (!el.querySelector) return ''; + const img = el.querySelector('img'); + return img && img.getAttribute('alt') ? img.getAttribute('alt').trim() : ''; + }; + + // 表单字段名:几何最近 label(垂直对齐优先)。 + // antd 布局常见"一个 form-item 内嵌整个 fieldset"(通讯信息/渠道信息)或 label 与控件分离, + // DOM 结构归因会错位(移动电话显示为通讯地址);改用与控件垂直中线最近的 label, + // 对嵌套/多列布局均有效。范围限定在元素最近的 form-item(含其内嵌 fieldset)。 + // 垂直偏差超过阈值(12px)视为不同行,不标注——避免超大表单里无关元素被乱标字段名。 + const fieldOf = (el) => { + let item = null; + let cur = el; + while (cur && cur.nodeType === 1 && cur.tagName !== 'BODY') { + if (cur.classList && (cur.classList.contains('ant-form-item') || cur.classList.contains('el-form-item'))) { + item = cur; + break; + } + cur = cur.parentElement; + } + const scope = item || document; + const er = el.getBoundingClientRect(); + const ey = er.y + er.height / 2; + let best = '', bd = 1e9; + const labs = scope.querySelectorAll('.ant-form-item-label label, .el-form-item__label'); + for (const l of labs) { + if (!l.offsetWidth) continue; + const lr = l.getBoundingClientRect(); + const ly = lr.y + lr.height / 2; + if (Math.abs(ly - ey) > 12) continue; // 必须真正同行(label 在控件左侧同一行) + const d = Math.abs(lr.right - er.x); + if (d < bd) { bd = d; best = (l.innerText || '').replace(/\\s+/g, ' ').trim(); } + } + return best.slice(0, 20); + }; + + // 收集全部根:document + 各级 open shadow root(穿透 Web Components) + const roots = [document]; + for (let i = 0; i < roots.length; i++) { + let all; + try { all = roots[i].querySelectorAll('*'); } catch (e) { continue; } + for (const n of all) { + if (n.shadowRoot) roots.push(n.shadowRoot); + } } - return { title: document.title, url: location.href, elements: out }; + + const entries = []; + const seen = new Set(); + let order = 0; + for (const root of roots) { + let els; + try { els = root.querySelectorAll(SELECTOR); } catch (e) { continue; } + for (const el of els) { + if (seen.has(el)) continue; + seen.add(el); + try { + if (!isVisible(el)) continue; + const r = el.getBoundingClientRect(); + const inVp = r.top < innerHeight && r.bottom > 0 && r.left < innerWidth && r.right > 0; + const layer = layerOf(el) || (inVp ? 1 : 0); + // select 的 innerText 会把全部选项拼在一起,改取当前选中项文本 + let txt; + if (el.tagName === 'SELECT' && el.selectedOptions && el.selectedOptions.length) { + txt = (el.selectedOptions[0].text || '').trim(); + } else { + txt = (el.innerText || '').replace(/\\s+/g, ' ').trim(); + } + const name = (txt || svgName(el) || imgName(el) + || (el.getAttribute('aria-label') || '').trim() + || resolveIds(el.getRootNode(), el.getAttribute('aria-labelledby') || '') + || (el.getAttribute('title') || '').trim() + || (el.getAttribute('placeholder') || '').trim() + ).replace(/\\s+/g, ' ').slice(0, 40); + // value 仅对文本类输入采集(checkbox 的 "on"、select 选中值等无意义) + const T = (el.getAttribute('type') || '').toLowerCase(); + const noVal = ['checkbox', 'radio', 'button', 'submit', 'reset', 'file', 'color', 'range', 'image'].includes(T); + const val = (el.tagName !== 'SELECT' && typeof el.value === 'string' && !noVal) + ? el.value.trim().slice(0, 40) : ''; + let options = []; + if (el.tagName === 'SELECT' && el.options) { + options = Array.from(el.options).slice(0, 15).map((o) => { + const t = ((o.text || '').trim() || (o.value || '').trim()).slice(0, 15); + return t + (o.disabled && t.indexOf('禁用') === -1 ? '(禁用)' : ''); + }).filter(Boolean); + } + // 纯文本容器判定:div/span 等无交互特征(role/可聚焦/输入类/字段归属)、 + // 无交互样式关键词的展示文本,快照标注 [文本] 提醒 agent 不可点击, + // 避免被误当控件(如"检索结果"标题——onclick 可能为空绑定、draggable="false" + // 是树节点默认属性,均不能作为交互依据) + const PURE_TAG = {DIV: 1, SPAN: 1, P: 1, UL: 1, OL: 1, LI: 1}; + const pure = !!PURE_TAG[el.tagName] + && !el.getAttribute('role') && !el.hasAttribute('tabindex') + && !el.hasAttribute('contenteditable') + && el.getAttribute('draggable') !== 'true' + && !el.getAttribute('aria-label') && !el.getAttribute('aria-labelledby') + && !el.getAttribute('title') && !el.getAttribute('placeholder') + && !el.getAttribute('ng-reflect-name') && !el.value && el.children.length <= 3 + && !/(btn|button|select|switch|checkbox|radio|menu|dropdown|tree|input|option|tab|link|trigger|click|icon|modal|dialog)/i.test(el.className || ''); + // 树节点展开箭头(switcher/expander):仅切换展开/折叠,不代表节点本身, + // 快照标注 [展开] 避免被误当"机构名/选项"点击(树弹窗里 ref 极易点错这类元素) + const switcher = /switcher|tree-toggle|tree-node-expander|expand-icon/i.test(el.className || ''); + entries.push({ + sel: cssPath(el), tag: el.tagName.toLowerCase(), text: name, field: fieldOf(el), + pure: pure, switcher: switcher, + aria: (el.getAttribute('aria-label') || '').slice(0, 40), + title: (el.getAttribute('title') || '').slice(0, 40), + ph: (el.getAttribute('placeholder') || '').slice(0, 40), + href: (el.getAttribute('href') || '').slice(0, 80), + type: el.getAttribute('type') || '', value: val, + checked: el.checked === true || el.getAttribute('aria-checked') === 'true', + disabled: el.disabled === true || el.getAttribute('aria-disabled') === 'true', + layer: layer, options: options, order: order++ + }); + } catch (e) {} + } + } + + // 排序:弹窗/浮层 → 视口内 → 页面其余(层内保持 DOM 顺序) + entries.sort((a, b) => (b.layer - a.layer) || (a.order - b.order)); + const out = []; + for (const en of entries) { + if (out.length >= MAX) break; + out.push({ + sel: en.sel, tag: en.tag, text: en.text, field: en.field, pure: en.pure, + switcher: en.switcher, + aria: en.aria, title: en.title, + ph: en.ph, href: en.href, type: en.type, value: en.value, + checked: en.checked, disabled: en.disabled, layer: en.layer, options: en.options + }); + } + return { title: document.title, url: location.href, elements: out, truncated: entries.length - out.length }; }""" _EXTRACT_JS = """(sel) => { @@ -75,38 +337,197 @@ _EXTRACT_JS = """(sel) => { return { title: document.title, url: location.href, text: el.innerText || '' }; }""" +# antd 1.x 日历面板操作:年(decade 翻页) → 月(prev/next 或月面板) → 日 +_DATE_PICK_JS = """(async (arg) => { + const y = arg.year, m = arg.month, d = arg.day; + const sleep = (ms) => new Promise(r => setTimeout(r, ms)); + const findCell = (cls, text) => Array.from(document.querySelectorAll(cls)) + .find(e => e.offsetWidth > 0 && e.textContent.trim() === text); + // ng-zorro 老版日历:事件绑定在 cell 内的 上,点 td 不触发选择 + const clickCell = (cell) => { + const a = cell.querySelector('a'); + (a || cell).click(); + }; + // 0) 等待面板就绪(点击输入框后面板带 dropDownAnimation 动画,需轮询) + const YEAR_SEL = '.ant-calendar-year-select'; + let ready = false; + for (let i = 0; i < 40; i++) { + if (document.querySelector(YEAR_SEL)) { ready = true; break; } + await sleep(100); + } + if (!ready) return false; + await sleep(150); + // 1) 年面板 + const yearSel = document.querySelector(YEAR_SEL); + yearSel.click(); + await sleep(350); + // 翻到目标年份所在十年:优先读 decade-btn(React 版 antd 有十年分组按钮), + // 没有则从当前年面板可见年份范围推断方向(ng-zorro 老版只有 prev/next 按钮) + const decadeStart = () => { + const btn = document.querySelector('.ant-calendar-year-panel-decade-btn'); + if (btn) { + const mm = (btn.textContent || '').match(/(\\d{4})/); + return mm ? parseInt(mm[1], 10) : NaN; + } + const nums = Array.from(document.querySelectorAll('.ant-calendar-year-panel-cell')) + .map(e => parseInt((e.textContent || '').trim(), 10)).filter(n => !isNaN(n)); + if (!nums.length) return NaN; + return Math.min.apply(null, nums); + }; + for (let i = 0; i < 15; i++) { + const start = decadeStart(); + if (!isNaN(start) && y >= start && y <= start + 9) break; + const nav = document.querySelector(y < start + ? '.ant-calendar-year-panel-prev-decade-btn' + : '.ant-calendar-year-panel-next-decade-btn'); + if (!nav) break; + nav.click(); + await sleep(300); + } + const ycell = findCell('.ant-calendar-year-panel-cell', String(y)); + if (!ycell) return false; + clickCell(ycell); + await sleep(300); + // 2) 月份:优先 prev/next-month 按钮循环(最多 12 次),失败则走月面板 + let curM = NaN; + const monthSel = document.querySelector('.ant-calendar-month-select'); + if (!monthSel) return false; + const readM = () => parseInt((monthSel.textContent || '').trim(), 10); + curM = readM(); + if (isNaN(curM)) curM = 1; + for (let i = 0; i < 12 && curM !== m; i++) { + const btn = document.querySelector(curM > m ? '.ant-calendar-prev-month-btn' : '.ant-calendar-next-month-btn'); + if (!btn) break; + btn.click(); + await sleep(220); + const t = readM(); + if (!isNaN(t) && t !== curM) curM = t; else break; + } + if (curM !== m) { + monthSel.click(); + await sleep(300); + const mcell = findCell('.ant-calendar-month-panel-cell', m + '月') + || findCell('.ant-calendar-month-panel-cell', String(m)); + if (!mcell) return false; + clickCell(mcell); + await sleep(300); + } + // 3) 日 + const dcell = findCell('.ant-calendar-date', String(d)); + if (!dcell) return false; + dcell.click(); + return true; +})""" + # 选择器结构字符:出现任意一个即视为"已是选择器语法",不做 text= 自动转换 _SELECTOR_META_RE = re.compile(r"[\[\]#.:>~+*@=()]") +# frame=N >> 前缀:允许定位 iframe 内元素 +_FRAME_RE = re.compile(r"^frame=(\d+)\s*>>\s*(.+)$", re.S) + def _normalize_selector(sel: str) -> str: - """选择器归一化:仅对不含结构字符的纯中文短语自动加 text= 前缀。 + """选择器归一化。 - 注意: - - input[placeholder="用户名"]、button:has-text("提交") 等是合法 CSS / - Playwright 引擎语法(中文出现在属性值/伪类参数里),必须原样透传, - 不能因"含中文"被误转成 text=xxx - - 含空格的 ASCII 串可能是 CSS 后代选择器(如 "h2 a"),同样不转换 + - 真实 Playwright 引擎(css/text/xpath/id/role/nth/visible/data-testid、internal:)原样透传 + - 伪引擎(placeholder/alt/title/testid/label=)Playwright 不支持,转换为 CSS 属性选择器 + (label= 转 text= 可见文本匹配),避免 Unknown engine 报错 + - 仅对不含结构字符的纯中文短语自动加 text= 前缀 + - text= 默认转 quoted 精确匹配(text="选项文字"),避免子串匹配命中多个元素 + 触发 strict mode violation;需要子串/模糊匹配时用正则 text=/关键词/ """ s = (sel or "").strip() if not s: raise ValueError("selector 不能为空") + # 伪引擎 → CSS 属性选择器(Playwright 无 placeholder/alt/title/testid 引擎) + m = re.match(r"^(placeholder|alt|title|testid|data-testid|label)=(.*)$", s) + if m: + eng, val = m.group(1), m.group(2).strip() + if not val: + raise ValueError(f"{eng}= 后面需要值") + if eng == "label": + return 'text="' + val + '"' + attr = {"testid": "data-testid"}.get(eng, eng) + return f'[{attr}="{val}"]' # 已带 Playwright 选择器引擎前缀 - if re.match(r"^(css|text|xpath|id|role|placeholder|label|alt|title|testid|data-testid|nth|visible)=|^(>>|internal:)", s): + if re.match(r"^(css|text|xpath|id|role|nth|visible|data-testid)=|^(>>|internal:)", s): + if s.startswith("text="): + val = s[len("text="):] + # 未加引号、非正则、非链式 → 转精确匹配 + if val and not val.startswith('"') and not val.startswith("/") and ">>" not in val: + return 'text="' + val + '"' return s if s.startswith("//") or s.startswith(".."): return "xpath=" + s - # 纯中文短语(无任何选择器结构字符)→ text= 引擎匹配 + # 纯中文短语(无任何选择器结构字符)→ text= 精确匹配 if re.search(r"[\u4e00-\u9fff]", s) and not _SELECTOR_META_RE.search(s): - return "text=" + s + return 'text="' + s + '"' return s +def _split_frame_prefix(sel: str) -> Tuple[int, str]: + """拆分 frame=N >> 前缀,返回 (frame 索引, 剩余选择器)。""" + m = _FRAME_RE.match((sel or "").strip()) + if m: + return int(m.group(1)), m.group(2).strip() + return 0, (sel or "").strip() + + +def _frame_of(page, idx: int): + """按索引取 frame,越界给出明确错误。""" + frames = page.frames + if not (0 <= idx < len(frames)): + raise ValueError(f"frame={idx} 不存在(当前共 {len(frames)} 个 frame),可先 snapshot 查看") + return frames[idx] + + +def _fmt_snapshot_line(e: dict, i: int) -> str: + """格式化单条快照元素(紧凑一行,含层级/禁用/选中/帧标注)。""" + tag = e["tag"] + if tag == "input" and e.get("type"): + tag += f"[{e['type']}]" + parts = [f"[{i}]"] + layer = e.get("layer") or 0 + if layer >= 3: + parts.append("[弹窗]") + elif layer == 2: + parts.append("[浮层]") + parts.append(tag) + if e.get("pure"): + parts.append("[文本]") + if e.get("switcher"): + parts.append("[展开]") + t = e.get("text") or "" + if e.get("field") and e["field"] != t: + parts.append(f'字段={e["field"]}') + if t: + parts.append(f'"{t}"') + if e.get("aria") and e["aria"] != t: + parts.append(f'aria="{e["aria"]}"') + if e.get("title") and e["title"] != t and e["title"] != e.get("aria"): + parts.append(f'title="{e["title"]}"') + if e.get("ph"): + parts.append(f'placeholder="{e["ph"]}"') + if e.get("value"): + parts.append(f'value="{e["value"]}"') + if e.get("href"): + parts.append(f"-> {e['href']}") + if e.get("checked"): + parts.append("[已选中]") + if e.get("disabled"): + parts.append("[禁用]") + if e.get("frame"): + parts.append(f"(帧{e['frame']})") + if e.get("options"): + parts.append("选项: " + " / ".join(e["options"])) + return " ".join(parts) + + # ---------- 动作定义 ---------- WebPageAction = Literal[ - "goto", "snapshot", "click", "fill", "hover", "select", - "scroll", "press", "wait", "extract", "back", "forward", + "goto", "snapshot", "click", "fill", "type", "hover", "select", "check", "uncheck", + "drag_to", "date", "scroll", "press", "wait", "extract", "back", "forward", "reload", "screenshot", "eval_js", ] @@ -118,11 +539,11 @@ class WebPageActionItem(BaseModel): ref: Optional[int] = Field(default=None, description="snapshot 返回的元素序号(优先于 selector)。") selector: Optional[str] = Field( default=None, - description="Playwright 选择器,合法写法原样传入即可:CSS(#id、.cls、input[placeholder=\"用户名\"])、" - "text=登录、placeholder=用户名、role=button[name=\"提交\"]、xpath=//div。" - "仅纯中文短语会自动按 text= 匹配;不要给 CSS/引擎语法外再套 text= 前缀。", + description="Playwright 选择器:CSS(#id、.cls、input[placeholder=\"用户名\"])、text=登录、role=button[name=\"提交\"]、xpath=//div 等原样传入。" + "placeholder=/alt=/title=/testid=/label= 简写自动转换为 CSS 属性选择器(label= 按可见文本匹配)。" + "仅纯中文短语会自动按 text= 匹配。操作 iframe 内元素用 frame=N >> 前缀(N 为 snapshot 中的帧序号)。", ) - text: str = Field(default="", description="fill 要输入的文本。") + text: str = Field(default="", description="fill/type 要输入的文本。") press_enter: bool = Field(default=False, description="fill 输入完成后按回车(触发表单提交/搜索)。") button: Literal["left", "right"] = Field(default="left", description="鼠标按键(click 用)。") double_click: bool = Field(default=False, description="是否双击(click 用)。") @@ -130,10 +551,13 @@ class WebPageActionItem(BaseModel): clicks: int = Field(default=0, description="滚轮量(scroll 用,与 gui_tool 一致:负数为向下)。") scroll_to: Literal["", "top", "bottom"] = Field(default="", description="滚动到页面顶部/底部(scroll 用,优先于 clicks)。") value: str = Field(default="", description="select 下拉选项的 value 或可见文本。") + target_ref: Optional[int] = Field(default=None, description="拖拽目标元素 ref(drag_to 用,与 target_selector 二选一)。") + target_selector: Optional[str] = Field(default=None, description="拖拽目标选择器(drag_to 用)。") wait_seconds: float = Field(default=1.0, description="等待秒数(wait 用)。") wait_selector: str = Field(default="", description="等待该选择器元素出现(wait 用,优先于固定秒数)。") full_page: bool = Field(default=False, description="是否整页截图(screenshot 用)。") max_length: int = Field(default=3000, description="extract 文本最大返回长度。") + max_elements: int = Field(default=0, description="snapshot 元素数量上限;默认 0 表示全部保留(不截断),传 >0 时限制数量(弹窗/浮层/视口内元素优先,超出部分计数提示)。") js: str = Field(default="", description="eval_js 要执行的 JS 表达式或箭头函数。") @@ -158,11 +582,18 @@ class WebPage(BaseTool): description: str = ( "对浏览器当前页面执行 DOM 级精准操作(毫秒级,优于 gui_tool 视觉定位)。\n" "典型流程:browser_control 打开 URL → web_page snapshot 获取元素清单 → 按 ref/selector 批量操作。\n" - "支持动作:goto/snapshot/click/fill/hover/select/scroll/press/wait/extract/back/forward/reload/screenshot/eval_js。\n" + "支持动作:goto/snapshot/click/fill/type/hover/select/check/uncheck/drag_to/date/scroll/press/wait/extract/back/forward/reload/screenshot/eval_js。\n" + "type 为逐字输入(真实键事件),用于 antd/angular 等对 fill 一次性注入不敏感的搜索/联想输入框;fill 为整体注入(普通输入框更快)。\n" + "date 用于 readonly 日期控件(antd datepicker 等):传入 YYYY-MM-DD,自动操作日历面板完成选择。\n" + "snapshot 说明:覆盖 iframe(标注 (帧k))与 shadow DOM;元素按 弹窗[弹窗]/浮层[浮层] → 视口内 → 页面其余 排序(嵌套浮层按最外层弹窗层级标注);" + "图标按钮按 aria/title 命名;原生 select 附选项列表(用 select 动作选值);复选框/单选框用 check/uncheck;" + "[禁用]/[已选中] 标注;默认全部元素保留,传 max_elements>0 可限制数量(超出部分计数提示)。\n" "元素定位:优先用 snapshot 返回的 ref 序号;用 selector 时合法语法原样传入" - "(CSS:input[placeholder=\"用户名\"];引擎:text=登录 / placeholder=用户名 / role=button[name=\"提交\"] / xpath=//div)。\n" + "(CSS:input[placeholder=\"用户名\"];引擎:text=登录 / role=button[name=\"提交\"] / xpath=//div;" + "placeholder=用户名 等简写自动转 CSS);type 未定位时输入到当前焦点(配合 click 展开 select 后直接输入);" + "操作 iframe 内元素加 frame=N >> 前缀(N 为 snapshot 中 (帧N) 的编号)。\n" "同一页面的连续操作必须合并为一次调用(actions 传多个动作),页面跳转后需重新 snapshot。\n" - "改变页面状态的操作会附操作后截图;纯观察批次(snapshot/extract)不附图。\n" + "改变页面状态的操作会附操作后截图;snapshot 附当前页面截图(配合元素清单核对视觉布局);纯文本批次(extract/wait/eval_js)不附图。\n" "仅用于已打开的浏览器页面操作,其余情况严禁调用。" ) response_format: Literal["content", "content_and_artifact"] = "content_and_artifact" @@ -171,25 +602,138 @@ class WebPage(BaseTool): async def _exec_action(self, session, item: WebPageActionItem) -> str: page = session._page - def _resolve() -> str: - """解析元素选择器:ref 优先,其次 selector(归一化)。""" + def _resolve() -> Tuple[Any, str]: + """解析元素定位:ref 优先(携带 frame),其次 selector(支持 frame=N >> 前缀)。""" if item.ref is not None: - sel = session.get_ref_selector(item.ref) - if sel is None: - raise ValueError(f"ref={item.ref} 不存在(页面可能已跳转),请重新执行 snapshot") - return sel + info = session.get_ref(item.ref) + # refs_stale:页面被替换或 URL 变化(reload/SPA 跳转)都会使旧 ref 指向错误元素 + if info is None or session.refs_stale(page): + raise ValueError( + f"ref={item.ref} 无效(页面可能已跳转、刷新或切换标签页),请重新执行 snapshot" + ) + return _frame_of(page, info.get("frame", 0)), info["sel"] if not item.selector: raise ValueError("该动作需要提供 ref 或 selector 定位元素") - return _normalize_selector(item.selector) + frame_idx, sel = _split_frame_prefix(item.selector) + return _frame_of(page, frame_idx), _normalize_selector(sel) + + async def _loc_op(frame, sel: str, op, *, hint: str = ""): + """执行 locator 操作: + - 超时且疑似被遮挡(backdrop/浮层残留)→ Escape 清理后重试一次 + - text= 命中多个元素(strict mode violation,典型如 placeholder、已选值、 + 下拉选项文本相同)→ 若候选包含下拉选项/菜单项则自动优选对应 li 重试, + 否则给出带候选清单的歧义诊断 + - text= 精确匹配找不到 → 自动子串匹配收集真实候选文本并报告, + 避免 agent 反复猜全名;下拉已展开但无匹配时提示远程搜索型下拉 + - 仍失败则附 Playwright 原始 actionability 诊断 + """ + async def _text_candidates(val: str, limit: int = 6) -> list[str]: + """子串匹配收集页面可见的真实文本候选(去重,用于精确匹配失败的降级报告)。""" + try: + return await frame.evaluate("""(arg) => { + const v = arg.v, lim = arg.lim; + const out = [], seen = new Set(); + const nodes = document.querySelectorAll( + 'a,button,span,div,li,td,input[placeholder],option'); + for (const e of nodes) { + if (e.children.length > 3) continue; + let t = ''; + if (e.tagName === 'INPUT' && e.getAttribute('placeholder')) { + t = e.getAttribute('placeholder'); + } else { + t = (e.textContent || '').trim().replace(/\\s+/g, ' '); + } + if (!t || !t.includes(v)) continue; + const r = e.getBoundingClientRect(); + if (r.width < 1 || r.height < 1) continue; + const key = t.slice(0, 40); + if (seen.has(key)) continue; + seen.add(key); + out.push(t.slice(0, 40)); + if (out.length >= lim) break; + } + return out; + }""", {"v": val, "lim": limit}) + except Exception: + return [] + + async def _timeout_msg(sel, detail) -> str: + """超时诊断: + - 元素实际存在(仅被遮挡/不可点)→ 提示遮挡,引导关闭弹窗后重试 + - text= 精确匹配找不到 → 子串候选报告 + - 其他 → 原样诊断 + 远程搜索提示 + """ + # 元素存在但不可点(被弹窗/浮层遮挡、视口外等)——与"找不到"区分 + try: + if await frame.locator(sel).count() > 0: + return ( + f"元素存在但操作超时: {sel}(可能被弹窗/浮层遮挡、disabled 或不可见)。" + f"详情: {detail[:300]}。可先关闭遮挡弹窗(点击 Close / 按 Escape)后重试" + ) + except Exception: + pass + m = re.search(r'text="([^"]+)"', sel) + if m: + cand = await _text_candidates(m.group(1)) + if cand: + return ( + f"未找到精确文本 \"{m.group(1)}\",页面最接近的候选:{cand}。" + "请用候选文本重试(text=\"候选文本\")或 snapshot 取 ref 序号" + ) + return ( + f"元素操作超时: {sel}。详情: {detail[:400]}。" + "建议先 snapshot 获取元素清单用 ref 定位;" + "若为下拉选项,可能为远程搜索型下拉(需先在搜索框 type 输入关键词过滤)" + ) - async def _loc_op(op, sel: str): - """执行 locator 操作,定位超时时给出 snapshot+ref 引导提示。""" try: return await op() except Exception as e: if type(e).__name__ == "TimeoutError": + detail = str(e).replace("\n", " ").strip() + # 统一重试一次:遮挡场景(backdrop/浮层残留)先 Escape; + # 其余场景直接重试(很多超时是元素仍在懒加载/动画渲染中,重试即等出)。 + # 3s 超时 + 1 次重试 ≈ 最坏 6.6s,比"一次等满再报错"更快反馈且更稳。 + if "intercepts pointer events" in detail or "backdrop" in detail: + try: + await page.keyboard.press("Escape") + await asyncio.sleep(0.6) + except Exception: + pass + try: + return await op() + except Exception as e2: + if type(e2).__name__ == "TimeoutError": + raise ValueError( + await _timeout_msg(sel, str(e2).replace(chr(10), " ")) + ) from e2 + raise + detail = str(e).replace("\n", " ").strip() + if "strict mode violation" in detail: + # 自动重试:下拉选项/菜单项/树节点与 placeholder/已选值文本相同时, + # 优选用 li 结构限定(点"选项"的心智模型) + m = re.search(r'text="([^"]+)"', sel) + if m: + val = m.group(1).replace('\\"', '"') + for item_sel in ( + "li.ant-select-dropdown-menu-item", + ".ant-dropdown-menu-item", + ".ant-select-item-option", + 'li[class*="option"]', + ".ant-select-tree-node", + ): + try: + nsel = f'{item_sel}:text-is("{val}")' + if await frame.locator(nsel).count() == 1: + await frame.locator(nsel).click() + return "已点击选项: " + val + ("(" + hint + ")" if hint else "") + except Exception: + continue raise ValueError( - f"元素定位超时(10s): {sel}。建议先执行 snapshot 获取元素清单,再用 ref 序号定位" + f"元素定位歧义: {sel} 匹配到多个元素。详情: {detail[:400]}。" + '可改用更精确的 selector,如 text="完整文字"(精确)、' + 'li.ant-select-dropdown-menu-item:text-is("选项文字")、' + "或先 snapshot 取 ref 序号" ) from e raise @@ -200,63 +744,144 @@ class WebPage(BaseTool): return f"已打开 {_short_url(page.url)}({await page.title()})" if item.action == "snapshot": - data = await page.evaluate(_SNAPSHOT_JS) - elements = data.get("elements", []) - refs = {i + 1: e["sel"] for i, e in enumerate(elements)} + frames = page.frames + collected: list[dict] = [] + truncated = 0 + titles: dict[int, str] = {} + for fi, fr in enumerate(frames): + try: + data = await fr.evaluate(_SNAPSHOT_JS, {"maxElements": item.max_elements}) + except Exception: + continue + for e in data.get("elements") or []: + e["frame"] = fi + collected.extend(data.get("elements") or []) + truncated += data.get("truncated", 0) + titles[fi] = data.get("title") or "" + if not collected and not frames: + raise ValueError("当前页面无可用 frame,无法生成快照") + refs = {i + 1: {"frame": e["frame"], "sel": e["sel"]} for i, e in enumerate(collected)} session.set_refs(refs, page) - lines = [f"页面: {data.get('title', '')} | {data.get('url', '')}"] - lines.append(f"可交互元素共 {len(elements)} 个(click/fill 等用 ref 序号定位):") - for i, e in enumerate(elements, 1): - tag = e["tag"] - if tag == "input" and e.get("type"): - tag += f"[{e['type']}]" - parts = [f"[{i}] {tag}"] - if e.get("text"): - parts.append(f"\"{e['text']}\"") - if e.get("aria"): - parts.append(f"aria=\"{e['aria']}\"") - if e.get("ph"): - parts.append(f"placeholder=\"{e['ph']}\"") - if e.get("href"): - parts.append(f"-> {e['href']}") - lines.append(" ".join(parts)) + lines = [f"页面: {titles.get(0, '')} | {_short_url(page.url)}"] + if len(frames) > 1: + lines.append(f"页面含 {len(frames)} 个 frame,非主 frame 元素以 (帧k) 标注(弹窗在 iframe 内时请留意)") + extra = f",另有 {truncated} 个超出上限未列出" if truncated else "" + lines.append(f"可交互元素共 {len(collected)} 个{extra}(排序: 弹窗/浮层 → 视口内 → 页面其余)") + for i, e in enumerate(collected, 1): + lines.append(_fmt_snapshot_line(e, i)) return "\n".join(lines) if item.action == "click": - sel = _resolve() - loc = page.locator(sel) + frame, sel = _resolve() + loc = frame.locator(sel) if item.double_click: - await _loc_op(lambda: loc.dblclick(button=item.button), sel) + await _loc_op(frame, sel, lambda: loc.dblclick(button=item.button)) else: - await _loc_op(lambda: loc.click(button=item.button), sel) + await _loc_op(frame, sel, lambda: loc.click(button=item.button)) return f"已点击元素({item.ref and f'ref={item.ref}' or item.selector})" if item.action == "fill": - sel = _resolve() - loc = page.locator(sel) - await _loc_op(lambda: loc.fill(item.text), sel) + frame, sel = _resolve() + loc = frame.locator(sel) + await _loc_op(frame, sel, lambda: loc.fill(item.text)) if item.press_enter: await page.keyboard.press("Enter") return f"已输入并回车: \"{item.text[:50]}\"" return f"已输入: \"{item.text[:50]}\"" + if item.action == "type": + # 逐字输入(真实键事件):antd/angular 等搜索型输入框对 fill 的 + # 一次性注入不敏感(不触发过滤/联想),必须逐键键入。 + # 未提供 ref/selector 时回退到当前焦点(配合 click 展开 select 后 + # 直接输入的心智模型——焦点已在搜索框上) + if not item.text: + raise ValueError("type 需要 text(逐字输入,模拟真实键盘)") + if item.ref is None and not item.selector: + await page.keyboard.type(item.text, delay=50) + return f"已逐字输入(当前焦点): \"{item.text[:50]}\"" + frame, sel = _resolve() + loc = frame.locator(sel) + await _loc_op(frame, sel, lambda: loc.press_sequentially(item.text, delay=50)) + return f"已逐字输入: \"{item.text[:50]}\"" + if item.action == "hover": - sel = _resolve() - loc = page.locator(sel) - await _loc_op(lambda: loc.hover(), sel) + frame, sel = _resolve() + loc = frame.locator(sel) + await _loc_op(frame, sel, lambda: loc.hover()) return "已悬停元素" if item.action == "select": if not item.value: raise ValueError("select 需要 value(选项 value 或可见文本)") - sel = _resolve() - loc = page.locator(sel) + frame, sel = _resolve() + loc = frame.locator(sel) try: - await _loc_op(lambda: loc.select_option(value=item.value), sel) + await _loc_op(frame, sel, lambda: loc.select_option(value=item.value)) except Exception: - await _loc_op(lambda: loc.select_option(label=item.value), sel) + await _loc_op(frame, sel, lambda: loc.select_option(label=item.value)) return f"已选择: {item.value[:50]}" + if item.action in ("check", "uncheck"): + frame, sel = _resolve() + loc = frame.locator(sel) + if item.action == "check": + await _loc_op(frame, sel, lambda: loc.check()) + return "已勾选元素" + await _loc_op(frame, sel, lambda: loc.uncheck()) + return "已取消勾选元素" + + if item.action == "drag_to": + frame, sel = _resolve() + if item.target_ref is not None: + tinfo = session.get_ref(item.target_ref) + if tinfo is None or session.refs_stale(page): + raise ValueError(f"target_ref={item.target_ref} 无效,请重新执行 snapshot") + tframe = _frame_of(page, tinfo.get("frame", 0)) + tsel = tinfo["sel"] + elif item.target_selector: + tidx, tsel_raw = _split_frame_prefix(item.target_selector) + tframe = _frame_of(page, tidx) + tsel = _normalize_selector(tsel_raw) + else: + raise ValueError("drag_to 需要 target_ref 或 target_selector 指定目标位置") + await _loc_op(frame, sel, lambda: frame.locator(sel).drag_to(tframe.locator(tsel))) + return "已将元素拖拽到目标位置" + + if item.action == "date": + # readonly 日期控件(antd datepicker 等):点击输入框弹出日历面板, + # 自动完成 年(decade 翻页) → 月 → 日 选择,免去手动面板多步操作 + # text 与 value 均可传日期(兼容 select 字段的 value 习惯) + date_text = item.text or item.value + if not date_text or not re.match(r"^\d{4}-\d{2}-\d{2}$", date_text): + raise ValueError("date 需要日期格式 YYYY-MM-DD(如 2005-07-09)") + frame, sel = _resolve() + loc = frame.locator(sel) + await _loc_op(frame, sel, lambda: loc.click()) + await asyncio.sleep(0.6) + y, m, d = (int(x) for x in date_text.split("-")) + try: + done = await frame.evaluate(_DATE_PICK_JS, {"year": y, "month": m, "day": d}) + except Exception: + done = False + if not done: + # 失败时关闭可能残留的面板,避免遮挡后续操作 + try: + await page.keyboard.press("Escape") + except Exception: + pass + raise ValueError( + f"date: 日历面板操作失败({date_text}),元素可能不是 antd 日期控件" + "或面板未弹出(被遮罩遮挡时工具会自动 Escape 重试)。" + "可 screenshot 核对面板结构后重试" + ) + await asyncio.sleep(0.6) + val = "" + try: + val = await loc.input_value() + except Exception: + pass + return f"已选择日期: {date_text}" + (f"(输入框值: {val})" if val else "") + if item.action == "scroll": if item.scroll_to == "top": await page.evaluate("window.scrollTo(0, 0)") @@ -276,8 +901,10 @@ class WebPage(BaseTool): if item.action == "wait": if item.wait_selector: - await page.wait_for_selector( - _normalize_selector(item.wait_selector), + frame_idx, sel_raw = _split_frame_prefix(item.wait_selector) + frame = _frame_of(page, frame_idx) + await frame.wait_for_selector( + _normalize_selector(sel_raw), timeout=max(item.wait_seconds, 1.0) * 1000, ) return f"已等到元素出现: {item.wait_selector}" @@ -290,9 +917,11 @@ class WebPage(BaseTool): except Exception: title = "" if item.selector: - # 走 locator(支持 CSS/text=/xpath= 等全部引擎;多匹配时拼接前 20 个) - sel = _normalize_selector(item.selector) - loc = page.locator(sel) + # 走 locator(支持 CSS/text=/xpath= 等全部引擎与 frame 前缀;多匹配时拼接前 20 个) + frame_idx, sel_raw = _split_frame_prefix(item.selector) + frame = _frame_of(page, frame_idx) + sel = _normalize_selector(sel_raw) + loc = frame.locator(sel) count = await loc.count() if count == 0: raise ValueError(f"未找到元素: {item.selector}") @@ -306,8 +935,17 @@ class WebPage(BaseTool): texts.append(t) text = "\n".join(texts) else: - data = await page.evaluate(_EXTRACT_JS, None) - text = ((data or {}).get("text") or "").strip() + # 无选择器:主 frame + 各 iframe 的正文拼接(frame 段落带标注) + parts: list[str] = [] + for fi, fr in enumerate(page.frames): + try: + data = await fr.evaluate(_EXTRACT_JS, None) + except Exception: + continue + t = ((data or {}).get("text") or "").strip() + if t: + parts.append(t if fi == 0 else f"\n[帧{fi}]\n{t}") + text = "\n".join(parts) limit = min(max(item.max_length, 200), 10000) head = f"页面: {title} | {_short_url(page.url)}\n" body = text[:limit] + ("...(已截断)" if len(text) > limit else "") @@ -337,8 +975,16 @@ class WebPage(BaseTool): raise ValueError(f"不支持的动作类型: {item.action}") # ---------- 批量执行(一次 call_async 提交,会话线程内串行) ---------- - # 纯观察型动作:结果本身就是页面状态文本,无需再附截图(省视觉 token) + # 纯观察型动作:执行前等待页面稳定(含快照——需读取最新 DOM) _OBSERVE_ONLY = {"snapshot", "extract", "eval_js", "wait"} + # 纯文本型动作:结果本身就是页面状态文本,无需附截图(省视觉 token); + # snapshot 不在此列——元素清单需配合页面截图核对视觉布局 + _TEXT_ONLY = {"extract", "eval_js", "wait"} + # 状态变更动作:执行后自动附最新快照(agent 无需再手动 snapshot 看结果) + _STATE_CHANGE_ACTIONS = { + "click", "fill", "type", "select", "check", "uncheck", "date", + "press", "scroll", "goto", "drag_to", "hover", "refresh", + } async def _run_batch(self, session, actions: list[WebPageActionItem]) -> Tuple[str, Optional[bytes]]: await session._ensure_connected() @@ -363,14 +1009,36 @@ class WebPage(BaseTool): line = await self._exec_action(session, item) lines.append(f"步骤{idx} [{item.action}] {line}") prev_observe = is_observe + # 状态变更动作(点击/输入/选择/滚动/导航等)后标记脏, + # 保证后续截图/观察重新等待页面稳定 + if not is_observe and item.action != "wait": + try: + session._mark_dirty() + except Exception: + pass except Exception as e: lines.append(f"步骤{idx} [{item.action}] 失败: {e}") break - # 操作结束截图:空动作或含非观察型动作(点击/输入等改变页面状态的操作)时附加; - # 纯观察批次(snapshot/extract 等)文本结果已含页面信息,跳过截图。 + # 操作后自动快照:批次含状态变更动作(click/fill/type/select/date/scroll/press/goto 等) + # 且未显式调用 snapshot 时,自动追加最新元素清单——agent 一次调用即可拿到 + # "操作结果 + 新状态",无需再手动 snapshot。限 120 条(弹窗/浮层/视口内优先), + # 需要更多元素时显式 snapshot 并传 max_elements。自动快照同时刷新 refs 注册表。 + if any(a.action in self._STATE_CHANGE_ACTIONS for a in actions) and not any( + a.action == "snapshot" for a in actions + ): + try: + snap_line = await self._exec_action( + session, WebPageActionItem(action="snapshot", max_elements=120) + ) + lines.append("—— 操作后自动快照(最新状态,后续可直接用 ref)——") + lines.append(snap_line) + except Exception as e: + lines.append(f"[自动快照失败: {str(e)[:200]}]") + # 操作结束截图:空动作、含快照或含非纯文本动作(点击/输入等改变页面状态的操作)时附加; + # 纯文本批次(extract/wait/eval_js)结果已含页面信息,跳过截图。 # 截图统一走 session.screenshot(内含页面稳定等待,避免截到加载动画) need_shot = screenshot_requested or (not actions) or any( - a.action not in self._OBSERVE_ONLY for a in actions + a.action not in self._TEXT_ONLY for a in actions ) if need_shot: try: diff --git a/src/agent/utils/agent_utils.py b/src/agent/utils/agent_utils.py index 01d6f7d..4ac4c85 100644 --- a/src/agent/utils/agent_utils.py +++ b/src/agent/utils/agent_utils.py @@ -5,8 +5,8 @@ - 所有历史数据基于 JSON 文件存储(multi_inputs 目录下) 主要子系统: - 1. 历史与元数据: get_history / get_session_meta / update_session_meta - 2. 上下文压缩: summarizer + 轻量/全量压缩 + 1. 历史与元数据: get_history / load_compressed_context / save_compressed_context + 2. 上下文压缩: summarize_chat_text + 链式压缩块(compressed_context)+ 原生历史重建 3. 文档处理: Documents_process 多格式解析 4. 文本/音频: split_text、merge_wavs、assistant_text @@ -173,24 +173,32 @@ def _save_session_meta(session_id: str, meta: dict) -> None: session_storage.save_session_extra(session_id, existing) -def get_session_meta(session_id: str, agent_name: str) -> dict: - """读取会话在指定 Agent 下的压缩元数据(不含 turn_tool_context)。""" +def load_compressed_context(session_id: str) -> dict: + """读取主 Agent 的压缩上下文(链式压缩块结构)。 + + 结构: {"version": 2, "cursor_msg": int, "blocks": [ + {seq, content, msg_from, msg_to, turns, merged_units, + tokens_before, tokens_after, compressed_at}]} + + 输入: + session_id: 会话 ID。 + + 输出: + 压缩上下文 dict;无记录 / 旧结构返回空结构(不做旧数据迁移)。 + """ meta = _load_session_meta(session_id) - agent_meta = meta.get(agent_name, {}) - return { - "cursor_index": agent_meta.get("cursor_index", -1), - "compressed_content": agent_meta.get("compressed_content", ""), - } + ctx = (meta.get("main") or {}).get("compressed_context") + if not isinstance(ctx, dict) or not isinstance(ctx.get("blocks"), list): + return {"version": 2, "cursor_msg": 0, "blocks": []} + return ctx -def update_session_meta(session_id: str, agent_name: str, - cursor_index: int, compressed_content: str) -> None: - """写入或更新 session_meta(仅 cursor 和压缩内容)。""" +def save_compressed_context(session_id: str, ctx: dict) -> None: + """写入主 Agent 的压缩上下文(仅覆盖 compressed_context 键,不动其他字段)。""" meta = _load_session_meta(session_id) - meta[agent_name] = { - "cursor_index": cursor_index, - "compressed_content": compressed_content, - } + entry = dict(meta.get("main") or {}) + entry["compressed_context"] = ctx + meta["main"] = entry _save_session_meta(session_id, meta) @@ -230,7 +238,7 @@ def update_session_dynamic_tail(session_id: str, agent_key: str, text: str, max_ max_entries: 历史条目上限(防止无界增长),超出丢弃最旧。 说明: - 与 compressed_content 合并保存(不覆盖);连续相同内容去重, + 独立于 compressed_context 保存(不覆盖);连续相同内容去重, 使 todo 进度等状态变更形成可追溯的历史记录,供后续轮次注入查看 (充分利用前缀缓存:变化部分始终位于消息末尾)。 """ @@ -262,118 +270,192 @@ def get_session_dynamic_tail_history(session_id: str, agent_key: str) -> list[st return list(entry.get("dynamic_tail_history") or []) -def build_tool_history_messages(session_id: str, agent_key: str = "main") -> list: - """构建压缩游标之后的"历史思考与工具调用"合成消息列表。 +def build_native_history_messages(session_id: str, msg_cursor: int = 0) -> list: + """重建压缩游标之后的历史为原生 LangChain 消息(链式压缩块的输入/输出视图)。 + + 单轮拼接顺序(每条消息 = 1 个重建序号单位,压缩块按此区间计数): + HumanMessage(用户原生 content 部分: text / image_url / file) hist_user_{turn_id} + AIMessage(思考→additional_kwargs.reasoning_content,反思→content, + tool_calls=[真实 id/name/args]) × 每个 assistant 组 hist_assist_{turn_id}_{g} + —— 同一模型响应(AIMessage)的并行调用共享 group_index,合并为一条 + ToolMessage(result_text, tool_call_id/name 严格匹配) × 每个 call hist_tool_{turn_id}_{g}_{i} + ...未消费的尾部反思(与组内思考/反思去重后) hist_tail_{turn_id}_{i} + AIMessage(最终无工具回复文本) hist_final_{turn_id} + + 所有消息带稳定 id 与 synthetic/tool_ctx 标记(兼容 _filter_short_term_memory + 与压缩节点的 RemoveMessage)。旧记录无 group_index 时每组回退为单 call。 输入: session_id: 会话 ID。 - agent_key: session_meta 中的 agent 键(主 Agent 为 "main")。 + msg_cursor: 已压缩的重建序号(此序号之前的历史已由压缩块承载,跳过不重建)。 输出: - synthetic HumanMessage(带 ``tool_ctx`` 标记)列表;无历史或 - 游标之后无内容时为空列表。 - - 上下文拼接格式(前缀缓存友好,确定性构建): - [历史思考] {thinking} ← 该工具调用前模型产出的深度思考(reasoning_content) - [历史反思] {reflection} ← 该工具调用前模型产出的反思(content) - [历史工具调用] {name}({args}) → {result} - (思考、反思、工具调用按因果顺序交错;思考/反思独立注入——工具无 - 结果文本(失败/无返回值)时仍保留;每轮末尾未被工具调用消费的 - 尾部思考/反思去重后追加在该轮工具调用之后) - - 系统定位: - runtime.execute_agent 构建上下文时调用,替代原"最近5轮"注入, - 改为注入压缩游标之后的所有轮次思考+工具调用历史(游标之前的 - 部分已包含在压缩摘要中,由 ``compressed_content`` 承载)。 - 所有消息从磁盘确定性构建、id 稳定(think_ctx_{idx}/ - reflect_ctx_{idx}/tool_ctx_{idx}),满足 LLM 前缀缓存条件; - compress 节点按 id 精确移除。 + LangChain 消息列表;无历史 / 游标之后无内容时为空列表。 """ - if not session_id: - return [] - meta = get_session_meta(session_id, agent_key) - cursor = meta.get("cursor_index", -1) - result: list = [] + out: list[BaseMessage] = [] try: + from agent.history import session_storage from agent.history.tool_call_recorder import list_turns - turns = list(list_turns(session_id, limit=100000)) # 时间倒序 - turns.reverse() # 转为时间正序 - idx = 0 - for turn in turns: - # 本轮已被工具调用消费的思考/反思内容(用于尾部去重; - # 含游标之前的工具调用——被摘要覆盖的思考同样不得重复注入) - consumed_thinkings: set[str] = set() - consumed_reflections: set[str] = set() - for tc in turn.get("tool_calls") or []: - _think = str(tc.get("thinking", "") or "").strip() - _reflect = str(tc.get("reflection", "") or "").strip() - if _think: - consumed_thinkings.add(_think) - if _reflect: - consumed_reflections.add(_reflect) - if idx >= cursor: - tn = tc.get("name", "unknown") - ta = json.dumps(tc.get("args", {}), ensure_ascii=False)[:300] - tr = str(tc.get("result_text", "") or "")[:500] - # 思考/反思独立注入:即使工具无结果文本(失败/无返回值), - # 模型的思考/反思仍有上下文价值 - if _think: - result.append(HumanMessage( - content=f"[历史思考] {_think[:500]}", - additional_kwargs={"synthetic": True, "tool_ctx": True}, - id=f"think_ctx_{idx}", - )) - if _reflect: - result.append(HumanMessage( - content=f"[历史反思] {_reflect[:500]}", - additional_kwargs={"synthetic": True, "tool_ctx": True}, - id=f"reflect_ctx_{idx}", - )) - if tn and tr: - # 分配稳定 id(基于全局工具调用序号),供压缩节点 RemoveMessage 精确移除 - result.append(HumanMessage( - content=f"[历史工具调用] {tn}({ta}) → {tr}", - additional_kwargs={"synthetic": True, "tool_ctx": True}, - id=f"tool_ctx_{idx}", - )) - idx += 1 - # 尾部思考/反思:该轮末尾未被任何工具调用消费的(分别去重后追加) - if idx > cursor: - think_tail_idx = 0 - reflect_tail_idx = 0 - for ref in turn.get("reflections") or []: - _thought = str(ref.get("thought", "") or "").strip() - _reflection = str(ref.get("reflection", "") or "").strip() - if _thought and _thought not in consumed_thinkings: - result.append(HumanMessage( - content=f"[历史思考] {_thought[:500]}", - additional_kwargs={"synthetic": True, "tool_ctx": True}, - id=f"think_tail_{turn.get('turn_id', idx)}_{think_tail_idx}", - )) - think_tail_idx += 1 - if _reflection and _reflection not in consumed_reflections: - result.append(HumanMessage( - content=f"[历史反思] {_reflection[:500]}", - additional_kwargs={"synthetic": True, "tool_ctx": True}, - id=f"reflect_tail_{turn.get('turn_id', idx)}_{reflect_tail_idx}", - )) - reflect_tail_idx += 1 - # 旧格式兼容(仅 content,无 thought/reflection 字段): - # 视为思考注入(与旧行为一致) - if not _thought and not _reflection: - rc = str(ref.get("content", "") or "").strip() - if rc and rc not in consumed_thinkings: - result.append(HumanMessage( - content=f"[历史思考] {rc[:500]}", - additional_kwargs={"synthetic": True, "tool_ctx": True}, - id=f"think_tail_{turn.get('turn_id', idx)}_{think_tail_idx}", - )) - think_tail_idx += 1 + except Exception: + return out + try: + turn_ids = [str(x) for x in (session_storage.list_turn_ids(session_id) or [])] + except Exception: + turn_ids = [] + records_by_turn: dict[str, dict] = {} + try: + for rec in list_turns(session_id, limit=100000): + records_by_turn[str(rec.get("turn_id", ""))] = rec except Exception: pass - return result + if not turn_ids: + # 回退:以记录中的 turn_id 定序(某些后端 list_turn_ids 不可用) + turn_ids = sorted(records_by_turn.keys()) + for turn_id in turn_ids: + try: + turn_msgs = session_storage.load_turn_messages(session_id, turn_id) or [] + except Exception: + turn_msgs = [] + record = records_by_turn.get(turn_id) or {} + calls = [c for c in (record.get("tool_calls") or []) if isinstance(c, dict)] + reflections = record.get("reflections") or [] + for m in _rebuild_turn_messages(turn_id, turn_msgs, calls, reflections): + if msg_cursor > 0: + msg_cursor -= 1 + continue + out.append(m) + return out +def _native_user_message(turn_id: str, content) -> HumanMessage | None: + """将 turn 文件中的用户 content 部分还原为原生多模态 HumanMessage。 + + 图片部分用 relpath 解析回会话目录绝对路径(入模时由 llm._preprocess_messages + 自动转 data URL 并注入尺寸提示);文件部分保留 path 供其转为文本引用提示。 + """ + parts_out: list[dict[str, Any]] = [] + if isinstance(content, str): + if content.strip(): + parts_out.append({"type": "text", "text": content}) + elif isinstance(content, list): + for p in content: + if not isinstance(p, dict): + continue + if p.get("type") == "text": + t = str(p.get("text") or "").strip() + if t: + parts_out.append({"type": "text", "text": t}) + continue + rel = str(p.get("relpath") or "").replace("\\", "/") + abs_path = "" + root_norm = os.path.normpath(SESSIONS_ROOT) + if rel and not os.path.isabs(rel): + abs_path = os.path.normpath(os.path.join(root_norm, rel.replace("/", os.sep))) + try: + within = os.path.commonpath([abs_path, root_norm]) == root_norm + except ValueError: + within = False + if not within: + abs_path = "" + if p.get("type") == "image_url": + if abs_path and os.path.isfile(abs_path): + parts_out.append({"type": "image_url", "image_url": {"url": abs_path}, "name": p.get("name") or os.path.basename(abs_path)}) + else: + parts_out.append({"type": "text", "text": f"[图片已丢失: {p.get('name') or '未命名'}]"}) + elif p.get("type") == "file": + if abs_path and os.path.isfile(abs_path): + parts_out.append({"type": "file", "path": abs_path, "name": p.get("name") or os.path.basename(abs_path)}) + else: + parts_out.append({"type": "text", "text": f"[附件已丢失: {p.get('name') or '未命名'}]"}) + if not parts_out: + return None + return HumanMessage(content=parts_out, additional_kwargs={"synthetic": True, "tool_ctx": True}, id=f"hist_user_{turn_id}") + + +def _tool_result_content(entry: dict) -> str: + """工具结果文本:result_text + 产物文件路径提示(图片/文件由 Agent 经文件工具打开)。""" + lines = [str(entry.get("result_text") or "").strip()] + for fname in (entry.get("result_images") or []) + (entry.get("result_files") or []): + if fname: + lines.append(f"[工具产物: {fname}]") + return "\n".join(x for x in lines if x) + + +def _rebuild_turn_messages(turn_id: str, turn_msgs: list, calls: list[dict], reflections: list) -> list: + """按原生格式重建单轮消息序列(顺序:user → (组AI+ToolMessage)* → 尾部反思 → 最终回复)。""" + from langchain_core.messages import AIMessage, ToolMessage # noqa: F401 + msgs: list = [] + # 1) 用户消息 + user_entry = next((m for m in turn_msgs if isinstance(m, dict) and m.get("role") == "user" and isinstance(m.get("user"), dict)), None) + if user_entry: + hu = _native_user_message(turn_id, (user_entry.get("user") or {}).get("content") or "") + if hu: + msgs.append(hu) + # 2) 工具调用分组:group_index 相同 → 同一模型响应(并行调用合并为一条 AIMessage) + group_order: list = [] + group_map: dict = {} + fallback_idx = 0 + consumed_thinkings: set[str] = set() + consumed_reflections: set[str] = set() + for c in calls: + g = c.get("group_index") + if not isinstance(g, int): + fallback_idx += 1 + g = f"fb_{fallback_idx}" # 旧记录无分组 → 每 call 独立成组 + if g not in group_map: + group_map[g] = [] + group_order.append(g) + group_map[g].append(c) + for g in group_order: + entries = group_map[g] + first = entries[0] + thinking = str(first.get("thinking") or "").strip() + reflection = str(first.get("reflection") or "").strip() + kwargs = {"additional_kwargs": {"synthetic": True, "tool_ctx": True}, "id": f"hist_assist_{turn_id}_{g}"} + if thinking: + kwargs["additional_kwargs"]["reasoning_content"] = thinking + consumed_thinkings.add(thinking) + if reflection: + consumed_reflections.add(reflection) + tool_calls = [{"id": str(c.get("id") or ""), "name": str(c.get("name") or "unknown"), "args": c.get("args") or {}} for c in entries] + msgs.append(AIMessage(content=reflection, tool_calls=tool_calls, **kwargs)) + for i, c in enumerate(entries): + content = _tool_result_content(c) + msgs.append(ToolMessage( + content=content if content else "[无返回内容]", + tool_call_id=str(c.get("id") or ""), + name=str(c.get("name") or "unknown"), + id=f"hist_tool_{turn_id}_{g}_{i}", + additional_kwargs={"synthetic": True, "tool_ctx": True}, + )) + # 3) 尾部反思(未被任何工具调用组消费的思考/反思,按文本去重) + tail_idx = 0 + for r in reflections: + if not isinstance(r, dict): + continue + thought = str(r.get("thought") or "").strip() + reflection = str(r.get("reflection") or r.get("content") or "").strip() + if not thought and not reflection: + continue + if thought and thought in consumed_thinkings: + thought = "" + if reflection and reflection in consumed_reflections: + reflection = "" + if not thought and not reflection: + continue + kwargs = {"additional_kwargs": {"synthetic": True, "tool_ctx": True}, "id": f"hist_tail_{turn_id}_{tail_idx}"} + if thought: + kwargs["additional_kwargs"]["reasoning_content"] = thought + msgs.append(AIMessage(content=reflection, **kwargs)) + tail_idx += 1 + # 4) 最终无工具回复(turn 文件 assistant.text) + asst_entry = next((m for m in turn_msgs if isinstance(m, dict) and m.get("role") == "assistant" and isinstance(m.get("assistant"), dict)), None) + if asst_entry: + text = str((asst_entry.get("assistant") or {}).get("text") or "").strip() + if text: + msgs.append(AIMessage(content=text, additional_kwargs={"synthetic": True, "tool_ctx": True}, id=f"hist_final_{turn_id}")) + return msgs + # ---------- Turn 级消息存储 ---------- import threading as _threading diff --git a/src/agent/utils/env_utils.py b/src/agent/utils/env_utils.py index f235231..f11ceb3 100644 --- a/src/agent/utils/env_utils.py +++ b/src/agent/utils/env_utils.py @@ -56,7 +56,7 @@ def _refresh_globals(): 此处仅保留全局默认档位作为无会话上下文(cron、describe_image 等)的兜底。 """ global WORKING_DIR, WORKSPACE_DIR, USER_PYTHON_PATH - global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT + global LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, LLM_TIMEOUT, LLM_MAX_RETRIES global LLM_CONTEXT_WINDOW, COMPRESS_RATE, THINKING_LEVEL global IMG_SIZE, RAG_CHUNK_SIZE, RAG_CHUNK_OVERLAP, GROUNDING_WIDTH, GROUNDING_HEIGHT global SEND_FILE_SIZE_LIMIT @@ -71,6 +71,7 @@ def _refresh_globals(): 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_MAX_RETRIES = max(0, _get_env("LLM_MAX_RETRIES", default="3", cast=int)) LLM_CONTEXT_WINDOW = int(os.getenv("LLM_CONTEXT_WINDOW", 262144)) COMPRESS_RATE = float(os.getenv("COMPRESS_RATE", 0.6)) THINKING_LEVEL = _get_env("THINKING_LEVEL", default="low") diff --git a/src/agent/utils/tool_call_utils.py b/src/agent/utils/tool_call_utils.py index 066cbe4..37f11ac 100644 --- a/src/agent/utils/tool_call_utils.py +++ b/src/agent/utils/tool_call_utils.py @@ -106,7 +106,7 @@ def invoke_tool_and_build_message(tool, tool_name: str, tool_args: dict, tool_ca _policy_note = "" if _policy_decision(tool_name, tool_args) == "approve": _need_ask = True - _policy_note = _policy_check_violation(tool_name, tool_args) or "检测到超出工作空间范围的操作" + _policy_note = _policy_check_violation(tool_name, tool_args) or "未通过安全策略" if _need_ask: _agent = get_current_agent_name() diff --git a/src/web/app.css b/src/web/app.css index 4a97337..f74df61 100644 --- a/src/web/app.css +++ b/src/web/app.css @@ -5,7 +5,6 @@ @import url("./css/audio.css"); @import url("./css/composer.css"); @import url("./css/settings.css"); -@import url("./css/pending.css"); @import url("./css/toolcalls.css"); @import url("./css/notify-strip.css"); @import url("./css/skills.css"); diff --git a/src/web/app.js b/src/web/app.js index 7fba346..950f9a5 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -18,6 +18,7 @@ import { setRenderAttachmentChips as audioSetRenderChips } from './js/audio.js'; // ---- 聊天渲染 ---- import { renderMessages, renderMessagesIncremental, renderMessage, renderAttachmentChips, scrollChatToBottom, + appendNewMessages, appendThinkingIndicator, streamAssistantMessage, renderAudioBubble, addTextQuote, initChatQuoteMenu, } from './js/chat-render.js'; @@ -27,8 +28,23 @@ import { setRenderPersistentToolCalls } from './js/chat-render.js'; import { injectLiveToolCalls, setLiveUiDeps } from './js/toolcalls.js'; import { setRenderAudioBubble as toolcallsSetAudioBubble } from './js/toolcalls.js'; -// ---- 人工确认浮窗 ---- -import { updatePendingOverlay, clearPendingOverlay } from './js/pending-overlay.js'; +// ---- 人工确认浮窗 / Todo(通知条实现,原 pending-overlay.js / todo.js 迁移后由 notify-strip 接管) ---- +import { updatePendingStrip, clearPendingStrip, updateTodoStrip, clearTodoStrip, saveStripSession } from './js/notify-strip.js'; + +/** 清除 pending 通知条(先持久化会话快照) */ +function clearPendingOverlay() { + saveStripSession(); + clearPendingStrip(); +} + +/** 清除 todo 通知条(先持久化会话快照) */ +function clearTodoOverlay() { + saveStripSession(); + clearTodoStrip(); +} + +const updatePendingOverlay = updatePendingStrip; +const updateTodoOverlayFromRecords = updateTodoStrip; // ---- 会话管理 ---- import { renderSessions, bootstrap } from './js/sessions.js'; @@ -39,7 +55,7 @@ import { sendChat, handlePauseClick } from './js/send.js'; import { setSendDeps } from './js/send.js'; // ---- Todo ---- -import { updateTodoOverlayFromRecords, clearTodoOverlay } from './js/todo.js'; +// (notify-strip 已在上方统一导入:updateTodoStrip / clearTodoStrip / saveStripSession) // ---- Token 活跃度卡 / 分时段标语 ---- import { initUsageCard, refreshUsageCard } from './js/usage-stats.js'; @@ -99,7 +115,7 @@ import { renderPersistentToolCalls } from './js/toolcalls.js'; setRenderPersistentToolCalls(renderPersistentToolCalls); toolcallsSetAudioBubble(renderAudioBubble); -// live snapshot 驱动人工确认浮窗与 Todo 浮窗(toolcalls 收到快照后回调) +// live snapshot 驱动人工确认通知条与 Todo 通知条(toolcalls 收到快照后回调) setLiveUiDeps({ updatePendingOverlay, updateTodoOverlayFromRecords, @@ -122,6 +138,7 @@ setSendDeps({ injectLiveToolCalls, clearTodoOverlay, streamAssistantMessage, + appendNewMessages, onSendStart: () => setComposerCentered(false), }); @@ -686,7 +703,7 @@ if (sendBtn) { if (state.sending) { handlePauseClick().catch((e) => showToast(e.message || String(e))); } else if (state._pendingHumanAction) { - import('./js/pending-overlay.js').then((m) => m.clearPendingOverlay()); + import('./js/notify-strip.js').then((m) => { m.saveStripSession(); m.clearPendingStrip(); }).catch(() => {}); state._pendingHumanAction = false; if (sendBtn) { sendBtn.classList.remove("btn-pause"); diff --git a/src/web/css/base.css b/src/web/css/base.css index ec79a22..cb558bb 100644 --- a/src/web/css/base.css +++ b/src/web/css/base.css @@ -71,7 +71,8 @@ body { background-position: center; background-repeat: no-repeat; filter: blur(var(--bg-dof)); - will-change: filter, background-size, background-image; + /* 不声明 will-change:全屏固定层常驻合成器大图层消耗显存并在 + backdrop-filter 表面上反复重采样;模糊层变化频率低,无需预提升 */ transition: background-image 0.5s ease; } diff --git a/src/web/css/composer.css b/src/web/css/composer.css index 1f37c21..8c6647b 100644 --- a/src/web/css/composer.css +++ b/src/web/css/composer.css @@ -91,7 +91,7 @@ } .confirm-dialog { - background: #fff; + background: var(--surface, rgba(255, 255, 255, 0.94)); border-radius: 14px; padding: 1.6rem; max-width: 420px; @@ -251,7 +251,9 @@ gap: 0.4rem; align-items: flex-end; width: 100%; - transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1); + /* transition: all 在状态切换(欢迎/聊天)时动画化布局属性,配合输入区 + backdrop-filter 产生约 350ms 的连续重绘风暴;改为仅动画宽度变化 */ + transition: max-width 0.35s cubic-bezier(0.4, 0, 0.2, 1); } .mm-input { @@ -300,9 +302,9 @@ padding: 0.6rem 8rem 2.2rem 0.8rem; font: inherit; font-size: 0.92rem; - background: rgba(255, 255, 255, 0.32); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); + background: color-mix(in srgb, #fff var(--composer-alpha, 32%), transparent); + backdrop-filter: blur(var(--composer-blur, 12px)); + -webkit-backdrop-filter: blur(var(--composer-blur, 12px)); color: var(--text); line-height: 1.5; } @@ -314,7 +316,7 @@ .mm-input textarea:focus { outline: 2px solid color-mix(in srgb, var(--accent) 35%, transparent); outline-offset: 1px; - background: rgba(255, 255, 255, 0.42); + background: color-mix(in srgb, #fff calc(var(--composer-alpha, 32%) + 10%), transparent); } /* ===== Bottom-left: 输出模式 / Agent 模式 + 操作按钮 ===== */ @@ -358,7 +360,7 @@ .inline-drop-trigger:hover, .inline-drop-trigger.is-open { color: var(--accent); - background: rgba(255, 255, 255, 0.3); + background: color-mix(in srgb, var(--surface) 30%, transparent); } .inline-drop-trigger--model { @@ -374,7 +376,7 @@ .inline-drop-trigger--model:hover, .inline-drop-trigger--model.is-open { color: var(--accent); - background-color: rgba(255, 255, 255, 0.25); + background-color: color-mix(in srgb, var(--surface) 25%, transparent); } /* 下拉面板通用样式 */ @@ -383,7 +385,7 @@ bottom: calc(100% + 6px); left: 0; min-width: 120px; - background: #fff; + background: var(--surface, rgba(255, 255, 255, 0.94)); border-radius: 10px; box-shadow: 0 6px 24px var(--shadow), 0 2px 6px rgba(0, 0, 0, 0.06); z-index: 100; @@ -448,7 +450,7 @@ } .ws-btn:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.4); + background: color-mix(in srgb, var(--surface) 40%, transparent); opacity: 0.85; } @@ -470,7 +472,7 @@ right: 0; min-width: 260px; max-width: 380px; - background: #fff; + background: var(--surface, rgba(255, 255, 255, 0.94)); border-radius: 12px; box-shadow: 0 8px 32px var(--shadow), 0 2px 8px rgba(0, 0, 0, 0.08); z-index: 100; @@ -636,7 +638,7 @@ } .mic-btn:hover:not(:disabled) { - background: rgba(255, 255, 255, 0.4); + background: color-mix(in srgb, var(--surface) 40%, transparent); opacity: 0.85; } @@ -656,7 +658,7 @@ } .mic-btn.is-recording { - background: rgba(254, 226, 226, 0.85); + background: color-mix(in srgb, var(--danger) 18%, transparent); box-shadow: 0 0 0 3px var(--danger-bg); opacity: 1; } @@ -682,7 +684,7 @@ } .attachment-btn:hover { - background: rgba(255, 255, 255, 0.4); + background: color-mix(in srgb, var(--surface) 40%, transparent); opacity: 0.85; } @@ -714,7 +716,7 @@ .inline-select--model:hover { color: var(--accent); - background-color: rgba(255, 255, 255, 0.25); + background-color: color-mix(in srgb, var(--surface) 25%, transparent); } /* 下拉选项:浅色背景,无边框 */ @@ -722,7 +724,7 @@ .inline-select option { border: none; outline: none; - background: #fff; + background: var(--surface, rgba(255, 255, 255, 0.94)); color: var(--text); padding: 0.35rem 0.6rem; font-size: 0.78rem; @@ -786,7 +788,7 @@ padding: 0.35rem 0.6rem; border: none; outline: none; - background: rgba(255, 255, 255, 0.4); + background: color-mix(in srgb, var(--surface) 40%, transparent); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); color: var(--text); @@ -991,7 +993,7 @@ .action-icon-btn:hover { opacity: 0.75; - background: rgba(255, 255, 255, 0.3); + background: color-mix(in srgb, var(--surface) 30%, transparent); } .action-icon-btn.is-active { @@ -1031,7 +1033,7 @@ } .file-preview-dialog { - background: #fff; + background: var(--surface, rgba(255, 255, 255, 0.94)); border-radius: 14px; box-shadow: 0 16px 48px rgba(0, 0, 0, 0.18); width: min(90vw, 900px); @@ -1091,7 +1093,7 @@ transition: background 0.12s ease; } -.file-preview-btn:hover { background: rgba(0,0,0,0.06); } +.file-preview-btn:hover { background: var(--surface-hover); } .file-preview-close:hover { color: var(--danger); } .file-preview-body { @@ -1261,82 +1263,43 @@ .access-mode-item--full { color: var(--danger); } .access-mode-item--full.is-selected { background: color-mix(in srgb, var(--danger) 14%, transparent); } -/* ===== 思考模式面板:档位标签 + 横向渐变色滑动条 ===== +/* ===== 思考模式面板:纵向选项列表(agent / plan / agent思考 / plan思考 / goal)===== 面板右对齐向左展开(按钮位于右侧工具栏,避免伸出窗口右缘被截断) */ .think-mode-panel { - width: 164px; + width: 158px; right: 0; left: auto; - padding: 0.6rem 0.65rem 0.55rem; + padding: 0.35rem; } -.think-mode-labels { +.think-mode-items { display: flex; - justify-content: space-between; - margin-bottom: 0.4rem; + flex-direction: column; + gap: 1px; } -.think-mode-label { +.think-mode-item { + display: block; + width: 100%; border: none; background: transparent; color: var(--muted); - font-size: 0.62rem; + font-size: 0.68rem; font-family: var(--font-family, inherit); + text-align: left; + padding: 0.34rem 0.5rem; + border-radius: 5px; cursor: pointer; - padding: 0.1rem 0.1rem; - border-radius: 4px; white-space: nowrap; transition: color 0.15s ease, background 0.15s ease; } -.think-mode-label:hover { +.think-mode-item:hover { color: var(--text); + background: color-mix(in srgb, var(--accent) 7%, transparent); } -.think-mode-label.is-active { +.think-mode-item.is-selected { color: var(--text); font-weight: 700; background: color-mix(in srgb, var(--accent) 16%, transparent); } -.think-mode-track { - position: relative; - height: 6px; - border-radius: 3px; - cursor: pointer; - touch-action: none; - /* 渐变色滑动条:冷色(不思考) → 暖色(深度思考) */ - background: linear-gradient(90deg, - color-mix(in srgb, var(--text-tertiary) 55%, transparent), - var(--accent) 35%, - var(--warning) 65%, - var(--danger) 100%); -} -.think-mode-fill { - position: absolute; - top: 0; - left: 0; - height: 100%; - border-radius: 3px; - background: linear-gradient(90deg, - color-mix(in srgb, var(--text-tertiary) 55%, transparent), - var(--accent) 35%, - var(--warning) 65%, - var(--danger) 100%); - opacity: 0.9; -} -.think-mode-handle { - position: absolute; - top: 50%; - width: 14px; - height: 14px; - border-radius: 50%; - background: var(--surface); - border: 2px solid var(--accent); - box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 22%, transparent), var(--shadow); - transform: translate(-50%, -50%); - cursor: grab; - touch-action: none; - pointer-events: none; -} -.think-mode-handle:active { - cursor: grabbing; -} /* ===== Token 环形指示器(模型选择器左侧)===== */ .token-ring-wrap { @@ -1475,7 +1438,12 @@ overflow: hidden; display: flex; flex-direction: column; - background: color-mix(in srgb, var(--surface, #fff) 90%, #000); + /* 先暗化 10%(保持原观感),再按 --terminal-alpha 与背景混合(透明度可调) */ + background: color-mix(in srgb, + color-mix(in srgb, var(--surface, #fff) 90%, #000) var(--terminal-alpha, 100%), + transparent); + backdrop-filter: blur(var(--terminal-blur, 0px)); + -webkit-backdrop-filter: blur(var(--terminal-blur, 0px)); border-top: 1px solid var(--border, rgba(0, 0, 0, 0.12)); animation: terminal-dock-in 0.25s cubic-bezier(0.4, 0, 0.2, 1); } @@ -1603,8 +1571,9 @@ display: none; position: absolute; inset: 0; - overflow-y: auto; - padding: 8px 10px; + /* 滚动交给 xterm 自带 viewport,外层只裁切 */ + overflow: hidden; + padding: 6px 8px; background: color-mix(in srgb, var(--surface, #fff) 90%, #000); } @@ -1612,59 +1581,134 @@ display: block; } -.term-out { +/* xterm 容器:fit addon 依据此容器的实际像素尺寸计算行列数 */ +.term-holder { + position: relative; + width: 100%; + height: 100%; font-family: "Cascadia Mono", Consolas, "Courier New", monospace; - font-size: 0.8rem; - line-height: 1.45; - color: var(--text); - white-space: pre-wrap; - word-break: break-all; } -/* ANSI 颜色 → 主题色 */ -.t-fg-muted { color: var(--muted); } -.t-fg-danger { color: var(--danger, #ef4444); } -.t-fg-success { color: var(--success, #22c55e); } -.t-fg-warning { color: var(--warning, #eab308); } -.t-fg-accent { color: var(--accent, #0e7490); } -.t-fg-purple { color: #c084fc; } -.t-fg-cyan { color: #22d3ee; } -.t-bg-dark { background: rgba(0, 0, 0, 0.25); } -.t-bg-danger { background: var(--danger-bg, rgba(239, 68, 68, 0.12)); } -.t-bg-success { background: var(--success-bg, rgba(34, 197, 94, 0.12)); } -.t-bg-warning { background: var(--warning-bg, rgba(234, 179, 8, 0.12)); } -.t-bg-accent { background: color-mix(in srgb, var(--accent) 18%, transparent); } +.term-holder .xterm { + height: 100%; +} -/* 命令行(提示符 + 输入)作为终端输出的最后一行,随内容滚动,观感等同真实终端 */ -.terminal-input-row { +.term-holder .xterm-viewport { + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb, rgba(148, 163, 184, 0.2)) transparent; +} + +/* ===== 后台任务标签(agent terminal_execute 挂起任务,与交互式终端视觉区分) ===== */ +.term-tab--task { + color: var(--accent); +} + +.term-tab--task .term-tab-icon { + width: 14px; + height: 14px; + display: block; + flex-shrink: 0; +} + +.term-tab--task.term-tab--dead .term-tab-title { + text-decoration: none; + opacity: 0.65; +} + +.term-task-pane { + display: none; + position: absolute; + inset: 0; + flex-direction: column; + padding: 6px 8px; + background: color-mix(in srgb, var(--surface, #fff) 90%, #000); +} + +.term-task-pane.active { display: flex; - align-items: baseline; - gap: 6px; - margin-top: 1px; } -.terminal-prompt { - font-family: "Cascadia Mono", Consolas, "Courier New", monospace; - font-size: 0.8rem; - color: var(--success, #22c55e); - white-space: nowrap; - user-select: none; +.term-task-head { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; + padding: 2px 4px 6px; + margin-bottom: 4px; + border-bottom: 1px solid var(--border, rgba(0, 0, 0, 0.12)); + font-size: 0.72rem; + color: var(--muted); + min-width: 0; } -.terminal-input { +.term-task-cmd { flex: 1; min-width: 0; - border: none; - outline: none; - background: transparent; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--text); - caret-color: var(--success, #22c55e); - font-family: "Cascadia Mono", Consolas, "Courier New", monospace; - font-size: 0.8rem; - line-height: 1.45; - padding: 0; } -.terminal-input:disabled { - opacity: 0.5; +.term-task-status { + flex-shrink: 0; + padding: 0.06rem 0.5rem; + border-radius: 0.55rem; + font-size: 0.66rem; + font-weight: 600; + white-space: nowrap; + color: var(--muted); + background: var(--bg-tertiary, rgba(0, 0, 0, 0.06)); +} + +.term-task-status--running { + color: var(--success, #22c55e); + background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); +} + +.term-task-status--err { + color: var(--danger, #ef4444); + background: color-mix(in srgb, var(--danger, #ef4444) 14%, transparent); +} + +.term-task-kill { + flex-shrink: 0; + border: none; + border-radius: 7px; + padding: 0.2rem 0.65rem; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; + background: var(--danger-bg, rgba(239, 68, 68, 0.12)); + color: var(--danger, #ef4444); + transition: background 0.12s ease, filter 0.12s ease; +} + +.term-task-kill:hover { + filter: brightness(1.1); +} + +.term-task-output { + flex: 1; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: var(--scrollbar-thumb, rgba(148, 163, 184, 0.2)) transparent; + margin: 0; + padding: 6px 4px; + font-family: "Cascadia Mono", Consolas, "Courier New", monospace; + font-size: 12px; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; + color: var(--text); +} + +.term-task-output .term-task-err { + color: var(--warning, #f59e0b); +} + +.term-task-output .term-task-truncated { + color: var(--warning, #f59e0b); + font-size: 11px; } diff --git a/src/web/css/cron.css b/src/web/css/cron.css index 144c8ef..80bf1a3 100644 --- a/src/web/css/cron.css +++ b/src/web/css/cron.css @@ -42,7 +42,7 @@ padding: 0; border: none; border-radius: 7px; - background: rgba(14, 116, 144, 0.12); + background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent); color: var(--accent); font-size: 1.05rem; line-height: 1; @@ -54,7 +54,7 @@ } .cron-new-btn:hover { - background: rgba(14, 116, 144, 0.22); + background: color-mix(in srgb, var(--accent, #0e7490) 22%, transparent); transform: scale(1.08); } @@ -129,12 +129,12 @@ } .cron-item:hover { - background: rgba(255, 255, 255, 0.28); + background: color-mix(in srgb, var(--surface, #fff) 30%, transparent); } .cron-item.active { - border-color: rgba(14, 116, 144, 0.65); - background: rgba(236, 254, 255, 0.35); + border-color: color-mix(in srgb, var(--accent, #0e7490) 65%, transparent); + background: color-mix(in srgb, var(--surface, #fff) 35%, transparent); } .cron-item-body { @@ -221,7 +221,7 @@ } .cron-item-action-btn:hover { - background: rgba(0, 0, 0, 0.08); + background: var(--bg-tertiary, rgba(0, 0, 0, 0.04)); color: var(--muted); } @@ -255,7 +255,7 @@ padding: 0.5rem 0.85rem; margin-bottom: 0.4rem; border-radius: 10px; - background: rgba(255, 255, 255, 0.42); + background: color-mix(in srgb, var(--surface, #fff) 42%, transparent); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); border: 1px solid rgba(148, 163, 184, 0.28); @@ -308,11 +308,11 @@ } .cron-status-badge--pending { background: rgba(148, 163, 184, 0.2); color: var(--text-tertiary); } -.cron-status-badge--running { background: rgba(59, 130, 246, 0.16); color: #1d4ed8; } +.cron-status-badge--running { background: rgba(59, 130, 246, 0.16); color: var(--accent); } .cron-status-badge--completed { background: var(--success-bg); color: var(--success); } .cron-status-badge--failed { background: var(--danger-bg); color: var(--danger-hover); } .cron-status-badge--expired { background: var(--warning-bg); color: var(--warning); } -.cron-status-badge--disabled { background: rgba(203, 213, 225, 0.3); color: var(--text-tertiary); } +.cron-status-badge--disabled { background: var(--bg-tertiary, rgba(203, 213, 225, 0.3)); color: var(--text-tertiary); } .cron-banner-actions { display: flex; @@ -328,7 +328,7 @@ padding: 0.28rem 0.6rem; border: 1px solid rgba(14, 116, 144, 0.3); border-radius: 7px; - background: rgba(14, 116, 144, 0.08); + background: color-mix(in srgb, var(--accent, #0e7490) 8%, transparent); color: var(--accent); font-size: 0.7rem; font-weight: 600; @@ -338,8 +338,8 @@ } .cron-banner-btn:hover:not(:disabled) { - background: rgba(14, 116, 144, 0.18); - border-color: rgba(14, 116, 144, 0.5); + background: color-mix(in srgb, var(--accent, #0e7490) 18%, transparent); + border-color: color-mix(in srgb, var(--accent, #0e7490) 50%, transparent); } .cron-banner-btn:disabled { @@ -380,11 +380,11 @@ width: min(560px, 92vw); max-height: 88vh; max-height: 88dvh; - background: rgba(248, 250, 252, 0.97); + background: var(--surface, rgba(248, 250, 252, 0.97)); backdrop-filter: blur(24px); -webkit-backdrop-filter: blur(24px); border-radius: 18px; - border: 1px solid rgba(148, 163, 184, 0.35); + border: 1px solid var(--border); box-shadow: 0 24px 64px var(--shadow); display: flex; flex-direction: column; @@ -413,7 +413,7 @@ border-radius: 999px; border: none; outline: none; - background: rgba(255, 255, 255, 0.5); + background: color-mix(in srgb, var(--surface, #fff) 50%, transparent); cursor: pointer; font-size: 1rem; color: var(--text-tertiary); @@ -421,7 +421,7 @@ } .cron-edit-close:hover { - background: rgba(255, 255, 255, 0.85); + background: color-mix(in srgb, var(--surface, #fff) 85%, transparent); color: var(--text); } @@ -459,7 +459,7 @@ padding: 0.5rem 0.65rem; border: 1px solid rgba(148, 163, 184, 0.45); border-radius: 8px; - background: rgba(255, 255, 255, 0.7); + background: color-mix(in srgb, var(--surface, #fff) 70%, transparent); color: var(--text); font-size: 0.82rem; font-family: var(--font-family); @@ -472,7 +472,7 @@ .cron-field textarea:focus, .cron-field select:focus { border-color: var(--accent); - background: rgba(255, 255, 255, 0.95); + background: color-mix(in srgb, var(--surface, #fff) 95%, transparent); } .cron-field textarea { @@ -485,7 +485,7 @@ .cron-trigger-types { display: flex; gap: 0.3rem; - background: rgba(255, 255, 255, 0.5); + background: color-mix(in srgb, var(--surface, #fff) 50%, transparent); padding: 0.2rem; border-radius: 9px; border: 1px solid rgba(148, 163, 184, 0.3); @@ -509,7 +509,7 @@ } .cron-trigger-type-btn.active { - background: rgba(14, 116, 144, 0.14); + background: color-mix(in srgb, var(--accent, #0e7490) 14%, transparent); color: var(--accent); } @@ -579,13 +579,13 @@ } .cron-edit-btn--cancel { - background: rgba(255, 255, 255, 0.6); + background: color-mix(in srgb, var(--surface, #fff) 60%, transparent); color: var(--text-tertiary); border: 1px solid rgba(148, 163, 184, 0.4); } .cron-edit-btn--cancel:hover { - background: rgba(255, 255, 255, 0.9); + background: color-mix(in srgb, var(--surface, #fff) 90%, transparent); } .cron-edit-btn--save { @@ -668,7 +668,7 @@ height: 38px; border: 1px solid rgba(148, 163, 184, 0.4); border-radius: 10px; - background: rgba(255, 255, 255, 0.6); + background: color-mix(in srgb, var(--surface, #fff) 60%, transparent); color: var(--muted); font-size: 0.78rem; font-weight: 600; @@ -682,7 +682,7 @@ } .cron-weekday-btn:hover { - border-color: rgba(14, 116, 144, 0.5); + border-color: color-mix(in srgb, var(--accent, #0e7490) 50%, transparent); color: var(--accent); transform: translateY(-1px); } @@ -722,8 +722,8 @@ display: inline-flex; align-items: center; gap: 0.3rem; - background: rgba(255, 255, 255, 0.55); - border: 1px solid rgba(148, 163, 184, 0.35); + background: color-mix(in srgb, var(--surface, #fff) 55%, transparent); + border: 1px solid var(--border); border-radius: 12px; padding: 0.25rem 0.4rem; } @@ -751,7 +751,7 @@ } .cron-time-arrow:hover { - background: rgba(14, 116, 144, 0.12); + background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent); color: var(--accent); } @@ -766,7 +766,7 @@ height: 30px; border: 1px solid transparent; border-radius: 7px; - background: rgba(255, 255, 255, 0.85); + background: color-mix(in srgb, var(--surface, #fff) 85%, transparent); color: var(--text); font-size: 0.95rem; font-weight: 700; @@ -825,7 +825,7 @@ padding: 0.5rem 0.65rem; border: 1px solid rgba(148, 163, 184, 0.45); border-radius: 8px; - background: rgba(255, 255, 255, 0.7); + background: color-mix(in srgb, var(--surface, #fff) 70%, transparent); color: var(--text); font-size: 0.82rem; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; @@ -836,7 +836,7 @@ .cron-advanced-raw input:focus { border-color: var(--accent); - background: rgba(255, 255, 255, 0.95); + background: color-mix(in srgb, var(--surface, #fff) 95%, transparent); } /* ===== 一次性延时主题化选择器(once 类型) ===== */ @@ -871,7 +871,7 @@ padding: 0.5rem 0.7rem; border: 1px solid rgba(148, 163, 184, 0.45); border-radius: 8px; - background: rgba(255, 255, 255, 0.7); + background: color-mix(in srgb, var(--surface, #fff) 70%, transparent); color: var(--text); font-size: 0.82rem; cursor: pointer; @@ -880,7 +880,7 @@ } .cron-date-trigger:hover { - border-color: rgba(14, 116, 144, 0.5); + border-color: color-mix(in srgb, var(--accent, #0e7490) 50%, transparent); } .cron-date-trigger.is-placeholder { @@ -907,10 +907,10 @@ left: 0; z-index: 30; width: 260px; - background: rgba(248, 250, 252, 0.98); + background: var(--surface, rgba(248, 250, 252, 0.98)); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); - border: 1px solid rgba(148, 163, 184, 0.35); + border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 12px 32px var(--shadow); padding: 0.6rem; @@ -938,7 +938,7 @@ height: 24px; border: none; border-radius: 6px; - background: rgba(255, 255, 255, 0.6); + background: color-mix(in srgb, var(--surface, #fff) 60%, transparent); color: var(--muted); cursor: pointer; display: inline-flex; @@ -948,7 +948,7 @@ } .cron-calendar-nav:hover { - background: rgba(14, 116, 144, 0.12); + background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent); color: var(--accent); } @@ -988,7 +988,7 @@ } .cron-calendar-day:hover:not(:disabled) { - background: rgba(14, 116, 144, 0.12); + background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent); } .cron-calendar-day.is-other-month { @@ -1044,12 +1044,12 @@ } .cron-calendar-footer .cron-cal-today { - background: rgba(14, 116, 144, 0.12); + background: color-mix(in srgb, var(--accent, #0e7490) 12%, transparent); color: var(--accent); } .cron-calendar-footer .cron-cal-today:hover { - background: rgba(14, 116, 144, 0.22); + background: color-mix(in srgb, var(--accent, #0e7490) 22%, transparent); } /* once 时间行垂直对齐日期 trigger */ diff --git a/src/web/css/edit-mode.css b/src/web/css/edit-mode.css index a36e88d..537c464 100644 --- a/src/web/css/edit-mode.css +++ b/src/web/css/edit-mode.css @@ -28,12 +28,12 @@ } .mode-switch-item:hover { - color: #334155; - background: rgba(255, 255, 255, 0.3); + color: var(--text); + background: color-mix(in srgb, var(--surface) 30%, transparent); } .mode-switch-item.active { - color: #0e7490; + color: var(--accent); } .mode-switch-sep { @@ -124,17 +124,17 @@ padding: 0.22rem 0.45rem; border: none; border-radius: 6px; - background: rgba(255, 255, 255, 0.25); + background: color-mix(in srgb, var(--surface) 25%, transparent); cursor: pointer; font: inherit; font-size: 0.68rem; - color: #475569; + color: var(--muted); transition: background 0.15s; text-align: center; } .doc-tree-btn:hover { - background: rgba(255, 255, 255, 0.5); + background: color-mix(in srgb, var(--surface) 50%, transparent); } .doc-tree { @@ -177,22 +177,22 @@ } .doc-tree-file--active { - background: rgba(14, 116, 144, 0.25) !important; + background: color-mix(in srgb, var(--accent) 25%, transparent) !important; } /* git 状态着色:新增绿 / 修改橙 / 删除橙+删除线 */ .doc-tree-file--modified { - color: #f59e0b !important; + color: var(--warning) !important; } .doc-tree-file--new { - color: #22c55e !important; + color: var(--success) !important; } .doc-tree-file--deleted { - color: #f59e0b !important; + color: var(--warning) !important; text-decoration: line-through; - text-decoration-color: #f59e0b; + text-decoration-color: var(--warning); } .doc-tree-file--deleted:hover { @@ -204,14 +204,14 @@ opacity: 0.4; } .doc-tree-file--drop-target { - background: rgba(14, 116, 144, 0.35) !important; - outline: 1px dashed rgba(14, 116, 144, 0.6); + background: color-mix(in srgb, var(--accent) 35%, transparent) !important; + outline: 1px dashed color-mix(in srgb, var(--accent) 60%, transparent); outline-offset: -1px; } .doc-tree-folder--drop-target { - background: rgba(96, 165, 250, 0.15) !important; - outline: 2px dashed rgba(96, 165, 250, 0.5); + background: color-mix(in srgb, var(--accent) 15%, transparent) !important; + outline: 2px dashed color-mix(in srgb, var(--accent) 50%, transparent); outline-offset: -2px; } @@ -220,9 +220,9 @@ flex: 1; min-width: 0; padding: 0.05rem 0.2rem; - border: 1px solid #0e7490; + border: 1px solid var(--accent); border-radius: 3px; - background: #3c3c3c; + background: var(--surface); color: #ffffff; font: inherit; font-size: 0.76rem; @@ -246,8 +246,8 @@ position: fixed; z-index: 1000; min-width: 160px; - background: #252526; - border: 1px solid #454545; + background: var(--surface); + border: 1px solid var(--border); border-radius: 6px; box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); padding: 0.3rem 0; @@ -257,22 +257,22 @@ padding: 0.35rem 1rem; font-size: 0.78rem; cursor: pointer; - color: #cccccc; + color: var(--text); transition: background 0.1s; } .doc-context-menu-item:hover { - background: #094771; + background: color-mix(in srgb, var(--accent) 25%, transparent); } .doc-context-menu-item--danger { - color: #ef4444; + color: var(--danger); } .doc-context-menu-sep { height: 1px; margin: 0.2rem 0.5rem; - background: #454545; + background: var(--divider); } /* ===== Edit 工作区 ===== */ @@ -376,7 +376,7 @@ .edit-tab--modified::after { content: "●"; font-size: 0.5rem; - color: #eab308; + color: var(--warning); margin-left: 0.15rem; } @@ -398,7 +398,7 @@ } .edit-tab-close:hover { - background: rgba(128,128,128,0.2); + background: var(--border); color: var(--edit-tab-active-text, #fff); } @@ -450,12 +450,12 @@ /* --- 预览区 Agent 变更彩色边框 --- */ .edit-file-preview--agent-new { - border: 3px solid #22c55e !important; + border: 3px solid var(--success) !important; border-radius: 4px; } .edit-file-preview--agent-modified { - border: 3px solid #eab308 !important; + border: 3px solid var(--warning) !important; border-radius: 4px; } @@ -486,7 +486,7 @@ height: 100%; border: none; border-radius: 4px; - background: #fff; + background: var(--surface); } .edit-empty-hint { @@ -595,11 +595,11 @@ } .doc-summary-item-name--modified { - color: #eab308; + color: var(--warning); } .doc-summary-item-name--deleted { - color: #ef4444; + color: var(--danger); } .doc-summary-actions { @@ -629,16 +629,16 @@ } .doc-summary-accept:hover { - background: rgba(34, 197, 94, 0.35); + background: color-mix(in srgb, var(--success) 35%, transparent); } .doc-summary-reject { - background: rgba(239, 68, 68, 0.15); - color: #ef4444; + background: var(--danger-bg, rgba(239, 68, 68, 0.12)); + color: var(--danger); } .doc-summary-reject:hover { - background: rgba(239, 68, 68, 0.35); + background: color-mix(in srgb, var(--danger) 35%, transparent); } .doc-summary-line-stat { @@ -651,7 +651,7 @@ /* --- 行级引用高亮 --- */ .edit-editor-line--referenced { - background: rgba(14, 116, 144, 0.2); + background: color-mix(in srgb, var(--accent) 20%, transparent); border-left: 2px solid #0e7490; padding-left: 0.35rem; } @@ -660,13 +660,13 @@ .doc-tree-icon--plain_text { color: #9cdcfe; } .doc-tree-icon--docx { color: #4a9eff; } .doc-tree-icon--pptx { color: #ff6b35; } -.doc-tree-icon--pdf { color: #ef4444; } +.doc-tree-icon--pdf { color: var(--danger); } .doc-tree-icon--tabular { color: #22c55e; } .doc-tree-icon--image { color: #c586c0; } /* 拖拽覆盖区域提示 */ .edit-editor-pane.drag-over { - border-color: #0e7490; + border-color: var(--accent); box-shadow: inset 0 0 0 2px rgba(14, 116, 144, 0.3); } diff --git a/src/web/css/layout.css b/src/web/css/layout.css index b84099e..2d254e0 100644 --- a/src/web/css/layout.css +++ b/src/web/css/layout.css @@ -65,9 +65,9 @@ width: var(--sidebar-w); min-width: var(--sidebar-w); border-right: none; - background: var(--surface, rgba(255, 255, 255, 0.94)); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); + background: color-mix(in srgb, var(--surface, rgba(255, 255, 255, 0.94)) var(--sidebar-alpha, 100%), transparent); + backdrop-filter: blur(var(--sidebar-blur, 12px)); + -webkit-backdrop-filter: blur(var(--sidebar-blur, 12px)); display: flex; flex-direction: column; padding: 0.5rem 0 0.5rem 0.5rem; @@ -316,12 +316,15 @@ max-width: 88vw; display: flex; flex-direction: column; - background: var(--surface, rgba(255, 255, 255, 0.94)); - backdrop-filter: blur(12px); - -webkit-backdrop-filter: blur(12px); + background: color-mix(in srgb, var(--surface, rgba(255, 255, 255, 0.94)) var(--right-alpha, 100%), transparent); + /* 仅在打开态启用 backdrop-filter:关闭动画期间面板滑出,逐帧重算模糊会卡顿。 + (背景模糊 + transform 组合无法稳定走合成器,导致关闭明显滞后于打开) */ + backdrop-filter: none; + -webkit-backdrop-filter: none; border-left: 1px solid var(--border, rgba(0, 0, 0, 0.1)); transform: translateX(100%); transition: transform 0.28s cubic-bezier(0.4, 0, 0.2, 1); + will-change: transform; z-index: 60; box-shadow: -8px 0 24px rgba(0, 0, 0, 0.12); } @@ -333,6 +336,8 @@ .app-root.right-panel-open .right-panel { transform: translateX(0); + backdrop-filter: blur(var(--right-blur, 12px)); + -webkit-backdrop-filter: blur(var(--right-blur, 12px)); } .right-panel-head { @@ -659,7 +664,9 @@ align-items: center; justify-content: flex-end; gap: 0.4rem; - background: var(--surface, rgba(255, 255, 255, 0.94)); + background: color-mix(in srgb, var(--surface, rgba(255, 255, 255, 0.94)) var(--topbar-alpha, 100%), transparent); + backdrop-filter: blur(var(--topbar-blur, 0px)); + -webkit-backdrop-filter: blur(var(--topbar-blur, 0px)); border-bottom: 1px solid var(--border, rgba(0, 0, 0, 0.1)); } diff --git a/src/web/css/notify-strip.css b/src/web/css/notify-strip.css index a84b1c7..8c42ce8 100644 --- a/src/web/css/notify-strip.css +++ b/src/web/css/notify-strip.css @@ -118,6 +118,12 @@ text-overflow: ellipsis; } +/* 权限审查类:警示色字体(与普通人机交互缩略行区分) */ +.strip-item-text--review { + color: var(--warning, #f59e0b); + font-weight: 600; +} + /* 计数 chip */ .strip-item-count { flex-shrink: 0; @@ -153,6 +159,16 @@ mask: url(/image/人机交互.svg) center / contain no-repeat; } +/* Terminal(后台终端任务)图标 */ +.strip-terminal-icon { + flex-shrink: 0; + width: 0.9rem; + height: 0.9rem; + background-color: var(--accent); + -webkit-mask: url(/image/终端.svg) center / contain no-repeat; + mask: url(/image/终端.svg) center / contain no-repeat; +} + /* ===== 展开区(垂直位于行容器下方) ===== */ .strip-expand-area { padding: 0.35rem 0.6rem 0.5rem 0.6rem; @@ -287,54 +303,145 @@ overflow-wrap: anywhere; } -/* 选项胶囊 */ -.strip-card-options { - display: flex; - flex-wrap: wrap; - gap: 0.35rem; - margin-bottom: 0.35rem; -} -.strip-option-pill { - border: none; - border-radius: 999px; - padding: 0.22rem 0.7rem; - font-size: 0.72rem; + + + + + +/* ===== 人机交互多问题卡片:无边框/无分割线的自上而下列表 ===== */ +.strip-hq-prompt { + font-size: 0.82rem; color: var(--text); - background: var(--bg-tertiary); + line-height: 1.5; + margin-bottom: 0.4rem; +} + +.strip-hq-options { + display: flex; + flex-direction: column; + gap: 0.1rem; + margin-bottom: 0.4rem; +} + +.strip-hq-option { + display: block; + width: 100%; + border: none; + background: transparent; + text-align: left; + font-family: var(--font-family, inherit); + font-size: 0.8rem; + color: var(--muted); + padding: 0.32rem 0.5rem; + border-radius: 7px; cursor: pointer; - transition: background 0.15s ease, color 0.15s ease; + transition: background 0.12s ease, color 0.12s ease; } -.strip-option-pill:hover { +.strip-hq-option:hover { background: var(--surface-hover); + color: var(--text); } -.strip-option-pill.is-selected { - background: var(--accent); - color: #fff; +.strip-hq-option.is-selected { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); + font-weight: 600; } -/* 补充输入框(无边框简约风) */ -.strip-card-input { +.strip-hq-input { width: 100%; box-sizing: border-box; border: none; outline: none; resize: vertical; - min-height: 2rem; - padding: 0.35rem 0.5rem; + min-height: 2.4rem; + padding: 0.4rem 0.55rem; border-radius: 8px; - background: var(--bg-tertiary); + background: var(--surface-hover); color: var(--text); - font-size: 0.76rem; - font-family: var(--font-family); - line-height: 1.45; + font-size: 0.8rem; + font-family: var(--font-family, inherit); + line-height: 1.5; transition: background 0.15s ease; } -.strip-card-input:focus { +.strip-hq-input::placeholder { + color: var(--text-tertiary); +} + +/* 底部:左下角问题切换箭头(圆角矩形按钮)+ 右下角 忽略/确认 */ +.strip-hq-footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.4rem; + margin-top: 0.5rem; +} + +.strip-hq-nav { + display: flex; + gap: 0.25rem; +} + +.strip-hq-nav-btn { + border: none; + min-width: 2.2rem; + height: 1.75rem; + border-radius: 8px; background: var(--surface-hover); + color: var(--text); + font-family: var(--font-family, inherit); + font-size: 0.95rem; + line-height: 1; + cursor: pointer; + transition: background 0.12s ease; +} + +.strip-hq-nav-btn:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 14%, transparent); + color: var(--accent); +} + +.strip-hq-nav-btn:disabled { + opacity: 0.35; + cursor: default; +} + +.strip-hq-actions { + display: flex; + gap: 0.35rem; +} + +.strip-hq-act { + border: none; + padding: 0.32rem 0.95rem; + border-radius: 8px; + font-family: var(--font-family, inherit); + font-size: 0.8rem; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.strip-hq-act--ignore { + background: transparent; + color: var(--text-tertiary); +} + +.strip-hq-act--ignore:hover { + background: var(--surface-hover); + color: var(--text); +} + +.strip-hq-act--confirm { + background: color-mix(in srgb, var(--accent) 16%, transparent); + color: var(--accent); + font-weight: 600; +} + +.strip-hq-act--confirm:hover { + background: color-mix(in srgb, var(--accent) 26%, transparent); } /* 卡片底部按钮行(右对齐) */ @@ -359,13 +466,11 @@ transform: scale(0.97); } -.strip-card-btn--continue, .strip-card-btn--approve { background: var(--accent); color: #fff; } -.strip-card-btn--continue:hover, .strip-card-btn--approve:hover { background: var(--accent-hover); } @@ -447,3 +552,93 @@ opacity: 0; transform: scale(0.6); } + +/* ===== 后台终端任务列表(通知条展开区) ===== */ +.strip-terminal-list { + list-style: none; + margin: 0; + padding: 0.15rem 0 0; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.strip-task-row { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.3rem 0.45rem; + border-radius: 8px; + background: color-mix(in srgb, var(--bg-tertiary) 55%, transparent); + font-size: 0.75rem; + min-width: 0; +} + +.strip-task-dot { + flex-shrink: 0; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--text-tertiary); +} + +.strip-task-dot--running { + background: var(--success); + animation: strip-task-pulse 1.4s ease infinite; +} + +.strip-task-dot--err { + background: var(--danger, #ef4444); +} + +@keyframes strip-task-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.35; } +} + +.strip-task-cmd { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; +} + +.strip-task-meta { + flex-shrink: 0; + font-size: 0.68rem; + color: var(--muted); + white-space: nowrap; +} + +.strip-task-kill { + flex-shrink: 0; + border: none; + border-radius: 7px; + padding: 0.22rem 0.6rem; + font-size: 0.7rem; + font-weight: 600; + cursor: pointer; + background: var(--danger-bg, rgba(239, 68, 68, 0.12)); + color: var(--danger, #ef4444); + transition: background 0.12s ease, filter 0.12s ease; +} + +.strip-task-kill:hover { + filter: brightness(1.1); +} + +.strip-task-status { + flex-shrink: 0; + font-size: 0.68rem; + color: var(--muted); + white-space: nowrap; +} + +.strip-task-status--err { + color: var(--danger, #ef4444); + font-weight: 600; +} diff --git a/src/web/css/pending-overlay.css b/src/web/css/pending-overlay.css deleted file mode 100644 index ad08ddb..0000000 --- a/src/web/css/pending-overlay.css +++ /dev/null @@ -1,472 +0,0 @@ -/* ===== HumanInteraction / 待确认工具 浮窗(主题变量化)===== */ -.pending-overlay { - position: fixed; - top: 80px; - left: 50%; - transform: translateX(-50%); - z-index: 901; - min-width: 320px; - max-width: 500px; - width: max-content; - background: var(--surface); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - border-radius: var(--radius, 12px); - box-shadow: 0 8px 32px var(--overlay-bg); - border: 1px solid var(--border); - border-top: 2px solid var(--accent); - overflow: hidden; - transition: all 0.3s ease; - color: var(--text); -} - -.pending-overlay-header { - padding: 0.6rem 1rem; - font-size: 0.85rem; - font-weight: 700; - color: var(--accent); - cursor: pointer; - user-select: none; - display: flex; - align-items: center; - gap: 0.4rem; - border-bottom: 1px solid var(--border); - background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 8%, transparent), transparent); -} - -.pending-overlay-header:hover { - background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 12%, transparent), var(--surface-hover)); -} - -.pending-overlay-body { - max-height: 50vh; - overflow-y: auto; - padding: 0.4rem 0; -} - -/* ----- 折叠态 ----- */ -.pending-overlay--collapsed .pending-overlay-body { - display: none; -} -.pending-overlay--collapsed { - min-width: 180px; - max-width: 240px; -} - -/* ===== agent 分组(主 Agent / 不同子 Agent)===== */ -.pending-overlay-agent-group { - padding: 0.2rem 0; -} -.pending-overlay-agent-group + .pending-overlay-agent-group { - border-top: 1px dashed var(--border); -} - -.pending-overlay-agent-header { - padding: 0.35rem 0.8rem; - font-size: 0.76rem; - font-weight: 700; - color: var(--muted); - display: flex; - align-items: center; - gap: 0.3rem; - text-transform: uppercase; - letter-spacing: 0.02em; -} -.pending-overlay-agent-header .pending-overlay-agent-tag { - display: inline-block; - padding: 0.05rem 0.4rem; - border-radius: 999px; - font-size: 0.68rem; - background: color-mix(in srgb, var(--accent) 16%, transparent); - color: var(--accent); - font-weight: 600; -} - -/* ===== 单个 pending 卡片 ===== */ -.pending-overlay-card { - margin: 0.3rem 0.6rem; - border: 1px solid var(--border); - border-radius: 10px; - overflow: hidden; - background: var(--surface-hover); - box-shadow: 0 1px 4px var(--shadow); - transition: opacity 0.3s; -} - -.pending-overlay-card--resolved { - opacity: 0.45; - pointer-events: none; -} - -.pending-overlay-card--resolved .pending-overlay-btn, -.pending-overlay-card--resolved .pending-overlay-send-btn { - pointer-events: none; -} - -.pending-overlay-card-header { - display: flex; - align-items: center; - gap: 0.35rem; - padding: 0.45rem 0.7rem; - cursor: pointer; - user-select: none; - font-size: 0.82rem; - color: var(--text); - border-bottom: 1px solid var(--border); - transition: background 0.15s; -} - -.pending-overlay-card-header:hover { - background: color-mix(in srgb, var(--accent) 6%, transparent); -} - -.pending-overlay-card-icon { - flex-shrink: 0; -} - -.pending-overlay-card-title { - flex: 1; - font-weight: 600; -} - -.pending-overlay-card-toggle { - flex-shrink: 0; - font-size: 0.7rem; - color: var(--muted); - transition: transform 0.2s; -} - -/* ----- 卡片折叠态 ----- */ -.pending-overlay-card--collapsed .pending-overlay-card-body { - display: none; -} - -.pending-overlay-card-body { - padding: 0.5rem 0.7rem; -} - -.pending-overlay-prompt { - font-size: 0.8rem; - color: var(--muted); - line-height: 1.5; - margin-bottom: 0.5rem; - white-space: pre-wrap; -} - -/* 工作空间越界审批提示(访问策略 policy_note) */ -.pending-overlay-note { - font-size: 0.78rem; - color: var(--warning, #b45309); - background: var(--warning-bg, color-mix(in srgb, #f59e0b 12%, transparent)); - border: 1px solid color-mix(in srgb, var(--warning, #f59e0b) 45%, transparent); - border-radius: 8px; - padding: 0.4rem 0.55rem; - margin-bottom: 0.5rem; - line-height: 1.5; - white-space: pre-wrap; - word-break: break-all; -} - -/* ===== tool_call 卡片:参数摘要只读展示 ===== */ -.pending-overlay-tool-name { - font-size: 0.8rem; - font-weight: 600; - color: var(--accent); - margin-bottom: 0.3rem; -} - -.pending-overlay-tool-args { - font-size: 0.78rem; - color: var(--text); - background: color-mix(in srgb, var(--accent) 5%, transparent); - border: 1px solid color-mix(in srgb, var(--accent) 22%, transparent); - border-radius: 8px; - padding: 0.4rem 0.55rem; - margin-bottom: 0.5rem; - white-space: pre-wrap; - word-break: break-all; - max-height: 120px; - overflow-y: auto; - font-family: var(--font-family, inherit); - line-height: 1.55; -} - -/* ===== 按钮行(同意 / 拒绝 / 跳过)===== */ -.pending-overlay-btn-row { - display: flex; - gap: 0.4rem; - flex-wrap: wrap; - margin-top: 0.2rem; -} - -.pending-overlay-btn { - flex: 1; - min-width: 64px; - padding: 0.42rem 0.8rem; - border: none; - border-radius: 8px; - font: inherit; - font-size: 0.8rem; - font-weight: 600; - cursor: pointer; - transition: transform 0.15s, box-shadow 0.2s, background 0.2s, opacity 0.2s; -} -.pending-overlay-btn:active { - transform: translateY(1px); -} -.pending-overlay-btn:focus-visible, -.pending-overlay-send-btn:focus-visible, -.pending-overlay-submit-btn:focus-visible { - outline: 2px solid var(--accent); - outline-offset: 2px; -} -.pending-overlay-btn:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -.pending-overlay-btn--approve { - background: var(--accent); - color: #fff; -} -.pending-overlay-btn--approve:hover { - background: var(--accent-hover); - box-shadow: 0 0 12px color-mix(in srgb, var(--accent) 45%, transparent); -} - -.pending-overlay-btn--reject { - background: var(--danger); - color: #fff; -} -.pending-overlay-btn--reject:hover { - filter: brightness(1.08); - box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 45%, transparent); -} - -.pending-overlay-btn--skip { - background: var(--skip-btn-bg); - color: var(--text); - border: 1px solid var(--border); -} -.pending-overlay-btn--skip:hover { - background: color-mix(in srgb, var(--text) 12%, transparent); -} - -/* ===== 文本输入行 ===== */ -.pending-overlay-input-row { - display: flex; - gap: 0.4rem; - align-items: flex-end; -} - -.pending-overlay-textarea { - flex: 1; - resize: vertical; - min-height: 58px; - max-height: 160px; - border: 1px solid var(--border); - border-radius: 8px; - padding: 0.45rem 0.6rem; - font: inherit; - font-size: 0.82rem; - color: var(--text); - background: var(--surface); - transition: border-color 0.2s, box-shadow 0.2s; -} - -.pending-overlay-textarea:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); -} - -.pending-overlay-send-btn { - flex-shrink: 0; - padding: 0.4rem 1rem; - border: none; - border-radius: 8px; - background: var(--accent); - color: #fff; - font: inherit; - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; - transition: transform 0.2s, box-shadow 0.2s, background 0.2s; -} - -.pending-overlay-send-btn:hover { - background: var(--accent-hover); - box-shadow: 0 0 14px color-mix(in srgb, var(--accent) 50%, transparent); -} - -/* ===== Selection 问题块 ===== */ -.pending-overlay-question { - margin-bottom: 0.5rem; - border: none; - border-radius: 8px; - overflow: hidden; -} - -.pending-overlay-question:last-child { - margin-bottom: 0; -} - -.pending-overlay-question-header { - display: flex; - align-items: center; - gap: 0.3rem; - padding: 0.35rem 0.6rem; - cursor: pointer; - font-size: 0.78rem; - color: var(--muted); - font-weight: 600; - background: var(--surface-hover); - user-select: none; - transition: background 0.15s; -} - -.pending-overlay-question-header:hover { - background: color-mix(in srgb, var(--accent) 7%, transparent); -} - -.pending-overlay-q-icon { - flex-shrink: 0; - font-size: 0.75rem; -} - -/* ----- 问题折叠态 ----- */ -.pending-overlay-question--collapsed .pending-overlay-question-body { - display: none; -} - -.pending-overlay-question-body { - padding: 0.5rem 0.6rem; -} - -/* ===== 选项 pills ===== */ -.pending-overlay-option-pills { - display: flex; - flex-wrap: wrap; - gap: 0.3rem; - margin-bottom: 0.35rem; -} - -.pending-overlay-option-pill { - display: inline-flex; - align-items: center; - border: 1px solid var(--border); - border-radius: 999px; - padding: 0.3rem 0.7rem; - font: inherit; - font-size: 0.78rem; - cursor: pointer; - background: var(--surface); - color: var(--text); - transition: all 0.2s; - user-select: none; -} - -.pending-overlay-option-pill:hover { - background: color-mix(in srgb, var(--accent) 10%, transparent); - box-shadow: 0 0 8px color-mix(in srgb, var(--accent) 18%, transparent); - transform: translateY(-1px); -} - -.pending-overlay-option-pill--selected { - background: var(--accent) !important; - border-color: var(--accent) !important; - color: #fff !important; - font-weight: 600; - box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 30%, transparent); -} - -/* ===== "其他" 输入 ===== */ -.pending-overlay-other-row { - display: flex; - align-items: center; - gap: 0.3rem; -} - -.pending-overlay-other-input { - flex: 1; - border: 1px solid var(--border); - border-radius: 999px; - padding: 0.3rem 0.7rem; - font: inherit; - font-size: 0.78rem; - color: var(--text); - background: var(--surface); - transition: border-color 0.2s; -} - -.pending-overlay-other-input:focus { - outline: none; - border-color: var(--accent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 15%, transparent); -} - -/* ===== 提交行 ===== */ -.pending-overlay-submit-row { - padding: 0.4rem 0.7rem 0.6rem; - display: flex; - justify-content: center; - border-top: 1px solid var(--border); -} - -.pending-overlay-submit-btn { - padding: 0.45rem 1.5rem; - border: none; - border-radius: 999px; - font: inherit; - font-size: 0.82rem; - font-weight: 600; - cursor: pointer; - background: var(--accent); - color: #fff; - box-shadow: 0 2px 8px color-mix(in srgb, var(--accent) 25%, transparent); - transition: all 0.2s; -} - -.pending-overlay-submit-btn:hover { - background: var(--accent-hover); - box-shadow: 0 4px 16px color-mix(in srgb, var(--accent) 40%, transparent); - transform: translateY(-1px); -} - -/* ===== 双浮窗并排布局 ===== */ -.todo-list-overlay--dual { - left: calc(50% - 260px); - transform: none; -} - -.pending-overlay--dual { - left: calc(50% + 20px); - transform: none; -} - -/* ===== 响应式 ===== */ -@media (max-width: 768px) { - .todo-list-overlay--dual { - left: 1rem; - transform: none; - } - .pending-overlay--dual { - left: 1rem; - right: 1rem; - transform: none; - top: auto; - bottom: 0; - max-width: unset; - border-radius: 12px 12px 0 0; - } -} - -@media (max-width: 640px) { - .pending-overlay { - left: 1rem; - right: 1rem; - transform: none; - min-width: unset; - max-width: unset; - } -} diff --git a/src/web/css/pending.css b/src/web/css/pending.css deleted file mode 100644 index f75e85f..0000000 --- a/src/web/css/pending.css +++ /dev/null @@ -1,279 +0,0 @@ -/* ===== 人工交互 / 工具审批卡片 ===== */ -.pending-action-card { - margin-top: 0.75rem; - padding: 0.85rem 1rem; - border: none; - border-left: 3px solid color-mix(in srgb, var(--success) 45%, transparent); - border-radius: 12px; - background: linear-gradient(135deg, var(--surface) 0%, var(--bg-tertiary) 100%); - box-shadow: 0 4px 20px var(--shadow), inset 0 1px 0 rgba(255,255,255,0.6); - backdrop-filter: blur(4px); - transition: border-color 0.2s, box-shadow 0.2s; -} - -.pending-action-card:hover { - border-color: color-mix(in srgb, var(--success) 25%, transparent); - box-shadow: 0 4px 24px color-mix(in srgb, var(--success) 8%, transparent), inset 0 1px 0 rgba(255,255,255,0.6); -} - -.pending-action-card--resolved { - opacity: 0.68; - border-left-color: var(--border); -} - -.pending-action-title { - font-weight: 700; - font-size: 0.92rem; - margin-bottom: 0.45rem; - color: var(--text); - display: flex; - align-items: center; - gap: 0.35rem; -} - -.pending-action-prompt { - margin-bottom: 0.55rem; - color: var(--text-tertiary); - font-size: 0.84rem; - line-height: 1.6; - white-space: pre-wrap; -} - -/* information: textarea + inline arrow */ -.pending-action-input-row { - position: relative; -} - -.pending-action-editor, -.pending-action-editor-container { - width: 100%; -} - -.pending-action-editor { - width: 100%; - resize: vertical; - border: 1px solid var(--border); - border-radius: 10px; - padding: 0.6rem 0.75rem; - font: inherit; - font-size: 0.84rem; - color: var(--text); - background: var(--surface); - transition: border-color 0.2s, box-shadow 0.2s; -} - -.pending-action-editor:focus { - outline: none; - border-color: color-mix(in srgb, var(--success) 50%, transparent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 10%, transparent); -} - -.pending-action-editor--with-arrow { - padding-right: 2.8rem; -} - -.pending-action-editor-container { - position: relative; -} - -.pending-action-send-btn { - position: absolute; - right: 6px; - top: 50%; - transform: translateY(-50%); - width: 2.1rem; - height: 2.1rem; - border: none; - border-radius: 50%; - background: linear-gradient(135deg, var(--success) 0%, var(--accent) 100%); - color: #fff; - font-size: 1.1rem; - font-weight: 700; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - transition: transform 0.2s, box-shadow 0.2s, background 0.2s; - line-height: 1; - z-index: 1; -} - -.pending-action-send-btn:hover:not(:disabled) { - transform: translateY(-50%) scale(1.12); - background: linear-gradient(135deg, color-mix(in srgb, var(--success) 75%, white) 0%, var(--accent) 100%); - box-shadow: 0 0 14px color-mix(in srgb, var(--success) 55%, transparent), 0 0 28px color-mix(in srgb, var(--accent) 20%, transparent); -} - -.pending-action-send-btn:disabled { - opacity: 0.4; - cursor: not-allowed; - box-shadow: none; -} - -.pending-action-send-btn--small { - width: 1.6rem; - height: 1.6rem; - font-size: 0.9rem; - position: static; - flex-shrink: 0; - margin-left: 0.4rem; - transform: none; -} - -.pending-action-send-btn--small:hover:not(:disabled) { - transform: scale(1.12); -} - -/* selection: multi-question */ -.pending-action-question { - margin-bottom: 0.7rem; - padding: 0.6rem 0.7rem; - border: none; - border-radius: 10px; - background: var(--bg-tertiary); - transition: border-color 0.2s; -} - -.pending-action-question:hover { - border-color: color-mix(in srgb, var(--success) 20%, transparent); -} - -.pending-action-question-label { - font-weight: 600; - font-size: 0.86rem; - margin-bottom: 0.45rem; - color: var(--muted); -} - -/* option pills (non-editable, click to select) */ -.pending-action-option-pill { - display: inline-flex; - align-items: center; - border: none; - border-radius: 999px; - padding: 0.35rem 0.75rem; - font: inherit; - font-size: 0.8rem; - cursor: pointer; - background: var(--surface); - color: var(--muted); - transition: all 0.2s; - margin: 0.15rem 0.3rem 0.15rem 0; - user-select: none; -} - -.pending-action-option-pill:hover:not(:disabled) { - background: color-mix(in srgb, var(--success) 8%, transparent); - box-shadow: 0 0 8px color-mix(in srgb, var(--success) 12%, transparent); - transform: translateY(-1px); -} - -.pending-action-option-pill--selected { - background: linear-gradient(135deg, var(--success), var(--accent)) !important; - border-color: color-mix(in srgb, var(--accent) 70%, transparent) !important; - color: #fff !important; - font-weight: 600; - box-shadow: 0 2px 8px color-mix(in srgb, var(--success) 25%, transparent); -} - -.pending-action-option-pill:disabled { - opacity: 0.5; - cursor: not-allowed; -} - -/* "其他" input row */ -.pending-action-other-row { - display: flex; - align-items: center; - gap: 0.4rem; - margin-top: 0.4rem; -} - -.pending-action-other-input { - flex: 1; - border: 1px solid var(--border); - border-radius: 999px; - padding: 0.38rem 0.8rem; - font: inherit; - font-size: 0.8rem; - color: var(--text); - background: var(--surface); - transition: border-color 0.2s, box-shadow 0.2s; -} - -.pending-action-other-input:focus { - outline: none; - border-color: color-mix(in srgb, var(--success) 45%, transparent); - box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 10%, transparent); -} - -.pending-action-other-input:disabled { - opacity: 0.5; -} - -/* options container (legacy) */ -.pending-action-options { - display: flex; - flex-wrap: wrap; - gap: 0.3rem; - margin-bottom: 0.45rem; -} - -.pending-action-buttons { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.65rem; -} - -.pending-action-btn { - border: none; - border-radius: 999px; - padding: 0.45rem 1rem; - font: inherit; - font-size: 0.84rem; - font-weight: 500; - cursor: pointer; - background: var(--surface); - color: var(--muted); - transition: all 0.2s; - backdrop-filter: blur(4px); -} - -.pending-action-btn:hover:not(:disabled) { - background: var(--surface); - box-shadow: 0 2px 8px var(--shadow); - transform: translateY(-1px); -} - -.pending-action-btn:disabled { - opacity: 0.45; - cursor: not-allowed; -} - -.pending-action-btn--approve { - background: linear-gradient(135deg, var(--success), var(--accent)); - border-color: transparent; - color: #fff; - font-weight: 600; - box-shadow: 0 2px 8px color-mix(in srgb, var(--success) 20%, transparent); -} - -.pending-action-btn--approve:hover:not(:disabled) { - background: linear-gradient(135deg, color-mix(in srgb, var(--success) 75%, white), var(--accent-hover)); - box-shadow: 0 4px 16px color-mix(in srgb, var(--success) 40%, transparent), 0 0 24px color-mix(in srgb, var(--accent) 15%, transparent); - transform: translateY(-1px); - border-color: transparent; -} - -.pending-action-btn--reject { - background: var(--danger-bg); - color: var(--danger-hover); - font-weight: 500; -} - -.pending-action-btn--reject:hover:not(:disabled) { - background: var(--danger-bg); - box-shadow: 0 2px 10px color-mix(in srgb, var(--danger) 12%, transparent); - transform: translateY(-1px); -} diff --git a/src/web/css/responsive.css b/src/web/css/responsive.css index 859a809..e4ff314 100644 --- a/src/web/css/responsive.css +++ b/src/web/css/responsive.css @@ -92,11 +92,4 @@ /* ===== 响应式 ===== */ @media (max-width: 640px) { - .todo-list-overlay { - left: 1rem; - right: 1rem; - transform: none; - min-width: unset; - max-width: unset; - } } diff --git a/src/web/css/settings.css b/src/web/css/settings.css index 2f63342..cc09227 100644 --- a/src/web/css/settings.css +++ b/src/web/css/settings.css @@ -966,7 +966,7 @@ position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; - background: #cbd5e1; + background: color-mix(in srgb, var(--text-tertiary) 30%, transparent); border-radius: 22px; transition: background 0.2s; } diff --git a/src/web/css/toast.css b/src/web/css/toast.css index 36dd013..6cbae14 100644 --- a/src/web/css/toast.css +++ b/src/web/css/toast.css @@ -13,7 +13,7 @@ } .toast-item { - background: rgba(255, 255, 255, 0.88); + background: color-mix(in srgb, var(--surface, #fff) 92%, transparent); backdrop-filter: blur(14px); border-radius: 10px; padding: 10px 20px; diff --git a/src/web/css/todo.css b/src/web/css/todo.css deleted file mode 100644 index 6a4c16c..0000000 --- a/src/web/css/todo.css +++ /dev/null @@ -1,102 +0,0 @@ -/* ===== Todo 浮窗(主题变量化)===== */ -.todo-list-overlay { - position: fixed; - top: 80px; - left: 50%; - transform: translateX(-50%); - z-index: 900; - min-width: 280px; - max-width: 420px; - width: max-content; - background: var(--surface); - backdrop-filter: blur(16px); - -webkit-backdrop-filter: blur(16px); - border-radius: var(--radius, 12px); - box-shadow: 0 8px 32px var(--overlay-bg); - border: 1px solid var(--border); - overflow: hidden; - transition: all 0.3s ease; - color: var(--text); -} - -.todo-list-header { - padding: 0.6rem 1rem; - font-size: 0.85rem; - font-weight: 700; - color: var(--text); - cursor: pointer; - user-select: none; - display: flex; - align-items: center; - gap: 0.4rem; - border-bottom: 1px solid var(--border); -} - -.todo-list-header:hover { - background: var(--surface-hover); -} - -.todo-list-body { - list-style: none; - margin: 0; - padding: 0.5rem 0; - max-height: 300px; - overflow-y: auto; -} - -.todo-list-item { - display: flex; - align-items: center; - gap: 0.5rem; - padding: 0.4rem 1rem; - font-size: 0.8rem; - color: var(--muted); -} -.todo-list-item:last-child { - border-bottom: none; -} - -.todo-label { - flex: 1; - line-height: 1.4; -} - -/* ----- 勾已完成 ----- */ -.todo-check { - flex-shrink: 0; - width: 20px; - height: 20px; - border-radius: 50%; - background: var(--success); - color: #fff; - display: flex; - align-items: center; - justify-content: center; - font-size: 0.7rem; - font-weight: 700; - line-height: 1; -} - -/* ----- 加载旋转动画(pending) ----- */ -.todo-spinner { - flex-shrink: 0; - width: 20px; - height: 20px; - border-radius: 50%; - border: 2.5px solid color-mix(in srgb, var(--accent) 20%, transparent); - border-top-color: var(--accent); - animation: todo-spin 0.8s linear infinite; -} - -@keyframes todo-spin { - to { transform: rotate(360deg); } -} - -/* ----- 折叠态 ----- */ -.todo-list-overlay--collapsed .todo-list-body { - display: none; -} -.todo-list-overlay--collapsed { - min-width: 180px; - max-width: 240px; -} diff --git a/src/web/css/toolcalls.css b/src/web/css/toolcalls.css index 41f8d94..504d72a 100644 --- a/src/web/css/toolcalls.css +++ b/src/web/css/toolcalls.css @@ -35,14 +35,15 @@ transform: rotate(90deg); } .tc-list-toggle-body { - display: flex; + display: none; flex-direction: column; gap: 0.15rem; margin-top: 0.15rem; - max-height: 0; + max-height: none; overflow: hidden; } .tool-call-list-wrap--expanded .tc-list-toggle-body { + display: flex; overflow-y: auto; max-height: 75vh; } @@ -174,17 +175,20 @@ opacity: 0.8; } -/* 展开详情区(行下方) */ +/* 展开详情区(行下方)。折叠态用 display:none 而非 max-height:0: + max-height:0 仍会对巨型 result_text 全量排版(大会话渲染/滚动的主要开销), + display:none 完全跳过布局,仅在展开瞬间排版一次。 */ .tc-detail { margin: 0.15rem 0 0.3rem 1.4rem; padding: 0 0.45rem; - display: flex; + display: none; flex-direction: column; gap: 0.3rem; - max-height: 0; + max-height: none; overflow: hidden; } .tool-call-row--expanded + .tc-detail { + display: flex; max-height: 45vh; overflow-y: auto; padding: 0.3rem 0.45rem; @@ -306,10 +310,11 @@ color: var(--text, #475569); white-space: pre-wrap; word-break: break-word; - max-height: 0; + display: none; overflow: hidden; } .sub-reply-row--expanded .sub-reply-row-detail { + display: block; max-height: 400px; overflow-y: auto; } @@ -434,10 +439,11 @@ color: var(--muted, #64748b); white-space: pre-wrap; word-break: break-word; - max-height: 0; + display: none; overflow: hidden; } .think-row--expanded .think-row-detail { + display: block; max-height: 300px; padding: 0.35rem 0.5rem; overflow-y: auto; diff --git a/src/web/css/variables.css b/src/web/css/variables.css index 171cfd8..27d98fb 100644 --- a/src/web/css/variables.css +++ b/src/web/css/variables.css @@ -36,4 +36,16 @@ --shadow: rgba(0, 0, 0, 0.15); --scrollbar-thumb: rgba(0, 0, 0, 0.15); --scrollbar-track: transparent; + /* ──── 面板玻璃效果(透明度 = 面板背景相对 --surface 的不透明度;模糊度 = 背景模糊 px)──── + 均在「设置 → 外观 → 面板玻璃效果」中可调,留空/未设置时使用此处默认值 */ + --sidebar-alpha: 100%; + --sidebar-blur: 12px; + --right-alpha: 100%; + --right-blur: 12px; + --topbar-alpha: 100%; + --topbar-blur: 0px; + --terminal-alpha: 100%; + --terminal-blur: 0px; + --composer-alpha: 32%; + --composer-blur: 12px; } diff --git a/src/web/image/uimg_1787390644295.jpg b/src/web/image/uimg_1787390644295.jpg new file mode 100644 index 0000000..7397447 Binary files /dev/null and b/src/web/image/uimg_1787390644295.jpg differ diff --git a/src/web/image/uimg_mig_bg.png b/src/web/image/uimg_mig_bg.png new file mode 100644 index 0000000..39882c8 Binary files /dev/null and b/src/web/image/uimg_mig_bg.png differ diff --git a/src/web/image/下载.svg b/src/web/image/下载.svg deleted file mode 100644 index 8d37bd7..0000000 --- a/src/web/image/下载.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/web/image/文件树.svg b/src/web/image/文件树.svg deleted file mode 100644 index 163de90..0000000 --- a/src/web/image/文件树.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/web/index.html b/src/web/index.html index 2ff83d1..cc05cfb 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -17,6 +17,7 @@ + @@ -238,16 +239,12 @@ -@@ -473,7 +470,7 @@ 外观
@@ -512,6 +509,7 @@-+主题预设
即时生效,无需保存。
主题预设
点击即预览,修改后点击下方「保存主题设置」生效。
+背景暗角
0%
+字体
应用全局字体族。
@@ -541,6 +539,9 @@+@@ -820,6 +821,12 @@ +保存主题设置 +++++ +浏览器 Profile 目录
浏览器控制工具的持久用户数据目录。留空 = 每次启动使用一次性临时 Profile(登录态不持久、不留盘);填写路径 = 持久 Profile(登录态跨重启保留,目录不存在时自动创建)。
@@ -833,6 +840,9 @@ + + + + +保存运行时参数