更新压缩机制,提高前缀缓存命中率,优化UI渲染机制,提高帧数

This commit is contained in:
minorQAQ committed 2026-08-25 10:13:50 +08:00
1 parent 6131c2563e
commit 0ce72f74f1
175 files changed
+7621 -2611

No files matched your search

+131 -77
View File
@@ -728,11 +728,11 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
<div class="chips" data-reveal-list="left" data-stagger="70">
<span class="chip"><span>🖥️</span><span><b>真能操作桌面</b> — 截图 → 视觉定位 → 键鼠模拟,闭环完成 ERP 录入、表单填写、软件操控</span></span>
<span class="chip"><span>🧩</span><span><b>20 项内置工具</b> — 从终端命令到邮件收发,从 PPT 制作到图像生成,覆盖真实办公场景</span></span>
<span class="chip"><span>🧩</span><span><b>20 项内置工具 + 单文件插件化</b> — 从终端命令到邮件收发,从 PPT 制作到图像生成;新增工具 = 一个文件 + 协议接入,前端面板自动生成,无需大面积改动</span></span>
<span class="chip"><span>🏠</span><span><b>完全本地运行</b> — Electron 打包,数据不出本机;后端 FastAPI + 前端零延迟直读磁盘</span></span>
<span class="chip"><span>🎨</span><span><b>可深度定制</b> — 主题 / 字体 / 字号 / 背景 / 工作空间 / 提示词分层架构,全部可调</span></span>
<span class="chip"><span>🔁</span><span><b>工程级稳定性</b> — 循环检测、上下文压缩、阻塞式人工确认、轨迹回放,对抗 LLM 的「幻觉操作」</span></span>
<span class="chip"><span>💾</span><span><b>存储双后端(接口隔离)</b> — 会话与定时任务存储统一接口驱动,<b>JSON 可视化 / MySQL</b> 一键切换、不双写;新增存储方式只需实现接口,消费方无感知后端</span></span>
<span class="chip"><span>💾</span><span><b>存储多后端(接口隔离)</b> — 会话与定时任务存储统一接口驱动,<b>JSON / MySQL / SQLite</b> 一键切换、不双写;存储数据库与工具数据库配置隔离;新增存储方式只需实现接口,消费方无感知后端</span></span>
</div>
<blockquote class="quote reveal">
@@ -766,16 +766,16 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
<tr><th>工具</th><th>文件</th><th>能力</th></tr>
</thead>
<tbody>
<tr><td><span class="tool-emoji">🖱️</span> <strong>gui</strong></td><td><code>tools/gui.py</code></td><td>桌面 GUI 自动化:截图、视觉批量定位、点击/输入/快捷键/滚动</td></tr>
<tr><td><span class="tool-emoji">🌐</span> <strong>browser_control</strong></td><td><code>tools/browser_control.py</code></td><td>浏览器打开/关闭、地址导航、标签页交互</td></tr>
<tr><td><span class="tool-emoji">💻</span> <strong>terminal_execute</strong></td><td><code>tools/terminal_execute.py</code></td><td>执行任意终端命令,支持工作目录与超时</td></tr>
<tr><td><span class="tool-emoji">🖱️</span> <strong>gui</strong></td><td><code>tools/gui.py</code></td><td>桌面 GUI 自动化:截图、视觉批量定位、点击/输入/快捷键/滚动(桌面/任意应用轨)</td></tr>
<tr><td><span class="tool-emoji">🌐</span> <strong>browser_control</strong></td><td><code>tools/browser_control.py</code></td><td>浏览器打开/关闭、地址导航、标签页交互(Playwright + CDP 分离进程,Profile 默认一次性临时目录,可配置持久)</td></tr>
<tr><td><span class="tool-emoji">🧭</span> <strong>web_page</strong></td><td><code>tools/web_page.py</code></td><td>浏览器页面 <strong>DOM 级精准操作</strong>(浏览器轨):snapshot 元素清单、click/fill/scroll/press/extract/screenshot/eval_js 等,毫秒级、优于 GUI 视觉定位;典型流程 browser_control 打开 → web_page 操作</td></tr>
<tr><td><span class="tool-emoji">💻</span> <strong>terminal_execute</strong></td><td><code>tools/terminal_execute.py</code></td><td>执行任意终端命令(含目录/文件操作,可替代 directory_listing),支持工作目录与超时</td></tr>
<tr><td><span class="tool-emoji">🪟</span> <strong>software_control</strong></td><td><code>tools/software_control.py</code></td><td>Win32 API 启停软件、注册表、<code>os.startfile</code></td></tr>
<tr><td><span class="tool-emoji">📧</span> <strong>email</strong></td><td><code>tools/email.py</code></td><td>IMAP 收邮件 + SMTP 发邮件(含附件)</td></tr>
<tr><td><span class="tool-emoji">📄</span> <strong>doc</strong></td><td><code>tools/doc.py</code></td><td>创建/读取/编辑 docx / xlsx / pptx / pdf / txt</td></tr>
<tr><td><span class="tool-emoji">📂</span> <strong>directory_listing</strong></td><td><code>tools/directory_listing.py</code></td><td>递归列目录、文件元信息</td></tr>
<tr><td><span class="tool-emoji">📨</span> <strong>send_file</strong></td><td><code>tools/send_file.py</code></td><td>以邮件附件形式发送本地文件</td></tr>
<tr><td><span class="tool-emoji">🔍</span> <strong>web_search</strong></td><td><code>tools/web_search.py</code></td><td>联网搜索(Tavily / Firecrawl / 火山引擎)</td></tr>
<tr><td><span class="tool-emoji">🗄️</span> <strong>text2sql</strong></td><td><code>tools/text2sql.py</code></td><td>自然语言/SQL 操作数据库:查表结构、查询、增删改</td></tr>
<tr><td><span class="tool-emoji">🗄️</span> <strong>text2sql</strong></td><td><code>tools/text2sql.py</code></td><td>自然语言/SQL 操作数据库(MySQL / SQLite,工具数据库配置):查表结构、查询、增删改、建库建表</td></tr>
<tr><td><span class="tool-emoji">📚</span> <strong>rag</strong></td><td><code>tools/rag.py</code></td><td>ChromaDB 向量检索 + Reranker 重排序</td></tr>
<tr><td><span class="tool-emoji">🎨</span> <strong>image_gen</strong></td><td><code>tools/image_gen.py</code></td><td>调用 Z-Image-Turbo 生成图片</td></tr>
<tr><td><span class="tool-emoji">✅</span> <strong>todo_list</strong></td><td><code>tools/todo_list.py</code></td><td>多步任务规划与进度跟踪</td></tr>
@@ -785,11 +785,13 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
<tr><td><span class="tool-emoji">📅</span> <strong>date_query</strong></td><td><code>tools/date_query.py</code></td><td>当前日期时间查询</td></tr>
<tr><td><span class="tool-emoji">⏰</span> <strong>cron_manager</strong></td><td><code>tools/cron_manager.py</code></td><td>定时任务管理:查看/新增/修改/删除定时任务,支持 cron/间隔/一次性触发</td></tr>
<tr><td><span class="tool-emoji">📊</span> <strong>hard_excel_read</strong></td><td><code>tools/hard_excel_read.py</code></td><td>复杂 Excel 表结构读取:前 N 行原始数据 + 维度/合并区/数据区范围建议,供大模型解析结构</td></tr>
<tr><td><span class="tool-emoji">📥</span> <strong>excel2sql</strong></td><td><code>tools/excel2sql.py</code></td><td>按大模型给定的数据行列范围,将 Excel 数据区流式批量入库 MySQL(自动建库建表、表注释)</td></tr>
<tr><td><span class="tool-emoji">📥</span> <strong>excel2sql</strong></td><td><code>tools/excel2sql.py</code></td><td>按大模型给定的数据行列范围,将 Excel 数据区流式批量入库(MySQL / SQLite,自动建库建表)</td></tr>
</tbody>
</table>
</div>
<p class="reveal-up" style="color:var(--ink-soft);font-size:.92rem;margin-top:10px">🔒 <strong>main Agent 默认挂载</strong>(代码强制注入,不可通过配置移除,前端显示为锁定项):<code>call_subagent</code>(子 Agent 委派)、<code>doc_tool</code>(文档读写)、<code>human_interaction</code>(人机交互审查)、<code>todo_list</code>(任务规划)、<code>terminal_execute</code>(终端命令)。其余工具按 <code>config.json</code> 中 main agent 的 tools 列表自行增删;子 Agent 无此限制。</p>
<h3 class="reveal" style="font-size:1.4rem;margin:44px 0 16px">🎯 区别于其他 Agent 项目的核心设计</h3>
<div class="table-scroll reveal-scale">
@@ -799,13 +801,16 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
</thead>
<tbody>
<tr><td><strong>GUI 操作粒度</strong></td><td>一步一调用,N 个动作 = 2N+1 次 LLM 调用</td><td><strong>动作序列合并</strong>:同页多操作打包为一次 <code>gui</code> 调用,N 个动作仅 2 次 LLM 调用</td></tr>
<tr><td><strong>UI 自动化双轨</strong></td><td>单轨(截图 + 坐标模拟)</td><td><strong>Playwright + GUI 双轨兼顾容错与效率</strong>:浏览器内走 DOM 级精准操作(<code>web_page</code> 毫秒级、定位可靠);桌面/任意应用走 GUI 视觉定位(<code>gui_tool</code> 截图视觉闭环、可处理任意界面)——浏览器能 DOM 就不截图,桌面无 DOM 就走视觉</td></tr>
<tr><td><strong>视觉反馈</strong></td><td>仅返回坐标文本</td><td><strong>截图作为视觉闭环</strong>注入上下文,多模态 LLM 看到真实屏幕状态</td></tr>
<tr><td><strong>坐标定位</strong></td><td>逐元素请求</td><td><strong>批量定位</strong> <code>_batch_ground_elements</code>:N 个元素 1 次子模型调用</td></tr>
<tr><td><strong>循环失控</strong></td><td>无防护,token 烧穿</td><td><strong>两级循环检测</strong>:连续 3 次同工具警告反思 → 连续 5 次强制终止</td></tr>
<tr><td><strong>上下文膨胀</strong></td><td>简单截断</td><td><strong>图内分级压缩</strong>:token 超 <code>窗口×COMPRESS_RATE</code> 触发,压缩历史工具调用为累积摘要,每步 LLM 调用后检查</td></tr>
<tr><td><strong>上下文连续性</strong></td><td>思考链跨轮丢失</td><td><strong>历史思考/反思注入</strong>:压缩游标后的思考、反思、工具调用按因果顺序交错注入(稳定 id、确定性构建,前缀缓存友好);深度思考与反思分离存储</td></tr>
<tr><td><strong>部署形态</strong></td><td>仅 Web / 仅 API</td><td><strong>同一套源码双模式</strong>:<code>uvicorn</code> Web 服务 ↔ Electron 桌面应用</td></tr>
<tr><td><strong>文件操作</strong></td><td>HTTP 上传下载</td><td>桌面端走 <strong>IPC 直读磁盘</strong>,零延迟;Web 端走 <code>/api/fs/*</code></td></tr>
<tr><td><strong>提示词</strong></td><td>单层 system prompt</td><td><strong>四层提示词架构</strong>:系统级 / 工具描述 / 运行时注入 / 内部子模型</td></tr>
<tr><td><strong>插件化</strong></td><td>注册式插件表(适配新插件需改框架主流程)</td><td><strong>前后端单文件插件化</strong>:后端按 <code>BaseTool</code> 协议单文件实现、前端按 <code>setXxxDeps</code> 依赖注入协议接入,<strong>监听事件 / 回调驱动而非注册式</strong>——新增或替换一个插件只需保持协议签名,宿主装配零改动</td></tr>
</tbody>
</table>
</div>
@@ -825,7 +830,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
<span class="img-path">./git_image/运行-todolist.png</span>
</div>
</figure>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)与信息收集以浮窗形式实时弹出,用户可同意、拒绝、跳过或补充信息;<b>拒绝也只是一次工具返回值</b>,Agent 自行调整方案继续,不会结束本轮&emsp;|&emsp;<b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表</sub></p>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)与信息收集以「输入区上方通知条」形式实时弹出:只显示标题与数量角标,点击展开为完整卡片(标题/内容/选项胶囊/补充输入框),选择或补充后作为工具返回值,Agent 继续执行;<b>拒绝也只是一次工具返回值</b>,Agent 自行调整方案继续,不会结束本轮&emsp;|&emsp;<b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,同样以通知条展示(最新步骤 + 数量角标,可展开全部步骤),Todo 状态<b>会话级存储</b>(chat 与 cron 各自独立,切换会话自动恢复)</sub></p>
<figure class="shot-card reveal-rotate" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px;justify-items:center;margin:24px 0 6px">
<div class="img-card" style="width:min(360px,100%)" data-path="./git_image/运行-文件修改.png">
@@ -839,7 +844,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
<span class="img-path">./git_image/运行-工具调用1.png</span>
</div>
</figure>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>文件编辑模式</b> — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织&emsp;|&emsp;<b>工具调用记录</b> — 实时展示每轮 ReAct 的工具调用链、参数及返回值</sub></p>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>文件编辑模式</b> — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织&emsp;|&emsp;<b>工具调用记录</b> — 实时展示每轮 ReAct 的工具调用链、参数及返回值;思考(reasoning_content)与反思(content)分离显示为独立的 Thought / Reflection 行</sub></p>
<figure class="shot-card reveal-rotate" style="margin:24px 0 6px;text-align:center">
<div class="img-card" style="width:min(560px,100%);margin:0 auto" data-path="./git_image/运行-工具调用2.png">
@@ -862,7 +867,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
<span class="img-path">./git_image/运行-edit模式.png</span>
</div>
</figure>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>定时任务管理</b> — 支持 cron / 间隔 / 一次性三种触发方式,状态实时可见&emsp;|&emsp;<b>Edit 编辑模式</b> — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent</sub></p>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>定时任务管理</b> — 支持 cron / 间隔 / 一次性三种触发方式;任务按<b>工作区分组</b>展示;任务会话与聊天同构(点击会话式查看历史、多轮对话、流式渲染),任务在<b>独立子进程</b>中执行;模式切换锁:chat 运行时可查看 cron、cron 运行时可查看 chat,双容器渲染不打断流式输出&emsp;|&emsp;<b>Edit 编辑模式</b> — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent</sub></p>
<figure class="shot-card reveal-rotate" style="margin:24px 0 6px;text-align:center">
<div class="img-card" style="width:min(560px,100%);margin:0 auto" data-path="./git_image/修改图片.png">
@@ -941,7 +946,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
<div class="lead reveal" style="margin-top:26px">
<ul data-reveal-list="left" data-stagger="60">
<li class="li-rv"><strong><code>call_model</code></strong>(<code>core/nodes.py</code>):追加动态尾部(TodoList 状态 + 循环提醒;固定反思引导已并入系统提示词前缀)→ LLM 调用 → 每步检查压缩(token 超阈值标记)→ 提取 thinking</li>
<li class="li-rv"><strong><code>call_model</code></strong>(<code>core/nodes.py</code>):本轮回插思考/反思(<code>[思考]</code> / <code>[反思]</code> 合成消息)→ 追加动态尾部(TodoList 状态 + 循环提醒;固定反思引导已并入系统提示词前缀)→ LLM 调用 → 每步检查压缩(token 超阈值标记)→ 提取 thinking / reflection 独立记录</li>
<li class="li-rv"><strong><code>should_continue</code></strong>(<code>core/routing.py</code>):含 tool_calls → 循环检测 → 路由到 <code>tools</code> 或 <code>END</code>(需人工确认的工具不在此暂停,而是在工具执行钳点阻塞征求用户意见,见「阻塞式人机交互」)</li>
<li class="li-rv"><strong><code>process_tool_artifact</code></strong>:gui 截图作为 synthetic HumanMessage 注入,实现视觉闭环</li>
<li class="li-rv"><strong><code>compress</code></strong>(<code>core/nodes.py</code>):token 超 <code>窗口×COMPRESS_RATE</code> 时,将历史工具调用压缩为累积摘要(见「实现细节」)</li>
@@ -951,7 +956,7 @@ footer.visible .footer-line{width:min(560px,80%);transition:width 1.3s var(--eas
<h3 class="reveal" style="font-size:1.4rem;margin:44px 0 16px">多 Agent 模式</h3>
<p class="reveal-up" style="color:var(--ink-soft);margin-bottom:18px">
多 Agent 并非独立图,而是<strong>同一张 react 图内的工具级并行</strong>:<code>tools</code> 节点由 <code>make_parallel_tool_node</code>(线程池)取代 ToolNode,一轮内的多个工具调用并行执行;其中 <code>agent_call</code> 工具会以主 Agent 当前消息快照启动<strong>子 Agent 的独立图实例</strong>执行子任务,结果以工具返回值注入主 Agent 上下文。子 Agent 内的人机交互同样为阻塞式——在子图线程中等待浮窗应答,完成后自然回到主图:
多 Agent 并非独立图,而是<strong>同一张 react 图内的工具级并行</strong>:<code>tools</code> 节点由 <code>make_parallel_tool_node</code>(线程池)取代 ToolNode,一轮内的多个工具调用并行执行;其中 <code>agent_call</code> 工具会以主 Agent 当前消息快照启动<strong>子 Agent 的独立图实例</strong>执行子任务,结果以工具返回值注入主 Agent 上下文。子 Agent 内的人机交互同样为阻塞式——在子图线程中等待通知条应答,完成后自然回到主图:
</p>
<div class="line-cnt reveal" data-line="type" data-interval="55">
@@ -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 ──────────────────────┘</pre>
@@ -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</pre>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin:14px 0 18px">
首次运行会弹出<strong>配置向导</strong>(3 步),写入 <code>src/agent/config/env_config.json</code> 后自动启动:
首次运行会弹出<strong>配置向导</strong>(3 步),写入 <code>src/agent/agents/config.json</code> 后自动启动:
</p>
<div class="table-scroll reveal-scale">
@@ -1094,7 +1096,7 @@ npm install</pre>
</table>
</div>
<blockquote class="quote reveal">💡 之后启动直接进入主界面,不再弹向导。如需重配,删除 <code>src/agent/config/env_config.json</code> 即可重触首次流程。</blockquote>
<blockquote class="quote reveal">💡 之后启动直接进入主界面,不再弹向导。如需重配,删除 <code>src/agent/agents/config.json</code>(或复制 <code>config_dist.json</code> 为新配置)即可重触首次流程。</blockquote>
<h4 class="reveal-up" style="font-size:1.1rem;margin:22px 0 4px">3. 打包为安装程序</h4>
<div class="term reveal">
@@ -1113,10 +1115,10 @@ npm run build</pre>
<summary>📦 打包原理(点击展开)</summary>
<div class="pack-body">
<ul data-reveal-list="left" data-stagger="50">
<li class="li-rv"><code>package.json</code> 的 <code>build.files</code> 仅打包 <code>electron/</code>、<code>src/</code>、<code>requirements.txt</code>,<strong>排除</strong> <code>env_config.json</code>(每个用户独立生成)</li>
<li class="li-rv"><code>package.json</code> 的 <code>build.files</code> 仅打包 <code>electron/</code>、<code>src/</code>、<code>requirements.txt</code>,<strong>排除</strong> <code>config.json</code>(每个用户独立生成,模板 <code>config_dist.json</code> 随包分发)</li>
<li class="li-rv"><code>asar: false</code>:源码不加密,便于审计与二次开发</li>
<li class="li-rv">NSIS 安装器:允许改安装目录、创建桌面/开始菜单快捷方式、卸载时清理 AppData</li>
<li class="li-rv">首次启动由 <code>setup.html</code> 向导引导:检测 Python → 配置 LLM → 写 <code>env_config.json</code> → 启动 FastAPI → 加载 UI</li>
<li class="li-rv">首次启动由 <code>setup.html</code> 向导引导:检测 Python → 配置 LLM → 写 <code>config.json</code> → 启动 FastAPI → 加载 UI</li>
<li class="li-rv">主进程 <code>findSystemPython()</code> 依次检查:用户配置路径 → 常见 Python 安装位置 → PATH 中的 <code>python</code></li>
</ul>
</div>
@@ -1142,8 +1144,8 @@ pip install -r requirements.txt
<span class="cmt"># 3. 配置环境(首次)</span>
<span class="cmt"># 复制模板并填写 LLM 凭证</span>
Copy-Item src\agent\config\env_config_dist.json src\agent\config\env_config.json
<span class="cmt"># 编辑 env_config.json,至少填写 models[].base_url / api_key / model</span>
Copy-Item src\agent\agents\config_dist.json src\agent\agents\config.json
<span class="cmt"># 编辑 config.json,至少填写 env.models[].base_url / api_key / model</span>
<span class="cmt"># 4. 启动 Web 服务</span>
$env:PYTHONPATH="src;$env:PYTHONPATH"
@@ -1172,10 +1174,10 @@ cloudflared tunnel --url http://localhost:8765</pre>
<div class="section-head">
<span class="sec-emoji">⚙️</span>
<h2>配置说明</h2>
<p class="sec-sub">所有运行时配置集中在 <code>src/agent/config/env_config.json</code>,首次向导自动生成。</p>
<p class="sec-sub">所有运行时配置集中在 <code>src/agent/agents/config.json</code>(合并单文件,含 <code>agents / tools / env / gui / workspace / theme</code> 分区),模板见 <code>config_dist.json</code>,首次向导自动生成。</p>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin-bottom:12px">关键字段:</p>
<p class="reveal-up" style="color:var(--ink-soft);margin-bottom:12px">env 分区关键字段:</p>
<div class="table-scroll reveal-scale">
<table data-row-anim="alt">
@@ -1183,12 +1185,9 @@ cloudflared tunnel --url http://localhost:8765</pre>
<tbody>
<tr><td><code>models[]</code></td><td><code>SETUP_REQUIRED</code></td><td>LLM 列表:<code>{id, name, model, api_key, base_url, timeout, max_retries}</code>;ASR / TTS / RAG / ImageGen 四个本地服务也在此注册(按 <code>model</code> 字段匹配,<code>api_key</code> 留空)</td></tr>
<tr><td><code>gui_model_id</code></td><td><code>""</code></td><td>GUI 视觉定位专用模型(留空则回退主模型)</td></tr>
<tr><td><code>WORKING_DIR</code></td><td>项目根目录</td><td>工作根目录(桌面端自动设为安装目录)</td></tr>
<tr><td><code>WORKSPACE_DIR</code></td><td><code>""</code></td><td>工作空间目录(相对 WORKING_DIR),留空则使用 WORKING_DIR</td></tr>
<tr><td><code>USER_PYTHON_PATH</code></td><td><code>""</code></td><td>指定 Python 解释器路径</td></tr>
<tr><td><code>LLM_CONTEXT_WINDOW</code></td><td><code>262144</code></td><td>上下文窗口大小(token)</td></tr>
<tr><td><code>COMPRESS_RATE</code></td><td><code>0.6</code></td><td>上下文压缩阈值比例(0~1),token 用量超过 <code>窗口×该值</code> 时触发压缩</td></tr>
<tr><td><code>THINKING_LEVEL</code></td><td><code>"low"</code></td><td>深度思考档位:low / high / xhigh / max / ultra(xhigh 及以上启用深度思考)</td></tr>
<tr><td><code>IMG_SIZE</code></td><td><code>768</code></td><td>截图短边尺寸</td></tr>
<tr><td><code>GROUNDING_WIDTH/HEIGHT</code></td><td><code>1000</code></td><td>视觉定位输入分辨率</td></tr>
<tr><td><code>LOOP_DETECT_REPEATED_TOOL_WARN</code></td><td><code>3</code></td><td>连续同工具同参多少次警告</td></tr>
@@ -1197,8 +1196,11 @@ cloudflared tunnel --url http://localhost:8765</pre>
<tr><td><code>SEND_FILE_SIZE_LIMIT</code></td><td><code>30</code></td><td>发送文件大小上限(MB)</td></tr>
<tr><td><code>WEB_SEARCH_API_KEY</code></td><td><code>""</code></td><td>联网搜索 API Key</td></tr>
<tr><td><code>WEB_SEARCH_ENGINE</code></td><td><code>"tavily"</code></td><td>搜索引擎选择(tavily / firecrawl / volcengine)</td></tr>
<tr><td><code>STORAGE_BACKEND</code></td><td><code>"json"</code></td><td>存储后端(json / mysql)</td></tr>
<tr><td><code>MYSQL_HOST</code> / <code>MYSQL_PORT</code> / <code>MYSQL_USER</code> / <code>MYSQL_PASSWORD</code> / <code>MYSQL_DATABASE</code></td><td><code>""</code></td><td>MySQL 连接参数(STORAGE_BACKEND=mysql 时生效)</td></tr>
<tr><td><code>STORAGE_BACKEND</code></td><td><code>"json"</code></td><td>存储后端:json(本地文件)/ mysql / sqlite</td></tr>
<tr><td><code>STORAGE_DB_TYPE</code> + <code>STORAGE_DB_HOST/PORT/USER/PASSWORD/DATABASE/PATH</code></td><td><code>""</code></td><td><strong>存储数据库</strong>配置(mysql 用 host/port/user/password/database;sqlite 用 path,留空默认 <code>history/sqlite/storage.sqlite</code>);缺省回退旧 <code>MYSQL_*</code>(零迁移)</td></tr>
<tr><td><code>TOOL_DB_TYPE</code> + <code>TOOL_DB_HOST/PORT/USER/PASSWORD/DATABASE/PATH</code></td><td><code>"mysql"</code></td><td><strong>工具数据库</strong>配置——数据库操作工具(text2sql / excel2sql)专用,<strong>与存储后端完全隔离</strong>;sqlite path 留空默认 <code>history/sqlite/tool</code> 目录,每个库名一个 <code>.sqlite</code> 文件</td></tr>
<tr><td><code>MYSQL_*</code></td><td><code>""</code></td><td><strong>旧键</strong>:存储数据库 MySQL 参数(已被 <code>STORAGE_DB_*</code> 取代,仅作回退兼容)</td></tr>
<tr><td><code>BROWSER_PROFILE_DIR</code></td><td><code>""</code></td><td>浏览器控制工具持久 Profile 目录;留空 = 每次启动一次性临时 Profile(登录态不持久、不留盘)</td></tr>
<tr><td><code>TOOL_TIMEOUT</code></td><td><code>300</code></td><td>工具执行超时时间(秒)</td></tr>
<tr><td><code>CRON_TIME_PERIOD_MINUTES</code></td><td><code>30</code></td><td>定时任务时段长度(分钟),同一时段全局并发=1</td></tr>
<tr><td><code>EMAIL_ADDRESS / EMAIL_AUTH_CODE</code></td><td><code>""</code></td><td>邮箱凭证(IMAP/SMTP)</td></tr>
@@ -1206,9 +1208,9 @@ cloudflared tunnel --url http://localhost:8765</pre>
</table>
</div>
<blockquote class="quote reveal">⚙️ 以上参数均可在主界面「设置」面板热修改,或通过 <code>/api/config/*</code> 接口程序化调整。</blockquote>
<blockquote class="quote reveal">⚙️ 思考档位(low / high / xhigh / max / ultra)为<strong>会话级</strong>配置(存于各会话 <code>session_meta.json</code>);以上参数均可在主界面「设置」面板热修改,或通过 <code>/api/config/*</code> 接口程序化调整。</blockquote>
<p class="reveal-up" style="color:var(--ink-soft);margin:16px 0">设置面板包含五个标签页,覆盖 Agent 运行的全部可调参数:</p>
<p class="reveal-up" style="color:var(--ink-soft);margin:16px 0">设置面板包含 11 个分组(常规 / 外观 / 模型 / Agent / 工具 / Skills / GUI / 网络搜索 / 邮件 / 存储与数据库 / 运行时参数),覆盖 Agent 运行的全部可调参数:</p>
<figure class="shot-card reveal-rotate" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px;justify-items:center;margin-bottom:6px">
<div class="img-card" style="width:min(360px,100%)" data-path="./git_image/5.png">
@@ -1222,7 +1224,7 @@ cloudflared tunnel --url http://localhost:8765</pre>
<span class="img-path">./git_image/1.png</span>
</div>
</figure>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>Agent 配置</b> — 管理多个 AI 角色,各自绑定独立模型与工具集&emsp;|&emsp;<b>工具配置</b> — 启用/禁用工具、调整参数及执行策略</sub></p>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>Agent 配置</b> — 管理多个 AI 角色,各自绑定独立模型与工具集(main Agent 默认挂载 5 个工具为锁定项,不可移除)&emsp;|&emsp;<b>工具配置</b> — 启用/禁用工具、调整参数及执行策略</sub></p>
<figure class="shot-card reveal-rotate" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:18px;justify-items:center;margin:24px 0 6px">
<div class="img-card" style="width:min(360px,100%)" data-path="./git_image/2.png">
@@ -1245,7 +1247,7 @@ cloudflared tunnel --url http://localhost:8765</pre>
<span class="img-path">./git_image/4.png</span>
</div>
</figure>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>主题配置</b> — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体</sub></p>
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>主题配置</b> — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体;另含 常规 / 模型 / Skills / 网络搜索 / 邮件 / 存储与数据库 / 运行时参数 等分组</sub></p>
</section>
<hr class="section-divider">
@@ -1280,11 +1282,15 @@ cloudflared tunnel --url http://localhost:8765</pre>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin-bottom:14px">上下文按「前缀稳定性」布局,最大化利用 LLM 服务端的前缀缓存:</p>
<div class="pre-card reveal">
[系统提示词(含固定反思引导,稳定)] + [压缩摘要] + [对话历史] + [用户消息] + [动态尾部(每轮变化)]
[系统提示词(含固定反思引导,稳定)] + [压缩摘要] + [对话历史] + [本轮回插的思考/反思]
+ [历史思考/反思/工具调用(压缩游标后)] + [用户消息] + [动态尾部(每轮变化)]
</div>
<div class="lead reveal" style="margin-top:18px">
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv"><strong>固定部分放前缀</strong>:反思引导等不变提示文本并入系统提示词、位于上下文最前 —— 前缀稳定即可命中缓存;避免把固定文本逐轮塞在消息中间导致整段缓存失效。</li>
<li class="li-rv"><strong>历史思考/反思/工具调用注入</strong>(<code>build_tool_history_messages</code>):压缩游标之后的所有轮次,按因果顺序交错注入 <code>[历史思考] → [历史反思] → [历史工具调用]</code>;思考/反思<strong>独立注入</strong>(工具无结果文本时仍保留),尾部未被工具消费的思考/反思去重后追加在轮末。所有消息从磁盘<strong>确定性构建、id 稳定</strong>(<code>think_ctx_{idx}</code> / <code>reflect_ctx_{idx}</code> / <code>tool_ctx_{idx}</code>),只追加在末尾 —— 满足前缀缓存命中条件;压缩节点按 id 精确移除。</li>
<li class="li-rv"><strong>思考与反思分离</strong>:深度思考(<code>reasoning_content</code>)与反思(<code>content</code>)<strong>独立存储</strong>(工具记录的 <code>thinking</code> / <code>reflection</code> 字段 + 轮末 <code>reflections</code> 列表)并独立注入上下文,二者可同时存在,确保注入内容与真实消息一一对应、前缀缓存准确命中。</li>
<li class="li-rv"><strong>本轮回插</strong>(<code>_inject_turn_thinking</code>):带工具调用的 AIMessage 前,将其携带的思考/反思以 <code>[思考]</code> / <code>[反思]</code> 合成消息插回本次调用的上下文(<code>reasoning_content</code> 不被 LLM API 回传),使模型在后续步骤中仍能看到自己的思考链;仅注入 <code>messages_for_llm</code>、不进图状态、id 稳定,不破坏前缀缓存。</li>
<li class="li-rv"><strong>变化部分放末尾</strong>:TodoList 状态、循环警告等每轮变化的内容追加在消息列表<strong>末尾</strong>,变化只影响尾部、不破坏前缀缓存;无 TodoList 且无循环提醒时<strong>不注入任何尾部提示</strong>(省 token)。</li>
<li class="li-rv"><strong>动态尾部作为长期记忆</strong>:每轮尾部文本持久化到 <code>session_meta</code> 的 <code>dynamic_tail_history</code>(连续相同去重),后续轮次把全部历史尾部注入上下文,Agent 能看到之前所有轮的 TodoList 状态与循环提醒。</li>
</ul>
@@ -1338,16 +1344,17 @@ cloudflared tunnel --url http://localhost:8765</pre>
<h3>阻塞式人机交互<span class="detail-tag">(交互类 / 展示类工具的统一模式)</span></h3>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin-bottom:16px">
人机交互不再「暂停图 + 存快照 + 续跑」,而是<strong>阻塞式普通工具</strong>:工具在图上线程中等待前端浮窗应答,答案(同意 / 拒绝 / 跳过 / 补充信息)只是普通工具返回值,图自然继续——<strong>拒绝不会结束整轮</strong>,Agent 自行调整方案。
人机交互不再「暂停图 + 存快照 + 续跑」,而是<strong>阻塞式普通工具</strong>:工具在图上线程中等待前端应答,答案(同意 / 拒绝 / 跳过 / 补充信息)只是普通工具返回值,图自然继续——<strong>拒绝不会结束整轮</strong>,Agent 自行调整方案。
</p>
<div class="line-cnt reveal" data-line="type" data-interval="55">
<pre>LLM 发出 human_interaction / 需确认工具调用
→ tools 节点执行工具
→ human_request.ask_human() 阻塞等待(注册到请求表,随 live snapshot 推送前端)
→ 前端浮窗出现,工具调用行显示"运行中"
→ 用户点击 → POST /api/human-action → 写入答案并唤醒
→ 前端"通知条"出现(输入区上方,标题 + 数量角标),工具调用行显示"运行中"
→ 用户点击展开 → 选项胶囊 / 补充输入框 → 提交(POST /api/human-action)→ 写入答案并唤醒
→ 工具返回答案文本 → ToolMessage → 图继续(无快照重放、无续跑状态机)</pre>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin:14px 0 4px"><strong>通知条(notify-strip)</strong>:todo 与人机交互不再使用浮窗,改为输入区上方的水平通知条——收起态只显示标题/最新步骤 + 数量角标,点击上下展开完整卡片;通知条左侧按钮可收起为输入区按钮,再次点击恢复;todo 与人机交互的挂起状态<strong>会话级存储</strong>(chat 与 cron 各自独立,切换会话自动保存/恢复)。</p>
<div class="table-scroll reveal-scale" style="margin-top:18px">
<table data-row-anim="alt">
<thead><tr><th>设计点</th><th>传统做法(本项目旧版)</th><th>阻塞式(当前)</th></tr></thead>
@@ -1366,7 +1373,7 @@ cloudflared tunnel --url http://localhost:8765</pre>
<div class="lead reveal">
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv"><strong>交互类工具</strong>(如 <code>human_interaction</code>):工具内调一次 <code>ask_human(meta)</code> 阻塞,自己格式化返回值</li>
<li class="li-rv"><strong>展示类工具</strong>(如 <code>todo_list</code>):纯文本返回、不维护全局 store,前端从通用 live 记录(工具调用的 args/结果)按工具名写一个小 adapter 渲染浮窗(如 <code>js/todo.js</code> 的 <code>updateTodoOverlayFromRecords</code>)</li>
<li class="li-rv"><strong>展示类工具</strong>(如 <code>todo_list</code>):纯文本返回、不维护全局 store,前端从通用 live 记录(工具调用的 args/结果)按工具名写一个小 adapter 渲染通知条(如 <code>js/notify-strip.js</code>)</li>
</ul>
</div>
@@ -1386,6 +1393,28 @@ cloudflared tunnel --url http://localhost:8765</pre>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin-top:14px">通过系统提示词前缀中的固定反思引导(<code>REFLECTION_PROMPT</code>)+ <code>MAIN_SYSTEM_PROMPT</code> 双重强调「同页操作必须合并为一次 <code>gui</code> 调用」实现。</p>
<!-- 5.1 UI 自动化双轨 -->
<div class="detail-head reveal">
<span class="detail-num">5.1</span>
<h3>UI 自动化双轨<span class="detail-tag">(Playwright + GUI,兼顾容错与效率)</span></h3>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin-bottom:12px">UI 操作按目标介质分两条轨道,由系统提示词引导 Agent 按「能 DOM 不截图」的原则选择:</p>
<div class="table-scroll reveal-scale">
<table data-row-anim="alt">
<thead><tr><th>轨道</th><th>工具</th><th>机制</th><th>适用场景</th><th>特点</th></tr></thead>
<tbody>
<tr><td><strong>浏览器轨</strong></td><td><code>browser_control</code> + <code>web_page</code></td><td>Playwright 会话管理器(CDP 分离进程,Agent 退出后浏览器保留、重启自动重连)+ DOM 级操作</td><td>浏览器内的网页操作</td><td><strong>毫秒级、定位可靠</strong>(snapshot 元素清单 → ref/selector 精准操作,不依赖视觉模型)</td></tr>
<tr><td><strong>桌面轨</strong></td><td><code>gui</code></td><td>截图 + 视觉批量定位(<code>_batch_ground_elements</code>)+ 键鼠模拟</td><td>桌面应用 / 任意界面(含浏览器无 DOM 场景)</td><td><strong>通用容错</strong>:截图视觉闭环,多模态 LLM 看到真实屏幕状态,可处理任何界面</td></tr>
</tbody>
</table>
</div>
<div class="lead reveal" style="margin-top:14px">
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv"><strong>典型流程</strong>:<code>browser_control</code> 打开 URL → <code>web_page</code> snapshot 获取元素清单 → 按 ref/selector 批量 click/fill/scroll → 页面跳转后重新 snapshot;浏览器不适用(如系统对话框、桌面软件)时回退 <code>gui</code> 视觉定位</li>
<li class="li-rv"><strong>两轨均遵循</strong>「连续操作合并为一次调用」(actions 列表批量传入)的性能原则</li>
</ul>
</div>
<!-- 6. 双模式源码共用 -->
<div class="detail-head reveal">
<span class="detail-num">6</span>
@@ -1405,56 +1434,81 @@ cloudflared tunnel --url http://localhost:8765</pre>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin-top:12px">前端通过 <code>window.electronAPI?.isElectron</code> 自动适配。</p>
<!-- 7. 存储双后端 -->
<!-- 7. 存储多后端 -->
<div class="detail-head reveal">
<span class="detail-num">7</span>
<h3>存储双后端<span class="detail-tag">(JSON 可视化 / MySQL,统一接口隔离)</span></h3>
<h3>存储多后端<span class="detail-tag">(JSON / MySQL / SQLite,统一接口隔离)</span></h3>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin-bottom:16px">
会话历史与定时任务存储统一在 <strong><code>agent/core/storage.py</code></strong>:接口(Protocol)驱动、双后端实现、<strong>消费方零感知后端选择</strong>(不判断 <code>STORAGE_BACKEND</code>)。
会话历史与定时任务存储统一在 <strong><code>agent/core/storage.py</code></strong>:接口(Protocol)驱动、多后端实现、<strong>消费方零感知后端选择</strong>(不判断 <code>STORAGE_BACKEND</code>)。
</p>
<p class="reveal-up" style="color:var(--ink);font-weight:800">接口与实现</p>
<div class="lead reveal" style="margin-top:8px">
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv"><code>ConversationRecordRepository</code>(对话消息 / 工具调用 / 轮次元数据 / 会话生命周期,16 方法)与 <code>TaskConfigRepository</code>(cron 任务配置,6 方法)两个 Protocol</li>
<li class="li-rv">每后端一个实现类:<code>JsonRecordRepository</code> / <code>MysqlRecordRepository</code>(含会话与 cron 双作用域)、<code>JsonTaskConfigRepository</code> / <code>MysqlTaskConfigRepository</code></li>
<li class="li-rv">每后端一个实现类:<code>JsonRecordRepository</code> / <code>MysqlRecordRepository</code> / <code>SqliteRecordRepository</code>(含会话与 cron 双作用域)、<code>JsonTaskConfigRepository</code> / <code>MysqlTaskConfigRepository</code> / <code>SqliteTaskConfigRepository</code></li>
<li class="li-rv"><strong>新增存储后端只需实现上述接口并在工厂注册一行</strong>;消费方新增功能/字段无需改动存储层(消息原样 JSON 存储、工具记录 meta 全量透传)</li>
</ul>
</div>
<p class="reveal-up" style="color:var(--ink);font-weight:800;margin-top:22px">作用域(scope)</p>
<div class="lead reveal" style="margin-top:8px">
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv"><code>session</code>(默认):主进程普通会话,JSON 落盘 <code>history/sessions/{session_id}/</code>,MySQL 用 <code>sessions / session_messages / session_tool_calls</code> 三表</li>
<li class="li-rv"><code>cron</code>:定时任务,JSON 落盘 <code>history/cron/{task_id}/</code>,MySQL 用 <code>cron_messages / cron_tool_calls</code> 两表;cron 子进程切换存储作用域后<strong>直写</strong>记录,主进程经 cron 作用域仓库读取</li>
<li class="li-rv"><code>session</code>(默认):主进程普通会话,JSON 落盘 <code>history/sessions/{session_id}/</code>,MySQL/SQLite 用 <code>sessions / session_messages / session_tool_calls</code> 三表</li>
<li class="li-rv"><code>cron</code>:定时任务,JSON 落盘 <code>history/cron/{task_id}/</code>,MySQL/SQLite 用 <code>cron_messages / cron_tool_calls</code> 两表;cron 子进程切换存储作用域后<strong>直写</strong>记录,主进程经 cron 作用域仓库读取</li>
</ul>
</div>
<div class="table-scroll reveal-scale" style="margin-top:18px">
<table data-row-anim="alt">
<thead><tr><th>后端</th><th>存储位置</th><th>说明</th></tr></thead>
<tbody>
<tr><td><strong>json</strong>(默认)</td><td><code>history/sessions/{session_id}/</code></td><td>消息与工具调用以人类可读 JSON 文件落盘(<code>turn_{turn_id}.json</code> / <code>tool_{turn_id}.json</code> / <code>session_meta.json</code> / <code>_session_tokens.json</code>),可直接打开查看/修改,便于审计与调试</td></tr>
<tr><td><strong>mysql</strong></td><td>session 三表 / cron 两表</td><td>消息与工具调用记录原样 JSON 入库、读取只走数据库;二进制产物(图片/附件等)两个后端均落盘</td></tr>
<tr><td><strong>json</strong>(默认)</td><td><code>history/sessions/{session_id}/</code></td><td>消息与工具调用以人类可读 JSON 文件落盘(<code>turn_{turn_id}.json</code> / <code>tool_{turn_id}.json</code> / <code>session_meta.json</code>),可直接打开查看/修改,便于审计与调试</td></tr>
<tr><td><strong>mysql</strong></td><td>session 三表 / cron 两表</td><td>消息与工具调用记录原样 JSON 入库、读取只走数据库;二进制产物(图片/附件等)所有后端均落盘</td></tr>
<tr><td><strong>sqlite</strong></td><td><code>history/sqlite/storage.sqlite</code>(单文件,session+cron 共用)</td><td>内置 <code>sqlite3</code> 零依赖,JSON 存 TEXT 列;<code>cron_tool_calls</code> 含 meta 列,尾部思考可完整落库;WAL 模式支持多进程并发</td></tr>
</tbody>
</table>
</div>
<div class="lead reveal" style="margin-top:18px">
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv">由 <code>STORAGE_BACKEND</code> + <code>MYSQL_HOST / MYSQL_PORT / MYSQL_USER / MYSQL_PASSWORD / MYSQL_DATABASE</code> 配置,<code>agent/core/db.py</code> 提供统一连接与幂等建表</li>
<li class="li-rv">由 <code>STORAGE_BACKEND</code>(json / mysql / sqlite)+ 存储数据库配置驱动;<strong>存储数据库与工具数据库完全隔离</strong>:存储用 <code>STORAGE_DB_*</code>(缺省回退旧 <code>MYSQL_*</code>,零迁移),数据库操作工具(text2sql / excel2sql)用 <code>TOOL_DB_*</code>(<strong>不回退存储配置</strong>);SQLite 工具库目录模式下每个库名 = 一个 <code>.sqlite</code> 文件</li>
<li class="li-rv">两类数据库均在设置 → 存储与数据库页配置(类型可选择 MySQL / SQLite);<code>agent/core/db.py</code> 提供统一连接(<code>connect_db</code>)与幂等建库建表(<code>ensure_database</code> / <code>ensure_sqlite_database</code> —— 库不存在时先建库再按表结构建表)</li>
<li class="li-rv"><strong>不双写</strong>:选哪种后端,读写就只走该后端;写入幂等(同一 <code>(session_id, turn_id)</code> 先 DELETE 再 INSERT);DB 异常仅打印日志,不阻断主流程</li>
<li class="li-rv">分支(<code>/api/sessions/branch</code>)与回滚等会话功能均基于存储接口实现,两个后端一致可用</li>
<li class="li-rv">分支(<code>/api/sessions/branch</code>)与回滚等会话功能均基于存储接口实现,各后端一致可用</li>
</ul>
</div>
<p class="reveal-up" style="color:var(--ink);font-weight:800;margin:22px 0 4px">双后端行为差异(设计内)</p>
<p class="reveal-up" style="color:var(--ink);font-weight:800;margin:22px 0 4px">后端行为差异(设计内)</p>
<div class="table-scroll reveal-scale">
<table data-row-anim="alt">
<thead><tr><th>场景</th><th>json</th><th>mysql</th></tr></thead>
<thead><tr><th>场景</th><th>json</th><th>mysql / sqlite</th></tr></thead>
<tbody>
<tr><td>轮次中实时工具记录</td><td>每次工具结果后<strong>增量写盘</strong> <code>tool_*.json</code>,进程崩溃后重启可回退恢复最近记录</td><td>不增量入库,由轮次结束 <code>save_tool_calls</code> 统一落库(无额外磁盘 IO,但崩溃时该轮记录丢失)</td></tr>
<tr><td>服务重启后运行中轮次</td><td><code>get_live_tool_calls</code> 回退读取增量文件,可继续展示</td><td>内存记录丢失、数据库无该轮记录,实时面板为空</td></tr>
<tr><td>cron 历史读取</td><td>直接读 <code>history/cron/</code> 下文件</td><td>子进程直写 cron 表,历史可正常读取</td></tr>
<tr><td>cron 历史读取</td><td>直接读 <code>history/cron/</code> 下文件</td><td>子进程直写数据库表,历史可正常读取</td></tr>
</tbody>
</table>
</div>
<!-- 8. 前后端单文件插件化 -->
<div class="detail-head reveal">
<span class="detail-num">8</span>
<h3>前后端单文件插件化<span class="detail-tag">(协议驱动,非注册式)</span></h3>
</div>
<p class="reveal-up" style="color:var(--ink-soft);margin-bottom:14px"><strong>核心思想</strong>:一个插件 = 后端一个文件 + 前端一个文件(或 adapter),通过<strong>协议</strong>接入;框架与插件之间靠<strong>事件 / 回调监听</strong>通信,而不是集中式注册表 + 修改主流程——新增或替换插件只需保持协议签名,<strong>宿主装配零改动</strong>。</p>
<p class="reveal-up" style="color:var(--ink);font-weight:800">后端:<code>BaseTool</code> 协议(<code>src/agent/tools/*.py</code>)</p>
<div class="lead reveal" style="margin-top:8px">
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv">每个工具是独立单文件,继承 LangChain <code>BaseTool</code>,实现 <code>name</code> / <code>description</code> / <code>args_schema</code> / <code>_run</code> 四要素即完成协议;<code>tools/__init__.py</code> 注册表一行挂载</li>
<li class="li-rv"><strong>前端零改动</strong>:工具配置面板(工具列表、参数表单、权限/自动执行开关、main 默认挂载)全部由 <code>/api/config/tools</code> 的 <code>registered_tool_names</code> / <code>tool_parameters</code> / <code>forced_permissions</code> <strong>自动生成</strong>——新增工具自动出现在设置面板与 Agent 工具选择器</li>
<li class="li-rv">展示类工具(如 <code>todo_list</code>)只需保持「纯文本返回」协议,前端按工具名写一个小 adapter 即可渲染通知条</li>
</ul>
</div>
<p class="reveal-up" style="color:var(--ink);font-weight:800;margin-top:20px">前端:<code>setXxxDeps / setXxxFn</code> 依赖注入协议(<code>src/web/js/*.js</code>)</p>
<div class="lead reveal" style="margin-top:8px">
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv">每个功能模块独立单文件,只导出 setter(协议接口)与自身功能函数,<strong>模块间零直接引用</strong>;宿主 <code>app.js</code> 作为装配中心做一次性连线(依赖注入):<code>setSendDeps({...})</code> / <code>setLiveUiDeps({...})</code> / <code>setRenderPersistentToolCalls(fn)</code> / <code>setRenderSkills(fn)</code></li>
<li class="li-rv"><strong>事件监听式数据流</strong>:<code>toolcalls.js</code> 收到 live snapshot(SSE/轮询)后,通过 <code>setLiveUiDeps</code> 注入的回调分发给各展示组件——组件是被回调通知(监听事件),而非向全局注册表登记</li>
<li class="li-rv"><strong>替换/新增模块零宿主改动</strong>:旧模块(如 <code>pending-overlay.js</code>)被新模块(<code>notify-strip.js</code>)替换时,只需保持协议导出签名(<code>setLiveUiDeps</code> / <code>updateTodoOverlayFromRecords</code> 等),宿主连线无需修改</li>
</ul>
</div>
</section>
<hr class="section-divider">
@@ -1500,11 +1554,11 @@ cloudflared tunnel --url http://localhost:8765</pre>
<tr><td><code>api.js</code></td><td>封装所有后端 API 请求</td></tr>
<tr><td><code>state.js</code></td><td>全局状态管理</td></tr>
<tr><td><code>send.js</code></td><td>消息发送逻辑</td></tr>
<tr><td><code>chat-render.js</code></td><td>聊天消息渲染</td></tr>
<tr><td><code>cron.js</code></td><td>定时任务管理面板</td></tr>
<tr><td><code>sessions.js</code></td><td>会话列表管理</td></tr>
<tr><td><code>toolcalls.js</code></td><td>工具调用实时面板</td></tr>
<tr><td><code>todo.js</code></td><td>TodoList 面板</td></tr>
<tr><td><code>chat-render.js</code></td><td>聊天消息渲染(双容器:chat / cron 各自独立,运行时增量渲染、空闲全量重渲染)</td></tr>
<tr><td><code>cron.js</code></td><td>定时任务面板(按工作区分组列表、类 chat 会话式历史、模式切换锁、实时运行)</td></tr>
<tr><td><code>sessions.js</code></td><td>会话列表管理(工作区分组,切换会话恢复会话级通知条状态)</td></tr>
<tr><td><code>toolcalls.js</code></td><td>工具调用实时面板(思考 Thought / 反思 Reflection 分离显示,rAF 合并高频快照)</td></tr>
<tr><td><code>notify-strip.js</code></td><td>★ 通知条系统:todo 与人机交互的输入区上方长条(收起/展开、按钮切换、会话级存储)</td></tr>
<tr><td><code>edit-mode.js</code></td><td>编辑模式(Monaco)</td></tr>
<tr><td><code>doc-tree.js</code> / <code>doc-mod-panel.js</code></td><td>文档树与修改面板</td></tr>
<tr><td><code>action-bar.js</code></td><td>底部操作栏</td></tr>
@@ -1520,7 +1574,7 @@ cloudflared tunnel --url http://localhost:8765</pre>
<tr><td><code>token-ring.js</code></td><td>上下文 Token 用量环形指示器(含压缩阈值刻度与三类占比)</td></tr>
<tr><td><code>access-mode.js</code></td><td>工作空间访问模式切换(限制访问 / 权限审查 / 完全访问)</td></tr>
<tr><td><code>think-level.js</code></td><td>思考模式档位选择(low / high / xhigh / max / ultra)</td></tr>
<tr><td><code>pending.js</code> / <code>pending-overlay.js</code></td><td>人工请求浮窗与决策提交(数据源为 live snapshot 的 <code>human_requests</code>)</td></tr>
<tr><td><code>pending.js</code></td><td>人工请求决策提交(数据源为 live snapshot 的 <code>human_requests</code>,配合 notify-strip 渲染)</td></tr>
<tr><td><code>rollback.js</code></td><td>消息回滚</td></tr>
<tr><td><code>workspace.js</code></td><td>工作空间切换</td></tr>
<tr><td><code>dialog.js</code></td><td>对话框</td></tr>
@@ -1530,15 +1584,15 @@ cloudflared tunnel --url http://localhost:8765</pre>
</div>
<h3 class="reveal" style="font-size:1.4rem;margin:44px 0 16px">CSS 架构(<code>src/web/css/</code>)</h3>
<p class="reveal-up" style="color:var(--ink-soft)"><code>variables.css</code> 定义主题变量,其余按模块拆分:<code>base / layout / chat / composer / cron / dialog / settings / skills / todo / toolcalls / edit-mode / canvas-editor / audio / animations / pending / pending-overlay / toast / responsive</code>。</p>
<p class="reveal-up" style="color:var(--ink-soft)"><code>variables.css</code> 定义主题变量,其余按模块拆分:<code>base / layout / chat / composer / cron / dialog / settings / skills / toolcalls / notify-strip / edit-mode / canvas-editor / audio / animations / toast / responsive</code>。</p>
<h3 class="reveal" style="font-size:1.4rem;margin:44px 0 16px">添加自定义工具</h3>
<div class="lead reveal">
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv">在 <code>src/agent/tools/</code> 新建 <code>my_tool.py</code>,继承 LangChain <code>BaseTool</code>,实现 <code>_run</code>,填写 <code>name</code> / <code>description</code> / <code>args_schema</code></li>
<li class="li-rv">在 <code>tool_config.json</code> 注册并配置启用状态与参数(权限设为 <code>confirm</code> 时,工具执行前会自动弹出确认浮窗,同意后执行、拒绝/跳过作为返回值继续)</li>
<li class="li-rv">交互类工具(需要向用户提问)在 <code>_run</code> 中调用 <code>core/human_request.ask_human(meta)</code> 阻塞等待答案;展示类工具(如进度面板)返回纯文本即可,前端从 live 工具记录按工具名写 adapter 渲染浮窗</li>
<li class="li-rv">重启后端,工具自动绑定到 LLM</li>
<li class="li-rv">在 <code>src/agent/tools/</code> 新建 <code>my_tool.py</code>,继承 LangChain <code>BaseTool</code>,实现 <code>_run</code>,填写 <code>name</code> / <code>description</code> / <code>args_schema</code>(协议四要素)</li>
<li class="li-rv">在 <code>config.json</code> 的 <code>tools</code> 分区注册并配置启用状态与参数(权限设为 <code>confirm</code> 时,工具执行前会自动弹出确认通知条,同意后执行、拒绝/跳过作为返回值继续);<strong>前端无需改动</strong>——设置面板工具列表 / 参数表单 / Agent 工具选择器由 API 自动生成</li>
<li class="li-rv">交互类工具(需要向用户提问)在 <code>_run</code> 中调用 <code>core/human_request.ask_human(meta)</code> 阻塞等待答案;展示类工具(如进度面板)返回纯文本即可,前端从 live 工具记录按工具名写 adapter 渲染通知条</li>
<li class="li-rv">重启后端,工具自动绑定到 LLM;若希望 main Agent 默认挂载,加入 <code>config_manager.MAIN_DEFAULT_TOOLS</code></li>
</ul>
</div>
@@ -1567,7 +1621,7 @@ Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force</pre>
<ul data-reveal-list="left" data-stagger="40">
<li class="li-rv"><strong>后端日志</strong>:桌面端 <code>npm start</code> 会在终端打印 <code>[FastAPI]</code> 日志</li>
<li class="li-rv"><strong>端口占用</strong>:<code>main.js</code> 的 <code>killPortProcess(8765)</code> 启动前自动清理</li>
<li class="li-rv"><strong>重置配置</strong>:删除 <code>src/agent/config/env_config.json</code> 重触首次向导</li>
<li class="li-rv"><strong>重置配置</strong>:删除 <code>src/agent/agents/config.json</code> 重触首次向导(或复制 <code>config_dist.json</code> 为新配置)</li>
<li class="li-rv"><strong>工具调用轨迹</strong>:<code>history/sessions/{session_id}/tool_{turn_id}.json</code></li>
</ul>
</div>
@@ -1589,7 +1643,7 @@ Copy-Item "src\web\js\themes.js" "src\web\js\themes.js" -Force</pre>
<tr><td>🐍 <strong>Python 版本</strong></td><td>建议 Python 3.11 / 3.12 / 3.13;首次向导会自动检测</td></tr>
<tr><td>🔐 <strong>未签名安装包</strong></td><td><code>.exe</code> 可能被 Windows Defender 拦截,点「更多信息 → 仍要运行」</td></tr>
<tr><td>🌐 <strong>首次启动联网</strong></td><td>加载 Google Fonts 需联网;离线环境字体选择器有 HTML 硬编码兜底</td></tr>
<tr><td>🔑 <strong>API Key 安全</strong></td><td><code>env_config.json</code> 含明文凭证,<strong>勿提交到公开仓库</strong>(已在 <code>.gitignore</code> 排除 <code>env_config.json</code>,仅保留 <code>env_config_dist.json</code> 模板)</td></tr>
<tr><td>🔑 <strong>API Key 安全</strong></td><td><code>src/agent/agents/config.json</code> 含明文凭证,<strong>勿提交到公开仓库</strong>(已在 <code>.gitignore</code> 排除,仅保留 <code>config_dist.json</code> 模板)</td></tr>
<tr><td>🎨 <strong>图标</strong></td><td>当前为 SVG,NSIS 打包后若显示默认图标,需准备 <code>.ico</code> 文件替换 <code>src/web/image/icon.ico</code></td></tr>
<tr><td>💾 <strong>历史存储</strong></td><td>默认会话历史与工具轨迹以 JSON 落盘在 <code>history/</code>(可切换 MySQL 后端),长期使用注意清理</td></tr>
<tr><td>⚡ <strong>GUI 性能</strong></td><td>同页多操作务必合并为一次 <code>gui</code> 调用,否则延迟显著(见「实现细节」)</td></tr>
+188 -84
View File
@@ -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 运行过程中的关键交互界面,支持文件从任意位置**拖拽
<img src="./git_image/运行-人机交互.png" alt="人机交互确认" width="360">
<img src="./git_image/运行-todolist.png" alt="任务规划 TodoList" width="360">
<br>
<sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)与信息收集以浮窗形式实时弹出,用户可同意、拒绝、跳过或补充信息;**拒绝也只是一次工具返回值**,Agent 自行调整方案继续,不会结束本轮&emsp;|&emsp;<b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表</sub>
<sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)与信息收集以「输入区上方通知条」形式实时弹出:只显示标题与数量角标,点击展开为完整卡片(标题/内容/选项胶囊/补充输入框),选择或补充后作为工具返回值,Agent 继续执行;**拒绝也只是一次工具返回值**,Agent 自行调整方案继续,不会结束本轮&emsp;|&emsp;<b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,同样以通知条展示(最新步骤 + 数量角标,可展开全部步骤),Todo 状态**会话级存储**(chat 与 cron 各自独立,切换会话自动恢复)&emsp;|&emsp;<b>后台终端任务</b> — `terminal_execute(background=True)` 挂起的长驻任务以第三条通知条展示("x 后台运行中 - y 已完成",展开可终止/查看状态);点击顶栏终端按钮展开底部终端栏,Agent 挂起任务以 "任务#PID" 标签与用户交互式 PowerShell 终端并列且视觉区分,实时滚动输出</sub>
</p>
<p align="center">
<img src="./git_image/运行-文件修改.png" alt="文件编辑模式" width="360">
<img src="./git_image/运行-工具调用1.png" alt="工具调用记录" width="360">
<br>
<sub><b>文件编辑模式</b> — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织&emsp;|&emsp;<b>工具调用记录</b> — 实时展示每轮 ReAct 的工具调用链、参数及返回值</sub>
<sub><b>文件编辑模式</b> — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织&emsp;|&emsp;<b>工具调用记录</b> — 实时展示每轮 ReAct 的工具调用链、参数及返回值;思考(reasoning_content)与反思(content)分离显示为独立的 Thought / Reflection 行</sub>
</p>
<p align="center">
@@ -106,7 +113,7 @@ Agent 运行过程中的关键交互界面,支持文件从任意位置**拖拽
<img src="./git_image/运行-定时任务.png" alt="定时任务创建" width="360">
<img src="./git_image/运行-edit模式.png" alt="Edit 编辑模式" width="360">
<br>
<sub><b>定时任务管理</b> — 支持 cron / 间隔 / 一次性三种触发方式,状态实时可见&emsp;|&emsp;<b>Edit 编辑模式</b> — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent</sub>
<sub><b>定时任务管理</b> — 支持 cron / 间隔 / 一次性三种触发方式;任务按**工作区分组**展示;任务会话与聊天同构(点击会话式查看历史、多轮对话、流式渲染),任务在**独立子进程**中执行;模式切换锁:chat 运行时可查看 cron、cron 运行时可查看 chat,双容器渲染不打断流式输出&emsp;|&emsp;<b>Edit 编辑模式</b> — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent</sub>
</p>
<p align="center">
@@ -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
<details>
<summary>📦 打包原理(点击展开)</summary>
- `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`
</details>
@@ -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 运行的全部可调参数:
<!-- 设置界面截图占位 -->
<p align="center">
<img src="./git_image/5.png" alt="Agent 配置" width="360">
<img src="./git_image/1.png" alt="工具配置" width="360">
<br>
<sub><b>Agent 配置</b> — 管理多个 AI 角色,各自绑定独立模型与工具集&emsp;|&emsp;<b>工具配置</b> — 启用/禁用工具、调整参数及执行策略</sub>
<sub><b>Agent 配置</b> — 管理多个 AI 角色,各自绑定独立模型与工具集(main Agent 默认挂载 5 个工具为锁定项,不可移除)&emsp;|&emsp;<b>工具配置</b> — 启用/禁用工具、调整参数及执行策略</sub>
</p>
<p align="center">
@@ -417,7 +428,7 @@ cloudflared tunnel --url http://localhost:8765
<p align="center">
<img src="./git_image/4.png" alt="主题配置" width="360">
<br>
<sub><b>主题配置</b> — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体</sub>
<sub><b>主题配置</b> — 12 套预设主题 + 自定义背景 / 模糊度 / 暗角 / 字体;另含 常规 / 模型 / Skills / 网络搜索 / 邮件 / 存储与数据库 / 运行时参数 等分组</sub>
</p>
---
@@ -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 端口** |
---
+80 -17
View File
@@ -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 } = {}) => {
+2 -1
View File
@@ -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) => {
+1
View File
@@ -45,6 +45,7 @@
}
},
"dependencies": {
"@homebridge/node-pty-prebuilt-multiarch": "^0.14.1",
"bluebird": "^3.7.2"
}
}
+6 -4
View File
@@ -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:
+90 -3
View File
@@ -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": "",
+15
View File
@@ -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",
+81
View File
@@ -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 提取。
+269 -73
View File
@@ -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}
+56 -26
View File
@@ -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})
+1 -1
View File
@@ -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)
+64
View File
@@ -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)
+8
View File
@@ -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)
+63 -9
View File
@@ -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"请调整命令或目标路径后重试。"
)
@@ -0,0 +1,3 @@
{
"pids": []
}
+416 -89
View File
@@ -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(前端可直接 <img> 展示)。
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(旧版记录)则保留以兼容
+1 -1
View File
@@ -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}}}}
{"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}}}}
+12
View File
@@ -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()
+4 -2
View File
@@ -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(),
+2 -1
View File
@@ -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
+85 -25
View File
@@ -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,
-61
View File
@@ -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)
+73 -14
View File
@@ -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,
+55 -3
View File
@@ -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()
+44
View File
@@ -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)
+621
View File
@@ -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
],
}
+46
View File
@@ -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)
File diff suppressed because it is too large. Load diff
+199 -117
View File
@@ -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
+2 -1
View File
@@ -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")
+1 -1
View File
@@ -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()
-1
View File
@@ -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");
+22 -5
View File
@@ -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");
+2 -1
View File
@@ -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;
}
+163 -119
View File
@@ -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;
}
+39 -39
View File
@@ -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 */
+37 -37
View File
@@ -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);
}
+14 -7
View File
@@ -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));
}
+224 -29
View File
@@ -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;
}
-472
View File
@@ -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;
}
}
-279
View File
@@ -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);
}
-7
View File
@@ -92,11 +92,4 @@
/* ===== 响应式 ===== */
@media (max-width: 640px) {
.todo-list-overlay {
left: 1rem;
right: 1rem;
transform: none;
min-width: unset;
max-width: unset;
}
}
+1 -1
View File
@@ -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;
}
+1 -1
View File
@@ -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;
-102
View File
@@ -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;
}
+13 -7
View File
@@ -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;
+12
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 MiB

