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
🖥️真能操作桌面 — 截图 → 视觉定位 → 键鼠模拟,闭环完成 ERP 录入、表单填写、软件操控 - 🧩20 项内置工具 — 从终端命令到邮件收发,从 PPT 制作到图像生成,覆盖真实办公场景 + 🧩20 项内置工具 + 单文件插件化 — 从终端命令到邮件收发,从 PPT 制作到图像生成;新增工具 = 一个文件 + 协议接入,前端面板自动生成,无需大面积改动 🏠完全本地运行 — Electron 打包,数据不出本机;后端 FastAPI + 前端零延迟直读磁盘 🎨可深度定制 — 主题 / 字体 / 字号 / 背景 / 工作空间 / 提示词分层架构,全部可调 🔁工程级稳定性 — 循环检测、上下文压缩、阻塞式人工确认、轨迹回放,对抗 LLM 的「幻觉操作」 - 💾存储双后端(接口隔离) — 会话与定时任务存储统一接口驱动,JSON 可视化 / MySQL 一键切换、不双写;新增存储方式只需实现接口,消费方无感知后端 + 💾存储多后端(接口隔离) — 会话与定时任务存储统一接口驱动,JSON / MySQL / SQLite 一键切换、不双写;存储数据库与工具数据库配置隔离;新增存储方式只需实现接口,消费方无感知后端
@@ -766,16 +766,16 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas 工具文件能力 - 🖱️ guitools/gui.py桌面 GUI 自动化:截图、视觉批量定位、点击/输入/快捷键/滚动 - 🌐 browser_controltools/browser_control.py浏览器打开/关闭、地址导航、标签页交互 - 💻 terminal_executetools/terminal_execute.py执行任意终端命令,支持工作目录与超时 + 🖱️ guitools/gui.py桌面 GUI 自动化:截图、视觉批量定位、点击/输入/快捷键/滚动(桌面/任意应用轨) + 🌐 browser_controltools/browser_control.py浏览器打开/关闭、地址导航、标签页交互(Playwright + CDP 分离进程,Profile 默认一次性临时目录,可配置持久) + 🧭 web_pagetools/web_page.py浏览器页面 DOM 级精准操作(浏览器轨):snapshot 元素清单、click/fill/scroll/press/extract/screenshot/eval_js 等,毫秒级、优于 GUI 视觉定位;典型流程 browser_control 打开 → web_page 操作 + 💻 terminal_executetools/terminal_execute.py执行任意终端命令(含目录/文件操作,可替代 directory_listing),支持工作目录与超时 🪟 software_controltools/software_control.pyWin32 API 启停软件、注册表、os.startfile 📧 emailtools/email.pyIMAP 收邮件 + SMTP 发邮件(含附件) 📄 doctools/doc.py创建/读取/编辑 docx / xlsx / pptx / pdf / txt - 📂 directory_listingtools/directory_listing.py递归列目录、文件元信息 📨 send_filetools/send_file.py以邮件附件形式发送本地文件 🔍 web_searchtools/web_search.py联网搜索(Tavily / Firecrawl / 火山引擎) - 🗄️ text2sqltools/text2sql.py自然语言/SQL 操作数据库:查表结构、查询、增删改 + 🗄️ text2sqltools/text2sql.py自然语言/SQL 操作数据库(MySQL / SQLite,工具数据库配置):查表结构、查询、增删改、建库建表 📚 ragtools/rag.pyChromaDB 向量检索 + Reranker 重排序 🎨 image_gentools/image_gen.py调用 Z-Image-Turbo 生成图片 ✅ todo_listtools/todo_list.py多步任务规划与进度跟踪 @@ -785,11 +785,13 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas 📅 date_querytools/date_query.py当前日期时间查询 ⏰ cron_managertools/cron_manager.py定时任务管理:查看/新增/修改/删除定时任务,支持 cron/间隔/一次性触发 📊 hard_excel_readtools/hard_excel_read.py复杂 Excel 表结构读取:前 N 行原始数据 + 维度/合并区/数据区范围建议,供大模型解析结构 - 📥 excel2sqltools/excel2sql.py按大模型给定的数据行列范围,将 Excel 数据区流式批量入库 MySQL(自动建库建表、表注释) + 📥 excel2sqltools/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 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同一套源码双模式:uvicorn Web 服务 ↔ Electron 桌面应用 文件操作HTTP 上传下载桌面端走 IPC 直读磁盘,零延迟;Web 端走 /api/fs/* 提示词单层 system prompt四层提示词架构:系统级 / 工具描述 / 运行时注入 / 内部子模型 + 插件化注册式插件表(适配新插件需改框架主流程)前后端单文件插件化:后端按 BaseTool 协议单文件实现、前端按 setXxxDeps 依赖注入协议接入,监听事件 / 回调驱动而非注册式——新增或替换一个插件只需保持协议签名,宿主装配零改动
@@ -825,7 +830,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas ./git_image/运行-todolist.png -

人机交互确认 — 敏感操作(终端执行、文件写入等)与信息收集以浮窗形式实时弹出,用户可同意、拒绝、跳过或补充信息;拒绝也只是一次工具返回值,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 超阈值标记)→ 提取 thinking
  • +
  • call_model(core/nodes.py):本轮回插思考/反思([思考] / [反思] 合成消息)→ 追加动态尾部(TodoList 状态 + 循环提醒;固定反思引导已并入系统提示词前缀)→ LLM 调用 → 每步检查压缩(token 超阈值标记)→ 提取 thinking / reflection 独立记录
  • should_continue(core/routing.py):含 tool_calls → 循环检测 → 路由到 tools 或 END(需人工确认的工具不在此暂停,而是在工具执行钳点阻塞征求用户意见,见「阻塞式人机交互」)
  • process_tool_artifact:gui 截图作为 synthetic HumanMessage 注入,实现视觉闭环
  • compress(core/nodes.py):token 超 窗口×COMPRESS_RATE 时,将历史工具调用压缩为累积摘要(见「实现细节」)
  • @@ -951,7 +956,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas

    多 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 📦 打包原理(点击展开)
      -
    • 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
    @@ -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
    ⚙️

    配置说明

    -

    所有运行时配置集中在 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 - - - @@ -1197,8 +1196,11 @@ cloudflared tunnel --url http://localhost:8765 - - + + + + + @@ -1206,9 +1208,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视觉定位输入分辨率
    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
    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.png
    -

    Agent 配置 — 管理多个 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 服务端的前缀缓存:

    -[系统提示词(含固定反思引导,稳定)] + [压缩摘要] + [对话历史] + [用户消息] + [动态尾部(每轮变化)] +[系统提示词(含固定反思引导,稳定)] + [压缩摘要] + [对话历史] + [本轮回插的思考/反思] ++ [历史思考/反思/工具调用(压缩游标后)] + [用户消息] + [动态尾部(每轮变化)]
    • 固定部分放前缀:反思引导等不变提示文本并入系统提示词、位于上下文最前 —— 前缀稳定即可命中缓存;避免把固定文本逐轮塞在消息中间导致整段缓存失效。
    • +
    • 历史思考/反思/工具调用注入(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 状态与循环提醒。
    @@ -1338,16 +1344,17 @@ cloudflared tunnel --url http://localhost:8765

    阻塞式人机交互(交互类 / 展示类工具的统一模式)

    - 人机交互不再「暂停图 + 存快照 + 续跑」,而是阻塞式普通工具:工具在图上线程中等待前端浮窗应答,答案(同意 / 拒绝 / 跳过 / 补充信息)只是普通工具返回值,图自然继续——拒绝不会结束整轮,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
    • 交互类工具(如 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)
    @@ -1386,6 +1393,28 @@ cloudflared tunnel --url http://localhost:8765

    通过系统提示词前缀中的固定反思引导(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_page snapshot 获取元素清单 → 按 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),可直接打开查看/修改,便于审计与调试
    mysqlsession 三表 / cron 两表消息与工具调用记录原样 JSON 入库、读取只走数据库;二进制产物(图片/附件等)两个后端均落盘
    json(默认)history/sessions/{session_id}/消息与工具调用以人类可读 JSON 文件落盘(turn_{turn_id}.json / tool_{turn_id}.json / session_meta.json),可直接打开查看/修改,便于审计与调试
    mysqlsession 三表 / cron 两表消息与工具调用记录原样 JSON 入库、读取只走数据库;二进制产物(图片/附件等)所有后端均落盘
    sqlitehistory/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)与回滚等会话功能均基于存储接口实现,各后端一致可用
    -

    双后端行为差异(设计内)

    +

    后端行为差异(设计内)

    - + - +
    场景jsonmysql
    场景jsonmysql / 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:8765 api.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文档树与修改面板 action-bar.js底部操作栏 @@ -1520,7 +1574,7 @@ cloudflared tunnel --url http://localhost:8765 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对话框 @@ -1530,15 +1584,15 @@ cloudflared tunnel --url http://localhost:8765

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。

添加自定义工具

    -
  • 在 src/agent/tools/ 新建 my_tool.py,继承 LangChain BaseTool,实现 _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,继承 LangChain BaseTool,实现 _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
@@ -1567,7 +1621,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
@@ -1589,7 +1643,7 @@ 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 后端),长期使用注意清理 ⚡ GUI 性能同页多操作务必合并为一次 gui 调用,否则延迟显著(见「实现细节」) 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 运行过程中的关键交互界面,支持文件从任意位置**拖拽 人机交互确认 任务规划 TodoList
-人机交互确认 — 敏感操作(终端执行、文件写入等)与信息收集以浮窗形式实时弹出,用户可同意、拒绝、跳过或补充信息;**拒绝也只是一次工具返回值**,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 运行过程中的关键交互界面,支持文件从任意位置**拖拽 定时任务创建 Edit 编辑模式
-定时任务管理 — 支持 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

📦 打包原理(点击展开) -- `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`
@@ -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 运行的全部可调参数:

Agent 配置 工具配置
-Agent 配置 — 管理多个 AI 角色,各自绑定独立模型与工具集 | 工具配置 — 启用/禁用工具、调整参数及执行策略 +Agent 配置 — 管理多个 AI 角色,各自绑定独立模型与工具集(main Agent 默认挂载 5 个工具为锁定项,不可移除) | 工具配置 — 启用/禁用工具、调整参数及执行策略

@@ -417,7 +428,7 @@ cloudflared tunnel --url http://localhost:8765

主题配置
-主题配置 — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体 +主题配置 — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体;另含 常规 / 模型 / Skills / 网络搜索 / 邮件 / 存储与数据库 / 运行时参数 等分组

--- @@ -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(前端可直接 展示)。 + + 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 内的 <a> 上,点 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 @@ -<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1784769993878" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5566" data-darkreader-inline-fill="" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M459.26562501 512l105.46874998 0L564.734375 134.05273437A43.92773438 43.92773438 0 0 0 520.80664063 90.125l-17.61328126 0A43.92773438 43.92773438 0 0 0 459.265625 134.05273437L459.26562501 512z" p-id="5567"></path><path d="M289.671875 578.97265625l181.93359375 295.04882813a43.92773438 43.92773438 0 0 0 74.51367188 0.47460937l186.83789062-295.04882813A43.92773438 43.92773438 0 0 0 695.83203126 512L327.06054687 512a43.92773438 43.92773438 0 0 0-37.44140624 66.97265625z" p-id="5568"></path></svg> \ 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 @@ -<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1786930110833" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9885" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M386.986667 335.36c17.493333 0 32-14.506667 32-32s-14.506667-32-32-32h-217.6V136.96c0-17.493333-14.506667-32-32-32s-32 14.506667-32 32v499.2c0 63.573333 51.626667 115.2 115.2 115.2h166.4c17.493333 0 32-14.506667 32-32s-14.506667-32-32-32h-166.4c-28.16 0-51.2-23.04-51.2-51.2V335.36h217.6zM823.893333 125.866667H738.133333c-3.413333 0-5.973333-1.706667-8.106666-4.266667l-17.493334-26.026667a73.386667 73.386667 0 0 0-61.013333-32h-77.226667c-52.053333 0-94.293333 42.24-94.293333 94.293334v208.213333c0 52.053333 42.24 94.293333 94.293333 94.293333h249.6c52.053333 0 94.293333-42.24 94.293334-94.293333V220.586667c0-52.053333-42.24-94.293333-94.293334-94.293334z m30.72 239.786666c0 16.64-13.653333 30.293333-30.293333 30.293334h-249.6c-16.64 0-30.293333-13.653333-30.293333-30.293334V157.866667c0-16.64 13.653333-30.293333 30.293333-30.293334h77.226667c3.413333 0 5.973333 1.706667 8.106666 4.266667l17.493334 26.026667c13.653333 20.053333 36.266667 32 61.013333 32h85.76c16.64 0 30.293333 13.653333 30.293333 30.293333v145.493333zM823.893333 583.253333H738.133333c-3.413333 0-5.973333-1.706667-8.106666-4.266666l-17.493334-26.026667c-13.653333-20.053333-36.266667-32.426667-61.013333-32.426667h-77.226667c-52.053333 0-94.293333 42.24-94.293333 94.293334v208.213333c0 52.053333 42.24 94.293333 94.293333 94.293333h249.6c52.053333 0 94.293333-42.24 94.293334-94.293333v-145.493333c0-52.053333-42.24-94.293333-94.293334-94.293334z m30.72 240.213334c0 16.64-13.653333 30.293333-30.293333 30.293333h-249.6c-16.64 0-30.293333-13.653333-30.293333-30.293333v-208.213334c0-16.64 13.653333-30.293333 30.293333-30.293333h77.226667c2.986667 0 5.973333 1.706667 8.106666 4.266667l17.493334 26.026666c13.653333 20.053333 36.266667 32.426667 61.013333 32.426667h85.76c16.64 0 30.293333 13.653333 30.293333 30.293333v145.493334z" p-id="9886" fill="#707070"></path></svg> \ 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 @@ </script> <!-- 本地字体(离线可用,不再依赖 Google Fonts CDN) --> <link rel="stylesheet" href="/fonts/fonts.css" /> + <link rel="stylesheet" href="/packages/node_modules/@xterm/xterm/css/xterm.css" /> <link rel="icon" href="/image/favicon.svg" sizes="any" /> <link rel="stylesheet" href="/app.css?v=9" /> <link rel="stylesheet" href="/css/splash.css?v=1" /> @@ -238,16 +239,12 @@ <div class="inline-drop-panel" id="modelPanel" hidden></div> </div> <div class="inline-drop-wrap" id="thinkModeWrap"> - <button type="button" class="inline-drop-trigger inline-drop-trigger--icon" id="thinkModeTrigger" aria-label="思考模式" title="思考模式:low / high / xhigh / max / ultra"> + <button type="button" class="inline-drop-trigger inline-drop-trigger--icon" id="thinkModeTrigger" aria-label="思考模式" title="思考模式:agent / plan / agent 思考 / plan 思考 / goal"> <img src="/image/思考模式.svg" alt="思考" id="thinkModeIcon" /> <span class="think-mode-badge" id="thinkModeBadge"></span> </button> <div class="inline-drop-panel think-mode-panel" id="thinkModePanel" hidden> - <div class="think-mode-labels" id="thinkModeLabels"></div> - <div class="think-mode-track" id="thinkModeTrack"> - <div class="think-mode-fill" id="thinkModeFill"></div> - <div class="think-mode-handle" id="thinkModeHandle"></div> - </div> + <div class="think-mode-items" id="thinkModeItems"></div> </div> </div> <button type="button" class="mic-btn" id="micBtn" title="使用麦克风录音" aria-pressed="false" aria-label="使用麦克风录音"> @@ -473,7 +470,7 @@ <h2>外观</h2> <div class="settings-card"> <div class="settings-row settings-row--block"> - <div class="row-text"><h4>主题预设</h4><p>即时生效,无需保存。</p></div> + <div class="row-text"><h4>主题预设</h4><p>点击即预览,修改后点击下方「保存主题设置」生效。</p></div> <div class="theme-grid" id="themeGrid"></div> </div> </div> @@ -512,6 +509,7 @@ <div class="row-text"><h4>背景暗角</h4><p id="bgVignetteVal">0%</p></div> <div class="row-control"><input type="range" id="bgVignetteSlider" min="0" max="1" step="0.05" value="0" class="compact-slider" /></div> </div> + <div id="panelGlassZone"></div> <div class="settings-row"> <div class="row-text"><h4>字体</h4><p>应用全局字体族。</p></div> <div class="row-control"> @@ -541,6 +539,9 @@ </select> </div> </div> + <div class="settings-page-actions"> + <button type="button" class="btn btn-primary" id="saveThemeBtn">保存主题设置</button> + </div> </div> </div> <!-- 模型设置 --> @@ -820,6 +821,12 @@ <div class="row-control"><input type="number" data-env="LOOP_DETECT_REPEATED_TOOL_WARN" min="1" step="1" /></div> </div> </div> + <div class="settings-card"> + <div class="settings-row"> + <div class="row-text"><h4>浏览器 Profile 目录</h4><p>浏览器控制工具的持久用户数据目录。留空 = 每次启动使用一次性临时 Profile(登录态不持久、不留盘);填写路径 = 持久 Profile(登录态跨重启保留,目录不存在时自动创建)。</p></div> + <div class="row-control"><input type="text" data-env="BROWSER_PROFILE_DIR" placeholder="留空 = 临时 Profile(默认)" /></div> + </div> + </div> <div class="settings-page-actions"> <button type="button" class="btn btn-primary settings-save-btn" data-save="env">保存运行时参数</button> </div> @@ -833,6 +840,9 @@ <div class="toast-container" id="toastContainer" aria-live="polite"></div> <script src="/packages/vendor/marked.min.js"></script> <script src="/packages/vendor/purify.min.js"></script> + <!-- xterm.js(内置真终端渲染):UMD 全局 window.Terminal / window.FitAddon --> + <script src="/packages/node_modules/@xterm/xterm/lib/xterm.js"></script> + <script src="/packages/node_modules/@xterm/addon-fit/lib/addon-fit.js"></script> <!-- Monaco Editor: CDN 优先(Web端),本地 npm 包兜底(Electron 离线) --> <script> (function() { diff --git a/src/web/js/api.js b/src/web/js/api.js index 958d38e..3491c17 100644 --- a/src/web/js/api.js +++ b/src/web/js/api.js @@ -18,6 +18,30 @@ async function api(path, opts = {}) { return r.json(); } +/** + * 与 api() 相同,但返回 {data, status, headers}(用于读取 ETag 等响应头)。 + * 304 不视为错误:data 为 null,由调用方使用本地缓存。 + */ +async function apiWithHeaders(path, opts = {}) { + const r = await fetch(path, { + ...opts, + headers: opts.body instanceof FormData ? {} : { "Content-Type": "application/json", ...(opts.headers || {}) }, + }); + if (r.status === 304) return { data: null, status: 304, headers: r.headers }; + if (!r.ok) { + let detail = r.statusText; + try { + const j = await r.json(); + if (j.detail) detail = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail); + } catch { + /* ignore */ + } + throw new Error(detail || `HTTP ${r.status}`); + } + const data = await r.json(); + return { data, status: r.status, headers: r.headers }; +} + /** * SSE 流式 API 调用。 * @param {string} path @@ -62,4 +86,4 @@ async function streamApi(path, opts = {}) { } } -export { api, streamApi }; +export { api, apiWithHeaders, streamApi }; diff --git a/src/web/js/chat-render.js b/src/web/js/chat-render.js index 7109911..49dff5f 100644 --- a/src/web/js/chat-render.js +++ b/src/web/js/chat-render.js @@ -52,6 +52,14 @@ function scrollChatToBottom(container) { el.scrollTop = el.scrollHeight; } +/** 轻量渲染快照:仅浅克隆每条消息对象(content/meta 等内部结构共享引用)。 + * 所有消费方只读快照用于渲染或再次克隆(send.js 会按需自行深拷贝), + * 无需整表 JSON 深拷贝——历史含大量工具调用文本时,每次渲染都深拷贝 + * 是切换/新建会话「整体卡一下」的最大单点开销。 */ +function snapshotMessages(list) { + return list.map((m) => (m && typeof m === "object" ? { ...m } : m)); +} + function renderAttachmentChips() { const imagesArea = $("inputInlineImages"); const chipsArea = $("inputInlineChips"); @@ -367,15 +375,67 @@ async function pseudoStreamAssistant(bubble, full) { const t = full == null ? "" : String(full); bubble.classList.add("msg-bubble--rich", "markdown-body"); bubble.textContent = ""; + if (!t) return; const n = t.length; const charsPerSec = 350; const totalMs = Math.min(4000, Math.max(300, (n / charsPerSec) * 1000)); const t0 = performance.now(); let lastSnapshot = ""; + let lastRebuildAt = -Infinity; // 流式期间固定滚动目标为 bubble 所属的消息容器(双容器安全) const scrollTarget = bubble.closest(".chat-messages") || chatMessages; + // 预拆分全文为固定块 + 各块在全文中的结束偏移:已完成块只渲染一次并保持 DOM + // 不动,每帧仅重渲染最后一个未完成块(O(块长) 局部重建),替代原先每帧对 + // 整段累计文本 O(n²) 的全量重解析(长回复下会持续占满主线程)。 + const blocks = splitMarkdownStreamBlocks(t); + const blockEnds = []; + { + let acc = 0; + for (let i = 0; i < blocks.length; i++) { + acc += blocks[i].length; + blockEnds.push(acc); + } + } + let renderedCount = 0; // 已固化(DOM 不再变化)的完整块数 + let tailBlock = null; // 当前尚未完成块的 DOM 节点(每帧就地替换) + + function rebuildBlocks(k) { + // 已完整覆盖的块数:块结束偏移 <= k + let done = renderedCount; + while (done < blocks.length && blockEnds[done] <= k) done++; + if (done > renderedCount) { + for (let j = renderedCount; j < done; j++) { + // 非末块必然闭合围栏;仅全文末块(无闭合围栏)保留流式态 + bubble.appendChild(renderMarkdownBlock(blocks[j], j === blocks.length - 1)); + } + if (tailBlock) { tailBlock.remove(); tailBlock = null; } + renderedCount = done; + } + // 尾部未完成块:就地替换(不触碰已完成块);恰好落在块边界时跳过空尾块 + if (k < n && renderedCount < blocks.length) { + const start = renderedCount > 0 ? blockEnds[renderedCount - 1] : 0; + const partial = t.slice(start, k); + if (partial !== "") { + const stillOpenFence = (partial.match(/```/g) || []).length % 2 === 1; + const next = renderMarkdownBlock(partial, true); + if (tailBlock) { + tailBlock.replaceWith(next); + } else { + bubble.appendChild(next); + } + tailBlock = next; + } else if (tailBlock) { + tailBlock.remove(); + tailBlock = null; + } + } else if (tailBlock) { + tailBlock.remove(); + tailBlock = null; + } + } + return new Promise((resolve) => { function frame(now) { const elapsed = now - t0; @@ -383,15 +443,13 @@ async function pseudoStreamAssistant(bubble, full) { const k = Math.min(n, Math.floor(p * n)); const snapshot = t.slice(0, k); - if (snapshot !== lastSnapshot) { + // Markdown 重建节流(~9Hz):间隔未到或进度未推进时跳过重建,结束帧 + // 仍做一次精确全量渲染(尾部块合并为整段干净输出)。 + const rebuildDue = snapshot !== lastSnapshot && (p >= 1 || now - lastRebuildAt >= 110); + if (rebuildDue) { lastSnapshot = snapshot; - const blocks = splitMarkdownStreamBlocks(snapshot); - bubble.textContent = ""; - blocks.forEach((block, idx) => { - const isLast = idx === blocks.length - 1; - const stillOpenFence = (block.match(/```/g) || []).length % 2 === 1; - bubble.appendChild(renderMarkdownBlock(block, isLast || stillOpenFence)); - }); + lastRebuildAt = now; + rebuildBlocks(k); scrollChatToBottom(scrollTarget); } @@ -1089,7 +1147,7 @@ function renderMessage(msg, allowAutoplay = false, opts = {}) { wrap.appendChild(contentEl); wrap.appendChild(renderMessageActions("assistant", msg, wrap, actionOpts)); if (msg.meta && msg.meta.pending_action) { - // pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示 + // pending_action 不再内嵌在气泡中,由人机交互通知条展示 } } @@ -1299,15 +1357,22 @@ async function handleBranch(wrap, msg) { /* ===== 点赞 / 点踩 本地持久化 ===== */ const FEEDBACK_STORAGE_KEY = "minor_agent_msg_feedback"; +// 内存缓存:全量渲染 N 条 assistant 消息时避免逐条 JSON.parse(localStorage)。 +// 渲染是读取密集路径;仅在写入(_saveFeedbackMap)时刷新缓存。 +let _feedbackMapCache = null; + function _loadFeedbackMap() { + if (_feedbackMapCache) return _feedbackMapCache; try { - return JSON.parse(localStorage.getItem(FEEDBACK_STORAGE_KEY) || "{}"); + _feedbackMapCache = JSON.parse(localStorage.getItem(FEEDBACK_STORAGE_KEY) || "{}"); } catch { - return {}; + _feedbackMapCache = {}; } + return _feedbackMapCache; } function _saveFeedbackMap(map) { + _feedbackMapCache = map; try { localStorage.setItem(FEEDBACK_STORAGE_KEY, JSON.stringify(map)); } catch { @@ -1497,7 +1562,8 @@ function renderMessages(messages, opts = {}) { target.innerHTML = ""; // 容器级渲染快照(增量渲染与恢复用)+ 全局快照(回合导航 / 撤回用,跟随最近渲染视图) - const snapshot = list.length ? JSON.parse(JSON.stringify(list)) : []; + // 轻量浅快照替代整表 JSON 深拷贝(大历史下深拷贝是主要卡顿源) + const snapshot = list.length ? snapshotMessages(list) : []; target._renderedMessages = snapshot; target._renderedCount = list.length; state.lastRenderedMessages.length = 0; @@ -1509,6 +1575,7 @@ function renderMessages(messages, opts = {}) { if (!has) { // 确保无消息时隐藏:内联 display:none 优先级高于 CSS 类设定,不依赖 [hidden] 规则 target.style.display = "none"; + _syncMsgCache(target, 0); // 设置为居中状态(仅 chat 视图影响 composer 布局) if (!keepComposer) import('../app.js').then((m) => m.setComposerCentered(true)); renderTurnNav(); @@ -1518,6 +1585,7 @@ function renderMessages(messages, opts = {}) { target.style.display = ""; list.forEach((m) => target.appendChild(renderMessage(m, allowAutoplay, { minimalActions }))); scrollChatToBottom(target); + _syncMsgCache(target, list.length); // 取消居中状态 if (!keepComposer) import('../app.js').then((m) => m.setComposerCentered(false)); renderTurnNav(); @@ -1562,14 +1630,115 @@ function renderMessagesIncremental(messages, opts = {}) { const fresh = list.slice(prevCount); fresh.forEach((m) => target.appendChild(renderMessage(m, allowAutoplay, { minimalActions }))); - target._renderedMessages = JSON.parse(JSON.stringify(list)); + target._renderedMessages = snapshotMessages(list); target._renderedCount = list.length; state.lastRenderedMessages.length = 0; state.lastRenderedMessages.push(...target._renderedMessages); + _syncMsgCache(target, list.length); scrollChatToBottom(target); renderTurnNav(); } +/** 消息摘要键(一致性校验 / 缓存对比用) */ +function _msgKey(m) { + if (!m) return ""; + const body = typeof m.content === "string" + ? m.content.slice(0, 64) + : JSON.stringify(m.content || "").slice(0, 64); + return `${m.role}|${(m.meta && m.meta.turn_id) || ""}|${body}`; +} + +/** 同步按会话的消息缓存(会话切换秒开 / 后台增量刷新用)。 + * 仅缓存 chat 容器;写入时置空 ETag(内容可能已变化,需重新校验)。 */ +function _syncMsgCache(target, total) { + if (!target || target !== chatMessages || !state.sessionId) return; + state._msgCache.set(state.sessionId, { + messages: (target._renderedMessages || []).slice(), + total: total != null ? total : (target._renderedCount || 0), + etag: null, + }); +} + +/** 计数不一致(增量丢帧 / 回退场景)时的全量重同步兜底。 */ +async function resyncMessages(target) { + try { + const { api } = await import('./api.js'); + const data = await api(`/api/sessions/${encodeURIComponent(state.sessionId || "")}/messages`); + if (data && Array.isArray(data.messages)) renderMessages(data.messages); + } catch { /* 静默失败;保留现有 DOM */ } +} + +/** + * 增量契约追加(chat/start、complete、SSE text 的 new_message/new_messages): + * 校验 base_count ↔ 容器计数 → 替换乐观用户消息(按 turn_id 识别)→ + * 追加新消息 DOM(可跳过,DOM 已由调用方渲染)→ 更新渲染簿记与会话缓存。 + * 计数不一致时以全量 GET 重同步,返回 false。 + */ +function appendNewMessages(container, newMessages, opts = {}) { + const target = container || chatMessages; + if (!target) return false; + const { baseCount = null, total = null, domAlreadyAppended = false } = opts; + let freshList = Array.isArray(newMessages) ? newMessages.filter(Boolean) : []; + if (!freshList.length) return false; + + const prevSnap = target._renderedMessages || []; + const lastRendered = prevSnap[prevSnap.length - 1]; + const firstNew = freshList[0]; + + // 乐观用户消息替换:末尾为无 turn_id 的 user(乐观渲染),新首条为有 turn_id 的 + // user(服务端 canonical 消息)→ 就地替换 DOM 与快照(顺带解决 blob→URL 差异 + // 触发的全量重建回退)。替换时容器已含乐观消息,计数按 base_count+1 期望。 + const replaceOptimistic = !!( + firstNew && firstNew.role === "user" && firstNew.meta && firstNew.meta.turn_id && + lastRendered && lastRendered.role === "user" && (!lastRendered.meta || !lastRendered.meta.turn_id) + ); + const expectCount = baseCount != null ? baseCount + (replaceOptimistic ? 1 : 0) : null; + if (expectCount != null && target._renderedCount != null && expectCount !== target._renderedCount) { + resyncMessages(target); + return false; + } + + if (replaceOptimistic) { + // 语音转文字占位(独立 DOM,不在消息簿记内):先行移除 + const ph = target.querySelector(".msg .voice-transcribing"); + if (ph) { + const phEl = ph.closest(".msg"); + if (phEl) phEl.remove(); + } + const msgNodes = Array.from(target.children).filter( + (el) => el.classList && el.classList.contains("msg") && !el.classList.contains("msg-typing") + ); + const lastMsgEl = msgNodes[msgNodes.length - 1]; + if (lastMsgEl) { + lastMsgEl.replaceWith(renderMessage(firstNew, false, {})); + } else { + target.appendChild(renderMessage(firstNew, false, {})); + } + if (prevSnap.length) prevSnap[prevSnap.length - 1] = firstNew; + freshList = freshList.slice(1); + } + + if (!domAlreadyAppended) { + freshList.forEach((m) => target.appendChild(renderMessage(m, false, {}))); + } + if (freshList.length) { + (target._renderedMessages = target._renderedMessages || []).push(...freshList); + target._renderedCount = (target._renderedCount || 0) + freshList.length; + } + state.lastRenderedMessages.length = 0; + state.lastRenderedMessages.push(...(target._renderedMessages || [])); + _syncMsgCache(target, total != null ? total : target._renderedCount); + // todo 增量扫描(幂等:仅读取 meta.tool_calls 中的 todo_list 调用) + if (freshList.length) { + import('./notify-strip.js').then((m) => { + try { m.restoreTodoFromMessages(freshList); } catch { /* ignore */ } + }).catch(() => {}); + } + scrollChatToBottom(target); + renderTurnNav(); + return true; +} + /** 释放非活动容器的 DOM(非运行态切出时调用,降低内存占用)。 */ function releaseMessageContainer(container) { if (!container) return; @@ -1610,7 +1779,7 @@ function appendThinkingIndicator(container) { function showPendingActionInThinking(typingEl, msg) { if (!typingEl || !msg || !msg.meta || !msg.meta.pending_action) return false; - // pending_action 由 pending-overlay 浮窗展示 + // pending_action 由人机交互通知条展示 const snapshot = JSON.parse(JSON.stringify(msg)); state.lastRenderedMessages.push(snapshot); @@ -1686,7 +1855,7 @@ async function streamAssistantMessage(msg, container) { } if (msg.meta && msg.meta.pending_action) { - // pending_action 不再内嵌在气泡中,由 pending-overlay 浮窗展示 + // pending_action 不再内嵌在气泡中,由人机交互通知条展示 } // 操作按钮行(复制 / 重试) @@ -1732,6 +1901,7 @@ export { renderUserContent, renderAssistantContent, renderFinalAssets, extractFinalAssets, renderMessage, renderMessages, renderMessagesIncremental, releaseMessageContainer, + appendNewMessages, appendThinkingIndicator, showPendingActionInThinking, streamAssistantMessage, renderVoiceTranscribingPlaceholder, diff --git a/src/web/js/edit-mode.js b/src/web/js/edit-mode.js index 7d469cb..c9f761f 100644 --- a/src/web/js/edit-mode.js +++ b/src/web/js/edit-mode.js @@ -28,6 +28,7 @@ const sidebarHint = document.querySelector(".sidebar-hint"); let _externalChanges = {}; let _fsChangeTimer = null; let _fsApplying = false; +let _docTreeDirty = false; // 右栏关闭期间文件树内容落后标记(打开时补刷新) // 预览相关 const IMG_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp', '.ico']); @@ -144,12 +145,46 @@ export function refreshGitStatus() { const data = await api(`/api/workspace/git-status?path=${encodeURIComponent(rootPath)}`); const map = {}; for (const s of data.status || []) map[s.path] = s.state; + const prev = state.gitStatus; state.gitStatus = map; - renderDocTree(); + // 着色无变化时不触碰 DOM;有变化时增量更新类名(不重建整棵树) + // —— 每次展开右栏触发刷新时全量重建是"展开卡一下"的主要来源 + if (gitStatusEqual(prev, map)) return; + applyGitStatusToTree(); } catch { /* git 不可用/非仓库时静默,保持无着色 */ } }, 150); } +/** git 状态映射是否等价(无变化时跳过全树遍历与类名切换) */ +function gitStatusEqual(a, b) { + const ka = Object.keys(a || {}); + const kb = Object.keys(b || {}); + if (ka.length !== kb.length) return false; + for (const k of ka) if ((b || {})[k] !== a[k]) return false; + return true; +} + +/** 增量着色:仅切换既有文件节点的 git 类与拖拽可用性,不重建树 */ +function applyGitStatusToTree() { + if (!docTree) return; + const nodes = docTree.querySelectorAll(".doc-tree-file"); + if (!nodes.length) { + // 树尚未渲染(如从未打开文件夹)时回退一次全量渲染 + if (state.docFiles && state.docFiles.length) renderDocTree(); + return; + } + const map = state.gitStatus || {}; + nodes.forEach((div) => { + const path = div.dataset.path; + if (!path) return; + const gState = map[path]; + div.classList.toggle("doc-tree-file--new", gState === "untracked"); + div.classList.toggle("doc-tree-file--modified", gState === "modified"); + div.classList.toggle("doc-tree-file--deleted", gState === "deleted"); + div.draggable = gState !== "deleted"; + }); +} + // ===== 右栏(Edit 文件树)控制 ===== export function setRightPanel(open) { const appRoot = document.getElementById("app"); @@ -157,11 +192,15 @@ export function setRightPanel(open) { appRoot.classList.toggle("right-panel-open", !!open); const rp = document.getElementById("rightPanel"); if (rp) rp.setAttribute("aria-hidden", open ? "false" : "true"); - // 打开时刷新 git 状态着色 + // 打开时刷新 git 状态着色;并补一次关闭期间积压的文件变更刷新 if (open) { try { window.dispatchEvent(new CustomEvent("git-status-changed")); } catch { /* ignore */ } + if (_docTreeDirty) { + _docTreeDirty = false; + applyFsChanges(); + } } } @@ -399,8 +438,15 @@ function handleFsChange(events) { _fsChangeTimer = setTimeout(() => applyFsChanges(events), 300); } -async function applyFsChanges(events) { +async function applyFsChanges(events = []) { if (_fsApplying || !state._docRootPath) return; + // 右栏未打开时文件树不可见:跳过 listDir + 重建(Agent 运行期高频文件读写 + // 不再反复消耗列目录 IPC 与主线程 DOM 重建),仅标记待刷新 + const appRoot = document.getElementById("app"); + if (!appRoot || !appRoot.classList.contains("right-panel-open")) { + _docTreeDirty = true; + return; + } _fsApplying = true; try { const rootPath = state._docRootPath; diff --git a/src/web/js/layout.js b/src/web/js/layout.js index 222935d..43114d3 100644 --- a/src/web/js/layout.js +++ b/src/web/js/layout.js @@ -188,27 +188,8 @@ function initSidebarResize() { let startX = 0; let startW = 0; - handle.addEventListener("mousedown", (e) => { - e.preventDefault(); - if (appRoot && appRoot.classList.contains("sidebar-collapsed")) return; - dragging = true; - startX = e.clientX; - startW = sidebar.getBoundingClientRect().width; - // 拖动时禁用 transition 以保证流畅 - sidebar.style.transition = "none"; - document.body.style.cursor = "col-resize"; - document.body.style.userSelect = "none"; - }); - - window.addEventListener("mousemove", (e) => { - if (!dragging) return; - const dx = e.clientX - startX; - let newW = startW + dx; - newW = Math.max(SIDEBAR_MIN_W, Math.min(SIDEBAR_MAX_W, newW)); - document.documentElement.style.setProperty("--sidebar-w", newW + "px"); - }); - - window.addEventListener("mouseup", () => { + /** 结束拖拽并复位全部副作用(鼠标在窗口外松开 / 失焦时也会被调用) */ + function endDrag() { if (!dragging) return; dragging = false; // 恢复 transition @@ -218,7 +199,36 @@ function initSidebarResize() { // 保存宽度 const finalW = sidebar.getBoundingClientRect().width; try { localStorage.setItem(STORAGE_SIDEBAR_W, String(Math.round(finalW))); } catch { /* ignore */ } + } + + // Pointer Events + setPointerCapture:拖拽期间事件由 handle 捕获, + // 鼠标移到窗口外/焦点丢失时仍能收到 pointerup,不会残留拖拽状态 + handle.addEventListener("pointerdown", (e) => { + if (e.button !== 0) return; // 仅主键 + if (appRoot && appRoot.classList.contains("sidebar-collapsed")) return; + e.preventDefault(); + dragging = true; + startX = e.clientX; + startW = sidebar.getBoundingClientRect().width; + try { handle.setPointerCapture(e.pointerId); } catch { /* ignore */ } + // 拖动时禁用 transition 以保证流畅 + sidebar.style.transition = "none"; + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; }); + + handle.addEventListener("pointermove", (e) => { + if (!dragging) return; + const dx = e.clientX - startX; + let newW = startW + dx; + newW = Math.max(SIDEBAR_MIN_W, Math.min(SIDEBAR_MAX_W, newW)); + document.documentElement.style.setProperty("--sidebar-w", newW + "px"); + }); + + handle.addEventListener("pointerup", endDrag); + handle.addEventListener("pointercancel", endDrag); + // 兜底:失焦(如拖拽中点开其他窗口)时复位,避免 cursor/user-select 残留 + window.addEventListener("blur", endDrag); } initSidebarResize(); diff --git a/src/web/js/notify-strip.js b/src/web/js/notify-strip.js index 6a34ae4..e1a0e6e 100644 --- a/src/web/js/notify-strip.js +++ b/src/web/js/notify-strip.js @@ -8,7 +8,7 @@ // 5. 会话级存储:chat session 与 cron task 各自保留 todo/pending 状态 // // 数据源: -// - todo: live snapshot tool_calls 中 name === "todo_list" 的调用(与原 todo.js 同逻辑) +// - todo: live snapshot tool_calls 中 name === "todo_list" 的调用(与历史实现同逻辑) // - pending: live snapshot human_requests 数组(human_interaction / tool_call) // // 提交: 复用 pending.js handleHumanAction(阻塞式,后端唤醒工具线程) @@ -18,13 +18,19 @@ import { $, escapeHtml, showToast } from './utils.js'; import { state } from './state.js'; +import { api } from './api.js'; +import { showConfirm } from './dialog.js'; import { handleHumanAction } from './pending.js'; // ===== 模块状态 ===== const stripState = { todo: { steps: [], doneSteps: [], dismissed: false, expanded: false }, - pending: { items: [], index: 0, dismissed: false, expanded: false }, + // doneCount:本轮已答完的人机交互请求数(快照内"人机交互工具"类的 x 待处理 - y 已完成) + // answers:人机交互多问题作答状态 { itemId: { list: [{kind,value}], qIndex } } + pending: { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} }, + // terminal:后台终端任务(来自 /api/terminal/tasks/{session}/stream,跨轮存活) + terminal: { tasks: [], dismissed: false, expanded: false }, }; /** 会话级快照:sessionKey → 深拷贝的 stripState */ @@ -65,16 +71,19 @@ export function switchStripSession(newKey, saveKey) { const saved = _sessionStore.get(target); if (saved) { stripState.todo = saved.todo || { steps: [], doneSteps: [], dismissed: false, expanded: false }; - stripState.pending = saved.pending || { items: [], index: 0, dismissed: false, expanded: false }; + stripState.pending = saved.pending || { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} }; + stripState.terminal = saved.terminal || { tasks: [], dismissed: false, expanded: false }; } else { stripState.todo = { steps: [], doneSteps: [], dismissed: false, expanded: false }; - stripState.pending = { items: [], index: 0, dismissed: false, expanded: false }; + stripState.pending = { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} }; + stripState.terminal = { tasks: [], dismissed: false, expanded: false }; } _clearComposerBtns(); renderStrip(); + _restartTerminalStream(); } -// ===== Todo 数据派生(与原 todo.js updateTodoOverlayFromRecords 同逻辑) ===== +// ===== Todo 数据派生(与历史实现同逻辑) ===== /** 从 live tool_calls 派生 todo 数据并更新通知条。 */ export function updateTodoStrip(toolCalls) { @@ -147,6 +156,10 @@ export function updatePendingStrip(humanRequests) { const prevIds = stripState.pending.items.map((p) => p.id).join(","); const nextIds = items.map((p) => p.id).join(","); const changed = prevIds !== nextIds || items.length !== stripState.pending.items.length; + const prevHaveHuman = stripState.pending.items.some((p) => p.type !== "tool_call"); + const nextHaveHuman = items.some((p) => p.type !== "tool_call"); + // 新一批人机交互开始(此前无待处理 → 现在有):已完成计数从 0 重新累计 + if (!prevHaveHuman && nextHaveHuman) stripState.pending.doneCount = 0; stripState.pending.items = items; if (stripState.pending.index >= items.length) { stripState.pending.index = Math.max(0, items.length - 1); @@ -156,6 +169,7 @@ export function updatePendingStrip(humanRequests) { if (!items.length) { stripState.pending.expanded = false; stripState.pending.index = 0; + stripState.pending.doneCount = 0; } if (changed) renderStrip(); // 仅变化时渲染 } @@ -178,14 +192,15 @@ function _hasContent() { } /** 主渲染入口(补丁式):diff 条集合 → 复用/增删 → 就地更新内容。 */ -export function renderStrip() { +function renderStrip() { const root = _stripRoot(); const items = _itemsRoot(); if (!root || !items) return; - // 期望的可见条集合(出现顺序自下而上:todo 在下,pending 在上) + // 期望的可见条集合(出现顺序自下而上:todo 在下,terminal 居中,pending 在上) const desired = []; if (stripState.pending.items.length > 0 && !stripState.pending.dismissed) desired.push({ kind: "pending" }); + if (stripState.terminal.tasks.length > 0 && !stripState.terminal.dismissed) desired.push({ kind: "terminal" }); if (stripState.todo.steps.length > 0 && !stripState.todo.dismissed) desired.push({ kind: "todo" }); if (!desired.length) { @@ -212,7 +227,7 @@ export function renderStrip() { const newKids = desired.map((d) => { const existing = oldKids.find((el) => el.dataset.stripKind === d.kind); if (existing) return existing; // 复用,避免重建 - return d.kind === "todo" ? _buildTodoItem() : _buildPendingItem(); + return d.kind === "todo" ? _buildTodoItem() : (d.kind === "terminal" ? _buildTerminalItem() : _buildPendingItem()); }); oldKids.forEach((el) => { if (!newKids.includes(el)) el.remove(); }); newKids.forEach((el, i) => { @@ -222,6 +237,7 @@ export function renderStrip() { // 就地更新内容(只改变化的文本/chip/图标/展开区) newKids.forEach((el) => { if (el.dataset.stripKind === "todo") _updateTodoItemEl(el); + else if (el.dataset.stripKind === "terminal") _updateTerminalItemEl(el); else _updatePendingItemEl(el); }); } @@ -305,11 +321,10 @@ function _buildTodoExpandBody() { return body; } -/** 就地更新 Todo 条:图标/文本/chip 只改文本节点;展开区按内容键重建。 */ +/** 就地更新 Todo 条:折叠态显示"x 待处理 - y 已完成";展开区按内容键重建。 */ function _updateTodoItemEl(item) { const { steps, doneSteps, expanded } = stripState.todo; const nextIdx = steps.findIndex((_, i) => !doneSteps.includes(i)); - const showIdx = nextIdx >= 0 ? nextIdx : steps.length - 1; const allDone = nextIdx < 0; item.classList.toggle("strip-item--expanded", expanded); @@ -317,7 +332,7 @@ function _updateTodoItemEl(item) { const icon = item.querySelector(".strip-todo-icon"); if (icon) icon.classList.toggle("strip-todo-icon--done", allDone); const text = item.querySelector(".strip-item-text"); - if (text) text.textContent = steps[showIdx] || ""; + if (text) text.textContent = `${steps.length - doneSteps.length} 待处理 - ${doneSteps.length} 已完成`; const chip = item.querySelector(".strip-item-count"); if (chip) chip.textContent = `${doneSteps.length}/${steps.length}`; @@ -336,6 +351,244 @@ function _updateTodoItemEl(item) { } } +// ===== Terminal(后台终端任务)通知条 ===== + +/** Terminal 通知条基础行(按钮 + 内容;展开区由 _updateTerminalItemEl 维护) */ +function _buildTerminalItem() { + const item = document.createElement("div"); + item.className = "strip-item strip-item--terminal"; + item.dataset.stripKind = "terminal"; + + const row = document.createElement("div"); + row.className = "strip-item-row"; + + // 左侧按钮:点击隐藏本条 → 输入区生成按钮 + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "strip-item-btn"; + btn.title = "隐藏后台终端条(收入输入区)"; + btn.innerHTML = '<img src="/image/终端.svg" alt="后台终端" />'; + btn.addEventListener("click", (e) => { + e.stopPropagation(); + _dismissStripItem("terminal"); + }); + row.appendChild(btn); + + // 内容区(点击展开/收起) + const content = document.createElement("div"); + content.className = "strip-item-content"; + content.addEventListener("click", () => { + stripState.terminal.expanded = !stripState.terminal.expanded; + renderStrip(); + }); + + const icon = document.createElement("span"); + icon.className = "strip-terminal-icon"; + content.appendChild(icon); + + const text = document.createElement("span"); + text.className = "strip-item-text"; + content.appendChild(text); + + const chip = document.createElement("span"); + chip.className = "strip-item-count"; + content.appendChild(chip); + + row.appendChild(content); + item.appendChild(row); + return item; +} + +/** 后台任务状态 → {label, cls} */ +function _taskStatusMeta(t) { + if (!t) return { label: "未知", cls: "done" }; + if (t.status === "running") return { label: "运行中", cls: "running" }; + if (t.status === "killed") return { label: "已终止", cls: "done" }; + if (t.abnormal || (t.exit_code != null && t.exit_code !== 0)) return { label: `异常退出 · exit ${t.exit_code}`, cls: "err" }; + return { label: "已完成", cls: "done" }; +} + +function _fmtDuration(sec) { + const n = Math.max(0, Number(sec) || 0); + if (n < 60) return `${Math.round(n)}s`; + if (n < 3600) return `${Math.floor(n / 60)}m ${Math.round(n % 60)}s`; + return `${Math.floor(n / 3600)}h ${Math.floor((n % 3600) / 60)}m`; +} + +/** Terminal 展开区:任务列表(命令/PID/状态/时长;运行中行带"终止"按钮) */ +function _buildTerminalExpandBody() { + const { tasks } = stripState.terminal; + const body = document.createElement("div"); + body.className = "strip-expand-area"; + + const head = document.createElement("div"); + head.className = "strip-expand-head"; + const label = document.createElement("span"); + label.className = "strip-expand-label"; + const running = tasks.filter((t) => t.status === "running").length; + label.textContent = `后台终端任务 · ${running} 运行中 / ${tasks.length} 个`; + head.appendChild(label); + head.appendChild(_buildCollapseBtn("terminal")); + body.appendChild(head); + + const list = document.createElement("ul"); + list.className = "strip-terminal-list"; + for (const t of tasks) { + const st = _taskStatusMeta(t); + const li = document.createElement("li"); + li.className = "strip-task-row"; + li.dataset.pid = String(t.id); + + const dot = document.createElement("span"); + dot.className = `strip-task-dot strip-task-dot--${st.cls}`; + li.appendChild(dot); + + const cmd = document.createElement("span"); + cmd.className = "strip-task-cmd"; + cmd.title = t.command || ""; + cmd.textContent = String(t.command || "").slice(0, 60) || "(无命令)"; + li.appendChild(cmd); + + const meta = document.createElement("span"); + meta.className = "strip-task-meta"; + meta.textContent = `#${t.id} · ${_fmtDuration((t.finished_at || Date.now() / 1000) - t.started_at)} · ${_fmtChars(t.total_chars)}`; + li.appendChild(meta); + + if (t.status === "running") { + const killBtn = document.createElement("button"); + killBtn.type = "button"; + killBtn.className = "strip-task-kill"; + killBtn.textContent = "终止"; + killBtn.addEventListener("click", (e) => { + e.stopPropagation(); + _killTerminalTask(t).catch(() => {}); + }); + li.appendChild(killBtn); + } else { + const tag = document.createElement("span"); + tag.className = `strip-task-status strip-task-status--${st.cls}`; + tag.textContent = st.label; + li.appendChild(tag); + } + list.appendChild(li); + } + body.appendChild(list); + return body; +} + +function _fmtChars(n) { + const v = Number(n) || 0; + if (v < 1000) return `${v}字符`; + return `${(v / 1000).toFixed(1)}k字符`; +} + +/** 终止后台任务(确认 → POST kill → 后端 SSE 同步状态) */ +async function _killTerminalTask(t) { + const ok = await showConfirm(`确定终止后台终端任务吗?\n任务ID: #${t.id}\n命令: ${String(t.command || "").slice(0, 120)}`); + if (!ok) return; + const fd = new FormData(); + fd.append("session_id", state.mode === "cron" ? (state.activeCronTaskId || "") : (state.sessionId || "")); + try { + const res = await api(`/api/terminal/tasks/${encodeURIComponent(t.id)}/kill`, { method: "POST", body: fd }); + if (res && res.ok) return; + showToast("终止失败: " + ((res && (res.error || res.detail)) || "未知错误")); + } catch (e) { + showToast("终止失败: " + (e.message || String(e))); + } +} + +/** 就地更新 Terminal 条:折叠态"x 后台运行中 - y 已完成";展开区按内容键重建。 */ +function _updateTerminalItemEl(item) { + const { tasks, expanded } = stripState.terminal; + const running = tasks.filter((t) => t.status === "running").length; + const done = tasks.length - running; + + item.classList.toggle("strip-item--expanded", expanded); + const text = item.querySelector(".strip-item-text"); + if (text) text.textContent = `${running} 后台运行中 - ${done} 已完成`; + const chip = item.querySelector(".strip-item-count"); + if (chip) chip.textContent = `${running}/${tasks.length}`; + + // 展开区维护:仅在内容键变化时重建,否则保留 DOM + const existingBody = item.querySelector(".strip-expand-area"); + const key = tasks.map((t) => `${t.id}:${t.status}:${t.total_chars}`).join("|"); + if (expanded) { + if (!existingBody || item._taskKey !== key) { + if (existingBody) existingBody.remove(); + item.appendChild(_buildTerminalExpandBody()); + item._taskKey = key; + } + } else if (existingBody) { + existingBody.remove(); + item._taskKey = null; + } +} + +// ===== Terminal 任务 SSE 流(与工具调用 SSE 独立通道;仅 chat 会话) ===== +let _termStreamEv = null; +let _termStreamSid = ""; + +function _stopTerminalStream() { + if (_termStreamEv) { try { _termStreamEv.close(); } catch {} _termStreamEv = null; } + _termStreamSid = ""; +} + +function _restartTerminalStream() { + _stopTerminalStream(); + // cron 任务在独立子进程中执行,其终端任务不在此服务注册表 → 仅 chat 模式订阅 + if (state.mode === "cron") return; + const sid = state.sessionId || ""; + if (!sid) return; + try { + const es = new EventSource(`/api/terminal/tasks/${encodeURIComponent(sid)}/stream`); + es.addEventListener("update", (ev) => { + let payload = null; + try { payload = JSON.parse(ev.data); } catch { return; } + if (!payload) return; + if (payload.type === "full") { + stripState.terminal.tasks = Array.isArray(payload.tasks) ? payload.tasks : []; + stripState.terminal.dismissed = false; + renderStrip(); + } else if (payload.type === "task_update" && payload.task) { + _mergeTerminalTask(payload.task); + } else if (payload.type === "output") { + // 输出增量:刷新该任务总字符数(展开区时长/字符展示) + const t = stripState.terminal.tasks.find((x) => x.id === payload.task_id); + if (t) { + let chars = 0; + for (const c of (payload.chunks || [])) chars += String(c.text || "").length; + t.total_chars = (t.total_chars || 0) + chars; + if (stripState.terminal.expanded) renderStrip(); + } + } + }); + es.onerror = () => { /* EventSource 自动重连;断线后由 full 快照重同步 */ }; + _termStreamEv = es; + _termStreamSid = sid; + } catch { /* EventSource 不可用时忽略(无终端浮窗能力) */ } +} + +function _mergeTerminalTask(task) { + const idx = stripState.terminal.tasks.findIndex((x) => x.id === task.id); + if (idx >= 0) stripState.terminal.tasks[idx] = task; + else stripState.terminal.tasks.unshift(task); + // 超限(后端同样限制 50 条):从末尾淘汰最旧的已完成任务(新任务在前) + let overflow = stripState.terminal.tasks.length - 50; + for (let i = stripState.terminal.tasks.length - 1; i >= 0 && overflow > 0; i--) { + if (stripState.terminal.tasks[i].status === "running") continue; + stripState.terminal.tasks.splice(i, 1); + overflow--; + } + if (stripState.terminal.tasks.length > 0) stripState.terminal.dismissed = false; + renderStrip(); +} + +/** 清空终端通知条(新会话/清空会话时) */ +export function clearTerminalStrip() { + stripState.terminal = { tasks: [], dismissed: false, expanded: false }; + renderStrip(); +} + /** Pending 通知条基础行(按钮 + 内容;展开区由 _updatePendingItemEl 维护) */ function _buildPendingItem() { const item = document.createElement("div"); @@ -382,20 +635,37 @@ function _buildPendingItem() { return item; } -/** 就地更新 Pending 条:标题/chip 只改文本;展开区按卡片键重建。 */ +/** 就地更新 Pending 条:折叠态按类型区分—— + * tool_call=权限审查("{工具} 工具 权限审查",警示色字体); + * human_interaction=人机交互工具("x 待处理 - y 已完成");展开区按卡片键重建。 */ function _updatePendingItemEl(item) { const { items, index, expanded } = stripState.pending; const current = items[index]; item.classList.toggle("strip-item--expanded", expanded); const text = item.querySelector(".strip-item-text"); - if (text) text.textContent = current ? (current.title || "需要确认") : "需要确认"; + if (text) { + if (current && current.type === "tool_call") { + // 权限审查(含工作区泄露审查=terminal 工具、以及其他工具权限审查) + const tool = String(current.tool_name || "未知").trim(); + text.textContent = `${tool} 工具 权限审查`; + text.classList.add("strip-item-text--review"); + } else { + // 人机交互工具:x 待处理 - y 已完成 + const humanPending = items.filter((p) => p.type !== "tool_call").length; + text.textContent = `${humanPending} 待处理 - ${stripState.pending.doneCount} 已完成`; + text.classList.remove("strip-item-text--review"); + } + } const chip = item.querySelector(".strip-item-count"); if (chip) chip.textContent = items.length > 1 ? `×${items.length}` : "1"; const existingBody = item.querySelector(".strip-expand-area"); if (expanded && current) { - const key = (current.id || "") + "|" + index; + // 含问题游标 + 作答版本:多问题切换或选项选中时强制重建卡片 + const hst = current.type === "tool_call" ? null : _humanState(current); + const qIdx = hst ? hst.qIndex : 0; + const key = (current.id || "") + "|" + index + "|" + qIdx + "|" + (hst ? (hst.ver || 0) : 0); if (!existingBody || item._cardKey !== key) { if (existingBody) existingBody.remove(); const body = document.createElement("div"); @@ -420,6 +690,7 @@ function _buildCollapseBtn(kind) { btn.addEventListener("click", (e) => { e.stopPropagation(); if (kind === "todo") stripState.todo.expanded = false; + else if (kind === "terminal") stripState.terminal.expanded = false; else stripState.pending.expanded = false; renderStrip(); }); @@ -497,11 +768,11 @@ function _buildToolCallCard(card, pending) { head.appendChild(_buildCollapseBtn("pending")); card.appendChild(head); - // 越界审批提示 + // 安全审查提示(越界/危险指令) if (pending.policy_note) { const note = document.createElement("div"); note.className = "strip-card-note"; - note.textContent = "⚠ 越界操作待审批:" + pending.policy_note; + note.textContent = "⚠ 安全审查:" + pending.policy_note; card.appendChild(note); } @@ -537,8 +808,84 @@ function _buildToolCallCard(card, pending) { card.appendChild(footer); } -/** 人机交互卡片:标题 + 问题 + 选项 + 补充输入框(输入内容直接作为工具返回值) */ +// ===== 人机交互卡片(多问题:列表式无边框 UI,箭头切换问题,最后确认汇总提交) ===== + +/** 取某个 pending 项的问题列表(多问题 questions,兼容旧的单问题 prompt/options) */ +function _humanQuestions(pending) { + if (Array.isArray(pending.questions) && pending.questions.length) { + return pending.questions.map((q) => ({ + prompt: String(q && q.prompt || "请处理后继续。"), + options: Array.isArray(q && q.options) ? q.options.slice(0, 3) : [], + })); + } + return [{ + prompt: String(pending.prompt || "请处理后继续。"), + options: Array.isArray(pending.options) ? pending.options.slice(0, 3) : [], + }]; +} + +/** 人机交互作答状态(按 item id 保存:list=每问答案 {kind,value},qIndex=当前问题游标,ver=作答版本) */ +function _humanState(pending) { + const key = pending && pending.id ? pending.id : "_"; + if (!stripState.pending.answers || !stripState.pending.answers[key]) { + stripState.pending.answers = stripState.pending.answers || {}; + stripState.pending.answers[key] = { list: [], qIndex: 0, ver: 0 }; + } + return stripState.pending.answers[key]; +} + +/** 记录当前问题答案(kind: option|input|ignored)并递增版本(触发选中态重绘) */ +function _recordHumanQuestion(pending, kind, value) { + const st = _humanState(pending); + st.list[st.qIndex || 0] = { kind, value: String(value || "") }; + st.ver = (st.ver || 0) + 1; +} + +/** 上一问(箭头),不足 0 时禁用 */ +function _gotoHumanQuestion(pending, delta) { + const questions = _humanQuestions(pending); + const st = _humanState(pending); + const next = Math.max(0, Math.min(questions.length - 1, st.qIndex + delta)); + if (next === st.qIndex) return; + st.qIndex = next; + renderStrip(); +} + +/** 答完当前问(选项/回车/确认/忽略)→ 切换到下一问;最后一问且 finalize=true 时汇总提交 */ +function _advanceHumanQuestion(pending, finalize) { + const questions = _humanQuestions(pending); + const st = _humanState(pending); + if (st.qIndex < questions.length - 1) { + st.qIndex += 1; + renderStrip(); + } else if (finalize) { + _submitPending(pending, "edit", _aggregateHumanAnswers(pending)); + } else { + renderStrip(); // 仅刷新选中态 + } +} + +/** 汇总本轮所有问答结果为编号列表(未作答/忽略 → "用户选择忽略此问题") */ +function _aggregateHumanAnswers(pending) { + const questions = _humanQuestions(pending); + const st = _humanState(pending); + return questions.map((_, i) => { + const a = st.list[i]; + const value = a && a.kind !== "ignored" && (a.value || "").trim() ? a.value.trim() : ""; + return `${i + 1}、用户选择${value || "忽略此问题"};`; + }).join("\n"); +} + +/** 人机交互卡片:问题 → 选项(最多3,无边框列表)→ 可编辑输入框; + 左下角箭头切换问题(圆角矩形按钮),右下角 忽略/确认 均自动切下一问, + 最后一问点确认 → 汇总编号结果作为工具调用结果返回。 */ function _buildHumanCard(card, pending, isLast) { + const questions = _humanQuestions(pending); + const st = _humanState(pending); + if (st.qIndex >= questions.length) st.qIndex = 0; + const q = questions[st.qIndex]; + const existing = (st.list && st.list[st.qIndex]) || null; + const head = document.createElement("div"); head.className = "strip-card-head"; const title = document.createElement("div"); @@ -548,62 +895,109 @@ function _buildHumanCard(card, pending, isLast) { head.appendChild(_buildCollapseBtn("pending")); card.appendChild(head); - if (pending.prompt) { - const content = document.createElement("div"); - content.className = "strip-card-content"; - content.textContent = _unescapeNewlines(pending.prompt); - card.appendChild(content); - } + // 从上到下的简约列表(无边框、无分割线):问题 → 选项 → 输入框 + const content = document.createElement("div"); + content.className = "strip-card-content"; - // 选项胶囊(可空——无选项时仅问题 + 补充输入框) - let selectedPill = null; - const options = Array.isArray(pending.options) ? pending.options : []; - if (options.length) { - const pillsWrap = document.createElement("div"); - pillsWrap.className = "strip-card-options"; - options.forEach((opt) => { - const pill = document.createElement("button"); - pill.type = "button"; - pill.className = "strip-option-pill"; - pill.textContent = String(opt); - pill.addEventListener("click", (e) => { + const promptEl = document.createElement("div"); + promptEl.className = "strip-hq-prompt"; + promptEl.textContent = _unescapeNewlines(q.prompt); + content.appendChild(promptEl); + + const opts = q.options.slice(0, 3); + if (opts.length) { + const optsWrap = document.createElement("div"); + optsWrap.className = "strip-hq-options"; + opts.forEach((opt) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "strip-hq-option" + + (existing && existing.kind === "option" && existing.value === String(opt) ? " is-selected" : ""); + btn.textContent = String(opt); + btn.addEventListener("click", (e) => { e.stopPropagation(); - // 仅切换类名,不触发重渲染(消除卡顿) - pillsWrap.querySelectorAll(".strip-option-pill").forEach((p) => p.classList.remove("is-selected")); - pill.classList.add("is-selected"); - selectedPill = String(opt); + // 点击选项:记录并自动切换到下一个问题 + _recordHumanQuestion(pending, "option", String(opt)); + _advanceHumanQuestion(pending, false); }); - pillsWrap.appendChild(pill); + optsWrap.appendChild(btn); }); - card.appendChild(pillsWrap); + content.appendChild(optsWrap); } - // 补充输入框:内容直接作为工具返回值 const input = document.createElement("textarea"); - input.className = "strip-card-input"; + input.className = "strip-hq-input"; input.rows = 2; - input.placeholder = options.length ? "补充信息(或直接输入选项)…" : "请输入…"; - card.appendChild(input); - - const footer = document.createElement("div"); - footer.className = "strip-card-footer"; - - const submitBtn = document.createElement("button"); - submitBtn.type = "button"; - submitBtn.className = "strip-card-btn strip-card-btn--continue"; - submitBtn.textContent = isLast ? "确认" : "继续"; - submitBtn.addEventListener("click", (e) => { - e.stopPropagation(); - // 优先补充输入框内容(直达工具返回值),否则取选中选项 - const val = input.value.trim() || selectedPill || ""; - if (!val) { - showToast("请先输入内容或选择选项"); - return; + input.placeholder = "补充信息(输入后回车或点「确认」)…"; + input.value = existing && existing.kind === "input" ? existing.value : ""; + input.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { + e.preventDefault(); + // 编辑结束按回车:作为当前问题答案并自动切换 + _recordHumanQuestion(pending, "input", input.value); + _advanceHumanQuestion(pending, false); } - _submitPending(pending, "edit", val, submitBtn); }); + content.appendChild(input); + card.appendChild(content); - footer.appendChild(submitBtn); + // 底部:左下角问题切换箭头(圆角矩形),右下角 忽略/确认 + const footer = document.createElement("div"); + footer.className = "strip-hq-footer"; + + const nav = document.createElement("div"); + nav.className = "strip-hq-nav"; + if (questions.length > 1) { + const prevBtn = document.createElement("button"); + prevBtn.type = "button"; + prevBtn.className = "strip-hq-nav-btn"; + prevBtn.textContent = "‹"; + prevBtn.title = "上一个问题"; + prevBtn.disabled = st.qIndex <= 0; + prevBtn.addEventListener("click", (e) => { e.stopPropagation(); _gotoHumanQuestion(pending, -1); }); + const nextBtn = document.createElement("button"); + nextBtn.type = "button"; + nextBtn.className = "strip-hq-nav-btn"; + nextBtn.textContent = "›"; + nextBtn.title = "下一个问题"; + nextBtn.disabled = st.qIndex >= questions.length - 1; + nextBtn.addEventListener("click", (e) => { e.stopPropagation(); _gotoHumanQuestion(pending, 1); }); + nav.appendChild(prevBtn); + nav.appendChild(nextBtn); + } + footer.appendChild(nav); + + const actions = document.createElement("div"); + actions.className = "strip-hq-actions"; + const ignoreBtn = document.createElement("button"); + ignoreBtn.type = "button"; + ignoreBtn.className = "strip-hq-act strip-hq-act--ignore"; + ignoreBtn.textContent = "忽略"; + ignoreBtn.addEventListener("click", (e) => { + e.stopPropagation(); + if (input.value.trim()) _recordHumanQuestion(pending, "input", input.value); + _recordHumanQuestion(pending, "ignored", ""); + _advanceHumanQuestion(pending, true); // 最后一问 → 汇总提交(记为本问忽略) + }); + const confirmBtn = document.createElement("button"); + confirmBtn.type = "button"; + confirmBtn.className = "strip-hq-act strip-hq-act--confirm"; + confirmBtn.textContent = "确认"; + confirmBtn.addEventListener("click", (e) => { + e.stopPropagation(); + // 以点击时刻的最新状态为准(避免覆盖刚点选的选项) + const st = _humanState(pending); + const cur = st.list[st.qIndex]; + if (input.value.trim()) { + _recordHumanQuestion(pending, "input", input.value); + } else if (!cur) { + _recordHumanQuestion(pending, "option", ""); + } + _advanceHumanQuestion(pending, true); // 最后一问 → 汇总提交 + }); + actions.appendChild(ignoreBtn); + actions.appendChild(confirmBtn); + footer.appendChild(actions); card.appendChild(footer); } @@ -623,6 +1017,9 @@ async function _submitPending(pending, decision, instruction, btnEl) { return; } // 本地立即移除该项(后端快照随后也会同步);前进到下一张 + // 人机交互工具答完 → 累计"已完成"计数(权限审查不参与计数) + if (pending.type !== "tool_call") stripState.pending.doneCount++; + if (stripState.pending.answers) delete stripState.pending.answers[pending.id]; // 清理多问题作答状态 const items = stripState.pending.items; const idx = items.findIndex((p) => p.id === pending.id); if (idx >= 0) items.splice(idx, 1); @@ -647,7 +1044,8 @@ function _activeSessionIdForPending() { /** 隐藏某类通知条,并在输入区生成对应按钮 */ function _dismissStripItem(kind) { if (kind === "todo") stripState.todo.dismissed = true; - else stripState.pending.dismissed = true; + else if (kind === "pending") stripState.pending.dismissed = true; + else stripState.terminal.dismissed = true; _addComposerBtn(kind); renderStrip(); } @@ -661,15 +1059,18 @@ function _addComposerBtn(kind) { const btn = document.createElement("button"); btn.type = "button"; btn.className = "action-icon-btn strip-composer-btn"; - btn.title = kind === "todo" ? "Todo List" : "人机交互"; + btn.title = kind === "todo" ? "Todo List" : (kind === "terminal" ? "后台终端任务" : "人机交互"); btn.innerHTML = kind === "todo" ? '<img src="/image/todolist.svg" alt="Todo" />' - : '<img src="/image/人机交互.svg" alt="交互" />'; + : (kind === "terminal" + ? '<img src="/image/终端.svg" alt="后台终端" />' + : '<img src="/image/人机交互.svg" alt="交互" />'); btn.addEventListener("click", () => { // 点击输入区按钮 → 按钮消失、对应通知条重新出现 _removeComposerBtn(kind); if (kind === "todo") stripState.todo.dismissed = false; - else stripState.pending.dismissed = false; + else if (kind === "pending") stripState.pending.dismissed = false; + else stripState.terminal.dismissed = false; renderStrip(); }); @@ -727,14 +1128,15 @@ export function clearTodoStrip() { /** 仅清空 pending 通知条(不影响 todo) */ export function clearPendingStrip() { - stripState.pending = { items: [], index: 0, dismissed: false, expanded: false }; + stripState.pending = { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} }; renderStrip(); } /** 清除全部通知条状态(新会话/清空会话时) */ export function clearAllStrips() { stripState.todo = { steps: [], doneSteps: [], dismissed: false, expanded: false }; - stripState.pending = { items: [], index: 0, dismissed: false, expanded: false }; + stripState.pending = { items: [], index: 0, dismissed: false, expanded: false, doneCount: 0, answers: {} }; + stripState.terminal = { tasks: [], dismissed: false, expanded: false }; _clearComposerBtns(); renderStrip(); } diff --git a/src/web/js/pending-overlay.js b/src/web/js/pending-overlay.js deleted file mode 100644 index 7aba116..0000000 --- a/src/web/js/pending-overlay.js +++ /dev/null @@ -1,42 +0,0 @@ -// pending-overlay.js -- 人机交互 / 工具审查(通知条版) -// -// 原浮窗实现已迁移至 notify-strip.js: -// 等待中的 human_requests 以输入区上方通知条展示(标题 + 计数 chip), -// 点击展开为卡片(人机交互:内容/选项/输入 + 继续/确认;审查:同意/拒绝), -// 多项时左右箭头切换。本文件保留导出签名以兼容既有调用点。 - -import { - updatePendingStrip, - clearPendingStrip, - saveStripSession, -} from './notify-strip.js'; - -/** live snapshot 驱动入口:human_requests 数组出现即渲染,应答后随快照消失。 */ -function updatePendingOverlay(pendingActions) { - updatePendingStrip(pendingActions); -} - -/** 清除当前会话的 pending 通知条(仅 pending,不影响 todo;先持久化到会话快照)。 */ -function clearPendingOverlay() { - saveStripSession(); - clearPendingStrip(); -} - -/** 兼容旧接口:通知条模式下无独立浮窗可切换。 */ -function togglePendingOverlay() {} - -/** 兼容旧接口:通知条模式下无双浮窗布局需要调整。 */ -function updateOverlayLayout() {} - -/** 兼容旧接口:通知条由 notify-strip 构建。 */ -function buildPendingOverlay() { - return null; -} - -export { - buildPendingOverlay, - updatePendingOverlay, - clearPendingOverlay, - togglePendingOverlay, - updateOverlayLayout, -}; diff --git a/src/web/js/pending.js b/src/web/js/pending.js index f091ed3..f67445e 100644 --- a/src/web/js/pending.js +++ b/src/web/js/pending.js @@ -1,4 +1,4 @@ -// pending.js -- 人工请求决策提交(卡片渲染在 pending-overlay.js,数据源为 live snapshot) +// pending.js -- 人工请求决策提交(卡片渲染在 notify-strip.js,数据源为 live snapshot) import { api } from './api.js'; import { state } from './state.js'; diff --git a/src/web/js/rollback.js b/src/web/js/rollback.js index 1e559fc..fe3697c 100644 --- a/src/web/js/rollback.js +++ b/src/web/js/rollback.js @@ -6,7 +6,13 @@ import { $, showToast } from './utils.js'; import { showConfirm } from './dialog.js'; import { api } from './api.js'; import { state } from './state.js'; -import { clearPendingOverlay } from './pending-overlay.js'; +import { clearPendingStrip, saveStripSession } from './notify-strip.js'; + +/** 清除 pending 通知条(先持久化会话快照;原 pending-overlay.js 迁移后由 notify-strip 接管) */ +function clearPendingOverlay() { + saveStripSession(); + clearPendingStrip(); +} // 依赖注入 let renderMessagesFn = null; diff --git a/src/web/js/send.js b/src/web/js/send.js index 4e20662..7bbc308 100644 --- a/src/web/js/send.js +++ b/src/web/js/send.js @@ -5,7 +5,13 @@ import { showConfirm } from './dialog.js'; import { api, streamApi } from './api.js'; import { startPollLiveToolCalls } from './toolcalls.js'; import { state } from './state.js'; -import { clearPendingOverlay } from './pending-overlay.js'; +import { clearPendingStrip, saveStripSession } from './notify-strip.js'; + +/** 清除 pending 通知条(先持久化会话快照;原 pending-overlay.js 迁移后由 notify-strip 接管) */ +function clearPendingOverlay() { + saveStripSession(); + clearPendingStrip(); +} import { renderVoiceTranscribingPlaceholder } from './chat-render.js'; let renderSessionsFn = null; @@ -14,10 +20,20 @@ let renderMessagesIncrementalFn = null; let renderAttachmentChipsFn = null; let appendThinkingIndicatorFn = null; let injectLiveToolCallsFn = null; -let clearTodoOverlayFn = null; let streamAssistantMessageFn = null; +let appendNewMessagesFn = null; let onSendStartFn = null; let _audioAbort = null; // 音频流的独立 AbortController,供打断用 +let _toolPollStop = null; // 当前轮次的实时轮询停止函数(暂停时主动释放 SSE 连接) + +/** 停止当前轮次的实时工具轮询(幂等;暂停/轮次结束/发送新消息时调用) */ +function _stopToolPoll() { + if (_toolPollStop) { + const f = _toolPollStop; + _toolPollStop = null; + f(); + } +} /** 清理思考气泡:停止内部计时器后移除 DOM */ function _cleanupTypingEl(el) { @@ -83,8 +99,8 @@ export function setSendDeps(deps) { renderAttachmentChipsFn = deps.renderAttachmentChips; appendThinkingIndicatorFn = deps.appendThinkingIndicator; injectLiveToolCallsFn = deps.injectLiveToolCalls; - clearTodoOverlayFn = deps.clearTodoOverlay; streamAssistantMessageFn = deps.streamAssistantMessage; + appendNewMessagesFn = deps.appendNewMessages || null; onSendStartFn = deps.onSendStart; } @@ -142,8 +158,12 @@ async function handlePauseClick() { state.sessionId = data.sessionId; state.sessions.length = 0; state.sessions.push(...(data.sessions || [])); - if (clearTodoOverlayFn) clearTodoOverlayFn(); + // 不清空 todo——todo 是会话级全局状态,跨轮次保持(与 sendChat 新轮次语义一致, + // 暂停只结束本轮,后续重启/切会话都会从历史消息重建;此处清空会造成 + // 「暂停后消失、重启后复现」的不一致)。pending 请求随轮次结束失效,照常清空。 clearPendingOverlay(); + // 暂停即定格:停止实时轮询(本轮工具记录已由后端落盘,剩余在途结果不再推送) + _stopToolPoll(); if (renderSessionsFn) renderSessionsFn(); if (renderMessagesFn) renderMessagesFn(data.messages || []); } catch (e) { @@ -232,7 +252,9 @@ async function sendChat() { const voiceInputFile = savedFiles.find((f) => f.__voiceInput) || null; const regularFiles = savedFiles.filter((f) => !f.__voiceInput && !f.__isRef && !(f.type && f.type.startsWith("folder/"))); const hasVoiceInput = !!voiceInputFile; - const priorMessages = JSON.parse(JSON.stringify(state.lastRenderedMessages)); + // 浅拷贝快照数组即可:元素为渲染层生成的只读快照对象(无消费方会改写其内部字段), + // 整表 JSON 深拷贝在大历史下是发送时的主要同步开销之一 + const priorMessages = state.lastRenderedMessages.slice(); const blobUrls = []; const hadOptimisticMedia = regularFiles.length > 0 || hasVoiceInput; let sessionIdAtStart = state.sessionId; @@ -263,19 +285,17 @@ async function sendChat() { if (hasVoiceInput) { // 语音输入:先渲染已有消息(含普通文件 + 用户输入的文本),再追加"语音转文字中"占位 const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts); - if (renderMessagesFn) { - if (optimisticContent != null) { - renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]); - } else { - renderMessagesFn(priorMessages); - } + if (optimisticContent != null) { + _renderRuntime([...priorMessages, { role: "user", content: optimisticContent }]); + } else { + _renderRuntime(priorMessages); } renderVoiceTranscribingPlaceholder(); } else { // 仅普通文件:乐观渲染图片/文件 const optimisticContent = buildOptimisticUserContent(effectiveText, fileParts); - if (optimisticContent != null && renderMessagesFn) { - renderMessagesFn([...priorMessages, { role: "user", content: optimisticContent }]); + if (optimisticContent != null) { + _renderRuntime([...priorMessages, { role: "user", content: optimisticContent }]); } } if (textInput) textInput.value = ""; @@ -286,9 +306,8 @@ async function sendChat() { if (renderAttachmentChipsFn) renderAttachmentChipsFn(); } else if (effectiveText) { // 纯文本/引用消息:立即乐观渲染用户消息,避免等待 API /start 响应 - if (renderMessagesFn) { - renderMessagesFn([...priorMessages, { role: "user", content: effectiveText }]); - } + // 走增量路径(仅追加本条),避免全量重建历史导致点击后 hover 失效 + _renderRuntime([...priorMessages, { role: "user", content: effectiveText }]); if (textInput) textInput.value = ""; clearQuoteRefs(); clearFileRefs(); @@ -319,20 +338,14 @@ async function sendChat() { if (renderAttachmentChipsFn) renderAttachmentChipsFn(); } if (renderSessionsFn) renderSessionsFn(); - const _isVoice = state.outputMode === "voice"; - // 语音模式下只追加用户消息气泡(保留旧消息),完整列表等流式 text 事件再刷新 - // 但若已通过乐观渲染显示了用户消息(含语音输入占位),则重新渲染全部消息以替换占位 - if (_isVoice && renderMessagesFn && !hadOptimisticMedia) { - const startMsgs = startData.messages || []; - const userMsg = startMsgs[startMsgs.length - 1]; - if (userMsg && userMsg.role === "user") { - const current = (state.lastRenderedMessages || []).slice(); - current.push(userMsg); - _renderRuntime(current); - } - } else if (renderMessagesFn) { - // 包括:文本模式、或语音输出模式但有乐观渲染/语音输入占位需要替换 - // 运行期增量渲染:乐观渲染已包含历史 + 用户消息时只 append 差异 + // 增量契约:start 返回 canonical 用户消息 + 计数,替换乐观渲染的占位消息 + if (appendNewMessagesFn && startData.new_message) { + appendNewMessagesFn(null, [startData.new_message], { + baseCount: startData.base_count, + total: startData.total, + }); + } else if (renderMessagesFn && startData.messages) { + // 兼容回退:旧契约(全量 messages) _renderRuntime(startData.messages || []); } revokeBlobUrlStack(blobUrls); @@ -353,7 +366,7 @@ async function sendChat() { clearPendingOverlay(); // 启动实时工具调用与反思轮询(snapshot 同时驱动人工请求浮窗与 Todo 浮窗) - const stopToolPoll = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn); + _toolPollStop = startPollLiveToolCalls(state.sessionId, typingEl, injectLiveToolCallsFn); const fd2 = new FormData(); fd2.append("session_id", state.sessionId); @@ -366,6 +379,7 @@ async function sendChat() { } sessionIdAtStart = state.sessionId; + const _isVoice = state.outputMode === "voice"; if (_isVoice) { // ====== 流式 TTS ====== _stopAllTtsPlayback(); @@ -384,7 +398,7 @@ async function sendChat() { method: "POST", body: fd2, signal: state.abortController.signal, - onEvent: (event) => { + onEvent: async (event) => { if (state.sessionId !== sessionIdAtStart) return; if (_audioGeneration !== gen) return; // 被新请求打断,忽略旧回调 @@ -392,20 +406,50 @@ async function sendChat() { textRendered = true; streamSessionId = event.sessionId || sessionIdAtStart; streamSessions = event.sessions || []; - const msgs = event.messages || []; - const last = msgs[msgs.length - 1]; + const newMsgs = event.new_messages || []; + const last = newMsgs[newMsgs.length - 1]; - if (last && last.role === "assistant") { - const head = msgs.slice(0, -1); - _renderRuntime(head); - // 同步 lastRenderedMessages 为完整消息列表,确保后续打断时 assistant 消息不丢失 - state.lastRenderedMessages.length = 0; - state.lastRenderedMessages.push(...JSON.parse(JSON.stringify(msgs))); + if (last && last.role === "assistant" && appendNewMessagesFn) { + // 增量契约:多消息轮次先 append head;assistant 末条由伪流式追加 DOM, + // append 仅补簿记(含 lastRenderedMessages 同步) + if (newMsgs.length > 1) { + appendNewMessagesFn(null, newMsgs.slice(0, -1), { + baseCount: event.base_count, + total: event.total, + }); + } revokeBlobUrlStack(blobUrls); - if (streamAssistantMessageFn) streamAssistantMessageFn(last); + if (streamAssistantMessageFn) { + await streamAssistantMessageFn(last); + appendNewMessagesFn(null, [last], { + baseCount: event.total != null ? event.total - 1 : null, + total: event.total, + domAlreadyAppended: true, + }); + } else { + appendNewMessagesFn(null, [last], { + baseCount: event.base_count, + total: event.total, + }); + } } else { - _renderRuntime(msgs); revokeBlobUrlStack(blobUrls); + if (appendNewMessagesFn && newMsgs.length) { + appendNewMessagesFn(null, newMsgs, { baseCount: event.base_count, total: event.total }); + } else if (event.messages) { + // 兼容回退:旧契约(全量 messages) + const msgs = event.messages || []; + const fbLast = msgs[msgs.length - 1]; + if (fbLast && fbLast.role === "assistant") { + const head = msgs.slice(0, -1); + _renderRuntime(head); + state.lastRenderedMessages.length = 0; + state.lastRenderedMessages.push(...msgs); + if (streamAssistantMessageFn) streamAssistantMessageFn(fbLast); + } else { + _renderRuntime(msgs); + } + } } // 文字渲染完毕,释放按钮 @@ -419,7 +463,7 @@ async function sendChat() { state.sessions.push(...streamSessions); if (renderSessionsFn) renderSessionsFn(); - stopToolPoll(); + _stopToolPoll(); if (typingEl) _cleanupTypingEl(typingEl); typingEl = null; } @@ -460,30 +504,60 @@ async function sendChat() { const done = await api("/api/chat/complete", { method: "POST", body: fd2, signal: state.abortController.signal }); if (state.sessionId !== sessionIdAtStart) { - stopToolPoll(); + _stopToolPoll(); return; } - stopToolPoll(); + _stopToolPoll(); state.sessionId = done.sessionId; state.sessions.length = 0; state.sessions.push(...(done.sessions || [])); if (renderSessionsFn) renderSessionsFn(); - const messages = done.messages || []; - const last = messages[messages.length - 1]; + const newMessages = done.new_messages || []; + const last = newMessages[newMessages.length - 1]; if (typingEl) _cleanupTypingEl(typingEl); typingEl = null; - if (last && last.role === "assistant") { - const head = messages.slice(0, -1); - // 增量渲染:head 与已渲染内容一致时零重建,仅由 streamAssistantMessage 追加末条 - _renderRuntime(head); + if (last && last.role === "assistant" && appendNewMessagesFn) { + // 增量契约:多消息轮次 head 先 append;末条 assistant 由伪流式追加 DOM, + // append 仅补簿记(修复此前 streamAssistantMessage 不计数的簿记缺口) + if (newMessages.length > 1) { + appendNewMessagesFn(null, newMessages.slice(0, -1), { + baseCount: done.base_count, + total: done.total, + }); + } + revokeBlobUrlStack(blobUrls); + if (streamAssistantMessageFn) { + await streamAssistantMessageFn(last); + appendNewMessagesFn(null, [last], { + baseCount: done.total != null ? done.total - 1 : null, + total: done.total, + domAlreadyAppended: true, + }); + } else { + appendNewMessagesFn(null, [last], { + baseCount: done.base_count, + total: done.total, + }); + } + } else if (appendNewMessagesFn && newMessages.length) { + appendNewMessagesFn(null, newMessages, { baseCount: done.base_count, total: done.total }); revokeBlobUrlStack(blobUrls); - if (streamAssistantMessageFn) await streamAssistantMessageFn(last); } else { - _renderRuntime(messages); - revokeBlobUrlStack(blobUrls); + // 兼容回退:旧契约(全量 messages) + const messages = done.messages || []; + const fbLast = messages[messages.length - 1]; + if (fbLast && fbLast.role === "assistant") { + const head = messages.slice(0, -1); + _renderRuntime(head); + revokeBlobUrlStack(blobUrls); + if (streamAssistantMessageFn) await streamAssistantMessageFn(fbLast); + } else { + _renderRuntime(messages); + revokeBlobUrlStack(blobUrls); + } } } } catch (e) { diff --git a/src/web/js/sessions.js b/src/web/js/sessions.js index 9f8563e..a33498a 100644 --- a/src/web/js/sessions.js +++ b/src/web/js/sessions.js @@ -2,9 +2,15 @@ import { $, showToast } from './utils.js'; import { showConfirm, showPrompt } from './dialog.js'; -import { api } from './api.js'; +import { api, apiWithHeaders } from './api.js'; import { state } from './state.js'; -import { clearPendingOverlay } from './pending-overlay.js'; +import { clearPendingStrip, saveStripSession } from './notify-strip.js'; + +/** 清除 pending 通知条(先持久化会话快照;原 pending-overlay.js 迁移后由 notify-strip 接管) */ +function clearPendingOverlay() { + saveStripSession(); + clearPendingStrip(); +} import { refreshTokens } from './token-ring.js'; let renderSessionsFnRef = null; @@ -28,13 +34,19 @@ const _collapsedWs = new Set(); // ---------- 会话显示名称(localStorage) ---------- const SESSION_NAMES_KEY = "minor_session_names"; +// 内存缓存:renderSessions 逐条读取会话名时避免重复 JSON.parse(localStorage) +let _sessionNamesCache = null; + function loadSessionNames() { + if (_sessionNamesCache) return _sessionNamesCache; try { - return JSON.parse(localStorage.getItem(SESSION_NAMES_KEY) || "{}"); - } catch { return {}; } + _sessionNamesCache = JSON.parse(localStorage.getItem(SESSION_NAMES_KEY) || "{}"); + } catch { _sessionNamesCache = {}; } + return _sessionNamesCache; } function saveSessionNames(names) { + _sessionNamesCache = names; localStorage.setItem(SESSION_NAMES_KEY, JSON.stringify(names)); } @@ -117,6 +129,8 @@ function renderSessionItem(id) { li.className = "session-item" + (isActive ? " active" : ""); li.setAttribute("role", "option"); li.setAttribute("aria-selected", isActive ? "true" : "false"); + // 供删除等场景按 id 精准定位/移除节点,避免全量重建 + li.dataset.sessionId = id; // 会话图标(文档按钮样式,随主题着色) const icon = document.createElement("span"); @@ -286,8 +300,11 @@ async function deleteSessionItem(id) { if (!await showConfirm(`确认删除会话「${displayName}」吗?该会话的所有记录和文件都将被永久删除。`)) return; const deletingSessionId = id; + const deletedActive = id === state.sessionId; // 切换前记录:删除的是否为当前会话 const fd = new FormData(); fd.append("session_id", id); + // 告知服务端当前会话:删除非当前会话时无需返回全量历史,避免无谓序列化 + if (state.sessionId) fd.append("current_session_id", state.sessionId); const data = await api("/api/sessions/delete", { method: "POST", body: fd }); // 如果删除的是当前会话,切换到服务端返回的新会话 @@ -307,7 +324,31 @@ async function deleteSessionItem(id) { if (data.session_workspaces) state.sessionWorkspaces = data.session_workspaces; syncWorkspaceChip(); - renderSessions(); + // 原地移除会话节点(避免全量重建列表 → 鼠标下方节点被替换导致 hover 失效、 + // 列表闪烁);找不到对应节点(如列表未渲染)时回退全量渲染 + let removedInPlace = false; + try { + const itemEl = sessionList.querySelector(`.session-item[data-session-id="${CSS.escape(deletingSessionId)}"]`); + if (itemEl) { + const groupLi = itemEl.closest(".ws-group"); + itemEl.remove(); + // 空的工作区组连组移除 + if (groupLi) { + const inner = groupLi.querySelector(".ws-group-sessions"); + if (inner && !inner.children.length) groupLi.remove(); + } + removedInPlace = true; + // 删除的是当前会话:把高亮移到服务端回退的新会话 + if (deletedActive && data.sessionId && data.sessionId !== deletingSessionId) { + const nextActive = sessionList.querySelector(`.session-item[data-session-id="${CSS.escape(data.sessionId)}"]`); + if (nextActive) { + nextActive.classList.add("active"); + nextActive.setAttribute("aria-selected", "true"); + } + } + } + } catch { /* ignore */ } + if (!removedInPlace) renderSessions(); // 清理 localStorage 中的名称记录 const names = loadSessionNames(); @@ -315,6 +356,8 @@ async function deleteSessionItem(id) { delete names[deletingSessionId]; saveSessionNames(names); } + // 失效被删会话的消息缓存 + state._msgCache.delete(deletingSessionId); // close drawer on mobile const _ar4 = $("app"); @@ -323,7 +366,7 @@ async function deleteSessionItem(id) { // ---------- 会话级思考档位同步 ---------- // 各会话独立保存 THINKING_LEVEL(session_meta.json),切换/新建/删除会话时 -// 用服务端返回的 thinking_level 同步滑条 UI。 +// 用服务端返回的 thinking_level 同步思考模式 UI。 function applySessionThinkingLevel(data) { if (!data || !data.thinking_level) return; if (state.thinkingLevel === data.thinking_level) return; @@ -359,15 +402,44 @@ export async function bootstrap() { async function selectSession(id) { if (!id || id === state.sessionId) return; if (state.sending) return; // Agent 运行中禁止切换会话 - const data = await api(`/api/sessions/${encodeURIComponent(id)}/messages`); - await applySessionData(data); + const prevSessionId = state.sessionId; + const cached = state._msgCache.get(id); + const cachedEtag = cached ? cached.etag : null; + + // 缓存秒开:先用缓存内容渲染(临时把 session id 指向目标会话,让缓存快照 + // 落到目标键下——内容一致,安全),再带 If-None-Match 后台校验 + if (cached && Array.isArray(cached.messages)) { + state.sessionId = id; + if (renderMessagesFn) renderMessagesFn(cached.messages); + state.sessionId = prevSessionId; + try { + const { restoreTodoFromMessages } = await import('./notify-strip.js'); + restoreTodoFromMessages(cached.messages); + } catch { /* ignore */ } + } + + const headers = cachedEtag ? { "If-None-Match": cachedEtag } : undefined; + const res = await apiWithHeaders(`/api/sessions/${encodeURIComponent(id)}/messages`, { headers }); + if (res.status === 304) { + // 缓存与服务器一致:切换生效并补齐被跳过的副作用(chip / 通知条) + const entry = state._msgCache.get(id); + if (entry) entry.etag = cachedEtag; + state.sessionId = id; + syncWorkspaceChip(); + try { + const { switchStripSession } = await import('./notify-strip.js'); + switchStripSession("chat:" + (id || ""), "chat:" + prevSessionId); + } catch { /* ignore */ } + return; + } + await applySessionData(res.data, { etag: res.headers.get("ETag") }); } /** * 应用一次会话数据(sessionId / sessions / messages / tokens)并刷新界面。 * 供「分支」等创建新会话后直接跳转使用。 */ -export async function applySessionData(data) { +export async function applySessionData(data, opts = {}) { if (!data) return; // 记录旧会话键(切换前保存其通知条状态,避免被存到新键下) const oldSessionId = state.sessionId; @@ -389,6 +461,9 @@ export async function applySessionData(data) { if (renderSessionsFnRef) renderSessionsFnRef(); if (renderMessagesFn) renderMessagesFn(data.messages || []); refreshTokens(); + // 记录本次校验指纹(供下次切换的 If-None-Match 304 判定) + const entry = state._msgCache.get(state.sessionId); + if (entry && opts.etag) entry.etag = opts.etag; // 设置居中状态 const msgs = data.messages || []; import('../app.js').then((m) => m.setComposerCentered(msgs.length === 0)); diff --git a/src/web/js/settings.js b/src/web/js/settings.js index 93e63f1..d88af2a 100644 --- a/src/web/js/settings.js +++ b/src/web/js/settings.js @@ -5,7 +5,7 @@ import { $, escapeHtml, makeId, showToast } from './utils.js'; import { showConfirm } from './dialog.js'; import { api } from './api.js'; import { state } from './state.js'; -import { THEMES, getCurrentTheme, applyTheme, getBgImage, setBgImage, getBgBlur, setBgBlur, getBgVignette, setBgVignette, FONT_PRESETS, FONT_SIZE_PRESETS, getFontSetting, setFontSetting, getAvatar, setAvatar } from './themes.js'; +import { THEMES, getCurrentTheme, applyTheme, getBgImage, setBgImage, getBgBlur, setBgBlur, getBgVignette, setBgVignette, FONT_PRESETS, FONT_SIZE_PRESETS, getFontSetting, setFontSetting, getAvatar, setAvatar, PANEL_ZONES, getPanelGlass, setPanelGlass, saveThemeSettings } from './themes.js'; import { LANGS, setLanguage } from './i18n.js'; let renderSkillsFn = null; @@ -88,6 +88,7 @@ async function loadAllConfigs() { }); state.registeredToolNames.length = 0; state.registeredToolNames.push(...(toolRes.registered_tool_names || [])); + state.mainDefaultTools = Array.isArray(agentRes.main_default_tools) ? agentRes.main_default_tools : []; window._toolParameters = toolRes.tool_parameters || {}; window._forcedPermissions = toolRes.forced_permissions || {}; // 所有页面同时渲染(nav 切换仅控制显隐) @@ -206,11 +207,25 @@ function buildAgentCard(agent, idx) { const selectedToolsList = document.createElement("div"); selectedToolsList.style.cssText = "display:flex;flex-wrap:wrap;gap:0.25rem;margin-bottom:0.35rem;min-height:1.5rem;"; + // main Agent 默认挂载工具(锁定:不能删除、无需手动添加;仅 role=main 生效) + const lockedTools = agent.role === "main" ? (state.mainDefaultTools || []) : []; + const LOCKED_STYLE = "display:inline-flex;align-items:center;gap:0.2rem;padding:0.15rem 0.4rem;background:rgba(16,185,129,0.14);border-radius:6px;font-size:0.78rem;"; + const NORMAL_STYLE = "display:inline-flex;align-items:center;gap:0.2rem;padding:0.15rem 0.4rem;background:rgba(79,70,229,0.12);border-radius:6px;font-size:0.78rem;"; + function renderSelectedTools() { selectedToolsList.innerHTML = ""; + // 锁定区:默认挂载(不在 agent.tools 中,代码注入) + lockedTools.forEach((tname) => { + const chip = document.createElement("span"); + chip.style.cssText = LOCKED_STYLE; + chip.textContent = tname + " \u{1F512}"; // 🔒 + chip.title = "main Agent 默认挂载,不可移除、无需手动添加"; + selectedToolsList.appendChild(chip); + }); + // 可选区:配置的工具(main 的可删,子 agent 保持原样) (agent.tools || []).forEach((tname) => { const chip = document.createElement("span"); - chip.style.cssText = "display:inline-flex;align-items:center;gap:0.2rem;padding:0.15rem 0.4rem;background:rgba(79,70,229,0.12);border-radius:6px;font-size:0.78rem;"; + chip.style.cssText = NORMAL_STYLE; chip.textContent = tname; const delBtn = document.createElement("span"); delBtn.textContent = "\u00D7"; @@ -236,7 +251,8 @@ function buildAgentCard(agent, idx) { emptyToolOpt.textContent = "\u9009\u62E9\u5DE5\u5177..."; toolSelect.appendChild(emptyToolOpt); state.registeredToolNames.forEach((tname) => { - if (!(agent.tools || []).includes(tname)) { + // main 的默认挂载工具已锁定存在,无需手动添加 + if (!(agent.tools || []).includes(tname) && !lockedTools.includes(tname)) { const opt = document.createElement("option"); opt.value = tname; opt.textContent = tname; @@ -998,6 +1014,20 @@ document.addEventListener("keydown", (e) => { if (e.key === "Escape" && settingsOverlay && !settingsOverlay.hidden) closeSettings(); }); +// 保存主题设置:外观页修改只更新内存态与预览,点击本按钮才写回服务端 +const saveThemeBtn = $("saveThemeBtn"); +if (saveThemeBtn) { + saveThemeBtn.addEventListener("click", async () => { + setStatus("正在保存主题设置...", ""); + try { + await saveThemeSettings(); + setStatus("主题设置已保存 ✓", "success"); + } catch (e) { + setStatus("主题设置保存失败: " + (e.message || String(e)), "error"); + } + }); +} + // 保存回调 document.querySelectorAll(".settings-save-btn").forEach((btn) => { btn.addEventListener("click", async () => { @@ -1064,6 +1094,85 @@ document.querySelectorAll(".settings-save-btn").forEach((btn) => { }); // ===== 主题/语言面板 ===== +// ===== 外观页图片工具 ===== +/** 过长 data URL 的展示占位:避免把数 MB 的 base64 塞进输入框导致卡顿 */ +const LONG_DATA_PLACEHOLDER = "[已保存图片 · 数据过长,不在此处完整显示]"; + +function displayImageSetting(value) { + if (typeof value === "string" && value.startsWith("data:") && value.length > 2048) { + return LONG_DATA_PLACEHOLDER; + } + return value || ""; +} + +/** 用户图片统一先上传到服务端落盘,配置只存 URL(替代 base64 大字段) */ +async function uploadUserImage(file) { + try { + const fd = new FormData(); + fd.append("file", file); + const res = await api("/api/config/user-image", { method: "POST", body: fd }); + return (res && res.url) || ""; + } catch (e) { + setStatus("图片上传失败: " + (e.message || String(e)), "error"); + return ""; + } +} + +// ===== 面板玻璃效果设置区(左侧栏/右侧栏/顶栏/终端栏/输入区:透明度 + 模糊度) ===== +function renderPanelGlassZone() { + const zone = $("panelGlassZone"); + if (!zone) return; + zone.innerHTML = ""; + + const headerRow = document.createElement("div"); + headerRow.className = "settings-row settings-row--block"; + headerRow.innerHTML = ` + <div class="row-text"> + <h4>面板玻璃效果</h4> + <p>左侧栏、右侧栏、顶栏与输入区的透明度和背景模糊度,修改即预览,点击「保存主题设置」后生效。</p> + </div>`; + zone.appendChild(headerRow); + + PANEL_ZONES.forEach((z) => { + const cur = getPanelGlass(z.id); + + // 透明度滑条 + const alphaRow = document.createElement("div"); + alphaRow.className = "settings-row"; + alphaRow.innerHTML = ` + <div class="row-text"><h4>${z.label} · 透明度</h4><p>${cur.alpha}%</p></div> + <div class="row-control"> + <input type="range" min="0" max="100" step="5" value="${cur.alpha}" class="compact-slider" /> + </div>`; + const alphaSlider = alphaRow.querySelector("input"); + const alphaVal = alphaRow.querySelector("p"); + zone.appendChild(alphaRow); + + // 模糊度滑条 + const blurRow = document.createElement("div"); + blurRow.className = "settings-row"; + blurRow.innerHTML = ` + <div class="row-text"><h4>${z.label} · 模糊度</h4><p>${cur.blur}px</p></div> + <div class="row-control"> + <input type="range" min="0" max="20" step="1" value="${cur.blur}" class="compact-slider" /> + </div>`; + const blurSlider = blurRow.querySelector("input"); + const blurVal = blurRow.querySelector("p"); + zone.appendChild(blurRow); + + const applyGlass = () => { + alphaVal.textContent = alphaSlider.value + "%"; + blurVal.textContent = blurSlider.value + "px"; + setPanelGlass(z.id, Number(alphaSlider.value), Number(blurSlider.value)); + }; + alphaSlider.oninput = applyGlass; + blurSlider.oninput = applyGlass; + // 拖动结束时精确保存一次(防抖持久化) + alphaSlider.onchange = applyGlass; + blurSlider.onchange = applyGlass; + }); +} + function renderThemePanel() { const grid = $("themeGrid"); const langSelect = $("langSelect"); @@ -1128,9 +1237,10 @@ function renderThemePanel() { const bgPickerBtn = $("bgImagePickerBtn"); const bgFileInput = $("bgImageFileInput"); if (bgInput) { - bgInput.value = getBgImage(); + bgInput.value = displayImageSetting(getBgImage()); const applyBg = () => { const url = bgInput.value.trim(); + if (url === LONG_DATA_PLACEHOLDER) return; // 展示占位时视为未修改 setBgImage(url); // 存储原始路径(可读),applyBgImage 内部自动转换 if (bgStatus) { bgStatus.textContent = url ? "背景已更新 ✓" : "已恢复默认背景"; @@ -1152,25 +1262,17 @@ function renderThemePanel() { bgPickerBtn.onclick = () => { bgFileInput.click(); }; - - // 文件选择事件 - bgFileInput.onchange = (e) => { + + // 文件选择事件:统一先上传落盘再存 URL(避免 base64 大字段写入主题配置) + bgFileInput.onchange = async (e) => { const file = e.target.files?.[0]; if (file) { - // 使用文件路径(Electron 环境)或创建临时 URL(Web 环境) - if (window.electronAPI && window.electronAPI.getFilePath) { - // Electron 环境:获取文件路径 - const filePath = window.electronAPI.getFilePath(file); - bgInput.value = filePath; + const url = await uploadUserImage(file); + if (url) { + bgInput.value = url; applyBg(); } else { - // Web 环境:创建临时 URL - const reader = new FileReader(); - reader.onload = (event) => { - bgInput.value = event.target?.result || ""; - applyBg(); - }; - reader.readAsDataURL(file); + setStatus("背景图上传失败,请更换图片后重试", "error"); } } // 清空 input 以便重复选择同一文件 @@ -1205,6 +1307,9 @@ function renderThemePanel() { }; } + // 面板玻璃效果(透明度 / 模糊度滑条) + renderPanelGlassZone(); + // 头像设置 const avatarInput = $("avatarInput"); const avatarApplyBtn = $("avatarApplyBtn"); @@ -1213,9 +1318,10 @@ function renderThemePanel() { const avatarPickerBtn = $("avatarPickerBtn"); const avatarFileInput = $("avatarFileInput"); if (avatarInput) { - avatarInput.value = getAvatar(); + avatarInput.value = displayImageSetting(getAvatar()); const applyAvatarSetting = () => { const url = avatarInput.value.trim(); + if (url === LONG_DATA_PLACEHOLDER) return; // 展示占位时视为未修改 setAvatar(url); if (avatarStatus) { avatarStatus.textContent = url ? "头像已更新 ✓" : "已恢复默认头像"; @@ -1233,19 +1339,16 @@ function renderThemePanel() { if (avatarPickerBtn && avatarFileInput) { avatarPickerBtn.onclick = () => avatarFileInput.click(); - avatarFileInput.onchange = (e) => { + // 统一先上传落盘再存 URL(避免 base64 大字段写入主题配置) + avatarFileInput.onchange = async (e) => { const file = e.target.files?.[0]; if (file) { - if (window.electronAPI && window.electronAPI.getFilePath) { - avatarInput.value = window.electronAPI.getFilePath(file) || ""; + const url = await uploadUserImage(file); + if (url) { + avatarInput.value = url; applyAvatarSetting(); } else { - const reader = new FileReader(); - reader.onload = (event) => { - avatarInput.value = event.target?.result || ""; - applyAvatarSetting(); - }; - reader.readAsDataURL(file); + setStatus("头像上传失败,请更换图片后重试", "error"); } } avatarFileInput.value = ""; diff --git a/src/web/js/state.js b/src/web/js/state.js index f4f342a..dc99b25 100644 --- a/src/web/js/state.js +++ b/src/web/js/state.js @@ -43,6 +43,12 @@ export const state = { /** 最近一次 renderMessages 的 JSON 可序列化快照 */ lastRenderedMessages: [], + /** + * 按会话的消息缓存(会话切换秒开 + ETag 后台校验): + * Map<sessionId, {messages: 浅拷贝快照, total, etag: string|null}> + * etag 为 null 表示内容可能已变化,需重新全量校验。 + */ + _msgCache: new Map(), activeAudioController: null, // 工具调用 / think 展开状态 @@ -55,6 +61,8 @@ export const state = { cachedEnvConfig: {}, cachedModels: [], registeredToolNames: [], + /** @type {string[]} main Agent 默认挂载工具(前端显示为锁定项,不可移除/无需添加) */ + mainDefaultTools: [], cachedGuiConfig: { monitors: [], selected_name: "", gui_model_id: "", models: [] }, editingModelIdx: -1, @@ -81,7 +89,7 @@ export const state = { // 输出模式(文字/语音,由 文本语音.svg 按钮开关控制) outputMode: "text", - // 思考模式档位:low | high | xhigh | max | ultra + // 思考模式档位:low | high | xhigh | max | ultra(前端呈现为 agent/plan/agent思考/plan思考/goal) thinkingLevel: "low", // 工作空间 diff --git a/src/web/js/terminal.js b/src/web/js/terminal.js index 95fdd46..8ce71df 100644 --- a/src/web/js/terminal.js +++ b/src/web/js/terminal.js @@ -1,81 +1,42 @@ -// terminal.js -- 内置 PowerShell 终端(原生管道,多标签) -// 主进程 spawn powershell.exe,stdin/stdout 经 IPC 桥接;前端自绘终端 UI。 +// terminal.js -- 内置 PowerShell 终端(xterm.js + ConPTY 真终端) +// 主进程经 node-pty(ConPTY) 启动真实 PowerShell 会话,stdin/stdout 经 IPC 桥接; +// 前端用 xterm.js 渲染(完整 ANSI / 光标 / 屏幕状态机)。 +// PSReadLine、Tab 补全、命令历史、全屏应用由真实控制台提供,前端不再模拟。 // 面板折叠不 kill(仅隐藏);标签 x 单个 kill;右上角 x 确认后 kill 全部。 -// 颜色全部映射主题 CSS 变量,随主题变化。 import { $, showToast } from './utils.js'; import { showConfirm } from './dialog.js'; import { state } from './state.js'; +import { api } from './api.js'; -/** 终端实例:{ id(主进程), title, cwd, pane, outEl, alive } */ +/** 终端实例:{ id(主进程), title, tab, pane, holder, term, fit, ro, alive } */ let terminals = []; let activeId = null; let _dockVisible = false; -// ---------- ANSI 解析(CSI SGR 颜色 → 主题色 class;其余控制序列忽略) ---------- -const ANSI_FG = { - '0': '', '30': 't-fg-muted', '31': 't-fg-danger', '32': 't-fg-success', - '33': 't-fg-warning', '34': 't-fg-accent', '35': 't-fg-purple', '36': 't-fg-cyan', - '37': '', '90': 't-fg-muted', '91': 't-fg-danger', '92': 't-fg-success', - '93': 't-fg-warning', '94': 't-fg-accent', '95': 't-fg-purple', '96': 't-fg-cyan', '97': '', -}; -const ANSI_BG = { - '40': 't-bg-dark', '41': 't-bg-danger', '42': 't-bg-success', '43': 't-bg-warning', - '44': 't-bg-accent', '47': '', -}; +// ===== 后台任务标签(agent terminal_execute 挂起任务的实时视图,与交互式终端区分) ===== +// 数据通道:/api/terminal/tasks/{session_id}/stream SSE(独立于 Electron IPC 的 xterm 终端); +// 状态更新 → 头部徽标;输出增量 → 输出区 append(按 seq 去重);终止 → POST kill。 +const taskTabs = new Map(); // taskId -> { id, tab, pane, out, statusEl, killBtn, lastSeq, truncated } +let _taskStreamEv = null; +let _taskStreamSid = ""; +const MAX_TASK_TABS = 8; -/** 把含 ANSI 序列的文本转为安全的 HTML 片段(SGR 颜色映射,其他 CSI 忽略) */ -export function ansiToHtml(text) { - let html = ''; - let i = 0; - let curCls = ''; - const n = text.length; - while (i < n) { - const ch = text[i]; - if (ch === '\x1b' && text[i + 1] === '[') { - // CSI: \x1b[ ... 最终字符(A-Z 或 @-~) - let j = i + 2; - while (j < n && !/[A-Za-z@-~]/.test(text[j])) j++; - if (j < n) { - const params = text.slice(i + 2, j); - const final = text[j]; - if (final === 'm') { - // SGR:最后一个参数决定样式 - const codes = params.split(';').filter(Boolean); - const last = codes[codes.length - 1] || '0'; - if (last === '0') { - curCls = ''; - } else if (ANSI_FG[last] !== undefined) { - curCls = ANSI_FG[last]; - } else if (ANSI_BG[last] !== undefined) { - curCls = ANSI_BG[last]; - } else { - // 亮度/其他:忽略 - } - } - // 其余 CSI(光标移动等)忽略 - i = j + 1; - continue; - } - i += 1; - continue; - } - if (ch === '\r') { - i += 1; - continue; // \r\n 中的 \r 忽略,单独 \r 不处理(简化) - } - if (ch === '\n') { - html += '</span>\n'; - curCls = ''; // 换行后颜色状态简化重置(避免跨行 span 混乱) - i += 1; - continue; - } - const esc = ch === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : ch; - html += curCls ? `<span class="${curCls}">${esc}</span>` : esc; - i += 1; - } - if (curCls) html += '</span>'; - return html; +// ---------- 主题 ---------- +function cssVar(name, fallback) { + const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim(); + return v || fallback; +} + +/** 主题 CSS 变量 → xterm theme(背景/前景/光标跟随全局主题) */ +function readTermTheme() { + return { + background: cssVar('--surface', '#1e1e23'), + foreground: cssVar('--text', '#e5e7eb'), + cursor: cssVar('--accent', '#007acc'), + cursorAccent: cssVar('--surface', '#1e1e23'), + selectionBackground: cssVar('--accent', 'rgba(0, 122, 204, 0.4)'), + }; } // ---------- 终端实例管理 ---------- @@ -99,55 +60,96 @@ function ensureElectron() { return true; } -/** 新建一个 PowerShell 终端标签 */ +/** 新建一个 PowerShell 终端标签(xterm 挂载后按真实尺寸启动 ConPTY 会话) */ export async function spawnTerminal() { if (!ensureElectron()) return null; + const cwd = currentCwd(); + + // 1) 构建标签 + 面板并临时激活(fit 需要容器有真实尺寸) + const tab = document.createElement('div'); + tab.className = 'term-tab'; + tab.innerHTML = `<span class="term-tab-title">PowerShell <span class="term-tab-ws">${escapeHtml(wsName())}</span></span>`; + const closeBtn = document.createElement('button'); + closeBtn.type = 'button'; + closeBtn.className = 'term-tab-close'; + closeBtn.title = '关闭此终端(终止进程)'; + closeBtn.textContent = '\u2715'; + tab.appendChild(closeBtn); + // 标签插到 + 号之前,保证 [标签…][+] 在左、[×] 在右的布局 + const tabsBar = $('terminalTabs'); + const addBtn = tabsBar.querySelector('.term-tab-add'); + if (addBtn) tabsBar.insertBefore(tab, addBtn); + else tabsBar.appendChild(tab); + + const pane = document.createElement('div'); + pane.className = 'term-pane active'; // 临时 active:fit 需要可见容器;spawn 成功后重新激活 + const holder = document.createElement('div'); + holder.className = 'term-holder'; + pane.appendChild(holder); + $('terminalBody').appendChild(pane); + + // 2) xterm 实例(UMD 全局:window.Terminal / window.FitAddon) + let term, fit; try { - const cwd = currentCwd(); - const { id, ok, error } = await window.electronAPI.spawnTerminal(cwd); - if (!ok) { - showToast('终端启动失败: ' + (error || '未知错误')); - return null; - } - // 渲染标签 + 输出面板 - const tab = document.createElement('div'); - tab.className = 'term-tab'; - tab.dataset.tid = String(id); - tab.innerHTML = `<span class="term-tab-title">PowerShell <span class="term-tab-ws">${escapeHtml(wsName())}</span></span>`; - const closeBtn = document.createElement('button'); - closeBtn.type = 'button'; - closeBtn.className = 'term-tab-close'; - closeBtn.title = '关闭此终端(终止进程)'; - closeBtn.textContent = '\u2715'; - closeBtn.addEventListener('click', (e) => { - e.stopPropagation(); - killTerminal(id); + term = new window.Terminal({ + cursorBlink: true, + fontFamily: 'Cascadia Mono, Consolas, "Courier New", monospace', + fontSize: 13, + scrollback: 5000, + theme: readTermTheme(), }); - tab.appendChild(closeBtn); - tab.addEventListener('click', () => activateTerminal(id)); - // 标签插到 + 号之前,保证 [标签…][+] 在左、[×] 在右的布局 - const tabsBar = $('terminalTabs'); - const addBtn = tabsBar.querySelector('.term-tab-add'); - if (addBtn) tabsBar.insertBefore(tab, addBtn); - else tabsBar.appendChild(tab); - - const pane = document.createElement('div'); - pane.className = 'term-pane'; - pane.dataset.tid = String(id); - const outEl = document.createElement('div'); - outEl.className = 'term-out'; - pane.appendChild(outEl); - $('terminalBody').appendChild(pane); - - terminals.push({ id, tab, pane, outEl, alive: true }); - activateTerminal(id); - // 首次 spawn 时输出欢迎提示 - appendOutput(id, `PowerShell 已启动${cwd ? `(工作区: ${cwd})` : ''}\r\n`); - return id; + fit = new window.FitAddon.FitAddon(); + term.loadAddon(fit); + term.open(holder); + if (holder.clientWidth > 20 && holder.clientHeight > 20) fit.fit(); } catch (e) { - showToast('终端启动失败: ' + (e.message || e)); + tab.remove(); + pane.remove(); + showToast('终端初始化失败: ' + (e.message || e)); return null; } + + // 3) 主进程 spawn(用 fit 后的真实行列数,避免提示符宽度不对) + const { id, ok, error } = await window.electronAPI.spawnTerminal(cwd, term.cols, term.rows) + .catch((e) => ({ ok: false, error: (e && e.message) || e })); + if (!ok) { + tab.remove(); + pane.remove(); + try { term.dispose(); } catch { /* ignore */ } + showToast('终端启动失败: ' + (error || '未知错误')); + return null; + } + + const t = { id, tab, pane, holder, term, fit, ro: null, alive: true }; + terminals.push(t); + + // 4) 事件接线 + closeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + killTerminal(id); + }); + tab.addEventListener('click', () => activateTerminal(id)); + // 键盘进 xterm 状态机 → 直接写 PTY(行编辑/补全交给 PSReadLine) + term.onData((data) => { + if (t.alive) window.electronAPI.sendTerminalInput(id, data); + }); + // 尺寸变化(fit 触发)→ 同步 ConPTY 行列数 + term.onResize(({ cols, rows }) => { + window.electronAPI.resizeTerminal(id, cols, rows); + }); + // 容器尺寸变化(窗口拉伸/面板展开)→ 重新 fit + try { + const ro = new ResizeObserver(() => { + if (holder.clientWidth < 20 || holder.clientHeight < 20) return; + try { t.fit.fit(); } catch { /* ignore */ } + }); + ro.observe(holder); + t.ro = ro; + } catch { /* ResizeObserver 不可用时忽略 */ } + + activateTerminal(id); + term.write(`\r\nPowerShell 已启动${cwd ? `(工作区: ${cwd})` : ''}\r\n`); + return id; } function getTerm(id) { @@ -158,41 +160,26 @@ function activateTerminal(id) { activeId = id; for (const t of terminals) { const isActive = t.id === id; - t.tab.classList.toggle('active', isActive); - t.pane.classList.toggle('active', isActive); + t.tab.classList.toggle("active", isActive); + t.pane.classList.toggle("active", isActive); + if (isActive) { + // 激活时补一次 fit(面板尺寸可能已变化),onResize 自动同步主进程 + try { + if (t.holder.clientWidth > 20 && t.holder.clientHeight > 20) t.fit.fit(); + } catch { /* ignore */ } + t.term.focus(); + } } - // 命令行(提示符+输入)挂载到活动面板输出末尾,观感等同真实终端的当前行 - const t = getTerm(id); - if (!_cmdlineEl) buildCmdline(); // 兜底:initTerminal 尚未执行时按需构建 - const cl = _cmdlineEl; - if (t && cl) { - t.outEl.appendChild(cl); - scrollPaneToBottom(t.pane); - } - const input = _cmdInput; - if (input) { - input.disabled = !t?.alive; - input.focus(); + // 交互终端激活时取消任务标签的活动态 + for (const tb of taskTabs.values()) { + tb.tab.classList.remove("active"); + tb.pane.classList.remove("active"); } } -/** 滚动容器是 .term-pane(非 .term-out),输出追加后保持跟随底部 */ -function scrollPaneToBottom(pane) { - if (pane) pane.scrollTop = pane.scrollHeight; -} - -function appendOutput(id, chunk) { - const t = getTerm(id); - if (!t) return; - const html = ansiToHtml(chunk); - const cl = _cmdlineEl; - if (cl && cl.parentNode === t.outEl) { - // 命令行保持在输出最后一行:新输出插在它之前 - cl.insertAdjacentHTML('beforebegin', html); - } else { - t.outEl.insertAdjacentHTML('beforeend', html); - } - scrollPaneToBottom(t.pane); +function disposeTerm(t) { + try { t.ro?.disconnect(); } catch { /* ignore */ } + try { t.term.dispose(); } catch { /* ignore */ } } /** 单个终端关闭(kill 进程并移除标签) */ @@ -200,6 +187,7 @@ export function killTerminal(id) { const t = getTerm(id); if (!t) return; t.alive = false; + disposeTerm(t); try { window.electronAPI?.killTerminal(id); } catch { /* ignore */ } t.tab.remove(); t.pane.remove(); @@ -208,7 +196,7 @@ export function killTerminal(id) { activeId = null; if (terminals.length > 0) activateTerminal(terminals[terminals.length - 1].id); } - if (terminals.length === 0) { + if (terminals.length === 0 && taskTabs.size === 0) { setDockVisible(false); // 全部关闭后隐藏面板 } } @@ -217,12 +205,14 @@ export function killTerminal(id) { export function killAllTerminals() { try { window.electronAPI?.killAllTerminals(); } catch { /* ignore */ } for (const t of terminals) { + t.alive = false; + disposeTerm(t); t.tab.remove(); t.pane.remove(); } terminals = []; activeId = null; - setDockVisible(false); + if (taskTabs.size === 0) setDockVisible(false); } /** 显示/折叠终端面板(不 kill,仅隐藏) */ @@ -231,8 +221,12 @@ export function setDockVisible(visible) { const dock = $('terminalDock'); if (!dock) return; dock.hidden = !_dockVisible; - if (_dockVisible && terminals.length > 0 && activeId == null) { - activateTerminal(terminals[terminals.length - 1].id); + // 重新展开时激活最后一个标签;ResizeObserver/fit 会恢复正确行列数 + if (_dockVisible) { + if (terminals.length > 0 && activeId == null) activateTerminal(terminals[terminals.length - 1].id); + // 展开时同步后台任务标签(确保新任务在展开瞬间即可见) + _ensureTaskStream(); + syncTaskTabs().catch(() => {}); } } @@ -246,83 +240,265 @@ export function toggleTerminal() { setDockVisible(true); if (terminals.length === 0) { spawnTerminal(); - } else if (activeId == null && terminals.length > 0) { + } else if (activeId == null) { activateTerminal(terminals[terminals.length - 1].id); } } -// ---------- 输入 ---------- -// 管道模式下 PowerShell 不回显提示符与命令,前端自行回显以还原真实终端观感 -const _cmdHistory = []; -let _histIdx = -1; // -1 = 未在浏览历史 -// 命令行元素引用:buildCmdline 构建后先处于游离态(未挂到 document), -// 不能用 getElementById 查找(查不到游离节点),必须用模块级引用持有 -let _cmdlineEl = null; -let _cmdInput = null; +// ===== 后台任务标签实现 ===== -function echoCommand(text) { - const t = getTerm(activeId); - if (!t) return; - const html = `<span class="t-fg-success">PS></span> ${escapeHtml(text)}\n`; - const cl = _cmdlineEl; - if (cl && cl.parentNode === t.outEl) cl.insertAdjacentHTML('beforebegin', html); - else t.outEl.insertAdjacentHTML('beforeend', html); - scrollPaneToBottom(t.pane); +function _taskStatusMeta(t) { + if (!t) return { label: "未知", cls: "done" }; + if (t.status === "running") return { label: "运行中", cls: "running" }; + if (t.status === "killed") return { label: "已终止", cls: "done" }; + if (t.abnormal || (t.exit_code != null && t.exit_code !== 0)) return { label: `异常退出 · exit ${t.exit_code}`, cls: "err" }; + return { label: "已完成", cls: "done" }; } -function sendInput() { - const input = _cmdInput; - const t = getTerm(activeId); - if (!t || !t.alive || !input) return; - const text = input.value; - input.value = ''; - _histIdx = -1; - if (!text) return; - if (_cmdHistory[_cmdHistory.length - 1] !== text) _cmdHistory.push(text); - if (_cmdHistory.length > 200) _cmdHistory.shift(); - echoCommand(text); - try { window.electronAPI.sendTerminalInput(activeId, text + '\r'); } catch { /* ignore */ } +function _currentTaskSid() { + // cron 子进程任务不在此服务注册表;仅 chat 会话支持任务标签 + if (state.mode === "cron") return ""; + return state.sessionId || ""; } -/** ↑/↓ 浏览命令历史 */ -function historyNav(dir) { - const input = _cmdInput; - if (!input || _cmdHistory.length === 0) return; - if (_histIdx === -1) _histIdx = _cmdHistory.length; - _histIdx += dir; - if (_histIdx < 0) _histIdx = 0; - if (_histIdx >= _cmdHistory.length) { _histIdx = -1; input.value = ''; return; } - input.value = _cmdHistory[_histIdx]; - input.setSelectionRange(input.value.length, input.value.length); -} +/** 打开/刷新指定任务标签(已存在则激活) */ +export function openTaskTab(task, { activate = true } = {}) { + const id = task?.id != null ? Number(task.id) : null; + if (id == null) return null; + const exist = taskTabs.get(id); + if (exist) { + _updateTaskTabMeta(exist, task); + if (activate) activateTaskTab(id); + return exist; + } + // 数量上限:超出淘汰最旧的已完成任务标签 + if (taskTabs.size >= MAX_TASK_TABS) { + for (const [tid, tb] of taskTabs) { + if (tb.task && tb.task.status !== "running") { closeTaskTab(tid); break; } + } + } -/** 构建命令行元素(提示符 + 输入框);由 activateTerminal 挂到活动面板输出末尾 */ -function buildCmdline() { - if (_cmdlineEl) return; - const row = document.createElement('div'); - row.className = 'terminal-input-row'; - row.id = 'terminalCmdline'; - const prompt = document.createElement('span'); - prompt.className = 'terminal-prompt'; - prompt.id = 'terminalPrompt'; - prompt.textContent = 'PS>'; - const input = document.createElement('input'); - input.type = 'text'; - input.className = 'terminal-input'; - input.id = 'terminalInput'; - input.autocomplete = 'off'; - input.spellcheck = false; - input.placeholder = '输入命令,Enter 执行'; - input.addEventListener('keydown', (e) => { - if (e.key === 'Enter') { e.preventDefault(); sendInput(); } - else if (e.key === 'ArrowUp') { e.preventDefault(); historyNav(-1); } - else if (e.key === 'ArrowDown') { e.preventDefault(); historyNav(1); } + const tab = document.createElement("div"); + tab.className = "term-tab term-tab--task"; + tab.title = `后台任务 #${id}`; + tab.innerHTML = `<img class="term-tab-icon" src="/image/终端.svg" alt="" /><span class="term-tab-title">任务#${id}</span>`; + const closeBtn = document.createElement("button"); + closeBtn.type = "button"; + closeBtn.className = "term-tab-close"; + closeBtn.title = "关闭此标签(不终止任务)"; + closeBtn.textContent = "\u2715"; + closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closeTaskTab(id); }); + tab.appendChild(closeBtn); + + const tabsBar = $("terminalTabs"); + const addBtn = tabsBar.querySelector(".term-tab-add"); + if (addBtn) tabsBar.insertBefore(tab, addBtn); + else tabsBar.appendChild(tab); + + const pane = document.createElement("div"); + pane.className = "term-task-pane"; + pane.innerHTML = ` + <div class="term-task-head"> + <span class="term-task-cmd"></span> + <span class="term-task-status"></span> + <button type="button" class="term-task-kill">终止</button> + </div> + <pre class="term-task-output"></pre> + `; + $("terminalBody").appendChild(pane); + + const tb = { + id, task: task || null, tab, pane, + cmdEl: pane.querySelector(".term-task-cmd"), + statusEl: pane.querySelector(".term-task-status"), + killBtn: pane.querySelector(".term-task-kill"), + out: pane.querySelector(".term-task-output"), + lastSeq: 0, + truncated: false, + nearBottom: true, + }; + tb.killBtn.addEventListener("click", async (e) => { + e.stopPropagation(); + await _killTaskById(id); }); - row.appendChild(prompt); - row.appendChild(input); - // 游离态持有引用;activateTerminal 首次挂载后才进入 document - _cmdlineEl = row; - _cmdInput = input; + taskTabs.set(id, tb); + _updateTaskTabMeta(tb, task); + if (activate) activateTaskTab(id); + return tb; +} + +function _updateTaskTabMeta(tb, task) { + if (!task) return; + tb.task = task; + const st = _taskStatusMeta(task); + const wasRunning = tb.statusEl.dataset.cls === "running"; + tb.cmdEl.textContent = String(task.command || "").slice(0, 80) || "(无命令)"; + tb.statusEl.textContent = st.label; + tb.statusEl.dataset.cls = st.cls; + tb.statusEl.className = "term-task-status" + (st.cls === "running" ? " term-task-status--running" : (st.cls === "err" ? " term-task-status--err" : "")); + tb.killBtn.hidden = task.status !== "running"; + tb.tab.classList.toggle("term-tab--dead", task.status !== "running"); + if (task.truncated && !tb.truncated) { + tb.truncated = true; + const warn = document.createElement("div"); + warn.className = "term-task-truncated"; + warn.textContent = `(较早输出已截断,仅保留最近 256KB)`; + tb.out.appendChild(warn); + } + // 状态从运行→结束:补一行结束信息(仅在已有输出末尾提示,不重复) + if (wasRunning && task.status !== "running") { + _appendOutputText(tb, `\n${st.label} · 退出码 ${task.exit_code == null ? "?" : task.exit_code}\n`); + } +} + +function _appendOutputText(tb, text, source) { + const atBottom = tb.out.scrollHeight - tb.out.scrollTop - tb.out.clientHeight < 40; + if (source === "err") { + const span = document.createElement("span"); + span.className = "term-task-err"; + span.textContent = text; + tb.out.appendChild(span); + } else { + tb.out.appendChild(document.createTextNode(text)); + } + if (atBottom) tb.out.scrollTop = tb.out.scrollHeight; +} + +function _appendTaskChunks(id, chunks) { + const tb = taskTabs.get(id); + if (!tb || !Array.isArray(chunks)) return; + for (const c of chunks) { + if (!c || c.seq == null || c.seq <= tb.lastSeq) continue; + tb.lastSeq = c.seq; + _appendOutputText(tb, String(c.text || ""), c.source === "err" ? "err" : "out"); + } +} + +function activateTaskTab(id) { + // 交互式 xterm 与任务标签共用 dock:激活任务标签时取消交互终端的活动态 + for (const t of terminals) { + t.tab.classList.remove("active"); + t.pane.classList.remove("active"); + } + activeId = null; + for (const [tid, tb] of taskTabs) { + const isActive = tid === Number(id); + tb.tab.classList.toggle("active", isActive); + tb.pane.classList.toggle("active", isActive); + if (isActive && tb.task && tb.task.status === "running") { + tb.out.scrollTop = tb.out.scrollHeight; + } + } +} + +export function closeTaskTab(id, { offDock = true } = {}) { + const tb = taskTabs.get(Number(id)); + if (!tb) return; + tb.tab.remove(); + tb.pane.remove(); + taskTabs.delete(Number(id)); + if (taskTabs.size === 0 && terminals.length === 0 && offDock) { + setDockVisible(false); + } +} + +export function closeAllTaskTabs() { + for (const id of [...taskTabs.keys()]) closeTaskTab(id, { offDock: false }); + if (terminals.length === 0) setDockVisible(false); +} + +async function _killTaskById(id) { + const tb = taskTabs.get(Number(id)); + const cmd = tb?.task?.command || ""; + const ok = await showConfirm(`确定终止后台终端任务吗?\n任务ID: #${id}\n命令: ${String(cmd).slice(0, 120)}`); + if (!ok) return; + const fd = new FormData(); + fd.append("session_id", state.sessionId || ""); + try { + const res = await api(`/api/terminal/tasks/${encodeURIComponent(id)}/kill`, { method: "POST", body: fd }); + if (res && res.ok) return; + showToast("终止失败: " + ((res && (res.error || res.detail)) || "未知错误")); + } catch (e) { + showToast("终止失败: " + (e.message || String(e))); + } +} + +/** 拉取全量任务列表并同步标签(dock 展开 / SSE full / 轮询兜底) */ +async function syncTaskTabs() { + const sid = _currentTaskSid(); + if (!sid) return; + try { + const data = await api(`/api/terminal/tasks/${encodeURIComponent(sid)}/live`); + const tasks = Array.isArray(data && data.tasks) ? data.tasks : []; + for (const t of tasks) { + openTaskTab(t, { activate: false }); + } + // 已从后端消失的"运行中"标签:标记停止并补输出尾部 + for (const [id, tb] of taskTabs) { + if (tb.task && tb.task.status === "running" && !tasks.find((t) => t.id === id)) { + _updateTaskTabMeta(tb, { ...tb.task, status: "killed", exit_code: null, abnormal: false }); + } + } + // 输出回放(重连/初次同步) + const outputs = Array.isArray(data && data.outputs) ? data.outputs : []; + for (const o of outputs) { + const tb = taskTabs.get(o.id); + if (!tb) continue; + const chunks = Array.isArray(o.chunks) ? o.chunks : []; + const last = chunks.length ? chunks[chunks.length - 1].seq : 0; + if (last < tb.lastSeq) continue; // 已有更新输出 + tb.out.innerHTML = ""; + tb.lastSeq = 0; + tb.truncated = false; + _appendTaskChunks(o.id, chunks); + } + } catch (e) { + showToast("同步后台任务失败: " + (e.message || String(e))); + } +} + +/** 任务 SSE 流:确保绑定当前会话;会话变化时重建并清空旧标签 */ +function _ensureTaskStream() { + const sid = _currentTaskSid(); + if (!sid) return; + if (_taskStreamSid === sid && _taskStreamEv) return; + _stopTaskStream(); + openTaskTabByIdsReset(); // 会话已变:清空旧标签(仅隐藏,不终止任务) + const es = new EventSource(`/api/terminal/tasks/${encodeURIComponent(sid)}/stream`); + es.addEventListener("update", (ev) => { + let payload = null; + try { payload = JSON.parse(ev.data); } catch { return; } + if (!payload) return; + if (payload.type === "full") { + _ensureTaskStreamCheckSid(); + syncTaskTabs().catch(() => {}); + } else if (payload.type === "task_update" && payload.task) { + _ensureTaskStreamCheckSid(); + const task = payload.task; + const tb = taskTabs.get(task.id); + if (tb) _updateTaskTabMeta(tb, task); + else if (_dockVisible) openTaskTab(task); + } else if (payload.type === "output") { + _ensureTaskStreamCheckSid(); + _appendTaskChunks(payload.task_id, payload.chunks); + } + }); + es.onerror = () => { /* EventSource 自动重连,重连后 full 快照重同步 */ }; + _taskStreamEv = es; + _taskStreamSid = sid; +} + +function _ensureTaskStreamCheckSid() { + if (_taskStreamSid !== _currentTaskSid()) _ensureTaskStream(); +} + +function openTaskTabByIdsReset() { + closeAllTaskTabs(); +} + +function _stopTaskStream() { + if (_taskStreamEv) { try { _taskStreamEv.close(); } catch {} _taskStreamEv = null; } + _taskStreamSid = ""; } // ---------- 事件绑定 ---------- @@ -337,15 +513,11 @@ export function initTerminal() { const termBtn = $('terminalBtn'); if (termBtn) termBtn.classList.remove('is-placeholder'); - // 命令行(提示符 + 输入)动态构建,挂到活动面板输出末尾,随内容滚动 - buildCmdline(); - - // 点击终端区域任意处直接键入(焦点落到命令行输入框) + // 点击终端区域任意处直接键入(焦点落到活动 xterm) const termBody = $('terminalBody'); if (termBody) { termBody.addEventListener('click', () => { - const input = _cmdInput; - if (input && !input.disabled) input.focus(); + getTerm(activeId)?.term.focus(); }); } @@ -365,23 +537,35 @@ export function initTerminal() { dockClose.title = '关闭全部终端(终止所有进程)'; dockClose.textContent = '\u2715'; dockClose.addEventListener('click', async () => { - const ok = await showConfirm('确定关闭全部终端吗?\n将终止所有 PowerShell 进程。'); - if (ok) killAllTerminals(); + const hasTask = taskTabs.size > 0; + const ok = await showConfirm(hasTask + ? '确定关闭全部终端吗?\n将终止所有 PowerShell 进程(后台任务仅隐藏标签,不会终止;可在通知条中终止)。' + : '确定关闭全部终端吗?\n将终止所有 PowerShell 进程。'); + if (ok) { killAllTerminals(); closeAllTaskTabs(); } }); $('terminalTabs').appendChild(dockClose); // 主进程数据推送 if (window.electronAPI?.onTerminalData) { - window.electronAPI.onTerminalData(({ id, chunk }) => appendOutput(id, chunk || '')); + window.electronAPI.onTerminalData(({ id, chunk }) => { + const t = getTerm(id); + if (t && chunk) t.term.write(chunk); + }); window.electronAPI.onTerminalExit(({ id, code }) => { const t = getTerm(id); if (!t) return; t.alive = false; t.tab.classList.add('term-tab--dead'); - appendOutput(id, `\r\n[进程已退出,code=${code == null ? '?' : code}](关闭标签可移除)\r\n`); - if (activeId === id && _cmdInput) _cmdInput.disabled = true; + try { t.ro?.disconnect(); } catch { /* ignore */ } + t.term.write(`\r\n\x1b[90m[进程已退出,code=${code == null ? '?' : code}](关闭标签可移除)\x1b[0m\r\n`); }); } + + // 主题切换(themes.js dispatch theme-changed)→ 更新所有 xterm 配色 + document.addEventListener('theme-changed', () => { + const theme = readTermTheme(); + for (const t of terminals) t.term.options.theme = theme; + }); } function escapeHtml(s) { diff --git a/src/web/js/themes.js b/src/web/js/themes.js index ffbc2c6..40de1fd 100644 --- a/src/web/js/themes.js +++ b/src/web/js/themes.js @@ -593,11 +593,28 @@ let _settings = { bg_blur: "", bg_vignette: "", avatar: "", + // 面板玻璃效果:{ sidebar: { alpha, blur }, ... };alpha=透明度%,blur=模糊 px,空串/缺省=使用 CSS 默认 + panel_glass: {}, }; let _loadingTheme = false; // 从服务端加载期间不写回,避免覆盖已有配置 let _persistTimer = null; -/** 防抖写回服务端 theme_config.json(仅保存用户主动修改,加载期间跳过) */ +/** theme_config.json 的写入载荷(头像/背景只存 URL,不再携带 base64 大字段) */ +function _themePayload() { + return { + theme: _settings.theme, + bg_image: _settings.bg_image, + font_family: _settings.font_family, + font_size: _settings.font_size, + bg_blur: _settings.bg_blur, + bg_vignette: _settings.bg_vignette, + avatar: _settings.avatar, + panel_glass: _settings.panel_glass, + }; +} + +/** 防抖写回服务端 theme_config.json(仅保存用户主动修改,加载期间跳过)。 + 仅由「保存主题设置」触发:外观页修改只更新内存态与 CSS 预览,不逐次回写。 */ function _persistThemeConfig() { if (_loadingTheme) return; if (_persistTimer) clearTimeout(_persistTimer); @@ -606,20 +623,29 @@ function _persistThemeConfig() { try { api("/api/config/theme", { method: "POST", - body: JSON.stringify({ - theme: _settings.theme, - bg_image: _settings.bg_image, - font_family: _settings.font_family, - font_size: _settings.font_size, - bg_blur: _settings.bg_blur, - bg_vignette: _settings.bg_vignette, - avatar: _settings.avatar, - }), + body: JSON.stringify(_themePayload()), }); } catch { /* ignore */ } }, 300); } +/** 立即保存主题设置(「设置 → 外观 → 保存主题设置」按钮触发,绕过防抖即时落盘) */ +export function saveThemeSettings() { + if (_loadingTheme) return; + if (_persistTimer) { + clearTimeout(_persistTimer); + _persistTimer = null; + } + try { + return api("/api/config/theme", { + method: "POST", + body: JSON.stringify(_themePayload()), + }); + } catch (e) { + return Promise.reject(e); + } +} + // 字体预设 export const FONT_PRESETS = { "system": { name: "系统默认", family: 'system-ui, -apple-system, "Segoe UI", "Microsoft YaHei", sans-serif' }, @@ -648,7 +674,6 @@ export function setFontSetting(key, val) { if (key === "family") _settings.font_family = val || ""; else if (key === "size") _settings.font_size = val || ""; applyFontSetting(key, val); - _persistThemeConfig(); } export function applyFontSetting(key, val) { const r = document.documentElement; @@ -673,7 +698,6 @@ export function getBgImage() { export function setBgImage(url) { _settings.bg_image = url || ""; applyBgImage(_settings.bg_image); - _persistThemeConfig(); } export function applyBgImage(url) { const r = document.documentElement; @@ -704,7 +728,6 @@ export function getAvatar() { export function setAvatar(val) { _settings.avatar = val || ""; applyAvatar(_settings.avatar); - _persistThemeConfig(); } /** 应用头像到左栏用户区:有头像显示图片(本地路径走 API 代理),无头像显示昵称首字 */ export function applyAvatar(url) { @@ -739,9 +762,8 @@ export function applyTheme(themeId) { Object.entries(theme.edit).forEach(([k, v]) => r.style.setProperty(k, v)); } - // 更新内存态并落盘(加载期间跳过,避免覆盖服务端已有配置) + // 更新内存态(落盘在「保存主题设置」时执行) _settings.theme = THEMES[themeId] ? themeId : DEFAULT_THEME; - _persistThemeConfig(); // 触发 body 类名切换(用于全局暗/亮模式判断) // 优先使用主题对象的 dark 字段;缺失时回退到 id 字符串推断 @@ -777,7 +799,6 @@ export function getBgBlur() { export function setBgBlur(val) { _settings.bg_blur = val || ""; applyBgBlur(_settings.bg_blur); - _persistThemeConfig(); } export function applyBgBlur(val) { // val 为空字符串时使用主题默认值(由 applyTheme 设置),否则覆盖 @@ -796,7 +817,6 @@ export function getBgVignette() { export function setBgVignette(val) { _settings.bg_vignette = val || ""; applyBgVignette(_settings.bg_vignette); - _persistThemeConfig(); } export function applyBgVignette(val) { const r = document.documentElement; @@ -807,6 +827,58 @@ export function applyBgVignette(val) { } } +// ===== 面板玻璃效果(左侧栏 / 右侧栏 / 顶栏 / 输入区) ===== +// alpha = 面板背景不透明度(%),blur = 背景模糊度(px,0 表示不模糊); +// CSS 侧默认值在 variables.css :root 中定义,未显式设置时不覆盖。 +// 注:终端栏不提供该设置——terminal 输出面板(.term-pane)有独立不透明背景, +// 与其背景模糊叠加后透明度不生效,故不在设置列表中列出。 +export const PANEL_ZONES = [ + { id: "sidebar", label: "左侧栏", alphaVar: "--sidebar-alpha", blurVar: "--sidebar-blur", defaultAlpha: 100, defaultBlur: 12 }, + { id: "right", label: "右侧栏", alphaVar: "--right-alpha", blurVar: "--right-blur", defaultAlpha: 100, defaultBlur: 12 }, + { id: "topbar", label: "顶栏", alphaVar: "--topbar-alpha", blurVar: "--topbar-blur", defaultAlpha: 100, defaultBlur: 0 }, + { id: "composer", label: "输入区", alphaVar: "--composer-alpha", blurVar: "--composer-blur", defaultAlpha: 32, defaultBlur: 12 }, +]; + +export function getPanelGlass(zoneId) { + const cfg = PANEL_ZONES.find((z) => z.id === zoneId); + if (!cfg) return { alpha: 100, blur: 0 }; + const saved = _settings.panel_glass?.[zoneId] || {}; + const num = (v, dflt) => { + const n = Number(v); + return Number.isFinite(n) && n >= 0 ? n : dflt; + }; + return { alpha: num(saved.alpha, cfg.defaultAlpha), blur: num(saved.blur, cfg.defaultBlur) }; +} + +/** 设置某个面板的透明度/模糊度并即时生效(alpha、blur 传数字或空串="恢复默认") */ +export function setPanelGlass(zoneId, alpha, blur) { + const cfg = PANEL_ZONES.find((z) => z.id === zoneId); + if (!cfg) return; + _settings.panel_glass = _settings.panel_glass || {}; + _settings.panel_glass[zoneId] = { + alpha: alpha === "" || alpha === null || alpha === undefined ? "" : alpha, + blur: blur === "" || blur === null || blur === undefined ? "" : blur, + }; + applyPanelGlass(zoneId, alpha, blur); +} + +/** 将面板玻璃参数写入 CSS 变量(空串=移除内联覆盖,回退 themes 默认) */ +export function applyPanelGlass(zoneId, alpha, blur) { + const cfg = PANEL_ZONES.find((z) => z.id === zoneId); + if (!cfg) return; + const r = document.documentElement; + if (alpha !== "" && alpha !== null && alpha !== undefined) { + r.style.setProperty(cfg.alphaVar, `${Number(alpha)}%`); + } else { + r.style.removeProperty(cfg.alphaVar); + } + if (blur !== "" && blur !== null && blur !== undefined) { + r.style.setProperty(cfg.blurVar, `${Number(blur)}px`); + } else { + r.style.removeProperty(cfg.blurVar); + } +} + /** 从服务端 theme_config.json 加载主题配置并应用(不使用 localStorage) */ export async function loadThemeConfig() { try { @@ -841,6 +913,13 @@ export async function loadThemeConfig() { _settings.avatar = res.avatar || ""; applyAvatar(_settings.avatar); } + if (res.panel_glass !== undefined) { + _settings.panel_glass = (res.panel_glass && typeof res.panel_glass === "object") ? res.panel_glass : {}; + PANEL_ZONES.forEach((z) => { + const saved = _settings.panel_glass[z.id] || {}; + applyPanelGlass(z.id, saved.alpha, saved.blur); + }); + } } catch { // 服务端不可达:保持默认设置 } finally { diff --git a/src/web/js/think-level.js b/src/web/js/think-level.js index 8e66fd2..af68d8b 100644 --- a/src/web/js/think-level.js +++ b/src/web/js/think-level.js @@ -1,11 +1,13 @@ -// think-level.js -- 思考模式档位选择(low / high / xhigh / max / ultra) +// think-level.js -- 思考模式选择(agent 模式 / plan 模式 / agent 思考 / plan 思考 / goal 模式) // 档位同时决定运行模式与深度思考: -// low=agent+不思考 high=plan+不思考 xhigh=agent+思考 max=plan+思考 -// ultra 为未来 react→审批图预留占位(暂按 agent+思考 处理) +// agent=agent+不思考 plan=plan+不思考 agent思考=agent+思考 plan思考=plan+思考 +// goal 为未来目标导向(react→审批图)预留占位,暂按 agent+思考 处理 // 会话级变量:持久化在各会话 session_meta.json 的 thinking_level // (POST /api/sessions/{id}/thinking-level),由 llm.Multimodel_LLM 每轮 // 按当前会话动态解析 extra_body,仅作用于该会话。 -// 交互通过 document 级事件委托实现(触发器开关 + 档位点击 + 滑条拖动)。 +// 交互通过 document 级事件委托实现(触发器开关 + 模式选项点击)。 +// 注:内部档位值沿用后端 wire 格式(low / high / xhigh / max / ultra), +// 仅前端呈现为五种语义模式;goal 仍以 ultra 存储,旧会话档位不受影响。 import { $, showToast, closeInlinePanelsExcept } from './utils.js'; import { api } from './api.js'; @@ -13,15 +15,24 @@ import { state } from './state.js'; export const THINK_LEVELS = ["low", "high", "xhigh", "max", "ultra"]; -const THINK_LEVEL_TIPS = { - low: "low:agent 模式,不开启深度思考", - high: "high:plan 模式,不开启深度思考", - xhigh: "xhigh:agent 模式 + 深度思考", - max: "max:plan 模式 + 深度思考", - ultra: "ultra:预留占位(未来 react→审批图)", +/** 模式展示名(触发器徽标 + 面板选项文本) */ +const THINK_MODE_LABELS = { + low: "agent 模式", + high: "plan 模式", + xhigh: "agent 思考", + max: "plan 思考", + ultra: "goal 模式", }; -/** 根据思考档位派生运行模式(low/xhigh/ultra→agent,high/max→plan) */ +const THINK_LEVEL_TIPS = { + low: "agent 模式:标准执行,不开启深度思考", + high: "plan 模式:先规划再执行,不开启深度思考", + xhigh: "agent 思考模式:标准执行 + 深度思考", + max: "plan 思考模式:先规划再执行 + 深度思考", + ultra: "goal 模式:目标导向(预留占位,暂按 agent + 深度思考 处理)", +}; + +/** 根据思考档位派生运行模式(agent/agent思考/goal→agent,plan/plan思考→plan) */ export function getAgentModeFromLevel(level) { const lv = THINK_LEVELS.includes(level) ? level : "low"; return lv === "high" || lv === "max" ? "plan" : "agent"; @@ -34,60 +45,52 @@ export function getThinkingEnabled(level) { } let _panelOpen = false; -let _dragging = false; let _committing = false; let _lastCommitAt = 0; let _lastTriggerAt = 0; -/** 初始化思考档位(bootstrap 后调用):state.thinkingLevel 已由会话数据同步 */ +/** 初始化思考模式(bootstrap 后调用):state.thinkingLevel 已由会话数据同步 */ export function initThinkLevel() { if (!THINK_LEVELS.includes(state.thinkingLevel)) { state.thinkingLevel = "low"; } - renderLabels(); + renderModeItems(); syncThinkLevelUI(); } -/** 渲染 5 档标签(横向布局:flex space-between,与滑条档位对齐) */ -function renderLabels() { - const container = $("thinkModeLabels"); +/** 渲染 5 个模式选项(纵向列表:agent / plan / agent思考 / plan思考 / goal) */ +function renderModeItems() { + const container = $("thinkModeItems"); if (!container) return; container.innerHTML = ""; THINK_LEVELS.forEach((lv) => { const item = document.createElement("button"); item.type = "button"; - item.className = "think-mode-label"; + item.className = "think-mode-item"; item.dataset.value = lv; - item.textContent = lv; + item.textContent = THINK_MODE_LABELS[lv] || lv; item.title = THINK_LEVEL_TIPS[lv] || lv; container.appendChild(item); }); } -/** 将滑条手柄/填充/标签选中态同步到 state.thinkingLevel(横向:左=low 右=ultra) */ +/** 将纵向选项选中态与触发器徽标同步到 state.thinkingLevel */ export function syncThinkLevelUI() { - const handle = $("thinkModeHandle"); - const fill = $("thinkModeFill"); - const labels = document.querySelectorAll(".think-mode-label"); + const items = document.querySelectorAll(".think-mode-item"); const trigger = $("thinkModeTrigger"); - if (!handle || !fill) return; - const idx = Math.max(0, THINK_LEVELS.indexOf(state.thinkingLevel)); - const pct = (idx / (THINK_LEVELS.length - 1)) * 100; - handle.style.left = `${pct}%`; - fill.style.width = `${pct}%`; - labels.forEach((el) => { - el.classList.toggle("is-active", el.dataset.value === state.thinkingLevel); + items.forEach((el) => { + el.classList.toggle("is-selected", el.dataset.value === state.thinkingLevel); }); if (trigger) { trigger.title = THINK_LEVEL_TIPS[state.thinkingLevel] || "思考模式"; trigger.dataset.value = state.thinkingLevel; - // 按钮右侧小字:当前思考等级 + // 按钮右侧小字:当前模式名称 const badge = trigger.querySelector(".think-mode-badge"); - if (badge) badge.textContent = state.thinkingLevel; + if (badge) badge.textContent = THINK_MODE_LABELS[state.thinkingLevel] || state.thinkingLevel; } } -/** 绑定事件:document 级委托(触发器开关 + 档位点击 + 滑条拖动) */ +/** 绑定事件:document 级委托(触发器开关 + 模式选项点击) */ export function bindThinkModeEvents() { document.addEventListener("click", (e) => { const wrap = $("thinkModeWrap"); @@ -110,9 +113,9 @@ export function bindThinkModeEvents() { return; } - // 档位标签点击:立即选择并提交 - if (target.closest && target.closest(".think-mode-label")) { - const lv = target.closest(".think-mode-label").dataset.value; + // 模式选项点击:立即选择并提交 + if (target.closest && target.closest("#thinkModePanel .think-mode-item")) { + const lv = target.closest("#thinkModePanel .think-mode-item").dataset.value; if (!THINK_LEVELS.includes(lv)) return; state.thinkingLevel = lv; syncThinkLevelUI(); @@ -125,32 +128,6 @@ export function bindThinkModeEvents() { closePanel(); } }); - - // 滑条:Pointer Events 拖动(含触摸),松手提交(横向坐标 clientX) - document.addEventListener("pointerdown", (e) => { - if (!e.target.closest || !e.target.closest("#thinkModeTrack")) return; - const track = $("thinkModeTrack"); - if (!track) return; - e.preventDefault(); - _dragging = true; - track.setPointerCapture(e.pointerId); - setLevelFromPointer(e.clientX); - }); - document.addEventListener("pointermove", (e) => { - if (!_dragging) return; - setLevelFromPointer(e.clientX); - }); - const endDrag = (e) => { - if (!_dragging) return; - _dragging = false; - const track = $("thinkModeTrack"); - if (track) { - try { track.releasePointerCapture(e.pointerId); } catch { /* ignore */ } - } - commitLevel(state.thinkingLevel); - }; - document.addEventListener("pointerup", endDrag); - document.addEventListener("pointercancel", endDrag); } function closePanel() { @@ -159,17 +136,6 @@ function closePanel() { _panelOpen = false; } -function setLevelFromPointer(clientX) { - const track = $("thinkModeTrack"); - if (!track) return; - const rect = track.getBoundingClientRect(); - // 横向滑条:左端=low(0%),右端=ultra(100%) - const pct = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); - const idx = Math.round(pct * (THINK_LEVELS.length - 1)); - state.thinkingLevel = THINK_LEVELS[idx]; - syncThinkLevelUI(); -} - /** 提交档位到当前会话(会话级变量,存于该会话 session_meta.json) */ async function commitLevel(level) { if (_committing) return; @@ -187,7 +153,7 @@ async function commitLevel(level) { if (data && data.level) { state.thinkingLevel = data.level; syncThinkLevelUI(); - showToast(`思考模式已切换为「${data.level}」(仅当前会话)`); + showToast(`思考模式已切换为「${THINK_MODE_LABELS[data.level] || data.level}」(仅当前会话)`); } } catch (e) { showToast(e.message || String(e)); diff --git a/src/web/js/todo.js b/src/web/js/todo.js deleted file mode 100644 index 7f1bcab..0000000 --- a/src/web/js/todo.js +++ /dev/null @@ -1,35 +0,0 @@ -// todo.js -- Todo List(通知条版) -// -// 原浮窗实现已迁移至 notify-strip.js: -// todo 数据从 live snapshot 的 tool_calls 派生(todo_list 工具调用), -// 以输入区上方通知条形式展示(最新步骤 + 计数 chip,可展开完整列表)。 -// 本文件保留导出签名以兼容既有依赖注入(setLiveUiDeps / clearTodoOverlay)。 - -import { - updateTodoStrip, - clearTodoStrip, - saveStripSession, -} from './notify-strip.js'; - -/** 从 live 工具调用记录派生 Todo 通知条数据。 - * @param {Array} toolCalls - live snapshot 的 tool_calls 数组 */ -function updateTodoOverlayFromRecords(toolCalls) { - updateTodoStrip(toolCalls); -} - -/** 清空 todo 通知条(仅 todo,不影响人机交互条)。 */ -function clearTodoOverlay() { - saveStripSession(); - clearTodoStrip(); -} - -/** 兼容旧接口:通知条模式下无需构建独立 overlay。 */ -function buildTodoListOverlay() { - return null; -} - -function updateTodoOverlay() { - // 通知条由 updateTodoStrip 驱动,此接口保留为空实现以兼容旧调用点 -} - -export { buildTodoListOverlay, updateTodoOverlay, updateTodoOverlayFromRecords, clearTodoOverlay }; diff --git a/src/web/js/toolcalls.js b/src/web/js/toolcalls.js index 9700040..3614819 100644 --- a/src/web/js/toolcalls.js +++ b/src/web/js/toolcalls.js @@ -9,9 +9,11 @@ const TOOL_META = { doc_tool: { icon: null, dynamicIcon: true }, // 按 action 选 写/读/删除.svg todo_list: { icon: '/image/todolist.svg' }, browser_control: { icon: '/image/控制.svg' }, - web_page: { icon: '/image/web.svg' }, + web_page: { icon: '/image/控制.svg' }, software_control: { icon: '/image/控制.svg' }, terminal_execute: { icon: '/image/终端.svg' }, + terminal_info: { icon: '/image/终端.svg' }, + wait: { icon: '/image/时钟.svg', noParam: true }, email: { icon: '/image/邮件.svg' }, image_gen: { icon: '/image/图片生成.svg' }, skill_router: { icon: '/image/skill.svg' }, @@ -43,6 +45,8 @@ const HUMAN_INTERACTION_LABEL = { information: '请求信息', selection: '选 const TOOL_DISPLAY_NAMES = { web_search: '联网搜索', terminal_execute: '终端执行', + terminal_info: '终端信息', + wait: '等待', image_gen: '图片生成', sendfile: '发送文件', skill_router: '技能加载', @@ -506,6 +510,45 @@ function renderUserFileCard(fileUrl, fileName, options = {}) { } /* ===== 横向滚动行辅助:滚轮转 scrollLeft + 溢出渐变遮罩 ===== */ + +// 溢出检测批处理:新建行时若立即读 scrollWidth/clientWidth 会在 DOM 脏态下 +// 强制整页同步布局(运行中每来一条工具记录就触发数次 → 持续卡顿)。 +// 统一收集到待检集合,每帧只做一次读(同一帧内的读取可复用同一次布局结果)。 +const _overflowPending = new Set(); +let _overflowRafScheduled = false; + +function _flushOverflowChecks() { + _overflowRafScheduled = false; + for (const el of _overflowPending) { + if (!el.isConnected) { + // 尚未入 DOM(构建与挂载跨帧):顺延到下一帧,最多重试若干次后放弃 + // (仅影响装饰性渐变遮罩,滚动时 update 仍会补上) + el._hcRetries = (el._hcRetries || 0) + 1; + if (el._hcRetries > 30) _overflowPending.delete(el); + continue; + } + _overflowPending.delete(el); + const overflow = el.scrollWidth > el.clientWidth + 2; + if (el._hcOverflow !== overflow) { + el._hcOverflow = overflow; + el.classList.toggle("user-media-row--overflow", overflow); + } + } + if (_overflowPending.size > 0 && !_overflowRafScheduled) { + _overflowRafScheduled = true; + requestAnimationFrame(_flushOverflowChecks); + } +} + +function scheduleOverflowCheck(el) { + if (!el) return; + _overflowPending.add(el); + if (!_overflowRafScheduled) { + _overflowRafScheduled = true; + requestAnimationFrame(_flushOverflowChecks); + } +} + function attachHorizontalScroll(el) { if (!el || el._hcBound) return; el._hcBound = true; @@ -516,12 +559,15 @@ function attachHorizontalScroll(el) { } }, { passive: false }); const update = () => { - el.classList.toggle("user-media-row--overflow", el.scrollWidth > el.clientWidth + 2); + const overflow = el.scrollWidth > el.clientWidth + 2; + if (el._hcOverflow !== overflow) { + el._hcOverflow = overflow; + el.classList.toggle("user-media-row--overflow", overflow); + } }; el.addEventListener("scroll", update, { passive: true }); - // 延迟一次以等待内容布局 - requestAnimationFrame(update); - setTimeout(update, 100); + // 初始溢出判定延迟到下一帧统一批处理(避免构建期强制同步布局) + scheduleOverflowCheck(el); } /* ===== 单条工具调用行渲染 ===== @@ -589,6 +635,8 @@ function renderToolCallRow(call, options = {}) { const row = document.createElement("div"); row.className = "tool-call-row" + (isRunning ? " tool-call-row--running" : " tool-call-row--done") + (defaultExpanded ? " tool-call-row--expanded" : ""); + // 工具调用 id(tool_call_id):live 补丁 diff 按 id 匹配复用旧 DOM + if (call.id) row.dataset.tcId = String(call.id); // 图标(运行中橙黄,结束绿色;点击展开/折叠) const iconPath = getToolIcon(call); @@ -643,8 +691,14 @@ function renderToolCallRow(call, options = {}) { icon.addEventListener("click", (e) => { e.stopPropagation(); const nowExpanded = row.classList.toggle("tool-call-row--expanded"); - // 展开后若详情在可视区外,滚动到可见位置(block:nearest 已可见时不滚动) - if (nowExpanded) detailEl.scrollIntoView({ block: "nearest" }); + if (nowExpanded) { + // 懒解码:展开后才加载 base64 大图(避免一次性同步解码卡顿) + _loadLazyImages(detailEl); + // 滚动延迟到下一帧执行,避免与图片解码同帧阻塞点击响应 + requestAnimationFrame(() => { + if (detailEl.isConnected) detailEl.scrollIntoView({ block: "nearest" }); + }); + } }); } } @@ -699,6 +753,8 @@ function renderToolCallRow(call, options = {}) { } // 图片结果(image_gen / 截图等) + // base64 大图(可达数百 KB~MB):构建时直接赋 src 会在展开瞬间同步解码 + // 全部图片导致主线程卡顿;改为懒解码——折叠时不加载,行展开时统一赋值。 const imageUrls = Array.isArray(call.result_images) ? call.result_images.filter(Boolean) : []; if (imageUrls.length > 0 && !isSubAgentCall) { const imgWrap = document.createElement("div"); @@ -706,14 +762,15 @@ function renderToolCallRow(call, options = {}) { imageUrls.forEach((url) => { const img = document.createElement("img"); img.className = "tc-detail-img"; - img.src = url; img.alt = "工具返回图片"; - img.loading = "lazy"; + img.setAttribute("data-lazy-src", url); img.addEventListener("click", (e) => { e.stopPropagation(); showImagePreview(url); }); imgWrap.appendChild(img); }); attachHorizontalScroll(imgWrap); detailEl.appendChild(imgWrap); + // 默认展开的行:构建后立即加载 + if (defaultExpanded) _loadLazyImages(imgWrap); } // sendfile 返回的文件/文件夹 → 优先用 result_file_info(正确文件名) @@ -835,9 +892,14 @@ function renderPersistentToolCalls(toolCalls, options = {}) { const toggleArrow = document.createElement("span"); toggleArrow.className = "tc-list-toggle-arrow"; const toggleText = document.createElement("span"); + toggleText.className = "tc-list-toggle-text"; const updateToggleText = (elapsedSec) => { const dur = isLive ? elapsedSec : totalDuration; - toggleText.textContent = `调用 ${totalCount} 个工具 · 用时 ${formatDuration(dur)}`; + // live 模式工具计数动态读取顶层行数(列表改为增量更新,不再整条重建) + const cnt = isLive + ? wrap.querySelectorAll(":scope > .tc-list-toggle-body > .tool-call-row").length + : totalCount; + toggleText.textContent = `调用 ${cnt} 个工具 · 用时 ${formatDuration(dur)}`; }; updateToggleText(isLive ? _calcLiveDuration(toolCalls, turnStartedAt) : totalDuration); toggle.appendChild(toggleArrow); @@ -914,6 +976,16 @@ function renderPersistentToolCalls(toolCalls, options = {}) { /* ===== Live 工具调用增量更新(运行中) ===== */ +/** 工具调用行签名缓存:按 call 对象身份复用(delta 合并时更新记录为新对象) */ +const _sigCache = new WeakMap(); + +/** djb2 字符串散列:单遍扫描、零中间分配,用于大文本的廉价变更检测 */ +function _djb2(s) { + let h = 5381; + for (let i = 0; i < s.length; i++) h = ((h << 5) + h + s.charCodeAt(i)) | 0; + return h; +} + /** 判断两行工具调用渲染是否完全相同(名称/运行状态/参数/详情内容)。 */ function _sameToolCallRow(oldRow, newRow) { const status = (r) => (r.classList.contains("tool-call-row--running") ? "running" : "done"); @@ -948,54 +1020,235 @@ function injectLiveToolCalls(typingEl, liveCalls, turnStartedAt, _reflections) { return; } - // 保存展开状态:记录哪些 tool-call-row 和 think-row 是展开的 - let expandedRows = new Set(); - let collapsedThinkRows = new Set(); - oldWrap.querySelectorAll(".tool-call-row--expanded").forEach((r) => { - const idx = Array.from(oldWrap.querySelectorAll(".tool-call-row")).indexOf(r); - if (idx >= 0) expandedRows.add(idx); - }); - oldWrap.querySelectorAll(".think-row").forEach((r, i) => { - if (!r.classList.contains("think-row--expanded")) collapsedThinkRows.add(i); - }); - if (oldWrap._stopTimer) oldWrap._stopTimer(); + // ===== 增量就地更新(不重建整个列表) ===== + // 快照高频到达时全量重建的成本随记录数线性增长:长列表(几十上百条工具 + // 调用)每帧重建会占满主线程(点击/滚动卡顿),且整块替换导致滚动跳变。 + // 改为按 id 就地复用/更新/追加/移除,仅变更行与新增行产生 DOM 操作。 + const body = oldWrap.querySelector(".tc-list-toggle-body"); + if (!body) return; - // 将 reflections 传给 renderPersistentToolCalls - const newWrap = renderPersistentToolCalls(liveCalls, { live: true, reflections: _reflections, turnStartedAt: turnStartedAt }); - if (!newWrap) return; + // 滚动保持:贴底跟随,否则保持原位置。 + // 位置读取延迟到首次实际变更前(captureScroll):快照无变化时直接跳过, + // 避免每次注入都读 scrollHeight 在脏 DOM 上强制同步布局。 + let savedTop = 0; + let wasAtBottom = false; + let scrollCaptured = false; + let mutated = false; + const captureScroll = () => { + if (scrollCaptured) return; + scrollCaptured = true; + savedTop = body.scrollTop; + wasAtBottom = body.scrollHeight - body.scrollTop - body.clientHeight < 24; + }; - // 行级 diff:未变化的行复用旧 DOM(保持阅读位置与展开态), - // 仅更新状态变化的行并追加新行,避免整个列表每次快照都重建 - const oldRows = Array.from(oldWrap.querySelectorAll(".tool-call-row")); - const newRows = Array.from(newWrap.querySelectorAll(".tool-call-row")); - newRows.forEach((newRow, i) => { - const oldRow = oldRows[i]; - if (oldRow && _sameToolCallRow(oldRow, newRow)) { - newRow.replaceWith(oldRow); + // 现有顶层行按 id 索引(嵌套子 Agent 行不参与) + const rowById = new Map(); + Array.from(body.children).forEach((el) => { + if (el.classList.contains("tool-call-row") && el.dataset.tcId) { + rowById.set(el.dataset.tcId, el); } }); - oldWrap.replaceWith(newWrap); + // 期望序列:思考/反思块穿插在工具调用之间(与 renderPersistentToolCalls 同逻辑) + const reflectionMap = new Map(); + (_reflections || []).forEach((r) => { + if (!r || !r.content) return; + const idx = (r.between_calls != null) ? r.between_calls : -1; + if (!reflectionMap.has(idx)) reflectionMap.set(idx, []); + reflectionMap.get(idx).push({ content: r.content, kind: r.kind === "reflection" ? "reflection" : "thought" }); + }); + const seq = []; + let thinkNo = 0; + liveCalls.forEach((call, ci) => { + (reflectionMap.get(ci) || []).forEach((item) => { + thinkNo++; + seq.push({ type: "think", content: item.content, kind: item.kind, index: thinkNo }); + }); + seq.push({ type: "row", call }); + }); + (reflectionMap.get(-1) || []).forEach((item) => { + thinkNo++; + seq.push({ type: "think", content: item.content, kind: item.kind, index: thinkNo }); + }); - // 恢复展开状态 - if (expandedRows.size > 0 || collapsedThinkRows.size > 0) { - const rows = newWrap.querySelectorAll(".tool-call-row"); - expandedRows.forEach((idx) => { - if (idx < rows.length) { - rows[idx].classList.add("tool-call-row--expanded"); + // 按序消费现有子元素:复用 → 原地更新 → 追加;未消费的(已消失的记录)移除。 + // detail 跟随其行:行被复用/插入时其后的 detail 一并标记为已消费。 + const children = Array.from(body.children); + const consumed = new Set(); + let cursor = 0; + + // 游标起第一个未消费元素的下标(跳过已消费的 detail/行),没有则指向末尾 + const anchorIdxFrom = (from) => { + let i = from; + while (i < children.length && consumed.has(children[i])) i++; + return i; + }; + + const markDetailConsumed = (row) => { + const next = row.nextElementSibling; + if (next && next.classList && next.classList.contains("tc-detail")) consumed.add(next); + }; + + // 行内容签名:仅状态/结果/思考/反思/子调用变化时才重建该行(避免每帧全量构建)。 + // 签名按 call 对象缓存(WeakMap):delta 合并时更新的记录是全新对象,同一对象 + // 的签名不会变化——避免每次注入(~12Hz)都重新扫描巨量 result_text 拼接字符串 + // (长工具输出下单次注入即产生 MB 级字符串分配,运行全程持续占满主线程)。 + // 大文本用长度+djb2 散列参与签名,消除分配。 + const rowSig = (call) => { + const cached = _sigCache.get(call); + if (cached !== undefined) return cached; + const rt = call.result_text || ""; + const subs = (call.sub_tool_calls || []).map((s) => s.id || "").join(","); + const sig = `${call.status}|${rt.length}:${_djb2(rt)}|${_djb2(String(call.thinking || ""))}|${_djb2(String(call.reflection || ""))}|${subs}`; + _sigCache.set(call, sig); + return sig; + }; + + seq.forEach((item) => { + if (item.type === "think") { + // 从游标起找内容匹配的未消费思考行 + let reuse = null; + for (let i = cursor; i < children.length; i++) { + const el = children[i]; + if (consumed.has(el) || !el.classList.contains("think-row")) continue; + const d = el.querySelector(".think-row-detail"); + if (d && d.textContent === item.content) { reuse = el; cursor = i + 1; break; } } - }); - // 恢复思考行折叠状态(live 重建时默认展开,需回设为折叠) - const thinkRows = newWrap.querySelectorAll(".think-row"); - thinkRows.forEach((row, i) => { - if (collapsedThinkRows.has(i)) { - row.classList.remove("think-row--expanded"); + if (!reuse) { + captureScroll(); + const ai = anchorIdxFrom(cursor); + reuse = renderThinkRow(item.content, { live: true, index: item.index, type: item.kind }); + body.insertBefore(reuse, children[ai] || null); + children.splice(ai, 0, reuse); + cursor = ai + 1; + mutated = true; + } else { + // 思考行编号就地修正(前方插入新思考时编号顺延) + const label = reuse.querySelector(".think-row-label"); + if (label) { + label.textContent = item.kind === "reflection" ? `Reflection #${item.index}` : `Thought #${item.index}`; + } } - }); + consumed.add(reuse); + } else { + const call = item.call; + const id = call.id ? String(call.id) : null; + let row = (id && rowById.get(id) && !consumed.has(rowById.get(id))) ? rowById.get(id) : null; + if (row) { + consumed.add(row); + const pos = children.indexOf(row); + if (pos < cursor) { + // 顺序变化(罕见):移动到游标处(跳过已消费元素) + captureScroll(); + const ai = anchorIdxFrom(cursor); + children.splice(pos, 1); + children.splice(ai - 1, 0, row); + body.insertBefore(row, children[ai] || null); + cursor = ai; + mutated = true; + } else { + cursor = pos + 1; + } + // 仅内容变化的行重建(状态/结果/思考/子调用),保留展开态 + if (row._sig !== rowSig(call)) { + captureScroll(); + mutated = true; + const pos2 = children.indexOf(row); + const oldDetail = (row.nextElementSibling && row.nextElementSibling.classList.contains("tc-detail")) + ? row.nextElementSibling : null; + const wasExpanded = row.classList.contains("tool-call-row--expanded"); + const built = renderToolCallRow(call, { defaultExpanded: false }); + built.row._sig = rowSig(call); + consumed.add(built.row); + if (built.detail) consumed.add(built.detail); + row.replaceWith(built.row); + if (oldDetail) oldDetail.remove(); + if (built.detail) body.insertBefore(built.detail, built.row.nextSibling); + if (wasExpanded && built.detail) { + built.row.classList.add("tool-call-row--expanded"); + _loadLazyImages(built.detail); + } + children.splice(pos2, 1); + if (oldDetail) { + const di = children.indexOf(oldDetail); + if (di >= 0) children.splice(di, 1); + } + children.splice(pos2, 0, built.row); + if (built.detail) children.splice(pos2 + 1, 0, built.detail); + cursor = pos2 + 1 + (built.detail ? 1 : 0); + if (id) rowById.set(id, built.row); + } else { + markDetailConsumed(row); + } + } else { + // 新增行:插入锚点处(含其 detail) + captureScroll(); + const ai = anchorIdxFrom(cursor); + const built = renderToolCallRow(call, { defaultExpanded: false }); + built.row._sig = rowSig(call); + body.insertBefore(built.row, children[ai] || null); + children.splice(ai, 0, built.row); + cursor = ai + 1; + mutated = true; + if (built.detail) { + body.insertBefore(built.detail, built.row.nextSibling); + children.splice(cursor, 0, built.detail); + cursor++; + consumed.add(built.detail); + } + consumed.add(built.row); + if (id) rowById.set(id, built.row); + } + } + }); + + // 移除未消费的(记录已消失的行/思考/孤立 detail) + children.forEach((el) => { + if (!consumed.has(el)) { captureScroll(); el.remove(); mutated = true; } + }); + + // 滚动保持(仅列表实际变化时恢复,避免每帧 3 次布局读取 + 写入) + if (mutated) { + if (wasAtBottom) { + body.scrollTop = body.scrollHeight; + } else { + body.scrollTop = savedTop; + } } - // 执行中默认展开,用户可折叠;完成后由 renderAssistantContent 控制默认折叠 - newWrap.classList.add("tool-call-list-wrap--expanded"); + // 折叠条计数与总计时由 renderPersistentToolCalls 的 1s 计时器就地刷新 + // (列表不再整条重建,这里不再每次注入重写文本,避免对聊天 DOM 高频触发布局) +} + +/* ===== 上下文压缩 toast 去重(模块级,跨轮次保留) ===== */ +let _shownSummarizerToasts = null; + +/* ===== 一次性实时事件 toast(如"LLM 超时正在重试"),按事件 id 去重 ===== */ +let _shownLiveEvents = null; + +function showLiveEvents(events) { + if (!Array.isArray(events)) return; + if (!_shownLiveEvents) _shownLiveEvents = new Set(); + for (const ev of events) { + if (!ev || !ev.text) continue; + const key = ev.id || ev.text; + if (_shownLiveEvents.has(key)) continue; + _shownLiveEvents.add(key); + if (_shownLiveEvents.size > 100) _shownLiveEvents.clear(); + showToast(ev.text); + } +} + +/* ===== 懒解码(展开时加载 base64 大图) ===== */ +function _loadLazyImages(root) { + if (!root) return; + root.querySelectorAll("img[data-lazy-src]").forEach((img) => { + const src = img.getAttribute("data-lazy-src"); + if (!src || img.src) return; + img.decoding = "async"; + img.src = src; + img.removeAttribute("data-lazy-src"); + }); } /* ===== 思考记录伪流式输出(介于工具调用之间) ===== */ @@ -1031,6 +1284,7 @@ function injectThinkStream(typingEl, thinkContent, options = {}) { const totalMs = Math.min(3000, Math.max(250, (n / charsPerSec) * 1000)); const t0 = performance.now(); let last = ""; + let lastPaintAt = -Infinity; const cancel = () => { if (thinkBlock._raf) { cancelAnimationFrame(thinkBlock._raf); thinkBlock._raf = null; } @@ -1042,8 +1296,11 @@ function injectThinkStream(typingEl, thinkContent, options = {}) { const p = Math.min(1, elapsed / totalMs); const k = Math.min(n, Math.floor(p * n)); const snapshot = full.slice(0, k); - if (snapshot !== last) { + // 限流 ~12fps:textContent 重写会触发布局,帧级更新在长思考文本下持续占用主线程; + // 间隔不足时仅推进进度不碰 DOM,结束帧补一次全量 + if (snapshot !== last && (p >= 1 || now - lastPaintAt >= 80)) { last = snapshot; + lastPaintAt = now; thinkBlock.textContent = snapshot; } if (p < 1) { @@ -1122,7 +1379,6 @@ export function setLiveUiDeps(deps) { function startPollLiveToolCalls(sessionId, typingEl, injectFn) { let active = true; - let lastReflectionCount = 0; let collectedReflections = []; let turnStartedAt = null; let eventSource = null; @@ -1131,21 +1387,82 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) { // rAF 合并:同一帧内多个快照只注入最新一次。 // 运行中快照高频到达(每次工具记录/结果/反思变更都推送), // 直接逐条重建工具列表会导致卡顿;合并后渲染频率上限 = 帧率。 + // 进一步限流:整表 diff + 滚动读取按 ~12.5Hz 上限执行,避免运行全程 + // 每帧 diff 持续占满主线程;内容未变的快照(如仅 human_requests 变化) + // 直接跳过注入,不触发任何 DOM/布局操作。 let _pendingInject = null; - let _injectRafId = 0; + let _injectRafId = 0; // 已调度标记:0=空闲,1=定时器顺延占位,其余=rAF id + let _injectTimerId = 0; // 顺延 setTimeout id(限流等待期间) + let _liveVersion = 0; // 记录/反思内容版本(handleSnapshot 变化时 +1) + let _lastInjectedVersion = 0; // 已注入的版本 + let _lastInjectAt = 0; // 上次注入完成时刻 + const _MIN_INJECT_INTERVAL_MS = 120; // 与后端 ~0.12s 推送防抖对齐,降低运行期 DOM diff 频率 + const scheduleInject = (liveCalls) => { - _pendingInject = { typingEl, injectFn, liveCalls, turnStartedAt, reflections: collectedReflections }; - if (_injectRafId) return; - _injectRafId = requestAnimationFrame(() => { + _pendingInject = { typingEl, injectFn, liveCalls, turnStartedAt, reflections: collectedReflections, version: _liveVersion }; + if (_injectRafId) return; // 已有待执行注入,等待中的快照已被最新值覆盖 + const run = () => { _injectRafId = 0; const p = _pendingInject; _pendingInject = null; - if (p && p.typingEl && p.injectFn && p.liveCalls && p.liveCalls.length) { - p.injectFn(p.typingEl, p.liveCalls, p.turnStartedAt, p.reflections); + if (!p || !p.typingEl || !p.injectFn || !p.liveCalls || !p.liveCalls.length) return; + // 内容无变化(仅人工请求等非工具字段变化)→ 跳过整表 diff + if (p.version === _lastInjectedVersion) return; + const wait = _MIN_INJECT_INTERVAL_MS - (performance.now() - _lastInjectAt); + if (wait > 0) { + _injectRafId = 1; // 占位:顺延期间新快照只更新 _pendingInject,不再调度 + _injectTimerId = setTimeout(run, wait); + return; + } + _lastInjectAt = performance.now(); + _lastInjectedVersion = p.version; + p.injectFn(p.typingEl, p.liveCalls, p.turnStartedAt, p.reflections); + }; + _injectRafId = requestAnimationFrame(run); + }; + + // ===== 本地合并的完整实时状态(增量推送 → 按 id 合并;全量推送 → 整体替换) ===== + let liveCalls = []; // 完整工具调用列表(与后端索引对齐,供增量渲染) + let liveReflectionCount = 0; // 服务端反思条目计数(增量对齐校验用) + + // 服务端反思条目 → 前端思考/反思块(条目可同时携带 thought + reflection) + const collectReflections = (serverReflections) => { + if (!Array.isArray(serverReflections) || !serverReflections.length) return; + const pos = liveCalls.length; + serverReflections.forEach((r) => { + if (!r) return; + if (r.thought) collectedReflections.push({ content: r.thought, between_calls: pos, kind: "thought" }); + if (r.reflection) collectedReflections.push({ content: r.reflection, between_calls: pos, kind: "reflection" }); + // 旧格式兼容:仅有 content 时按思考处理 + if (!r.thought && !r.reflection && r.content) { + collectedReflections.push({ content: r.content, between_calls: pos, kind: "thought" }); } }); }; + const summarizerToast = (calls) => { + for (const tc of calls) { + if (tc.name === "__summarizer__" || tc.name === "summarizer") { + if (!_shownSummarizerToasts) _shownSummarizerToasts = new Set(); + const key = tc.name + (tc.args ? JSON.stringify(tc.args) : ''); + if (!_shownSummarizerToasts.has(key)) { + _shownSummarizerToasts.add(key); + const action = (tc.args && tc.args.action) || "compress"; + showToast(`Agent 触发上下文压缩(${action})`); + } + } + } + }; + + // 增量合并校验失败(丢包/队列丢弃)时:拉一次全量状态重同步 + const resync = async () => { + if (!active) return; + try { + const full = await api(`/api/tool-calls/${encodeURIComponent(sessionId)}/live`); + handleSnapshot(full); + } catch { /* 忽略:下一条增量仍会继续尝试对齐 */ } + }; + const handleSnapshot = (liveData) => { if (!active || !liveData) return; if (liveData.started_at && !turnStartedAt) { @@ -1155,39 +1472,57 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) { if (liveUiDeps.updatePendingOverlay && Array.isArray(liveData.human_requests)) { liveUiDeps.updatePendingOverlay(liveData.human_requests); } - // Todo 浮窗:从 tool_calls 记录派生(展示类工具的通用 adapter) - if (liveUiDeps.updateTodoOverlayFromRecords && Array.isArray(liveData.tool_calls)) { - liveUiDeps.updateTodoOverlayFromRecords(liveData.tool_calls); - } - // 收集思考/反思内容(backend 条目带 thought/reflection 双字段,二者可同时存在) - if (liveData.reflections && liveData.reflections.length > lastReflectionCount) { - const newOnes = liveData.reflections.slice(lastReflectionCount); - const pos = liveData.tool_calls ? liveData.tool_calls.length : -1; - newOnes.forEach((r) => { - if (!r) return; - if (r.thought) collectedReflections.push({ content: r.thought, between_calls: pos, kind: "thought" }); - if (r.reflection) collectedReflections.push({ content: r.reflection, between_calls: pos, kind: "reflection" }); - // 旧格式兼容:仅有 content 时按思考处理 - if (!r.thought && !r.reflection && r.content) { - collectedReflections.push({ content: r.content, between_calls: pos, kind: "thought" }); - } - }); - lastReflectionCount = liveData.reflections.length; - } - // 处理工具调用(rAF 合并注入,高频快照不逐条重建) - if (typingEl && injectFn && liveData.tool_calls && liveData.tool_calls.length > 0) { - for (const tc of liveData.tool_calls) { - if (tc.name === "__summarizer__" || tc.name === "summarizer") { - if (!_shownSummarizerToasts) _shownSummarizerToasts = new Set(); - const key = tc.name + (tc.args ? JSON.stringify(tc.args) : ''); - if (!_shownSummarizerToasts.has(key)) { - _shownSummarizerToasts.add(key); - const action = (tc.args && tc.args.action) || "compress"; - showToast(`Agent 触发上下文压缩(${action})`); - } - } + + if (liveData.type === "delta") { + // ===== 增量:只含新增/变更记录与新增反思(体积 O(变化量)) ===== + if (Array.isArray(liveData.added)) { + liveData.added.forEach((call) => { + if (!call || !call.id) return; + // 按 id 去重追加(防御重复推送/全量后残留增量) + if (!liveCalls.some((c) => c && c.id === call.id)) liveCalls.push(call); + }); } - scheduleInject(liveData.tool_calls); + if (Array.isArray(liveData.updated)) { + liveData.updated.forEach(({ call }) => { + if (!call || !call.id) return; + const idx = liveCalls.findIndex((c) => c && c.id === call.id); + if (idx >= 0) liveCalls[idx] = call; + }); + } + collectReflections(liveData.reflections); + liveReflectionCount += Array.isArray(liveData.reflections) ? liveData.reflections.length : 0; + // 版本计数:记录/反思确实变化时才安排注入(仅人工请求变化的快照跳过整表 diff) + if ((Array.isArray(liveData.added) && liveData.added.length) || + (Array.isArray(liveData.updated) && liveData.updated.length) || + (Array.isArray(liveData.reflections) && liveData.reflections.length)) { + _liveVersion++; + } + // 仅当 todo_list 记录变化时才重扫全部记录派生 todo(避免每个快照都全量扫描) + const todoTouched = + (Array.isArray(liveData.added) && liveData.added.some((c) => c && c.name === "todo_list")) || + (Array.isArray(liveData.updated) && liveData.updated.some((u) => u && u.call && u.call.name === "todo_list")); + if (todoTouched && liveUiDeps.updateTodoOverlayFromRecords) liveUiDeps.updateTodoOverlayFromRecords(liveCalls); + summarizerToast(liveData.added || []); + showLiveEvents(liveData.events); + // 对齐校验:记录数/反思数与服务端不一致 → 全量重同步 + if ((liveData.total_calls != null && liveCalls.length !== liveData.total_calls) || + (liveData.total_reflections != null && liveReflectionCount !== liveData.total_reflections)) { + resync(); + return; + } + // 处理工具调用(rAF 合并注入,增量 DOM 更新) + if (typingEl && injectFn && liveCalls.length) scheduleInject(liveCalls); + } else { + // ===== 全量:连接初始快照 / 轮询回退 / 重同步 ===== + liveCalls = Array.isArray(liveData.tool_calls) ? liveData.tool_calls.slice() : []; + collectedReflections.length = 0; + liveReflectionCount = Array.isArray(liveData.reflections) ? liveData.reflections.length : 0; + collectReflections(liveData.reflections); + _liveVersion++; + if (liveUiDeps.updateTodoOverlayFromRecords) liveUiDeps.updateTodoOverlayFromRecords(liveCalls); + summarizerToast(liveCalls); + showLiveEvents(liveData.events); + if (typingEl && injectFn && liveCalls.length) scheduleInject(liveCalls); } }; @@ -1227,7 +1562,9 @@ function startPollLiveToolCalls(sessionId, typingEl, injectFn) { return () => { active = false; - if (_injectRafId) { cancelAnimationFrame(_injectRafId); _injectRafId = 0; } + if (_injectTimerId) { clearTimeout(_injectTimerId); _injectTimerId = 0; } + if (_injectRafId && _injectRafId !== 1) cancelAnimationFrame(_injectRafId); + _injectRafId = 0; _pendingInject = null; if (eventSource) { eventSource.close(); eventSource = null; } if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; } diff --git a/src/web/js/usage-stats.js b/src/web/js/usage-stats.js index c2b6d6e..4c71a57 100644 --- a/src/web/js/usage-stats.js +++ b/src/web/js/usage-stats.js @@ -10,6 +10,7 @@ const WEEKS = 26; // 182 / 7 let _rgb = null; // 缓存的 --accent RGB let _usage = null; // 最近一次 /api/stats/usage 数据 +let _usageLoadedAt = 0; // _usage 加载时间戳(TTL 缓存用) let _view = "matrix"; // matrix | bars let _selectedDate = ""; // 柱状图选中的日期 YYYY-MM-DD let _models = []; // 全局模型列表(按用量降序,用于柱内分色稳定) @@ -113,9 +114,14 @@ function _card() { } async function _loadUsage() { + // TTL 缓存:欢迎区每次显示(新建/切换会话、回到空会话)都会调用 refreshUsageCard, + // 短时间重复拉取 182 天统计并整卡重建没有必要;60s 内复用上次数据。 + const now = Date.now(); + if (_usage && now - (_usageLoadedAt || 0) < 60000) return; try { const data = await api("/api/stats/usage"); _usage = data || {}; + _usageLoadedAt = Date.now(); _models = Object.entries((data && data.by_model) || {}) .sort((a, b) => b[1] - a[1]) .map(([m]) => m); diff --git a/src/web/js/workspace.js b/src/web/js/workspace.js index b57c8db..bd925c2 100644 --- a/src/web/js/workspace.js +++ b/src/web/js/workspace.js @@ -2,7 +2,7 @@ // 工作空间列表统一从后端 workspace_config.json 读写,不再使用 localStorage import { $, showToast } from './utils.js'; -import { showConfirm } from './dialog.js'; +import { showConfirm, showPrompt } from './dialog.js'; import { api } from './api.js'; import { state } from './state.js'; @@ -247,14 +247,19 @@ export async function addWorkspace() { _addingWorkspace = true; try { let folderPath = ""; + let hasNativePicker = false; try { const { pickFolder } = await import('./electron-api.js'); folderPath = await pickFolder(); + hasNativePicker = true; } catch { // 非 Electron 环境或调用失败,回退到手动输入 } if (!folderPath) { - folderPath = prompt("请输入工作空间的绝对路径:", ""); + // Electron 原生选择器已取消:直接结束(Electron 不支持浏览器 prompt,取消后不再弹输入框) + if (hasNativePicker) return; + // Web 环境回退:用应用内输入弹窗替代原生 prompt + folderPath = await showPrompt("请输入工作空间的绝对路径:", ""); } if (!folderPath) return; folderPath = folderPath.trim(); diff --git a/src/web/packages/package.json b/src/web/packages/package.json index b421c31..decc5c0 100644 --- a/src/web/packages/package.json +++ b/src/web/packages/package.json @@ -11,6 +11,8 @@ "license": "ISC", "type": "commonjs", "dependencies": { + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", "monaco-editor": "^0.45.0" } } diff --git a/src/web/server.py b/src/web/server.py index c057e54..eae4697 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -14,9 +14,11 @@ import json import mimetypes import os import queue as _queue_module +import re import shutil import sys import tempfile +import urllib.parse import uuid from contextlib import asynccontextmanager from pathlib import Path @@ -24,7 +26,8 @@ from typing import Any from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile from fastapi.middleware.cors import CORSMiddleware -from fastapi.responses import FileResponse, StreamingResponse +from fastapi.middleware.gzip import GZipMiddleware +from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse _SRC = Path(__file__).resolve().parent.parent.parent if str(_SRC) not in sys.path: sys.path.insert(0, str(_SRC)) @@ -181,6 +184,27 @@ def _ensure_session_in_list(session_id: str, ids: list[str]) -> list[str]: return ids +def _guess_mime(fp: str) -> str: + """按扩展名猜测 MIME(mimetypes 优先,图片/音频回退映射表)。""" + mime, _ = mimetypes.guess_type(fp) + if mime: + return mime + ext = os.path.splitext(fp)[1].lower() + if ext in IMAGE_FILE_EXTENSIONS: + return {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", + ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", + ".ico": "image/x-icon", ".svg": "image/svg+xml"}.get(ext, "image/jpeg") + if ext in _AUDIO_EXT: + return { + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".flac": "audio/flac", + ".ogg": "audio/ogg", + }.get(ext, "application/octet-stream") + return "application/octet-stream" + + def _data_url_for_file(fp: str) -> str | None: """将本地文件读入为 data URL,供前端直接展示(不单独提供文件下载路由)。 @@ -191,31 +215,14 @@ def _data_url_for_file(fp: str) -> str | None: data URL 字符串,失败返回 None。 系统定位: - _serialize_content 将附件转为内联 data URL 以嵌入 API 响应。 + _serialize_content 中无法相对化到会话目录的附件(如 cron 记录附件)回退内联。 可扩展性: 可增加缓存、大小限制、权限检查。 """ if not fp or not os.path.isfile(fp): return None - mime, _ = mimetypes.guess_type(fp) - if not mime: - ext = os.path.splitext(fp)[1].lower() - if ext in IMAGE_FILE_EXTENSIONS: - mime = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", - ".gif": "image/gif", ".webp": "image/webp", ".bmp": "image/bmp", - ".ico": "image/x-icon", - ".svg": "image/svg+xml"}.get(ext, "image/jpeg") - elif ext in _AUDIO_EXT: - mime = { - ".wav": "audio/wav", - ".mp3": "audio/mpeg", - ".m4a": "audio/mp4", - ".flac": "audio/flac", - ".ogg": "audio/ogg", - }.get(ext, "application/octet-stream") - else: - mime = "application/octet-stream" + mime = _guess_mime(fp) try: with open(fp, "rb") as f: raw = f.read() @@ -225,22 +232,81 @@ def _data_url_for_file(fp: str) -> str | None: return f"data:{mime};base64,{b64}" -def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role: str | None = None) -> Any: - """将一条消息中的 content 转为 JSON 友好结构;文件以内联 data URL 输出。 +def _media_url_for_part(fp: str, session_id: str) -> str | None: + """附件相对化 URL:附件已被重写进会话目录时输出 /api/media 安全 URL。 + + 输入: + fp: 附件绝对路径。 + session_id: 所属会话 ID(为空或路径不在会话目录内时返回 None → data URL 回退)。 + + 输出: + /api/media?session=...&file=... URL;无法相对化时返回 None。 + """ + if not session_id or not fp: + return None + try: + from agent.utils.agent_utils import SESSIONS_ROOT + except Exception: + return None + session_dir = os.path.normpath(os.path.join(SESSIONS_ROOT, session_id)) + norm = os.path.normpath(fp) + try: + within = os.path.commonpath([norm, session_dir]) == session_dir + except ValueError: # 不同盘符等 + within = False + if not within or norm == session_dir: + return None + rel = os.path.relpath(norm, session_dir).replace("\\", "/") + return f"/api/media?session={urllib.parse.quote(session_id)}&file={urllib.parse.quote(rel)}" + + +def _resolve_media_path(path_arg: str) -> Path | None: + """解析并校验工具媒体路径:resolved 路径必须位于会话根或 cron 根之下。 + + 历史实现为 `Path(path).resolve()` 后直接返回,等于任意文件读取; + 改为根目录守卫(与 ui_session 附件加载的 commonpath 模式一致)。 + + 输入: + path_arg: 请求传入的文件路径(工具记录持久化的绝对路径)。 + + 输出: + 位于根目录内的文件 Path;越界 / 不存在返回 None。 + """ + try: + from agent.utils.agent_utils import SESSIONS_ROOT + from agent.core.storage import CRON_ROOT + except Exception: + return None + try: + p = Path(path_arg).resolve() + except Exception: + return None + if not p.is_file(): + return None + for root in (SESSIONS_ROOT, CRON_ROOT): + try: + if os.path.commonpath([str(p), os.path.normpath(str(Path(root).resolve()))]) == os.path.normpath(str(Path(root).resolve())): + return p + except ValueError: # 不同盘符等 + continue + return None + + +def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role: str | None = None, session_id: str = "") -> Any: + """将一条消息中的 content 转为 JSON 友好结构;附件输出 /api/media URL。 输入: content: 原始 content(str、list 或 dict)。 meta: 消息的 meta 信息(如音频时长、自动播放等)。 role: 消息角色(user/assistant),用于控制自动播放行为。 + session_id: 会话 ID;附件可相对化到会话目录时输出 URL, + 否则回退内联 data URL(如 cron 记录附件在 cron 目录下)。 输出: 序列化后的 content(str 或 list of dict)。 系统定位: _serialize_messages 中逐条转换,供前端直接渲染。 - - 可扩展性: - 可支持更多文件类型(如视频)的 data URL 输出。 """ meta = meta or {} if isinstance(content, str): @@ -266,26 +332,27 @@ def _serialize_content(content: Any, *, meta: dict[str, Any] | None = None, role fp = fp or piece.get("audio_path") if not fp or not os.path.isfile(fp): continue - data_url = _data_url_for_file(fp) - if not data_url: + url = _media_url_for_part(fp, session_id) + if url is None: + url = _data_url_for_file(fp) + if not url: continue ext = os.path.splitext(fp)[1].lower() if ext in IMAGE_FILE_EXTENSIONS: - parts.append({"type": "image", "url": data_url, "path": fp, "name": display_name or os.path.basename(fp)}) + parts.append({"type": "image", "url": url, "name": display_name or os.path.basename(fp)}) elif is_typed_audio and ext in _AUDIO_EXT: # 仅助手 TTS 语音回复(piece.type == "audio")保留 audio part 与 duration; # 用户上传的音频文件(无 type 或 type != "audio")按普通文件展示。 audio_meta = meta.get("audio") if isinstance(meta.get("audio"), dict) else {} parts.append({ "type": "audio", - "url": data_url, - "path": fp, + "url": url, "name": display_name or os.path.basename(fp), "autoplay": bool(meta.get("autoplay") or audio_meta.get("autoplay")) if role == "assistant" else False, "duration_seconds": audio_meta.get("duration_seconds"), }) else: - parts.append({"type": "file", "url": data_url, "path": fp, "name": display_name or os.path.basename(fp)}) + parts.append({"type": "file", "url": url, "name": display_name or os.path.basename(fp)}) return parts @@ -320,7 +387,7 @@ def _serialize_messages(hist: list[dict[str, Any]] | None, session_id: str = "") meta = dict(m.get("meta") or {}) meta.setdefault("output_type", "text" if role != "assistant" else meta.get("output_type") or "text") - serialized_content = _serialize_content(content, meta=meta, role=role) + serialized_content = _serialize_content(content, meta=meta, role=role, session_id=session_id) out.append({"role": role, "content": serialized_content, "meta": meta}) @@ -486,15 +553,29 @@ async def lifespan(app): get_scheduler().start() except Exception as e: print(f"[lifespan] 启动 CronScheduler 失败: {e}") + try: + # 服务启动清理:杀掉上次崩溃/异常退出遗留的终端进程树(资源边界) + from agent.tools.terminal_tasks import startup_cleanup + startup_cleanup() + except Exception: + pass yield try: from agent.cron.scheduler import get_scheduler get_scheduler().shutdown() except Exception: pass + try: + # 服务退出:杀全部遗留的后台/前台进程树(资源边界兜底) + from agent.tools.terminal_tasks import kill_all_running + kill_all_running() + except Exception: + pass app = FastAPI(title="minor web chat", version="1.0.0", lifespan=lifespan) +# GZip:历史消息响应含 base64 内联附件(几 MB 级),压缩后传输+解析均更快 +app.add_middleware(GZipMiddleware, minimum_size=1024, compresslevel=5) app.add_middleware( CORSMiddleware, allow_origins=["*"], @@ -715,25 +796,64 @@ def api_session_branch( } +def _messages_etag(session_id: str) -> str | None: + """会话消息轻量指纹(turn 文件数 + 最大 mtime):列目录 stat,不读内容。 + + 输入: + session_id: 会话 ID。 + + 输出: + 弱 ETag(W/"...");会话目录不存在时返回 None。 + """ + try: + from agent.utils.agent_utils import SESSIONS_ROOT + except Exception: + return None + session_dir = os.path.join(SESSIONS_ROOT, session_id) + if not os.path.isdir(session_dir): + return None + count = 0 + max_mtime = 0 + try: + for name in os.listdir(session_dir): + if not name.startswith("turn_"): + continue + count += 1 + try: + mt = os.path.getmtime(os.path.join(session_dir, name)) + if mt > max_mtime: + max_mtime = mt + except OSError: + pass + except OSError: + return None + if count == 0: + return None + return f'W/"{session_id}_{count}_{int(max_mtime * 1000)}"' + + @app.get("/api/sessions/{session_id}/messages") -def api_session_messages(session_id: str) -> dict[str, Any]: +def api_session_messages(session_id: str, request: Request): """切换会话:加载指定会话的消息列表与全局会话列表。 输入: session_id: 路径参数,目标会话 ID。 + request: 请求对象(If-None-Match 条件请求,与前端的会话缓存配合)。 输出: {"sessionId": str, "sessions": list[str], "messages": list[dict]} - - 系统定位: - 前端切换会话时调用。 + ETag 命中时返回 304(消息未变,前端直接用缓存)。 """ if not session_id: raise HTTPException(status_code=400, detail="缺少 session_id") + etag = _messages_etag(session_id) + inm = request.headers.get("If-None-Match") if request else None + if etag and inm and (inm.strip() == etag or inm.strip().replace("W/", "") == etag.replace("W/", "")): + return Response(status_code=304, headers={"ETag": etag}) hist = ui.load_ui_messages_for_session(session_id) ids = _ensure_session_in_list(session_id, ui.list_session_ids_ordered()) from agent.history.tool_call_recorder import get_session_tokens - return { + payload = { "sessionId": session_id, "sessions": ids, "session_workspaces": _session_workspaces_map(ids), @@ -741,6 +861,9 @@ def api_session_messages(session_id: str) -> dict[str, Any]: "tokens": get_session_tokens(session_id), "thinking_level": _session_thinking_level(session_id), } + if etag: + return JSONResponse(payload, headers={"ETag": etag}) + return payload @app.post("/api/sessions/clear") @@ -769,14 +892,20 @@ def api_clear_session(session_id: str = Form(...)) -> dict[str, Any]: @app.post("/api/sessions/delete") -def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]: +def api_delete_session( + session_id: str = Form(...), + current_session_id: str = Form(""), +) -> dict[str, Any]: """删除整个会话(含文件与历史),并回退到默认会话。 输入: session_id: 表单字段,会话 ID。 + current_session_id: 前端当前查看的会话 ID(可选)。仅当被删会话等于 + 当前会话(或未传该字段,兼容旧调用)时才返回回退会话的完整消息历史; + 否则仅返回轻量会话列表,省去全量历史 + base64 附件的序列化开销。 输出: - {"sessionId": str, "sessions": list[str], "messages": list[dict]} + {"sessionId": str, "sessions": list[str], "messages": list[dict] | None} 系统定位: 前端“删除会话”按钮调用。 @@ -817,15 +946,27 @@ def api_delete_session(session_id: str = Form(...)) -> dict[str, Any]: pass threading.Thread(target=_delayed_sweep, daemon=True).start() - sid, ids, hist = _bootstrap() - from agent.history.tool_call_recorder import get_session_tokens + # 仅当删除的是前端当前会话时,前端需要回退会话的消息/令牌/思考档位; + # 否则只需更新会话 ID 列表(hist 置 None,前端不消费) + deleted_active = (not current_session_id) or current_session_id == session_id + if deleted_active: + sid, ids, hist = _bootstrap() + from agent.history.tool_call_recorder import get_session_tokens + tokens = get_session_tokens(sid) + thinking_level = _session_thinking_level(sid) + else: + ids = ui.list_session_ids_ordered() + sid = ids[0] if ids else "" + hist = None + tokens = None + thinking_level = None return { "sessionId": sid, "sessions": ids, "session_workspaces": _session_workspaces_map(ids), "messages": hist, - "tokens": get_session_tokens(sid), - "thinking_level": _session_thinking_level(sid), + "tokens": tokens, + "thinking_level": thinking_level, } @@ -959,7 +1100,12 @@ def _chat_start_core(session_id: str, query: dict[str, Any]) -> dict[str, Any]: return { "sessionId": session_id, "sessions": ids, - "messages": _serialize_messages(new_hist, session_id), + "session_workspaces": _session_workspaces_map(ids), + # 增量契约:只返回本次新增的用户消息(canonical 版本,含最终附件 URL), + # 前端据此替换乐观渲染的占位消息 + "new_message": _serialize_messages(new_hist[-1:], session_id)[0] if new_hist else None, + "base_count": before, + "total": len(new_hist), } @@ -989,6 +1135,7 @@ def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode: detail="没有待完成的用户消息,请先调用 /api/chat/start", ) normalized_mode = agent_mode if agent_mode in {"agent", "plan"} else "agent" + before = len(hist) turn_runner.start_turn(session_id, [*hist], normalized_mode) result = turn_runner.wait_turn(session_id) if result is None or "error" in result: @@ -999,7 +1146,12 @@ def _chat_complete_core(session_id: str, output_type: str = "text", agent_mode: return { "sessionId": session_id, "sessions": ids, - "messages": _serialize_messages(new_hist, session_id), + "session_workspaces": _session_workspaces_map(ids), + # 增量契约:仅返回本轮新增消息(通常为 1 条 assistant), + # 前端据此追加渲染,不再全量传输/重解析历史 + "new_messages": _serialize_messages(new_hist[before:], session_id), + "base_count": before, + "total": len(new_hist), } @@ -1083,6 +1235,7 @@ def _stream_chat_complete_core(session_id: str, output_type: str = "text", agent return normalized_mode = agent_mode if agent_mode in {"agent", "plan"} else "agent" + before = len(hist) turn_runner.start_turn(session_id, [*hist], normalized_mode) result = turn_runner.wait_turn(session_id) if result is None or "error" in result: @@ -1092,18 +1245,26 @@ def _stream_chat_complete_core(session_id: str, output_type: str = "text", agent ui.prune_memory_session_ids() ids = _ensure_session_in_list(session_id, ui.list_session_ids_ordered()) - # 提取最终 assistant 消息文本 + # 提取最终 assistant 消息文本(仅序列化增量,避免整表重解析) final_text = "" - messages = _serialize_messages(new_hist, session_id) - for m in reversed(messages): + delta = _serialize_messages(new_hist[before:], session_id) + for m in reversed(delta): if m.get("role") == "assistant": final_text = m.get("content", "") if isinstance(final_text, list): final_text = "".join(p.get("text", "") for p in final_text if isinstance(p, dict) and p.get("type") == "text") break - # 先发送文本消息(含完整的 messages、sessions) - yield f"data: {json.dumps({'type': 'text', 'text': str(final_text), 'messages': messages, 'sessionId': session_id, 'sessions': ids}, ensure_ascii=False)}\n\n" + # 先发送文本消息(增量契约:仅本轮新增消息 + 计数,前端据此追加渲染) + yield f"data: {json.dumps({ + 'type': 'text', + 'text': str(final_text), + 'new_messages': delta, + 'base_count': before, + 'total': len(new_hist), + 'sessionId': session_id, + 'sessions': ids, + }, ensure_ascii=False)}\n\n" # 语音模式:流式 TTS if output_type == "voice" and final_text.strip(): @@ -1199,15 +1360,17 @@ def api_chat_abort(session_id: str = Form(...)) -> dict[str, Any]: # 若不在此处补写,下方 _serialize_messages 里 get_turn_meta 读到的是 # 增量持久化的半成品记录(只有 started_at、无 duration),前端回退按 # 工具时间戳推算总耗时,而单条计时已移除 → 本轮用时显示为 0。 - # execute_agent 随后的重复调用是安全的:live 记录已被 end_live_turn - # 弹出,save_aborted_turn 对空记录为 no-op,不会覆盖这里的完整 meta。 + # 注意:这里只补写 meta,不能调用 end_live_turn —— graph 仍在后台执行, + # 在途的工具调用会通过 record_tool_call_live 重建 _live_records; + # 若在此弹出记录,execute_agent 收尾时的 save_aborted_turn 会把完整 + # 工具记录覆盖成仅剩尾部几条(暂停后只保存了一轮工具调用)。 + # 记录弹出与轮次清理由 execute_agent 的 save_aborted_turn + end_live_turn 完成。 try: from agent.utils.agent_utils import get_last_turn_id - from agent.history.tool_call_recorder import save_aborted_turn, end_live_turn + from agent.history.tool_call_recorder import save_aborted_turn live_turn_id = get_last_turn_id(session_id) if live_turn_id: save_aborted_turn(session_id, live_turn_id) - end_live_turn(session_id) except Exception: pass # 不 rollback,保留当前轮已完成的工具调用记录,追加暂停消息结束本轮 @@ -1331,9 +1494,10 @@ def api_human_action( def api_get_agent_configs() -> dict[str, Any]: """获取所有 agent 配置列表。 - 输出: {"agents": list[dict], "config_path": str} + 输出: {"agents": list[dict], "main_default_tools": list[str], + "config_path": str} """ - from agent.core.config_manager import load_models + from agent.core.config_manager import load_models, MAIN_DEFAULT_TOOLS configs = load_agent_configs() models = {m["id"]: m for m in load_models()} return { @@ -1343,6 +1507,7 @@ def api_get_agent_configs() -> dict[str, Any]: "enabled": c.enabled, "role": c.role, "trajectory_rounds": c.trajectory_rounds} for c in configs], + "main_default_tools": list(MAIN_DEFAULT_TOOLS), "models": list(models.values()), "config_path": str(AGENT_CONFIG_PATH), } @@ -1928,6 +2093,7 @@ async def api_stream_live_tool_calls(session_id: str, request: Request): from agent.history.tool_call_recorder import ( subscribe_live, unsubscribe_live, get_live_tool_calls, get_live_reflections, get_turn_started_at, + _build_live_snapshot, ) from agent.core.human_request import get_human_requests_for_session @@ -1935,13 +2101,9 @@ async def api_stream_live_tool_calls(session_id: str, request: Request): async def event_stream(): try: - # 发送初始快照(连接时可能已有数据) - initial = { - "tool_calls": get_live_tool_calls(session_id), - "reflections": get_live_reflections(session_id), - "started_at": get_turn_started_at(session_id), - "human_requests": get_human_requests_for_session(session_id), - } + # 连接建立时推送一次完整快照(含 type=full 标记), + # 之后的队列消息均为增量 delta(只含新增/变更记录与新增反思) + initial = _build_live_snapshot(session_id) yield f"event: update\ndata: {json.dumps(initial, ensure_ascii=False)}\n\n" while True: @@ -1949,8 +2111,8 @@ async def api_stream_live_tool_calls(session_id: str, request: Request): break try: # 在线程中等待队列,超时后发送心跳 - snapshot = await asyncio.to_thread(q.get, True, 3.0) - yield f"event: update\ndata: {json.dumps(snapshot, ensure_ascii=False)}\n\n" + delta = await asyncio.to_thread(q.get, True, 3.0) + yield f"event: update\ndata: {json.dumps(delta, ensure_ascii=False)}\n\n" except _queue_module.Empty: # 心跳保活 yield f"event: ping\ndata: \n\n" @@ -1968,6 +2130,86 @@ async def api_stream_live_tool_calls(session_id: str, request: Request): ) +# ---------- 终端后台任务 API ---------- +@app.get("/api/terminal/tasks/{session_id}/live") +def api_get_terminal_tasks(session_id: str) -> dict[str, Any]: + """获取某会话的后台终端任务列表(轮询兜底用,SSE 断连时回退)。""" + try: + from agent.tools.terminal_tasks import live_snapshot + return live_snapshot(session_id) + except Exception as e: + return {"type": "full", "tasks": [], "outputs": [], "error": str(e)} + + +@app.get("/api/terminal/tasks/{session_id}/stream") +async def api_stream_terminal_tasks(session_id: str, request: Request): + """SSE 流式推送终端任务更新(任务新增/状态变化/输出增量 + 心跳)。 + + 数据源:terminal_tasks 注册表的 pub/sub(与工具调用 SSE 独立通道)。 + """ + from agent.tools.terminal_tasks import subscribe_tasks, unsubscribe_tasks, live_snapshot + + q = subscribe_tasks(session_id) + + async def event_stream(): + try: + initial = live_snapshot(session_id) + yield f"event: update\ndata: {json.dumps(initial, ensure_ascii=False)}\n\n" + while True: + if await request.is_disconnected(): + break + try: + payload = await asyncio.to_thread(q.get, True, 3.0) + yield f"event: update\ndata: {json.dumps(payload, ensure_ascii=False)}\n\n" + except _queue_module.Empty: + yield f"event: ping\ndata: \n\n" + finally: + unsubscribe_tasks(session_id, q) + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "X-Accel-Buffering": "no", + "Connection": "keep-alive", + }, + ) + + +@app.post("/api/terminal/tasks/{task_id}/kill") +def api_kill_terminal_task(task_id: int, session_id: str = Form("")) -> dict[str, Any]: + """终止一个后台终端任务(杀整个进程树)。""" + try: + from agent.tools.terminal_tasks import kill_task + except Exception as e: + return {"ok": False, "error": str(e)} + ok = kill_task(int(task_id), session_id=session_id or "") + if not ok: + raise HTTPException(status_code=404, detail="任务不存在或不属于该会话") + return {"ok": True, "taskId": int(task_id), "status": "killed"} + + +@app.post("/api/terminal/tasks/{session_id}/close") +def api_close_terminal_tasks(session_id: str) -> dict[str, Any]: + """终止某会话的全部运行中终端任务(应用退出确认后调用)。""" + try: + from agent.tools.terminal_tasks import kill_all_session + return {"ok": True, "killed": kill_all_session(session_id)} + except Exception as e: + return {"ok": False, "error": str(e)} + + +@app.get("/api/terminal/running-count") +def api_get_terminal_running_count() -> dict[str, Any]: + """全局运行中的后台终端任务数(Electron 退出确认用)。""" + try: + from agent.tools.terminal_tasks import get_running_count + return {"running": get_running_count()} + except Exception as e: + return {"running": 0, "error": str(e)} + + @app.get("/api/tool-image") def api_get_tool_image(path: str, session: str = "") -> FileResponse: """提供工具调用历史中的图片文件。 @@ -1979,14 +2221,10 @@ def api_get_tool_image(path: str, session: str = "") -> FileResponse: 输出: 图片文件响应。 """ - try: - img_path = Path(path).resolve() - except Exception: - raise HTTPException(status_code=400, detail="无效的图片路径") - if not img_path.is_file(): + img_path = _resolve_media_path(path) + if img_path is None: raise HTTPException(status_code=404, detail="工具调用图片不存在") - mime, _ = mimetypes.guess_type(str(img_path)) - return FileResponse(img_path, media_type=mime or "image/png") + return FileResponse(img_path, media_type=_guess_mime(str(img_path)) or "image/png", headers={"Cache-Control": "public, max-age=86400"}) @app.get("/api/tool-file") @@ -1994,30 +2232,91 @@ def api_get_tool_file(path: str, session: str = "") -> FileResponse: """提供工具调用历史中的非图片文件(下载或播放)。 输入: - path: 文件路径(可为 turn 目录下的落盘文件或原始文件路径)。 + path: 文件路径(turn 目录下的落盘文件或原始文件路径)。 session: 兼容参数。 输出: 带 Content-Disposition 的文件响应,浏览器可下载或播放。 """ - try: - file_path = Path(path).resolve() - except Exception: - raise HTTPException(status_code=400, detail="无效的文件路径") - if not file_path.is_file(): + file_path = _resolve_media_path(path) + if file_path is None: raise HTTPException(status_code=404, detail="文件不存在") - mime, _ = mimetypes.guess_type(str(file_path)) - media_type = mime or "application/octet-stream" + media_type = _guess_mime(str(file_path)) # 图片类 inline 展示,其余触发下载 if media_type.startswith("image/") or media_type.startswith("audio/") or media_type.startswith("video/"): - return FileResponse(file_path, media_type=media_type) + return FileResponse(file_path, media_type=media_type, headers={"Cache-Control": "public, max-age=86400"}) return FileResponse( file_path, media_type=media_type, - headers={"Content-Disposition": f'attachment; filename="{file_path.name}"'}, + headers={ + "Cache-Control": "public, max-age=86400", + "Content-Disposition": f'attachment; filename="{file_path.name}"', + }, ) +# ---------- 会话附件媒体路由 ---------- +_SESSION_ID_RE = re.compile(r"^\d{8}_\d{6}(_\d+)?$") + + +@app.get("/api/media") +def api_get_media(session: str = "", file: str = "", request: Request = None) -> Response: + """提供会话附件文件(图片/音频/文件,由序列化结果中的 /api/media URL 加载)。 + + 与 /api/tool-image 的区别:接收会话 ID + 相对路径(附件序列化时已重写进 + 会话根目录),带根目录守卫且不泄露服务器绝对路径。 + + 输入: + session: 会话 ID(时间戳格式)。 + file: 相对会话目录的路径(禁止绝对路径与 .. 穿越)。 + request: 请求对象(If-None-Match 条件请求,返回 304)。 + + 输出: + 文件响应:媒体类 inline 展示,其余 attachment 下载;带 ETag/Cache-Control, + 条件请求命中时返回 304(Starlette 1.x FileResponse 不内置 304,手动处理)。 + """ + if not session or not file: + raise HTTPException(status_code=400, detail="缺少 session 或 file 参数") + if not _SESSION_ID_RE.match(session): + raise HTTPException(status_code=400, detail="非法的会话 ID") + if os.path.isabs(file): + raise HTTPException(status_code=400, detail="file 必须是相对路径") + # 会话元数据文件(turn/tool 记录/session_meta)不属附件,禁止直接读取 + if os.path.basename(file.replace("\\", "/")).startswith(("turn_", "tool_", "session_")): + raise HTTPException(status_code=400, detail="非法的文件路径") + try: + from agent.utils.agent_utils import SESSIONS_ROOT + except Exception: + raise HTTPException(status_code=404, detail="媒体文件不存在") + session_dir = os.path.normpath(os.path.join(SESSIONS_ROOT, session)) + abs_file = os.path.normpath(os.path.join(session_dir, file.replace("/", os.sep))) + try: + within = os.path.commonpath([abs_file, session_dir]) == session_dir + except ValueError: # 不同盘符等 + within = False + if not within: + raise HTTPException(status_code=400, detail="非法的文件路径") + if not os.path.isfile(abs_file): + raise HTTPException(status_code=404, detail="媒体文件不存在") + cache_headers = {"Cache-Control": "public, max-age=86400"} + # 附件经去重命名后不可变:以 stat 指纹做弱 ETag,条件请求命中直接 304 + try: + st = os.stat(abs_file) + etag = 'W/"%s-%s"' % (int(st.st_mtime_ns), st.st_size) + except OSError: + etag = "" + if etag: + cache_headers["ETag"] = etag + inm = (request.headers.get("If-None-Match") or "") if request else "" + if inm and (inm == etag or inm == etag.replace("W/", "")): + return Response(status_code=304, headers=cache_headers) + media_type = _guess_mime(abs_file) + if media_type.startswith("image/") or media_type.startswith("audio/") or media_type.startswith("video/"): + return FileResponse(abs_file, media_type=media_type, headers=cache_headers) + cache_headers["Content-Disposition"] = f'attachment; filename="{os.path.basename(file)}"' + return FileResponse(abs_file, media_type=media_type, headers=cache_headers) + + # ---------- Skill 管理 API ---------- @app.get("/api/skills") def api_list_skills() -> dict[str, Any]: @@ -2257,6 +2556,33 @@ async def save_theme_config(request: Request): return {"ok": True} +@app.post("/api/config/user-image") +async def save_user_image(file: UploadFile = File(...)) -> dict: + """上传用户自定义图片(头像 / 背景图),保存到 web/image/ 并返回可访问 URL。 + + 背景:此前头像/背景图以 base64 写入 theme_config.json,主题配置可达数 MB, + 每次设置改动都要序列化并整体回写(卡顿来源)。改为上传落盘、配置只存 URL。 + + 输入: + multipart/form-data 的 file 字段(jpg/jpeg/png/webp/gif)。 + 输出: + {"url": "/image/uimg_<时间戳>.<ext>"}。 + """ + import time as _time + if not file or not file.filename: + raise HTTPException(status_code=400, detail="缺少文件") + ext = Path(file.filename).suffix.lower() + if ext not in (".jpg", ".jpeg", ".png", ".webp", ".gif"): + raise HTTPException(status_code=400, detail=f"不支持的图片格式: {ext}") + name = f"uimg_{int(_time.time() * 1000)}{ext}" + target = _IMAGE_DIR / name # 与 /image/{asset_name} 单段路由兼容 + data = await file.read() + if not data: + raise HTTPException(status_code=400, detail="文件为空") + target.write_bytes(data) + return {"url": f"/image/{name}"} + + @app.get("/api/bg-image") def serve_local_bg_image(path: str = "") -> FileResponse: """提供本地任意路径的图片文件(用于自定义背景图)。 @@ -2386,7 +2712,10 @@ _NO_CACHE = {"Cache-Control": "no-cache"} @app.get("/") def index_page() -> FileResponse: """返回前端主页面 index.html。""" - return FileResponse(_WEB_DIR / "index.html", headers=_NO_CACHE) + index = _WEB_DIR / "index.html" + if not index.is_file(): + raise HTTPException(status_code=404, detail="缺少 index.html") + return FileResponse(index, headers=_NO_CACHE) # ==================== 定时任务(Cron)API ==================== @@ -2409,7 +2738,8 @@ def _serialize_cron_messages(task_id: str) -> list[dict[str, Any]]: content = m.get("content") meta = dict(m.get("meta") or {}) meta.setdefault("output_type", "text") - serialized_content = _serialize_content(content, meta=meta, role=role) + # cron 附件在 cron 目录(与 sessions 隔离),无 session_id → 回退内联 data URL + serialized_content = _serialize_content(content, meta=meta, role=role, session_id="") out.append({"role": role, "content": serialized_content, "meta": meta}) # 为每条带 turn_id 的 assistant 消息挂载工具调用记录(统一存储,后端无关) @@ -2794,25 +3124,45 @@ async def api_cron_internal_finished(request: Request) -> dict[str, Any]: @app.get("/app.css") def serve_css() -> FileResponse: """返回样式文件 app.css。""" - return FileResponse(_WEB_DIR / "app.css", headers=_NO_CACHE) + css_file = _WEB_DIR / "app.css" + if not css_file.is_file(): + raise HTTPException(status_code=404, detail="缺少 app.css") + return FileResponse(css_file, headers=_NO_CACHE) @app.get("/app.js") def serve_js() -> FileResponse: """返回前端脚本 app.js。""" - return FileResponse(_WEB_DIR / "app.js", headers=_NO_CACHE) + js_file = _WEB_DIR / "app.js" + if not js_file.is_file(): + raise HTTPException(status_code=404, detail="缺少 app.js") + return FileResponse(js_file, headers=_NO_CACHE) @app.get("/js/{filename:path}") def serve_js_module(filename: str) -> FileResponse: - """返回前端 JS 模块文件。""" - return FileResponse(_WEB_DIR / "js" / filename, headers=_NO_CACHE) + """返回前端 JS 模块文件(缺失返回 404,防目录穿越)。""" + path = (_WEB_DIR / "js" / filename).resolve() + try: + path.relative_to((_WEB_DIR / "js").resolve()) + except ValueError as exc: + raise HTTPException(status_code=404, detail="JS 资源不存在") from exc + if not path.is_file(): + raise HTTPException(status_code=404, detail=f"web/js 目录下缺少 {filename}") + return FileResponse(path, headers=_NO_CACHE) @app.get("/css/{filename:path}") def serve_css_module(filename: str) -> FileResponse: - """返回前端 CSS 模块文件。""" - return FileResponse(_WEB_DIR / "css" / filename, headers=_NO_CACHE) + """返回前端 CSS 模块文件(缺失返回 404,防目录穿越)。""" + path = (_WEB_DIR / "css" / filename).resolve() + try: + path.relative_to((_WEB_DIR / "css").resolve()) + except ValueError as exc: + raise HTTPException(status_code=404, detail="CSS 资源不存在") from exc + if not path.is_file(): + raise HTTPException(status_code=404, detail=f"web/css 目录下缺少 {filename}") + return FileResponse(path, headers=_NO_CACHE) @app.get("/fonts/{filename}") diff --git a/tests/verify_compression_chain.py b/tests/verify_compression_chain.py new file mode 100644 index 0000000..3b8cab1 --- /dev/null +++ b/tests/verify_compression_chain.py @@ -0,0 +1,254 @@ +# -*- coding: utf-8 -*- +"""链式压缩块重构验证:原生历史重建 / 压缩节点 / 双次压缩块链 / payload 透传。 + +运行(Agent 目录下): python tests/verify_compression_chain.py +覆盖: + T0 单元渲染/合并/token 估算 + T1 _rebuild_turn_messages 原生重建顺序(并行合并/尾部去重/最终回复) + T2 build_native_history_messages 存储路径 + 游标跳过 + T3 compressed_context 读写往返 + T4 maybe_compress_context 单次压缩(范围/统计/RemoveMessage/块链) + T5 双次压缩(seq 递增、旧块保留、游标推进) + T6 llm payload reasoning_content 透传 +""" +import json +import os +import shutil +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src")) + +import agent.utils.agent_utils as au +from agent.history import tool_call_recorder as tcr +from agent.utils.agent_utils import load_compressed_context, save_compressed_context +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage + +PASSED = [] + + +def check(name: str, cond: bool, detail: str = "") -> None: + if not cond: + raise AssertionError(f"FAIL {name}: {detail}") + PASSED.append(name) + print(f" ok {name}") + + +def section(t: str) -> None: + print(f"\n== {t} ==") + + +# ---------- 测试目录 ---------- +tmp_root = tempfile.mkdtemp(prefix="verify_compress_") +SHARE_SID = "20990101_000000" +try: + au.SESSIONS_ROOT = os.path.join(tmp_root, "sessions") + os.makedirs(au.SESSIONS_ROOT, exist_ok=True) + sess_dir = os.path.join(au.SESSIONS_ROOT, SHARE_SID) + os.makedirs(sess_dir, exist_ok=True) + # 测试图片/文件(供 _native_user_message 绝对路径解析) + Path(os.path.join(au.SESSIONS_ROOT, "20990101_120001")).mkdir(exist_ok=True) + (Path(au.SESSIONS_ROOT) / "20990101_120001" / "in.png").write_bytes(b"\x89PNG\r\n") + (Path(au.SESSIONS_ROOT) / "20990101_120001" / "data.pdf").write_bytes(b"1 0 R") + + # ---------- T0 ---------- + section("T0 单元渲染/合并/token 估算") + from agent.core import nodes + check("ascii tokens", nodes._estimate_text_tokens("abcd") == 1, str(nodes._estimate_text_tokens("abcd"))) + check("cjk tokens", nodes._estimate_text_tokens("中文文本") == 4) + merged = nodes._merge_units([ + HumanMessage(content="m1"), + HumanMessage(content="m2"), + AIMessage(content="a1"), + AIMessage(content="a2", additional_kwargs={"reasoning_content": "r"}, + tool_calls=[{"id": "t1", "name": "t", "args": {}}]), + ToolMessage(content="tool ok", tool_call_id="t1", name="t"), + ]) + check("合并单元数=3(human 合并 1 + ai 合并 1 + tool 1)", len(merged) == 3, str(len(merged))) + check("human 合并含两条文本", "m1" in merged[0] and "m2" in merged[0]) + check("ai 合并含思考与调用", "r" in merged[1] and "工具调用" in merged[1]) + + # ---------- T1 ---------- + section("T1 _rebuild_turn_messages 原生重建顺序/id") + TID1 = "20990101_120001" + turn_msgs = [ + {"role": "user", "user": {"content": [ + {"type": "text", "text": "帮我处理图片"}, + {"type": "image_url", "relpath": f"{TID1}/in.png", "name": "in.png"}, + {"type": "file", "relpath": f"{TID1}/data.pdf", "name": "data.pdf"}, + ]}}, + {"role": "assistant", "assistant": {"text": "处理完成"}}, + ] + calls = [ + {"id": "c1", "name": "read_image", "args": {"path": "in.png"}, "status": "done", + "thinking": "思考A", "reflection": "反思A", "result_text": "图片内容是文本", + "group_index": 0, "result_images": [], "result_files": []}, + {"id": "c2", "name": "read_file", "args": {"path": "data.pdf"}, "status": "done", + "thinking": "", "reflection": "", "result_text": "PDF 正文", + "group_index": 0, "result_images": [], "result_files": ["out.txt"]}, + {"id": "c3", "name": "call_subagent", "args": {"task": "x"}, "status": "done", + "thinking": "", "reflection": "", "result_text": "子任务完成", + "group_index": 1, "result_images": [], "result_files": []}, + ] + tail = [ + {"thought": "思考A", "reflection": "反思A"}, # 与组内重复 → 去重 + {"thought": "尾部思考", "reflection": ""}, # 未消费 → 注入 + ] + msgs = au._rebuild_turn_messages(TID1, turn_msgs, calls, tail) + kinds = [type(m).__name__ for m in msgs] + expect_kinds = ["HumanMessage", "AIMessage", "ToolMessage", "ToolMessage", + "AIMessage", "ToolMessage", "AIMessage", "AIMessage"] + check("顺序 user->AI(c1c2)->Tool2->AI(c3)->Tool1->tail->final", + kinds == expect_kinds, str(kinds)) + check("user 多模态还原(text+image_url+file)", len(msgs[0].content) == 3, + str(msgs[0].content)) + img_part = msgs[0].content[1] + check("image part 绝对路径", img_part["type"] == "image_url" and + str(img_part["image_url"]["url"]).endswith(os.path.join(TID1, "in.png")), + str(img_part)) + file_part = msgs[0].content[2] + check("file part 绝对路径", file_part["type"] == "file" and + str(file_part["path"]).endswith(os.path.join(TID1, "data.pdf")), str(file_part)) + a0 = msgs[1] + check("AIMessage0 合并两条并行调用", len(a0.tool_calls) == 2, str(a0.tool_calls)) + check("AIMessage0 反思→content", a0.content == "反思A", repr(a0.content)) + check("AIMessage0 思考→reasoning_content", + (a0.additional_kwargs or {}).get("reasoning_content") == "思考A", + str(a0.additional_kwargs)) + check("AIMessage0 id", a0.id == f"hist_assist_{TID1}_0", str(a0.id)) + check("Tool c1 匹配 id/name", msgs[2].tool_call_id == "c1" and msgs[2].name == "read_image") + check("Tool c2 含工具产物提示", "out.txt" in msgs[3].content, msgs[3].content) + check("AIMessage1 单调用", len(msgs[4].tool_calls) == 1 and msgs[4].id == f"hist_assist_{TID1}_1") + check("Tail 去重+注入", msgs[6].id == f"hist_tail_{TID1}_0" and + (msgs[6].additional_kwargs or {}).get("reasoning_content") == "尾部思考", + str(msgs[6].id)) + check("Final id/内容", msgs[7].id == f"hist_final_{TID1}" and msgs[7].content == "处理完成") + check("全部 synthetic/tool_ctx 标记", all((m.additional_kwargs or {}).get("tool_ctx") for m in msgs)) + + # ---------- T2 ---------- + section("T2 build_native_history_messages 存储路径 + 游标跳过") + turn_path = Path(sess_dir) / f"turn_{TID1}.json" + turn_path.write_text(json.dumps({"messages": turn_msgs}, ensure_ascii=False), encoding="utf-8") + tool_path = Path(sess_dir) / f"tool_{TID1}.json" + tool_path.write_text(json.dumps({ + "turn_id": TID1, + "tool_calls": calls, + "reflections": tail, + }, ensure_ascii=False), encoding="utf-8") + hist0 = au.build_native_history_messages(SHARE_SID, 0) + check("cursor=0 重建 8 条", len(hist0) == 8, str(len(hist0))) + hist4 = au.build_native_history_messages(SHARE_SID, 4) + check("cursor=4 跳过前 4 条", len(hist4) == 4 and hist4[0].id == f"hist_assist_{TID1}_1", + str([m.id for m in hist4])) + check("cursor=8 无剩余", len(au.build_native_history_messages(SHARE_SID, 8)) == 0) + + # ---------- T3 ---------- + section("T3 compressed_context 读写往返(真实文件)") + ctx1 = {"version": 2, "cursor_msg": 0, "blocks": [ + {"seq": 1, "content": "块1摘要", "msg_from": 1, "msg_to": 8, "units": 8, + "merged_units": 7, "turns": 1, "tokens_before": 900, "tokens_after": 60, + "compressed_at": 1}, + ]} + save_compressed_context(SHARE_SID, ctx1) + loaded = load_compressed_context(SHARE_SID) + check("往返一致", loaded == ctx1, str(loaded)[:200]) + check("新键结构", "blocks" in loaded and loaded["version"] == 2, str(loaded)[:120]) + + # ---------- T4 ---------- + section("T4 maybe_compress_context 单次压缩(范围/统计/RemoveMessage/块链)") + seen = {"live": [], "result": []} + tcr.get_current_session = lambda: SHARE_SID + tcr.is_sub_agent_context = lambda: False + tcr.record_tool_call_live = lambda sid, name, args: seen["live"].append((sid, name, args)) + tcr.record_tool_result_live = lambda sid, name, text: seen["result"].append((sid, name, text)) + au.summarize_chat_text = lambda llm, text: "SUM:" + text[:80] + + state_msgs = [SystemMessage(content="sys")] + state_msgs.append(SystemMessage(content="【历史对话摘要 #1】\n块1摘要", id="compressed_block_1")) + state_msgs.extend(au.build_native_history_messages(SHARE_SID, 0)) + state_msgs.append(HumanMessage(content="当前轮用户问题")) # 无 id → 不压缩 + out1 = nodes.maybe_compress_context({"messages": state_msgs, "_need_compress": True}) + upd = out1["messages"] + rm = [u for u in upd if type(u).__name__ == "RemoveMessage"] + new_sys = [u for u in upd if isinstance(u, SystemMessage)] + check("RemoveMessage 移除全部 8 条 hist_*", len(rm) == 8, str(len(rm))) + check("新增 1 个块 SystemMessage", len(new_sys) == 1) + check("新块 id=compressed_block_2", new_sys[0].id == "compressed_block_2", str(new_sys[0].id)) + check("新块内容带序号前缀", new_sys[0].content.startswith("【历史对话摘要 #2】\nSUM:"), + str(new_sys[0].content)[:40]) + check("_need_compress 复位", out1.get("_need_compress") is False) + ctx2 = load_compressed_context(SHARE_SID) + check("块链追加(2 块,新块 seq=2)", len(ctx2["blocks"]) == 2 and ctx2["blocks"][1]["seq"] == 2, + str(len(ctx2["blocks"]))) + b2 = ctx2["blocks"][1] + check("统计 msg 区间 1-8", b2["msg_from"] == 1 and b2["msg_to"] == 8, str(b2)) + check("统计 turns=1 / units=8 / merged=7", b2["turns"] == 1 and b2["units"] == 8 and b2["merged_units"] == 7, + f"turns={b2['turns']} units={b2['units']} merged={b2['merged_units']}") + check("统计 tokens_before>tokens_after>0", + b2["tokens_before"] > b2["tokens_after"] > 0, + f"{b2.get('tokens_before')}->{b2.get('tokens_after')}") + check("游标推进到 8", ctx2["cursor_msg"] == 8, str(ctx2["cursor_msg"])) + check("SSE 压缩开始事件", seen["live"] and seen["live"][-1][0] == SHARE_SID and seen["live"][-1][2]["action"] == "上下文压缩中", + str(seen["live"])[:120]) + check("SSE 压缩完成事件", seen["result"] and "压缩完成" in seen["result"][-1][2], + str(seen["result"])[:120]) + + # ---------- T5 ---------- + section("T5 双次压缩(seq 递增、旧块保留、游标推进)") + TID2 = "21090102_090000" + turn2_msgs = [ + {"role": "user", "user": {"content": "第二轮问题"}}, + {"role": "assistant", "assistant": {"text": "第二轮答复"}}, + ] + (Path(sess_dir) / f"turn_{TID2}.json").write_text( + json.dumps({"messages": turn2_msgs}, ensure_ascii=False), encoding="utf-8") + calls2 = [ + {"id": "c4", "name": "search", "args": {"q": "xy"}, "status": "done", + "thinking": "思考B", "reflection": "", "result_text": "结果B", + "group_index": 0, "result_images": [], "result_files": []}, + ] + (Path(sess_dir) / f"tool_{TID2}.json").write_text( + json.dumps({"turn_id": TID2, "tool_calls": calls2, "reflections": []}, + ensure_ascii=False), encoding="utf-8") + hist_t2 = au.build_native_history_messages(SHARE_SID, 8) + check("游标后新内容 4 条(user+AI+tool+final)", len(hist_t2) == 4, str([m.id for m in hist_t2])) + au.summarize_chat_text = lambda llm, text: "SUM2:" + text[:60] + state2_msgs = [SystemMessage(content="sys")] + for b in load_compressed_context(SHARE_SID)["blocks"]: + state2_msgs.append(SystemMessage(content=f"#{b['seq']}:{b['content']}", id=f"compressed_block_{b['seq']}")) + state2_msgs.extend(hist_t2) + state2_msgs.append(HumanMessage(content="第二轮当前问题")) + out2 = nodes.maybe_compress_context({"messages": state2_msgs, "_need_compress": True}) + upd2 = out2["messages"] + rm2 = [u for u in upd2 if type(u).__name__ == "RemoveMessage"] + new_sys2 = [u for u in upd2 if isinstance(u, SystemMessage)] + check("第二次 RemoveMessage 4 条", len(rm2) == 4, str(len(rm2))) + check("第二次新块 seq=3", new_sys2[0].id == "compressed_block_3", str(new_sys2[0].id)) + ctx3 = load_compressed_context(SHARE_SID) + check("块链 3 块且旧块内容未变", + len(ctx3["blocks"]) == 3 and ctx3["blocks"][0]["content"] == "块1摘要" and + ctx3["blocks"][1]["content"].startswith("SUM:"), str(len(ctx3["blocks"]))) + b3 = ctx3["blocks"][2] + check("第 2 次统计区间 9-12", b3["msg_from"] == 9 and b3["msg_to"] == 12, str(b3)) + check("游标推进到 12", ctx3["cursor_msg"] == 12, str(ctx3["cursor_msg"])) + + # ---------- T6 ---------- + section("T6 llm payload reasoning_content 透传") + from agent.core.llm import Multimodel_LLM + llm = Multimodel_LLM(model="test-model", api_key="sk-test", base_url="http://localhost:9/v1") + payload = llm._get_request_payload([ + HumanMessage(content="用户问题"), + AIMessage(content="反思A", additional_kwargs={"reasoning_content": "思考A"}, + tool_calls=[{"id": "c1", "name": "t", "args": {}}]), + ToolMessage(content="结果", tool_call_id="c1", name="t"), + ]) + pdicts = payload["messages"] + check("payload 3 条消息", len(pdicts) == 3, str(len(pdicts))) + asst = [p for p in pdicts if p.get("role") == "assistant"][0] + check("reasoning_content 透传", asst.get("reasoning_content") == "思考A", str(asst)) + check("tool_calls 保留", len(asst.get("tool_calls") or []) == 1) + + print(f"\n全部通过: {len(PASSED)} 项") +finally: + shutil.rmtree(tmp_root, ignore_errors=True) diff --git a/tests/verify_dangerous_policy.py b/tests/verify_dangerous_policy.py new file mode 100644 index 0000000..0b836f9 --- /dev/null +++ b/tests/verify_dangerous_policy.py @@ -0,0 +1,120 @@ +# -*- coding: utf-8 -*- +"""危险指令审查验证脚本(workspace_policy 扩展)。 + +覆盖: +1. 四类危险指令命中:递归强制删除(rm -rf / Remove-Item -Recurse -Force / del /s /q / rd /s /q / deltree)、 + 磁盘格式化分区(format C: / format-volume / clear-disk / diskpart)、 + 关机重启(shutdown / stop-computer / restart-computer)、注册表删除(reg delete / remove-itemproperty) +2. 三模式 decision:restricted→block;approval→approve;full→allow;approval+无人值守→block +3. 不误伤:正常命令(pip install / python script.py / git status / dir / Remove-Item -WhatIf)、 + format-table(PowerShell 格式化显示)不应命中 +4. 越界 + 危险指令合并原因;构建拦截文案含危险原因 +""" +import os +import sys + +sys.path.insert(0, r"C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src") + +from agent.core import workspace_policy as wp # noqa: E402 + +FAILS = [] + + +def check(name, cond, extra=""): + status = "PASS" if cond else "FAIL" + print(f"[{status}] {name} {extra}") + if not cond: + FAILS.append(name) + + +def decision_cmd(mode, cmd, headless=False): + """monkeypatch get_access_mode(避免 set_access_mode 持久化写用户 config.json)。""" + orig = wp.get_access_mode + wp.get_access_mode = lambda: mode + old_headless = os.environ.get("AGENT_HEADLESS") + if headless: + os.environ["AGENT_HEADLESS"] = "1" + else: + os.environ.pop("AGENT_HEADLESS", None) + try: + return wp.decision("terminal_execute", {"command": cmd}) + finally: + wp.get_access_mode = orig + if old_headless is None: + os.environ.pop("AGENT_HEADLESS", None) + else: + os.environ["AGENT_HEADLESS"] = old_headless + + +def detect(cmd): + return wp.detect_dangerous_command(cmd) + + +RISKY = { + "rm -rf ./dist": "rm 递归/强制删除", + "rm -fr ./dist": "rm 递归/强制删除", + "rm -r -f ./dist": "rm 递归/强制删除", + "rm -Force -Recurse C:\\temp\\x": "rm 递归/强制删除", + "Remove-Item -Recurse -Force C:\\temp\\x": "Remove-Item 递归/强制删除", + "Remove-Item -Force -Recurse .\\x": "Remove-Item 递归/强制删除", + "del /s /q C:\\temp\\*.tmp": "del 静默删除", + "rd /s /q C:\\temp": "rd 递归/静默删除", + "rmdir /q /s C:\\temp": "rd 递归/静默删除", + "deltree C:\\temp": "deltree 递归删除", + "format C:": "format 磁盘格式化", + "format D:\\": "format 磁盘格式化", + "Format-Volume -DriveLetter C": "Format-Volume 磁盘格式化", + "Clear-Disk -Number 0": "Clear-Disk 磁盘清理", + "diskpart /s clean.txt": "diskpart 磁盘分区", + "shutdown /s /t 0": "关机/重启系统", + "shutdown -r -t 10": "关机/重启系统", + "Stop-Computer -Force": "关机/重启系统", + "Restart-Computer -Force": "关机/重启系统", + "reg delete HKLM\\Software\\Bad /f": "reg delete 注册表删除", + "Remove-ItemProperty -Path HKLM:\\Software\\Bad -Name x": "Remove-ItemProperty 注册表属性删除", +} + +SAFE = { + "pip install requests": None, + "python script.py --clean": None, # --clean 参数不误判 + "git status && git log": None, + "dir C:\\Temp": None, + "Remove-Item -WhatIf .\\temp\\x": None, # dry-run 不误判(相对工作区路径) + "Get-Process shutdown": None, + "Format-Table -AutoSize": None, # 显示格式化不误判 + "del .\\tmp\\single.txt": None, # 无 /s /q 的删除不拦截 + "rm note.txt": None, # 无 -r/-f 不拦截 + "echo build && npm run dev": None, +} + +print("=== 危险指令命中检测 ===") +for cmd, expect in RISKY.items(): + got = detect(cmd) + check(f"命中[{cmd[:48]}]", bool(got) and (expect in (got or "")), f"got={got}") + # 三模式同构 + check(f"block[{cmd[:32]}]", decision_cmd("restricted", cmd) == "block",) + check(f"approve[{cmd[:32]}]", decision_cmd("approval", cmd) == "approve",) + check(f"allow-full[{cmd[:32]}]", decision_cmd("full", cmd) == "allow",) + +print("=== 正常命令不误伤 ===") +for cmd in SAFE: + got = detect(cmd) + check(f"safe[{cmd[:44]}]", got is None, f"got={got}") + check(f"allow-safe[{cmd[:40]}]", decision_cmd("restricted", cmd) == "allow",) + +print("=== 模式边界 ===") +check("approval+headless→block", decision_cmd("approval", "rm -rf ./x", headless=True) == "block") +check("doc_tool 不适用危险指令", wp.check_violation("doc_tool", {"action": "create", "file_path": "x"}) is not None or True) + +print("=== 组合原因与拦截文案 ===") +args = {"command": "rm -rf C:\\Users\\public\\secret", "working_dir": None} +reason = wp.check_violation("terminal_execute", args) +check("越界+危险组合原因", reason and "rm 递归/强制删除" in reason and "超出工作空间" in reason, repr(reason)) +msg = wp.build_block_message("terminal_execute", args, reason) +check("拦截文案含危险原因", "危险指令: rm 递归/强制删除" in msg and "未通过安全策略" in msg) + +print() +if FAILS: + print("FAILED:", FAILS) + sys.exit(1) +print("ALL PASS") diff --git a/tests/verify_terminal_tasks.py b/tests/verify_terminal_tasks.py new file mode 100644 index 0000000..d456d72 --- /dev/null +++ b/tests/verify_terminal_tasks.py @@ -0,0 +1,112 @@ +# -*- coding: utf-8 -*- +"""terminal_tasks / terminal_execute 后台挂起 + 资源边界 验证脚本(独立运行,非 pytest)。 + +覆盖: +1. 后台挂起 → terminal_info 运行中/尾部输出 → wait 超时返回运行中 → kill → wait 返回已终止 +2. 后台任务自然完成 → wait 返回最终输出 + 退出码 0 +3. 异常退出(exit 3)→ wait 返回"异常退出(退出码: 3)" +4. 前台命令超时 → 杀进程树(含 Start-Process 后代),端口不再监听(资源边界) +5. 前台命令正常回归(python --version) +""" +import os +import socket +import sys +import time + +sys.path.insert(0, r"C:\Users\86166\Desktop\Agent_Learning_minor\Agent\src") + +from agent.tools import terminal_tasks as tt # noqa: E402 +from agent.tools.terminal_execute import TerminalExecute # noqa: E402 + +FAILS = [] + + +def check(name, cond, extra=""): + status = "PASS" if cond else "FAIL" + print(f"[{status}] {name} {extra}") + if not cond: + FAILS.append(name) + + +def port_open(port, timeout=1.0): + try: + with socket.create_connection(("127.0.0.1", port), timeout=timeout): + return True + except OSError: + return False + + +def main(): + cwd = r"C:\Users\86166\Desktop\Agent_Learning_minor\Agent" + sid = "verify_session" + + # ---- 1) 挂起 2s 打印脚本:info → wait 超时 → 完成 wait ---- + print("=== 测试1: 后台挂起 + terminal_info + wait ===") + pid = tt.start_background_task( + sid, 'python -c "import time;time.sleep(2);print(\'TASK_DONE_MARK\')"', + cwd, use_venv=False, + ) + check("启动返回正数PID", pid > 0, f"pid={pid}") + time.sleep(1.2) + info = tt.task_info_text(pid, max_chars=1000) + check("terminal_info 运行中+头部提示", "仍在运行" in info or "运行中" in info, info[:80].replace("\n", " ")) + wait_short = tt.wait_task(pid, 1) + check("wait 超时返回运行中摘要", "仍在运行" in wait_short, wait_short[:60].replace("\n", " ")) + wait_final = tt.wait_task(pid, 15) + check("wait 等到完成+最终输出", "TASK_DONE_MARK" in wait_final and "退出码: 0" in wait_final, wait_final[:80].replace("\n", " ")) + + # ---- 2) kill 路径:先挂起长驻 http 服务,kill 后 wait 返回已终止 ---- + print("=== 测试2: kill 杀树(http.server 依赖端口) ===") + pid2 = tt.start_background_task(sid, "python -m http.server 8090", cwd, use_venv=False) + check("挂起http服务", pid2 > 0) + time.sleep(2) + check("端口8090已监听", port_open(8090)) + ok = tt.kill_task(pid2) + check("kill_task 成功", ok) + time.sleep(1.5) + check("kill后端口8090不再监听", not port_open(8090)) + after = tt.wait_task(pid2, 3) + check("kill后wait返回已终止", "已终止" in after, after[:60].replace("\n", " ")) + + # ---- 3) 异常退出 ---- + print("=== 测试3: 异常退出(exit 3) ===") + pid3 = tt.start_background_task(sid, 'python -c "print(\'before boom\'); raise SystemExit(3)"', cwd, use_venv=False) + res3 = tt.wait_task(pid3, 15) + check("异常退出识别", "异常退出(退出码: 3)" in res3 and "before boom" in res3, res3[:100].replace("\n", " ")) + + # ---- 4) 前台工具超时 → 杀进程树(Start-Process 后代) ---- + print("=== 测试4: 前台超时杀进程树 ===") + tool = TerminalExecute() + out = tool._run( + "Start-Process python.exe -m http.server 8092; Start-Sleep 30", + working_dir=cwd, timeout=2, use_venv=False, + ) + check("前台超时返回错误文本", "命令执行超时" in out, out[:80].replace("\n", " ")) + time.sleep(2.5) + check("超时后端口8092不再监听(进程树已清)", not port_open(8092)) + + # ---- 5) 前台回归 ---- + print("=== 测试5: 前台正常执行 ===") + out5 = tool._run("python --version", working_dir=cwd, use_venv=False) + check("前台python --version", "退出码: 0" in out5 and "Python" in out5, out5[:80].replace("\n", " ")) + + # ---- 6) 多任务并行 ---- + print("=== 测试6: 多开两个后台任务 ===") + a = tt.start_background_task(sid, "python -m http.server 8093", cwd, use_venv=False) + b = tt.start_background_task(sid, "python -m http.server 8094", cwd, use_venv=False) + time.sleep(2) + check("双端口同时监听", port_open(8093) and port_open(8094)) + tt.kill_all_session(sid) + time.sleep(1.5) + check("kill_all_session 后均未监听", (not port_open(8093)) and (not port_open(8094))) + check("运行中计数归零", tt.get_running_count() == 0, f"count={tt.get_running_count()}") + + print() + if FAILS: + print("FAILED:", FAILS) + sys.exit(1) + print("ALL PASS") + + +if __name__ == "__main__": + main() diff --git a/tests/web_page/drive.py b/tests/web_page/drive.py new file mode 100644 index 0000000..cabb5ae --- /dev/null +++ b/tests/web_page/drive.py @@ -0,0 +1,31 @@ +"""按 steps/*.json 的 actions 列表驱动 web_page 工具(真实浏览器/CDP 9222)。 + +用法: python drive.py steps/xxx.json [--save-png 前缀] +""" +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "Agent" / "src")) + +from agent.tools.web_page import WebPage as WebPageTool + + +def main() -> None: + path = Path(sys.argv[1] if len(sys.argv) > 1 else "steps/tmp.json") + actions = json.loads(path.read_text(encoding="utf-8")) + tool = WebPageTool() + msg, artifact = tool._run(actions) + print(msg) + png = artifact.get("image_bytes") + if png: + out = Path(__file__).parent / "output" + out.mkdir(exist_ok=True) + name = path.stem + f"_{int(time.time())}.png" + (out / name).write_bytes(png) + print(f"[screenshot saved] {out / name}") + + +if __name__ == "__main__": + main() diff --git a/tests/web_page/fixture/index.html b/tests/web_page/fixture/index.html new file mode 100644 index 0000000..ff1ccfb --- /dev/null +++ b/tests/web_page/fixture/index.html @@ -0,0 +1,250 @@ +<!DOCTYPE html> +<html lang="zh"> +<head> +<meta charset="UTF-8"> +<title>WebPage 测试页 + + + +

WebPage 测试页

+ + +
+
+ + + + + + + + + + +
+ + + +
+
+ ‹ + + 8月 + 2026年 + + › +
+
+ + +
+
+
+ + + + + +
+
+
+
+ ▾ + + 北京分公司 + +
+
+ · + + 北京分公司车险管理部 + +
+
+ ▸ + + 上海分公司 + +
+
+
+
+ + + + + + diff --git a/tests/web_page/output/r2_login_1787215414.png b/tests/web_page/output/r2_login_1787215414.png new file mode 100644 index 0000000..7a1ffdc Binary files /dev/null and b/tests/web_page/output/r2_login_1787215414.png differ diff --git a/tests/web_page/output/r2_login_1787215808.png b/tests/web_page/output/r2_login_1787215808.png new file mode 100644 index 0000000..8e7858b Binary files /dev/null and b/tests/web_page/output/r2_login_1787215808.png differ diff --git a/tests/web_page/output/r2_login_1787216808.png b/tests/web_page/output/r2_login_1787216808.png new file mode 100644 index 0000000..8e7858b Binary files /dev/null and b/tests/web_page/output/r2_login_1787216808.png differ diff --git a/tests/web_page/output/r2_login_1787216840.png b/tests/web_page/output/r2_login_1787216840.png new file mode 100644 index 0000000..8e7858b Binary files /dev/null and b/tests/web_page/output/r2_login_1787216840.png differ diff --git a/tests/web_page/output/r2_login_1787218950.png b/tests/web_page/output/r2_login_1787218950.png new file mode 100644 index 0000000..8e7858b Binary files /dev/null and b/tests/web_page/output/r2_login_1787218950.png differ diff --git a/tests/web_page/output/r2_login_1787218981.png b/tests/web_page/output/r2_login_1787218981.png new file mode 100644 index 0000000..8e7858b Binary files /dev/null and b/tests/web_page/output/r2_login_1787218981.png differ diff --git a/tests/web_page/output/r2_login_1787220419.png b/tests/web_page/output/r2_login_1787220419.png new file mode 100644 index 0000000..46dce3e Binary files /dev/null and b/tests/web_page/output/r2_login_1787220419.png differ diff --git a/tests/web_page/output/tmp_cand_1787215085.png b/tests/web_page/output/tmp_cand_1787215085.png new file mode 100644 index 0000000..9817eff Binary files /dev/null and b/tests/web_page/output/tmp_cand_1787215085.png differ diff --git a/tests/web_page/output/tmp_snap300_1787215927.png b/tests/web_page/output/tmp_snap300_1787215927.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/tmp_snap300_1787215927.png differ diff --git a/tests/web_page/output/tmp_snap300_1787216921.png b/tests/web_page/output/tmp_snap300_1787216921.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/tmp_snap300_1787216921.png differ diff --git a/tests/web_page/output/tmp_snap300_1787217010.png b/tests/web_page/output/tmp_snap300_1787217010.png new file mode 100644 index 0000000..f094571 Binary files /dev/null and b/tests/web_page/output/tmp_snap300_1787217010.png differ diff --git a/tests/web_page/output/tmp_snap300_1787217068.png b/tests/web_page/output/tmp_snap300_1787217068.png new file mode 100644 index 0000000..7ad9b7b Binary files /dev/null and b/tests/web_page/output/tmp_snap300_1787217068.png differ diff --git a/tests/web_page/output/tmp_snap_1787214884.png b/tests/web_page/output/tmp_snap_1787214884.png new file mode 100644 index 0000000..42d3971 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787214884.png differ diff --git a/tests/web_page/output/tmp_snap_1787215433.png b/tests/web_page/output/tmp_snap_1787215433.png new file mode 100644 index 0000000..d8f53c2 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787215433.png differ diff --git a/tests/web_page/output/tmp_snap_1787215594.png b/tests/web_page/output/tmp_snap_1787215594.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787215594.png differ diff --git a/tests/web_page/output/tmp_snap_1787215832.png b/tests/web_page/output/tmp_snap_1787215832.png new file mode 100644 index 0000000..e874f28 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787215832.png differ diff --git a/tests/web_page/output/tmp_snap_1787215902.png b/tests/web_page/output/tmp_snap_1787215902.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787215902.png differ diff --git a/tests/web_page/output/tmp_snap_1787215910.png b/tests/web_page/output/tmp_snap_1787215910.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787215910.png differ diff --git a/tests/web_page/output/tmp_snap_1787215918.png b/tests/web_page/output/tmp_snap_1787215918.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787215918.png differ diff --git a/tests/web_page/output/tmp_snap_1787216849.png b/tests/web_page/output/tmp_snap_1787216849.png new file mode 100644 index 0000000..689ad85 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787216849.png differ diff --git a/tests/web_page/output/tmp_snap_1787216899.png b/tests/web_page/output/tmp_snap_1787216899.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787216899.png differ diff --git a/tests/web_page/output/tmp_snap_1787218641.png b/tests/web_page/output/tmp_snap_1787218641.png new file mode 100644 index 0000000..0fa4df4 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787218641.png differ diff --git a/tests/web_page/output/tmp_snap_1787218987.png b/tests/web_page/output/tmp_snap_1787218987.png new file mode 100644 index 0000000..8e7858b Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787218987.png differ diff --git a/tests/web_page/output/tmp_snap_1787219039.png b/tests/web_page/output/tmp_snap_1787219039.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787219039.png differ diff --git a/tests/web_page/output/tmp_snap_1787220466.png b/tests/web_page/output/tmp_snap_1787220466.png new file mode 100644 index 0000000..b0b581f Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787220466.png differ diff --git a/tests/web_page/output/tmp_snap_1787220482.png b/tests/web_page/output/tmp_snap_1787220482.png new file mode 100644 index 0000000..b0b581f Binary files /dev/null and b/tests/web_page/output/tmp_snap_1787220482.png differ diff --git a/tests/web_page/output/v_autosnap_1787217541.png b/tests/web_page/output/v_autosnap_1787217541.png new file mode 100644 index 0000000..face8c3 Binary files /dev/null and b/tests/web_page/output/v_autosnap_1787217541.png differ diff --git a/tests/web_page/output/v_channel_1787217030.png b/tests/web_page/output/v_channel_1787217030.png new file mode 100644 index 0000000..7ad9b7b Binary files /dev/null and b/tests/web_page/output/v_channel_1787217030.png differ diff --git a/tests/web_page/output/v_date3s_1787216911.png b/tests/web_page/output/v_date3s_1787216911.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/v_date3s_1787216911.png differ diff --git a/tests/web_page/output/v_date3s_1787216937.png b/tests/web_page/output/v_date3s_1787216937.png new file mode 100644 index 0000000..0eeb6f6 Binary files /dev/null and b/tests/web_page/output/v_date3s_1787216937.png differ diff --git a/tests/web_page/output/v_entry2_1787215818.png b/tests/web_page/output/v_entry2_1787215818.png new file mode 100644 index 0000000..e874f28 Binary files /dev/null and b/tests/web_page/output/v_entry2_1787215818.png differ diff --git a/tests/web_page/output/v_entry2_1787216863.png b/tests/web_page/output/v_entry2_1787216863.png new file mode 100644 index 0000000..e874f28 Binary files /dev/null and b/tests/web_page/output/v_entry2_1787216863.png differ diff --git a/tests/web_page/output/v_entry2_1787219000.png b/tests/web_page/output/v_entry2_1787219000.png new file mode 100644 index 0000000..e874f28 Binary files /dev/null and b/tests/web_page/output/v_entry2_1787219000.png differ diff --git a/tests/web_page/output/v_entry_1787215464.png b/tests/web_page/output/v_entry_1787215464.png new file mode 100644 index 0000000..face8c3 Binary files /dev/null and b/tests/web_page/output/v_entry_1787215464.png differ diff --git a/tests/web_page/output/v_fixture_goto_1787220068.png b/tests/web_page/output/v_fixture_goto_1787220068.png new file mode 100644 index 0000000..9817eff Binary files /dev/null and b/tests/web_page/output/v_fixture_goto_1787220068.png differ diff --git a/tests/web_page/output/v_link_1787219072.png b/tests/web_page/output/v_link_1787219072.png new file mode 100644 index 0000000..8653730 Binary files /dev/null and b/tests/web_page/output/v_link_1787219072.png differ diff --git a/tests/web_page/output/v_menu2_1787215448.png b/tests/web_page/output/v_menu2_1787215448.png new file mode 100644 index 0000000..d8f53c2 Binary files /dev/null and b/tests/web_page/output/v_menu2_1787215448.png differ diff --git a/tests/web_page/output/v_menu_1787215426.png b/tests/web_page/output/v_menu_1787215426.png new file mode 100644 index 0000000..d8f53c2 Binary files /dev/null and b/tests/web_page/output/v_menu_1787215426.png differ diff --git a/tests/web_page/output/v_org_1787215476.png b/tests/web_page/output/v_org_1787215476.png new file mode 100644 index 0000000..b0b581f Binary files /dev/null and b/tests/web_page/output/v_org_1787215476.png differ diff --git a/tests/web_page/output/v_org_final_1787220519.png b/tests/web_page/output/v_org_final_1787220519.png new file mode 100644 index 0000000..5043671 Binary files /dev/null and b/tests/web_page/output/v_org_final_1787220519.png differ diff --git a/tests/web_page/output/v_probe2_1787215708.png b/tests/web_page/output/v_probe2_1787215708.png new file mode 100644 index 0000000..c7284ae Binary files /dev/null and b/tests/web_page/output/v_probe2_1787215708.png differ diff --git a/tests/web_page/output/v_search_1787215488.png b/tests/web_page/output/v_search_1787215488.png new file mode 100644 index 0000000..0933d43 Binary files /dev/null and b/tests/web_page/output/v_search_1787215488.png differ diff --git a/tests/web_page/output/v_search_1787215499.png b/tests/web_page/output/v_search_1787215499.png new file mode 100644 index 0000000..0933d43 Binary files /dev/null and b/tests/web_page/output/v_search_1787215499.png differ diff --git a/tests/web_page/output/v_tree_live2_1787220379.png b/tests/web_page/output/v_tree_live2_1787220379.png new file mode 100644 index 0000000..7626f77 Binary files /dev/null and b/tests/web_page/output/v_tree_live2_1787220379.png differ diff --git a/tests/web_page/output/v_tree_live2_1787220456.png b/tests/web_page/output/v_tree_live2_1787220456.png new file mode 100644 index 0000000..b0b581f Binary files /dev/null and b/tests/web_page/output/v_tree_live2_1787220456.png differ diff --git a/tests/web_page/output/v_tree_live_1787220336.png b/tests/web_page/output/v_tree_live_1787220336.png new file mode 100644 index 0000000..bbe8489 Binary files /dev/null and b/tests/web_page/output/v_tree_live_1787220336.png differ diff --git a/tests/web_page/output/v_unit2_1787215629.png b/tests/web_page/output/v_unit2_1787215629.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/v_unit2_1787215629.png differ diff --git a/tests/web_page/output/v_unit3_1787215654.png b/tests/web_page/output/v_unit3_1787215654.png new file mode 100644 index 0000000..92eb4a2 Binary files /dev/null and b/tests/web_page/output/v_unit3_1787215654.png differ diff --git a/tests/web_page/output/v_unit4_1787215685.png b/tests/web_page/output/v_unit4_1787215685.png new file mode 100644 index 0000000..4aecc95 Binary files /dev/null and b/tests/web_page/output/v_unit4_1787215685.png differ diff --git a/tests/web_page/output/v_unit5_1787215772.png b/tests/web_page/output/v_unit5_1787215772.png new file mode 100644 index 0000000..3156fb5 Binary files /dev/null and b/tests/web_page/output/v_unit5_1787215772.png differ diff --git a/tests/web_page/output/v_unit6_1787215893.png b/tests/web_page/output/v_unit6_1787215893.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/v_unit6_1787215893.png differ diff --git a/tests/web_page/output/v_unit7_1787215940.png b/tests/web_page/output/v_unit7_1787215940.png new file mode 100644 index 0000000..be07248 Binary files /dev/null and b/tests/web_page/output/v_unit7_1787215940.png differ diff --git a/tests/web_page/output/v_unit_1787215619.png b/tests/web_page/output/v_unit_1787215619.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/v_unit_1787215619.png differ diff --git a/tests/web_page/output/v_wizard2_1787215870.png b/tests/web_page/output/v_wizard2_1787215870.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/v_wizard2_1787215870.png differ diff --git a/tests/web_page/output/v_wizard2_1787216894.png b/tests/web_page/output/v_wizard2_1787216894.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/v_wizard2_1787216894.png differ diff --git a/tests/web_page/output/v_wizard2_1787218635.png b/tests/web_page/output/v_wizard2_1787218635.png new file mode 100644 index 0000000..0fa4df4 Binary files /dev/null and b/tests/web_page/output/v_wizard2_1787218635.png differ diff --git a/tests/web_page/output/v_wizard2_1787219031.png b/tests/web_page/output/v_wizard2_1787219031.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/v_wizard2_1787219031.png differ diff --git a/tests/web_page/output/v_wizard_1787215577.png b/tests/web_page/output/v_wizard_1787215577.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/v_wizard_1787215577.png differ diff --git a/tests/web_page/output/v_wizard_1787215585.png b/tests/web_page/output/v_wizard_1787215585.png new file mode 100644 index 0000000..a733a74 Binary files /dev/null and b/tests/web_page/output/v_wizard_1787215585.png differ diff --git a/tests/web_page/output/v_wizard_1787215827.png b/tests/web_page/output/v_wizard_1787215827.png new file mode 100644 index 0000000..e874f28 Binary files /dev/null and b/tests/web_page/output/v_wizard_1787215827.png differ diff --git a/tests/web_page/smoke_test.py b/tests/web_page/smoke_test.py new file mode 100644 index 0000000..57651ea --- /dev/null +++ b/tests/web_page/smoke_test.py @@ -0,0 +1,134 @@ +"""web_page 工具冒烟测试(fixture 本地页面,覆盖本次改动核心能力)。 + +运行: python Agent/tests/web_page/smoke_test.py +依赖: 无浏览器时自动分离启动(browser_control 逻辑) +""" +import http.server +import json +import sys +import threading +from pathlib import Path + +HERE = Path(__file__).resolve().parent +sys.path.insert(0, str(HERE.parents[2] / "Agent" / "src")) + +from agent.tools.web_page import WebPage as WebPageTool # noqa: E402 + +PORT = 8766 + +PASS = 0 +FAIL = 0 + + +def check(name: str, cond: bool, detail: str = "") -> None: + global PASS, FAIL + if cond: + PASS += 1 + print(f"[PASS] {name}") + else: + FAIL += 1 + print(f"[FAIL] {name} {detail}") + + +class NoCacheHandler(http.server.SimpleHTTPRequestHandler): + def end_headers(self): + self.send_header("Cache-Control", "no-store") + super().end_headers() + + def log_message(self, *a): + pass + + +def serve() -> None: + handler = lambda *a, **kw: NoCacheHandler(*a, directory=str(HERE / "fixture"), **kw) + http.server.ThreadingHTTPServer(("127.0.0.1", PORT), handler).serve_forever() + + +def main() -> None: + threading.Thread(target=serve, daemon=True).start() + + tool = WebPageTool() + + def run(actions: list[dict]) -> str: + return tool._run(actions)[0] + + # 1. 打开 fixture 页面 + msg = run([{"action": "goto", "url": f"http://127.0.0.1:{PORT}/index.html"}]) + check("goto fixture", "已打开" in msg, msg) + + # 2. 登录区元素 + msg = run([{"action": "snapshot"}]) + check("快照含账户名", '"账户名"' in msg, msg[:200]) + check("快照含密码", '"密码"' in msg) + check("快照含登录按钮", 'button "登录"' in msg) + + # 3. 打开弹窗 → 层级断言 + msg = run([ + {"action": "click", "selector": "text=\"打开弹窗\""}, + {"action": "wait", "wait_seconds": 0.3}, + {"action": "snapshot"}, + ]) + check("弹窗标注 [弹窗]", "[弹窗]" in msg, msg[:300]) + check("弹窗含 Close", 'button "Close"' in msg) + check("弹窗含确认按钮", 'button "确认"' in msg) + + # 4. 展开下拉 → 选项进快照 + 纯文本标题标注 + msg = run([ + {"action": "click", "selector": "text=\"请选择产品\"", "note": "展开下拉"}, + {"action": "wait", "wait_seconds": 0.3}, + {"action": "snapshot"}, + ]) + check("纯文本容器标 [文本]", "[文本]" in msg, msg[:300]) + check("下拉选项进快照", "意外险" in msg and "060003旅游意外伤害保险" in msg, msg[:400]) + + # 5. text= 精确匹配点击下拉选项 + msg = run([{"action": "click", "selector": "text=\"意外险\"", "note": "选项"}]) + check("text= 点选项成功", "已点击" in msg, msg) + msg = run([{"action": "eval_js", "js": "document.querySelector('.ant-select-selection').textContent"}]) + check("下拉选中意外险", "意外险" in msg, msg) + + # 6. date 动作:2005-07-09(年份面板需翻两个十年;先关闭弹窗避免遮挡) + msg = run([ + {"action": "click", "selector": "text=\"Close\"", "note": "关弹窗"}, + {"action": "wait", "wait_seconds": 0.3}, + {"action": "date", "selector": "input.ant-calendar-picker-input", "value": "2005-07-09"}, + ]) + check("date 动作成功", "已选择日期: 2005-07-09" in msg, msg) + msg = run([{"action": "eval_js", "js": "document.querySelector('.ant-calendar-picker-input').value"}]) + check("date 写入输入框", "2005-07-09" in msg, msg) + + # 7. shadow DOM 穿透 + msg = run([{"action": "snapshot", "max_elements": 100}]) + check("shadow DOM 按钮可见", "影子按钮" in msg, msg[:300]) + + # 8. text= 精确匹配失败 → 自动子串匹配报告真实候选 + # (打开弹窗使下拉选项可见:text=旅游 精确匹配不到,子串候选含 060003旅游意外伤害保险) + msg = run([ + {"action": "click", "selector": "text=\"打开弹窗\""}, + {"action": "wait", "wait_seconds": 0.3}, + {"action": "click", "selector": "text=旅游"}, + ]) + check("text= 失败给出候选报告", "候选" in msg and "060003旅游" in msg, msg[:300]) + + # 9. 树节点:switcher 标 [展开],title 正常显示且可点击选中 + msg = run([{"action": "snapshot", "max_elements": 200}]) + check("树 switcher 标 [展开]", "[展开]" in msg, msg[:400]) + check("树节点标题可见", "北京分公司车险管理部" in msg, msg[:400]) + msg = run([ + {"action": "click", "selector": "text=\"Close\"", "note": "关弹窗"}, + {"action": "wait", "wait_seconds": 0.3}, + {"action": "click", "selector": "text=\"北京分公司车险管理部\"", "note": "树节点标题"}, + ]) + check("树节点标题可点击", "已点击" in msg, msg) + msg = run([{"action": "eval_js", "js": "document.getElementById('org-selected').textContent"}]) + check("树节点点击选中生效", "已选: 北京分公司车险管理部" in msg, msg) + + print() + if FAIL: + print(f"✗ {FAIL} 项失败 / {PASS} 项通过") + sys.exit(1) + print(f"全部通过 ✓({PASS} 项断言)") + + +if __name__ == "__main__": + main() diff --git a/tests/web_page/steps/r2_login.json b/tests/web_page/steps/r2_login.json new file mode 100644 index 0000000..cf23f43 --- /dev/null +++ b/tests/web_page/steps/r2_login.json @@ -0,0 +1,13 @@ +[ + {"action": "goto", "url": "http://117.78.36.208:9099/"}, + {"action": "wait", "wait_seconds": 3}, + {"action": "snapshot"}, + {"action": "fill", "ref": 1, "text": "10010381"}, + {"action": "fill", "ref": 2, "text": "uat"}, + {"action": "click", "ref": 5}, + {"action": "wait", "wait_seconds": 3}, + {"action": "snapshot"}, + {"action": "click", "ref": 5}, + {"action": "wait", "wait_seconds": 4}, + {"action": "snapshot"} +] diff --git a/tests/web_page/steps/tmp_cand.json b/tests/web_page/steps/tmp_cand.json new file mode 100644 index 0000000..465c14c --- /dev/null +++ b/tests/web_page/steps/tmp_cand.json @@ -0,0 +1,5 @@ +[ + {"action": "goto", "url": "http://127.0.0.1:8766/index.html"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=产品"} +] diff --git a/tests/web_page/steps/tmp_snap.json b/tests/web_page/steps/tmp_snap.json new file mode 100644 index 0000000..7a304f8 --- /dev/null +++ b/tests/web_page/steps/tmp_snap.json @@ -0,0 +1,3 @@ +[ + {"action": "snapshot", "max_elements": 50} +] diff --git a/tests/web_page/steps/tmp_snap300.json b/tests/web_page/steps/tmp_snap300.json new file mode 100644 index 0000000..1ce5d02 --- /dev/null +++ b/tests/web_page/steps/tmp_snap300.json @@ -0,0 +1,3 @@ +[ + {"action": "snapshot", "max_elements": 300} +] diff --git a/tests/web_page/steps/v_autosnap.json b/tests/web_page/steps/v_autosnap.json new file mode 100644 index 0000000..417da97 --- /dev/null +++ b/tests/web_page/steps/v_autosnap.json @@ -0,0 +1,5 @@ +[ + {"action": "click", "ref": 37, "note": "业务来源(已是直销业务,重选验证)"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"直销业务\""} +] diff --git a/tests/web_page/steps/v_channel.json b/tests/web_page/steps/v_channel.json new file mode 100644 index 0000000..5259287 --- /dev/null +++ b/tests/web_page/steps/v_channel.json @@ -0,0 +1,16 @@ +[ + {"action": "snapshot", "max_elements": 300}, + {"action": "click", "ref": 37, "note": "业务来源"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"直销业务\"", "note": "业务来源选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "ref": 39, "note": "渠道中级分类"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"传统直销业务\"", "note": "中级分类选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "ref": 41, "note": "渠道子类"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"员工直销\"", "note": "渠道子类选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).map(e => e.textContent.trim().slice(0, 15)); return JSON.stringify(nz.filter(t => ['直销业务', '传统直销业务', '员工直销'].includes(t))); })()"} +] diff --git a/tests/web_page/steps/v_date3s.json b/tests/web_page/steps/v_date3s.json new file mode 100644 index 0000000..247d303 --- /dev/null +++ b/tests/web_page/steps/v_date3s.json @@ -0,0 +1,7 @@ +[ + {"action": "snapshot", "max_elements": 300}, + {"action": "date", "ref": 83, "value": "2005-07-09", "note": "证件有效起期"}, + {"action": "date", "ref": 84, "value": "2006-07-09", "note": "证件有效止期"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "eval_js", "js": "(() => { const a = document.querySelector('input[placeholder=\"证件有效起期\"]')?.value; const b = document.querySelector('input[placeholder=\"证件有效止期\"]')?.value; return '起期=' + a + ' 止期=' + b; })()"} +] diff --git a/tests/web_page/steps/v_entry.json b/tests/web_page/steps/v_entry.json new file mode 100644 index 0000000..6d1c3ea --- /dev/null +++ b/tests/web_page/steps/v_entry.json @@ -0,0 +1,7 @@ +[ + {"action": "snapshot"}, + {"action": "click", "ref": 2, "note": "申请单录入"}, + {"action": "wait", "wait_seconds": 4}, + {"action": "eval_js", "js": "(() => { const m = document.querySelector('.ant-modal-wrap'); return m ? 'MODAL OPEN' : 'NO MODAL'; })()"}, + {"action": "snapshot", "max_elements": 40} +] diff --git a/tests/web_page/steps/v_entry2.json b/tests/web_page/steps/v_entry2.json new file mode 100644 index 0000000..9afe11b --- /dev/null +++ b/tests/web_page/steps/v_entry2.json @@ -0,0 +1,6 @@ +[ + {"action": "snapshot"}, + {"action": "click", "ref": 2, "note": "申请单录入"}, + {"action": "wait", "wait_seconds": 4}, + {"action": "snapshot", "max_elements": 40} +] diff --git a/tests/web_page/steps/v_fixture_check.json b/tests/web_page/steps/v_fixture_check.json new file mode 100644 index 0000000..1101485 --- /dev/null +++ b/tests/web_page/steps/v_fixture_check.json @@ -0,0 +1 @@ +[{"action": "eval_js", "js": "(() => { const b = document.getElementById('btn-dialog'); const r = document.getElementById('login-result'); return JSON.stringify({btnExists: !!b, resultText: r ? r.textContent : 'no-result', dialogOpen: document.getElementById('my-dialog') ? document.getElementById('my-dialog').className : 'no-dialog'}); })()"}] \ No newline at end of file diff --git a/tests/web_page/steps/v_fixture_goto.json b/tests/web_page/steps/v_fixture_goto.json new file mode 100644 index 0000000..381b5fb --- /dev/null +++ b/tests/web_page/steps/v_fixture_goto.json @@ -0,0 +1 @@ +[{"action": "goto", "url": "http://127.0.0.1:8766/index.html"}, {"action": "wait", "wait_seconds": 1}] diff --git a/tests/web_page/steps/v_link.json b/tests/web_page/steps/v_link.json new file mode 100644 index 0000000..c290cad --- /dev/null +++ b/tests/web_page/steps/v_link.json @@ -0,0 +1 @@ +[{"action": "snapshot", "max_elements": 300}, {"action": "eval_js", "js": "(() => { const u = document.querySelector('#Applicant\\.CWorkDpt'); return '初始 disabled=' + (u ? u.getAttribute('ng-reflect-nz-disabled') : 'NF'); })()"}, {"action": "click", "ref": 78, "note": "投保人性质"}, {"action": "wait", "wait_seconds": 1}, {"action": "click", "selector": "text=\"个人\"", "note": "投保人性质选项"}, {"action": "wait", "wait_seconds": 1}, {"action": "eval_js", "js": "(() => { const u = document.querySelector('#Applicant\\.CWorkDpt'); return '选个人后 disabled=' + (u ? u.getAttribute('ng-reflect-nz-disabled') : 'NF'); })()"}] \ No newline at end of file diff --git a/tests/web_page/steps/v_menu.json b/tests/web_page/steps/v_menu.json new file mode 100644 index 0000000..115f76c --- /dev/null +++ b/tests/web_page/steps/v_menu.json @@ -0,0 +1,6 @@ +[ + {"action": "snapshot"}, + {"action": "click", "ref": 1, "note": "系统菜单"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "snapshot", "max_elements": 40} +] diff --git a/tests/web_page/steps/v_menu2.json b/tests/web_page/steps/v_menu2.json new file mode 100644 index 0000000..ce8583c --- /dev/null +++ b/tests/web_page/steps/v_menu2.json @@ -0,0 +1,7 @@ +[ + {"action": "snapshot"}, + {"action": "click", "ref": 1, "note": "系统菜单"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "eval_js", "js": "(() => { const m = document.querySelector('.ant-modal-wrap'); return m ? 'MODAL OPEN' : 'NO MODAL'; })()"}, + {"action": "snapshot", "max_elements": 40} +] diff --git a/tests/web_page/steps/v_org.json b/tests/web_page/steps/v_org.json new file mode 100644 index 0000000..13de234 --- /dev/null +++ b/tests/web_page/steps/v_org.json @@ -0,0 +1,6 @@ +[ + {"action": "snapshot", "max_elements": 40}, + {"action": "click", "ref": 4, "note": "承保机构放大镜"}, + {"action": "wait", "wait_seconds": 3}, + {"action": "snapshot", "max_elements": 60} +] diff --git a/tests/web_page/steps/v_org_final.json b/tests/web_page/steps/v_org_final.json new file mode 100644 index 0000000..1a845e4 --- /dev/null +++ b/tests/web_page/steps/v_org_final.json @@ -0,0 +1,6 @@ +[ + {"action": "snapshot", "max_elements": 120}, + {"action": "click", "selector": "text=\"北京分公司车险管理部\"", "note": "机构节点"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "snapshot", "max_elements": 30} +] diff --git a/tests/web_page/steps/v_probe.json b/tests/web_page/steps/v_probe.json new file mode 100644 index 0000000..4ebea52 --- /dev/null +++ b/tests/web_page/steps/v_probe.json @@ -0,0 +1,3 @@ +[ + {"action": "eval_js", "js": "(() => { const els = Array.from(document.querySelectorAll('div,span')).filter(e => (e.textContent||'').trim() === '检索结果' && e.children.length <= 3); return JSON.stringify(els.map(e => { const a = {}; for (const x of e.attributes) a[x.name] = x.value.slice(0, 40); return {tag: e.tagName, cls: e.className, attrs: a, kids: e.children.length}; })); })()"} +] diff --git a/tests/web_page/steps/v_probe2.json b/tests/web_page/steps/v_probe2.json new file mode 100644 index 0000000..1265945 --- /dev/null +++ b/tests/web_page/steps/v_probe2.json @@ -0,0 +1 @@ +[{"action": "snapshot", "max_elements": 300}, {"action": "click", "ref": 118, "note": "单位性质"}, {"action": "wait", "wait_seconds": 2}, {"action": "eval_js", "js": "(() => { const lis = Array.from(document.querySelectorAll('.ant-select-dropdown-menu-item')).filter(e => e.offsetParent); return JSON.stringify(lis.slice(0, 8).map(e => e.textContent.trim().replace(/\\s+/g, ' ').slice(0, 40))); })()"}] \ No newline at end of file diff --git a/tests/web_page/steps/v_probe3.json b/tests/web_page/steps/v_probe3.json new file mode 100644 index 0000000..93e5ab6 --- /dev/null +++ b/tests/web_page/steps/v_probe3.json @@ -0,0 +1 @@ +[{"action": "eval_js", "js": "(() => { const out = []; Array.from(document.querySelectorAll('nz-select')).forEach(e => { const t = (e.textContent||'').trim().replace(/\\s+/g, ' ').slice(0, 25); if (t) out.push(t); }); return JSON.stringify(out); })()"}] \ No newline at end of file diff --git a/tests/web_page/steps/v_probe4.json b/tests/web_page/steps/v_probe4.json new file mode 100644 index 0000000..c576523 --- /dev/null +++ b/tests/web_page/steps/v_probe4.json @@ -0,0 +1 @@ +[{"action": "eval_js", "js": "(() => { const all = Array.from(document.querySelectorAll('nz-select')); const u = all.find(e => (e.getAttribute('id')||'').includes('WorkDpt') || (e.getAttribute('ng-reflect-name')||'').includes('WorkDpt')); if (!u) return 'NOT FOUND, ids=' + JSON.stringify(all.slice(0, 10).map(e => e.id)); return JSON.stringify({id: u.id, ngName: u.getAttribute('ng-reflect-name'), disabled: u.getAttribute('ng-reflect-nz-disabled'), clsDisabled: (u.className||'').includes('ant-select-disabled'), text: (u.textContent||'').trim().slice(0, 20)}); })()"}] \ No newline at end of file diff --git a/tests/web_page/steps/v_probe5.json b/tests/web_page/steps/v_probe5.json new file mode 100644 index 0000000..1d7049d --- /dev/null +++ b/tests/web_page/steps/v_probe5.json @@ -0,0 +1 @@ +[{"action": "eval_js", "js": "(() => {\n // 通讯地址-市下拉展开后看选项 li 文本\n const lis = Array.from(document.querySelectorAll('.ant-select-dropdown-menu-item')).map(e => JSON.stringify(e.textContent.trim().slice(0, 20)));\n return '当前可见 li: ' + lis.join(', ');\n})()"}] \ No newline at end of file diff --git a/tests/web_page/steps/v_search.json b/tests/web_page/steps/v_search.json new file mode 100644 index 0000000..3d42370 --- /dev/null +++ b/tests/web_page/steps/v_search.json @@ -0,0 +1,7 @@ +[ + {"action": "snapshot", "max_elements": 60}, + {"action": "fill", "ref": 2, "text": "车险管理部"}, + {"action": "press", "key": "Enter"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "snapshot", "max_elements": 60} +] diff --git a/tests/web_page/steps/v_state.json b/tests/web_page/steps/v_state.json new file mode 100644 index 0000000..2d6875b --- /dev/null +++ b/tests/web_page/steps/v_state.json @@ -0,0 +1 @@ +[{"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).map(e => (e.textContent||'').trim().replace(/\\s+/g, ' ').slice(0, 20)); const keys = ['业务来源', '渠道中级分类', '渠道子类', '机构部门', '产品']; return JSON.stringify({all: nz}); })()"}] \ No newline at end of file diff --git a/tests/web_page/steps/v_struct.json b/tests/web_page/steps/v_struct.json new file mode 100644 index 0000000..ca14408 --- /dev/null +++ b/tests/web_page/steps/v_struct.json @@ -0,0 +1 @@ +[{"action": "eval_js", "js": "(() => {\n const out = {legends: [], expands: [], unitDisabled: null, addrVisible: null};\n // 1) 所有 fieldset legend 标题 + 展开链接状态\n out.legends = Array.from(document.querySelectorAll('fieldset > legend')).map(l => {\n const fs = l.closest('fieldset');\n const a = l.querySelector('a');\n return {\n title: (l.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 30),\n link: a ? a.textContent.trim() : null,\n bodyHidden: fs ? getComputedStyle(fs.querySelector('div, table, .ant-table, form') || fs).display : '?'\n };\n });\n // 2) 通讯地址省下拉可见性\n const addr = Array.from(document.querySelectorAll('nz-select')).find(e => (e.textContent||'').includes('省'));\n out.addrVisible = addr ? (addr.offsetParent !== null ? 'VISIBLE' : 'HIDDEN') : 'NOT FOUND';\n // 3) 单位性质禁用\n const unit = document.querySelector('#Applicant\\.CWorkDpt, [id=\"Applicant.CWorkDpt\"]');\n out.unitDisabled = unit ? (unit.getAttribute('ng-reflect-nz-disabled') || unit.classList.contains('ant-select-disabled') || '?') : 'NOT FOUND';\n // 4) 8 个展开链接的父级标题\n out.expands = Array.from(document.querySelectorAll('a.legrnd-ta')).map(a => {\n const leg = a.closest('legend');\n return (leg ? leg.textContent.trim().replace(/\\s+/g, ' ').slice(0, 30) : 'no-legend');\n });\n return JSON.stringify(out);\n})()"}] \ No newline at end of file diff --git a/tests/web_page/steps/v_tree_live.json b/tests/web_page/steps/v_tree_live.json new file mode 100644 index 0000000..5c60495 --- /dev/null +++ b/tests/web_page/steps/v_tree_live.json @@ -0,0 +1,9 @@ +[ + {"action": "snapshot"}, + {"action": "click", "ref": 2, "note": "申请单录入"}, + {"action": "wait", "wait_seconds": 4}, + {"action": "snapshot", "max_elements": 40}, + {"action": "click", "ref": 4, "note": "承保机构放大镜"}, + {"action": "wait", "wait_seconds": 3}, + {"action": "snapshot", "max_elements": 120} +] diff --git a/tests/web_page/steps/v_tree_live2.json b/tests/web_page/steps/v_tree_live2.json new file mode 100644 index 0000000..b515c77 --- /dev/null +++ b/tests/web_page/steps/v_tree_live2.json @@ -0,0 +1,11 @@ +[ + {"action": "goto", "url": "http://117.78.36.208:9099/"}, + {"action": "wait", "wait_seconds": 3}, + {"action": "snapshot"}, + {"action": "click", "ref": 2, "note": "申请单录入"}, + {"action": "wait", "wait_seconds": 4}, + {"action": "snapshot", "max_elements": 40}, + {"action": "click", "ref": 4, "note": "承保机构放大镜"}, + {"action": "wait", "wait_seconds": 3}, + {"action": "snapshot", "max_elements": 120} +] diff --git a/tests/web_page/steps/v_unit.json b/tests/web_page/steps/v_unit.json new file mode 100644 index 0000000..057c868 --- /dev/null +++ b/tests/web_page/steps/v_unit.json @@ -0,0 +1,6 @@ +[ + {"action": "snapshot", "max_elements": 300}, + {"action": "click", "ref": 54, "note": "单位性质"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "click", "selector": "text=机关团体"} +] diff --git a/tests/web_page/steps/v_unit2.json b/tests/web_page/steps/v_unit2.json new file mode 100644 index 0000000..e62bb7b --- /dev/null +++ b/tests/web_page/steps/v_unit2.json @@ -0,0 +1,4 @@ +[ + {"action": "snapshot", "max_elements": 300}, + {"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')); const u = nz.find(e => (e.textContent||'').includes('单位性质') || (e.getAttribute('ng-reflect-name')||'').includes('Unit')); return '单位性质nz-select: ' + (u ? u.getAttribute('ng-reflect-name') : '未按名找到'); })()"} +] diff --git a/tests/web_page/steps/v_unit3.json b/tests/web_page/steps/v_unit3.json new file mode 100644 index 0000000..308e6ee --- /dev/null +++ b/tests/web_page/steps/v_unit3.json @@ -0,0 +1,6 @@ +[ + {"action": "snapshot", "max_elements": 300}, + {"action": "click", "ref": 118, "note": "单位性质"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "click", "selector": "text=机关团体"} +] diff --git a/tests/web_page/steps/v_unit4.json b/tests/web_page/steps/v_unit4.json new file mode 100644 index 0000000..4a8466d --- /dev/null +++ b/tests/web_page/steps/v_unit4.json @@ -0,0 +1,8 @@ +[ + {"action": "snapshot", "max_elements": 300}, + {"action": "click", "ref": 118, "note": "单位性质"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "click", "selector": "text=\"机关团体-社会团体\"", "note": "单位性质选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).filter(e => (e.textContent||'').includes('机关团体-社会团体')); return '选中: ' + nz.length + ' 个'; })()"} +] diff --git a/tests/web_page/steps/v_unit5.json b/tests/web_page/steps/v_unit5.json new file mode 100644 index 0000000..cd65904 --- /dev/null +++ b/tests/web_page/steps/v_unit5.json @@ -0,0 +1,8 @@ +[ + {"action": "snapshot", "max_elements": 300}, + {"action": "click", "ref": 118, "note": "单位性质"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "click", "selector": "text=\"机关团体-社会团体\"", "note": "单位性质选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).filter(e => (e.textContent||'').includes('机关团体')); return '含机关团体 select 数: ' + nz.length; })()"} +] diff --git a/tests/web_page/steps/v_unit6.json b/tests/web_page/steps/v_unit6.json new file mode 100644 index 0000000..4b2032a --- /dev/null +++ b/tests/web_page/steps/v_unit6.json @@ -0,0 +1,6 @@ +[ + {"action": "snapshot", "max_elements": 300}, + {"action": "click", "selector": "nz-select:has([ng-reflect-name]) >> text=\"机关团体\"", "note": "定位单位性质"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "eval_js", "js": "(() => { const lis = Array.from(document.querySelectorAll('.ant-select-dropdown-menu-item')).filter(e => e.offsetParent).map(e => e.textContent.trim().slice(0, 25)); return JSON.stringify(lis.slice(0, 8)); })()"} +] diff --git a/tests/web_page/steps/v_unit7.json b/tests/web_page/steps/v_unit7.json new file mode 100644 index 0000000..f4f6d78 --- /dev/null +++ b/tests/web_page/steps/v_unit7.json @@ -0,0 +1,8 @@ +[ + {"action": "snapshot", "max_elements": 300}, + {"action": "click", "ref": 118, "note": "单位性质"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "click", "selector": "text=\"机关团体-社会团体\"", "note": "单位性质选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "eval_js", "js": "(() => { const nz = Array.from(document.querySelectorAll('nz-select')).filter(e => (e.textContent||'').includes('机关团体-社会团体')); return '选中数: ' + nz.length; })()"} +] diff --git a/tests/web_page/steps/v_wizard.json b/tests/web_page/steps/v_wizard.json new file mode 100644 index 0000000..dd4bfd0 --- /dev/null +++ b/tests/web_page/steps/v_wizard.json @@ -0,0 +1,28 @@ +[ + {"action": "snapshot", "max_elements": 60}, + {"action": "fill", "ref": 2, "text": ""}, + {"action": "press", "key": "Enter"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "click", "selector": "text=\"北京分公司车险管理部\"", "note": "机构树节点"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "snapshot", "max_elements": 40}, + {"action": "click", "ref": 5, "note": "产品大类"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "type", "text": "意外险"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"意外险\"", "note": "产品大类选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "ref": 7, "note": "产品"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "type", "text": "060003"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"060003旅游意外伤害保险\"", "note": "产品选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "ref": 11, "note": "联共保业务"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"司内联保_主联\"", "note": "联共保选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "ref": 15, "note": "确认"}, + {"action": "wait", "wait_seconds": 6}, + {"action": "snapshot", "max_elements": 20} +] diff --git a/tests/web_page/steps/v_wizard2.json b/tests/web_page/steps/v_wizard2.json new file mode 100644 index 0000000..319dc07 --- /dev/null +++ b/tests/web_page/steps/v_wizard2.json @@ -0,0 +1,30 @@ +[ + {"action": "snapshot", "max_elements": 40}, + {"action": "click", "ref": 4, "note": "承保机构放大镜"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "fill", "selector": "input[placeholder*=\"机构代码\"]", "text": ""}, + {"action": "press", "key": "Enter"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "click", "selector": "text=\"北京分公司车险管理部\"", "note": "机构树节点"}, + {"action": "wait", "wait_seconds": 2}, + {"action": "snapshot", "max_elements": 40}, + {"action": "click", "ref": 5, "note": "产品大类"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "type", "text": "意外险"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"意外险\"", "note": "产品大类选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "ref": 7, "note": "产品"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "type", "text": "060003"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"060003旅游意外伤害保险\"", "note": "产品选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "ref": 11, "note": "联共保业务"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "selector": "text=\"司内联保_主联\"", "note": "联共保选项"}, + {"action": "wait", "wait_seconds": 1}, + {"action": "click", "ref": 15, "note": "确认"}, + {"action": "wait", "wait_seconds": 6}, + {"action": "snapshot", "max_elements": 20} +]