-1
View File
@@ -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>

Before

Width:  |  Height:  |  Size: 862 B

-1
View File
@@ -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>

Before

Width:  |  Height:  |  Size: 2.1 KiB

+17 -7
View File
@@ -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() {
+25 -1
View File
@@ -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 };
+185 -15
View File
@@ -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,
+49 -3
View File
@@ -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;
+31 -21
View File
@@ -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();
+470 -68
View File
@@ -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();
}
-42
View File
@@ -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,
};
+1 -1
View File
@@ -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';
+7 -1
View File
@@ -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;
+128 -54
View File
@@ -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) {
+84 -9
View File
@@ -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));
+132 -29
View File
@@ -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 = "";
+9 -1
View File
@@ -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",
// 工作空间
+405 -221
View File
@@ -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 === '&' ? '&amp;' : ch === '<' ? '&lt;' : ch === '>' ? '&gt;' : 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&gt;</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) {
+96 -17
View File
@@ -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 {
+40 -74
View File
@@ -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));
-35
View File
@@ -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 };
+426 -89
View File
@@ -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; }
+6
View File
@@ -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);
+7 -2
View File
@@ -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();
+2
View File
@@ -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"
}
}
+440 -90
View File
@@ -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}")
+254
View File
@@ -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)
+120
View File
@@ -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")
+112
View File
@@ -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()
+31
View File
@@ -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()
+250
View File
@@ -0,0 +1,250 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>WebPage 测试页</title>
<style>
body { font-family: sans-serif; margin: 20px; }
.ant-form-item { display: flex; align-items: center; margin: 8px 0; }
.ant-form-item label { width: 90px; }
.ant-calendar-picker { position: relative; display: inline-block; }
.ant-calendar-picker-container {
position: absolute; top: 40px; left: 0; z-index: 1000;
background: #fff; border: 1px solid #ddd; box-shadow: 0 2px 8px rgba(0,0,0,.15);
padding: 8px; display: none; width: 260px;
}
.ant-calendar-picker-container.open { display: block; }
.ant-calendar-ym-select a { margin: 0 6px; cursor: pointer; }
.ant-calendar-date { display: inline-block; width: 26px; height: 24px; line-height: 24px;
text-align: center; cursor: pointer; }
.ant-calendar-date:hover { background: #e6f7ff; }
.ant-calendar-year-panel-cell, .ant-calendar-month-panel-cell {
display: inline-block; width: 60px; height: 26px; line-height: 26px; text-align: center;
cursor: pointer; }
.ant-calendar-year-panel-cell a, .ant-calendar-month-panel-cell a { color: #333; text-decoration: none; }
.ant-calendar-year-panel-prev-decade-btn, .ant-calendar-year-panel-next-decade-btn,
.ant-calendar-prev-month-btn, .ant-calendar-next-month-btn { cursor: pointer; margin: 0 4px; }
.ant-select-dropdown { display: none; border: 1px solid #ccc; background: #fff; max-height: 200px; overflow: auto; }
.ant-select-dropdown.open { display: block; }
.ant-select-dropdown-menu-item { padding: 4px 12px; cursor: pointer; }
.ant-select-dropdown-menu-item:hover { background: #e6f7ff; }
.dialog { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.4); z-index: 500; }
.dialog.open { display: flex; align-items: center; justify-content: center; }
.dialog-box { background: #fff; padding: 20px; width: 380px; border-radius: 6px; }
.popup { display: none; position: fixed; background: #fff; border: 1px solid #999;
box-shadow: 0 4px 12px rgba(0,0,0,.2); padding: 12px; z-index: 600; }
.popup.open { display: block; }
</style>
</head>
<body>
<h1>WebPage 测试页</h1>
<!-- 登录区 -->
<div class="ant-form-item"><label>账户名</label><input placeholder="账户名"></div>
<div class="ant-form-item"><label>密码</label><input type="password" placeholder="密码"></div>
<button id="btn-login">登录</button>
<span id="login-result"></span>
<!-- 打开弹窗按钮 -->
<button id="btn-dialog">打开弹窗</button>
<!-- 弹窗:表单 + 下拉 + 纯文本标题 -->
<div class="dialog" id="my-dialog" role="dialog" aria-modal="true">
<div class="dialog-box">
<button class="close" onclick="closeDialog()">Close</button>
<div class="ant-form-item"><label>姓名</label><input id="dlg-name" placeholder="姓名"></div>
<div class="ant-form-item">
<label>产品</label>
<div class="ant-select-selection" onclick="toggleSelect(this)">请选择产品</div>
<div class="ant-select-dropdown">
<div class="result-title" onclick="">检索结果</div>
<ul style="list-style:none;margin:0;padding:0">
<li class="ant-select-dropdown-menu-item">意外险</li>
<li class="ant-select-dropdown-menu-item">健康险</li>
<li class="ant-select-dropdown-menu-item">060003旅游意外伤害保险</li>
</ul>
</div>
</div>
<button id="dlg-ok" onclick="confirmDialog()">确认</button>
</div>
</div>
<!-- 日期控件(antd 1.x 结构模拟:点击输入框弹出面板) -->
<div class="ant-form-item">
<label>证件有效起期</label>
<span class="ant-calendar-picker">
<input class="ant-calendar-picker-input" placeholder="证件有效起期" readonly onclick="openCalendar(this)">
<div class="ant-calendar-picker-container">
<div class="ant-calendar-header">
<a class="ant-calendar-prev-month-btn" title="上个月" onclick="navMonth(-1)">‹</a>
<span class="ant-calendar-ym-select">
<a class="ant-calendar-month-select" title="选择月份" onclick="showMonthPanel()">8月</a>
<a class="ant-calendar-year-select" title="选择年份" onclick="showYearPanel()">2026年</a>
</span>
<a class="ant-calendar-next-month-btn" title="下个月" onclick="navMonth(1)">›</a>
</div>
<div class="ant-calendar-date-panel" id="date-panel"></div>
<div class="ant-calendar-year-panel" id="year-panel" style="display:none"></div>
<div class="ant-calendar-month-panel" id="month-panel" style="display:none"></div>
</div>
</span>
</div>
<!-- shadow DOM 组件 -->
<x-widget></x-widget>
<!-- 机构树(angular-tree-component / ant-tree 结构模拟:switcher + content-wrapper + title) -->
<div class="ant-form-item"><label>机构</label>
<div id="org-tree" class="angular-tree-component">
<div class="tree-children">
<div class="tree-node">
<span class="ant-tree-switcher ant-tree-switcher-open" onclick="toggleOrg(this)">▾</span>
<span class="ant-tree-node-content-wrapper">
<span class="ant-tree-title">北京分公司</span>
</span>
</div>
<div class="tree-node" style="padding-left: 20px">
<span class="ant-tree-switcher ant-tree-switcher-noop">·</span>
<span class="ant-tree-node-content-wrapper">
<span class="ant-tree-title">北京分公司车险管理部</span>
</span>
</div>
<div class="tree-node">
<span class="ant-tree-switcher ant-tree-switcher-close" onclick="toggleOrg(this)">▸</span>
<span class="ant-tree-node-content-wrapper">
<span class="ant-tree-title">上海分公司</span>
</span>
</div>
</div>
</div>
</div>
<span id="org-selected"></span>
<script>
// 树节点点击选中(title 可点)
document.querySelectorAll('#org-tree .ant-tree-title').forEach(function (t) {
t.addEventListener('click', function () {
document.getElementById('org-selected').textContent = '已选: ' + t.textContent.trim();
});
});
// 树 switcher 点击仅展开/折叠
window.toggleOrg = function (sw) {
var node = sw.closest('.tree-node');
var kids = node.querySelector('.tree-children');
var folded = sw.classList.toggle('ant-tree-switcher-close');
if (kids) kids.style.display = folded ? 'none' : 'block';
};
</script>
<script>
// ---------- 弹窗 ----------
window.openDialog = function () { document.getElementById('my-dialog').classList.add('open'); };
window.closeDialog = function () { document.getElementById('my-dialog').classList.remove('open'); };
window.confirmDialog = function () {
var v = document.getElementById('dlg-name').value;
document.getElementById('login-result').textContent = '确认:' + v;
closeDialog();
};
document.getElementById('btn-dialog').addEventListener('click', openDialog);
document.getElementById('btn-login').addEventListener('click', function () {
document.getElementById('login-result').textContent = '登录成功';
});
// ---------- 模拟 antd select ----------
window.toggleSelect = function (sel) {
var dd = sel.parentElement.querySelector('.ant-select-dropdown');
dd.classList.toggle('open');
};
document.querySelectorAll('.ant-select-dropdown-menu-item').forEach(function (li) {
li.addEventListener('click', function () {
var sel = li.closest('.ant-form-item').querySelector('.ant-select-selection');
sel.textContent = li.textContent;
li.closest('.ant-select-dropdown').classList.remove('open');
});
});
// ---------- 模拟 antd 日历 ----------
var curYear = 2026, curMonth = 8, selYear = 2026, selMonth = 8, selDay = 20;
window.renderDate = function () {
var p = document.getElementById('date-panel');
var first = new Date(curYear, curMonth - 1, 1).getDay();
var days = new Date(curYear, curMonth, 0).getDate();
var html = '';
for (var i = 0; i < first; i++) html += '<span class="ant-calendar-date" style="visibility:hidden">-</span>';
for (var d = 1; d <= days; d++) {
html += '<span class="ant-calendar-date" onclick="pickDate(' + d + ')">' + d + '</span>';
}
p.innerHTML = html;
document.querySelector('.ant-calendar-month-select').textContent = curMonth + '月';
document.querySelector('.ant-calendar-year-select').textContent = curYear + '年';
};
window.openCalendar = function (inp) {
var c = inp.parentElement.querySelector('.ant-calendar-picker-container');
var open = c.classList.toggle('open');
if (open) { renderDate(); showDatePanel(); }
};
window.showDatePanel = function () {
document.getElementById('date-panel').style.display = 'block';
document.getElementById('year-panel').style.display = 'none';
document.getElementById('month-panel').style.display = 'none';
};
window.showYearPanel = function () {
var p = document.getElementById('year-panel');
var start = Math.floor(curYear / 10) * 10;
var html = '<a class="ant-calendar-year-panel-prev-decade-btn" onclick="navDecade(-1)">«</a> ';
for (var y = start; y < start + 10; y++) {
html += '<span class="ant-calendar-year-panel-cell"><a onclick="pickYear(' + y + ')">' + y + '</a></span>';
}
html += ' <a class="ant-calendar-year-panel-next-decade-btn" onclick="navDecade(1)">»</a>';
p.innerHTML = html;
document.getElementById('date-panel').style.display = 'none';
document.getElementById('year-panel').style.display = 'block';
document.getElementById('month-panel').style.display = 'none';
};
window.navDecade = function (d) {
var start = Math.floor(curYear / 10) * 10 + d * 10;
curYear = start;
showYearPanel();
};
window.showMonthPanel = function () {
var p = document.getElementById('month-panel');
var html = '';
for (var m = 1; m <= 12; m++) {
html += '<span class="ant-calendar-month-panel-cell"><a onclick="pickMonth(' + m + ')">' + m + '月</a></span>';
}
p.innerHTML = html;
document.getElementById('date-panel').style.display = 'none';
document.getElementById('year-panel').style.display = 'none';
document.getElementById('month-panel').style.display = 'block';
};
window.pickYear = function (y) {
curYear = y; showDatePanel(); renderDate();
};
window.pickMonth = function (m) {
curMonth = m; showDatePanel(); renderDate();
};
window.navMonth = function (d) {
curMonth += d;
if (curMonth > 12) { curMonth = 1; curYear++; }
if (curMonth < 1) { curMonth = 12; curYear--; }
renderDate();
};
window.pickDate = function (d) {
selYear = curYear; selMonth = curMonth; selDay = d;
var pad = function (n) { return n < 10 ? '0' + n : '' + n; };
var inp = document.querySelector('.ant-calendar-picker-input');
inp.value = selYear + '-' + pad(selMonth) + '-' + pad(selDay);
document.querySelector('.ant-calendar-picker-container').classList.remove('open');
};
renderDate();
// ---------- shadow DOM ----------
class XWidget extends HTMLElement {
connectedCallback() {
const root = this.attachShadow({ mode: 'open' });
root.innerHTML = '<button id="x-btn">影子按钮</button><input id="x-input" placeholder="影子输入框">';
}
}
customElements.define('x-widget', XWidget);
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 325 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 188 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Loaded 100 of 175 files, more files were not shown because too many files have changed in this diff. Show more