更新压缩机制,提高前缀缓存命中率,优化UI渲染机制,提高帧数
No files matched your search
@@ -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 自行调整方案继续,不会结束本轮 | <b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表</sub></p>
|
||||
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)与信息收集以「输入区上方通知条」形式实时弹出:只显示标题与数量角标,点击展开为完整卡片(标题/内容/选项胶囊/补充输入框),选择或补充后作为工具返回值,Agent 继续执行;<b>拒绝也只是一次工具返回值</b>,Agent 自行调整方案继续,不会结束本轮 | <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 编辑器,支持代码高亮、差异对比、文件树拖拽组织 | <b>工具调用记录</b> — 实时展示每轮 ReAct 的工具调用链、参数及返回值</sub></p>
|
||||
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>文件编辑模式</b> — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织 | <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 / 间隔 / 一次性三种触发方式,状态实时可见 | <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,双容器渲染不打断流式输出 | <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 角色,各自绑定独立模型与工具集 | <b>工具配置</b> — 启用/禁用工具、调整参数及执行策略</sub></p>
|
||||
<p class="shot-cap reveal" style="transition-delay:.1s"><sub><b>Agent 配置</b> — 管理多个 AI 角色,各自绑定独立模型与工具集(main Agent 默认挂载 5 个工具为锁定项,不可移除) | <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>
|
||||
|
||||
@@ -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 自行调整方案继续,不会结束本轮 | <b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,支持主 / 子 Agent 独立任务列表</sub>
|
||||
<sub><b>人机交互确认</b> — 敏感操作(终端执行、文件写入等)与信息收集以「输入区上方通知条」形式实时弹出:只显示标题与数量角标,点击展开为完整卡片(标题/内容/选项胶囊/补充输入框),选择或补充后作为工具返回值,Agent 继续执行;**拒绝也只是一次工具返回值**,Agent 自行调整方案继续,不会结束本轮 | <b>任务规划 TodoList</b> — 多步任务自动拆解与进度跟踪,同样以通知条展示(最新步骤 + 数量角标,可展开全部步骤),Todo 状态**会话级存储**(chat 与 cron 各自独立,切换会话自动恢复) | <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 编辑器,支持代码高亮、差异对比、文件树拖拽组织 | <b>工具调用记录</b> — 实时展示每轮 ReAct 的工具调用链、参数及返回值</sub>
|
||||
<sub><b>文件编辑模式</b> — 内置 Monaco 编辑器,支持代码高亮、差异对比、文件树拖拽组织 | <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 / 间隔 / 一次性三种触发方式,状态实时可见 | <b>Edit 编辑模式</b> — 独立编辑区,Monaco 编辑器 + 文档树侧栏,支持拖拽文件/文件夹到附件区发送给 Agent</sub>
|
||||
<sub><b>定时任务管理</b> — 支持 cron / 间隔 / 一次性三种触发方式;任务按**工作区分组**展示;任务会话与聊天同构(点击会话式查看历史、多轮对话、流式渲染),任务在**独立子进程**中执行;模式切换锁:chat 运行时可查看 cron、cron 运行时可查看 chat,双容器渲染不打断流式输出 | <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 角色,各自绑定独立模型与工具集 | <b>工具配置</b> — 启用/禁用工具、调整参数及执行策略</sub>
|
||||
<sub><b>Agent 配置</b> — 管理多个 AI 角色,各自绑定独立模型与工具集(main Agent 默认挂载 5 个工具为锁定项,不可移除) | <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 端口** |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 } = {}) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -45,6 +45,7 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@homebridge/node-pty-prebuilt-multiarch": "^0.14.1",
|
||||
"bluebird": "^3.7.2"
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 提取。
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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": []
|
||||
}
|
||||
@@ -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 @@
|
||||
{"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}}}}
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -92,11 +92,4 @@
|
||||
|
||||
/* ===== 响应式 ===== */
|
||||
@media (max-width: 640px) {
|
||||
.todo-list-overlay {
|
||||
left: 1rem;
|
||||
right: 1rem;
|
||||
transform: none;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
After Width: | Height: | Size: 65 KiB |
|
After Width: | Height: | Size: 4.6 MiB |
@@ -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 +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,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() {
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 = "";
|
||||
|
||||
@@ -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",
|
||||
|
||||
// 工作空间
|
||||
|
||||
@@ -1,81 +1,42 @@
|
||||
// terminal.js -- 内置 PowerShell 终端(原生管道,多标签)
|
||||
// 主进程 spawn powershell.exe,stdin/stdout 经 IPC 桥接;前端自绘终端 UI。
|
||||
// terminal.js -- 内置 PowerShell 终端(xterm.js + ConPTY 真终端)
|
||||
// 主进程经 node-pty(ConPTY) 启动真实 PowerShell 会话,stdin/stdout 经 IPC 桥接;
|
||||
// 前端用 xterm.js 渲染(完整 ANSI / 光标 / 屏幕状态机)。
|
||||
// PSReadLine、Tab 补全、命令历史、全屏应用由真实控制台提供,前端不再模拟。
|
||||
// 面板折叠不 kill(仅隐藏);标签 x 单个 kill;右上角 x 确认后 kill 全部。
|
||||
// 颜色全部映射主题 CSS 变量,随主题变化。
|
||||
|
||||
import { $, showToast } from './utils.js';
|
||||
import { showConfirm } from './dialog.js';
|
||||
import { state } from './state.js';
|
||||
import { api } from './api.js';
|
||||
|
||||
/** 终端实例:{ id(主进程), title, cwd, pane, outEl, alive } */
|
||||
/** 终端实例:{ id(主进程), title, tab, pane, holder, term, fit, ro, alive } */
|
||||
let terminals = [];
|
||||
let activeId = null;
|
||||
let _dockVisible = false;
|
||||
|
||||
// ---------- ANSI 解析(CSI SGR 颜色 → 主题色 class;其余控制序列忽略) ----------
|
||||
const ANSI_FG = {
|
||||
'0': '', '30': 't-fg-muted', '31': 't-fg-danger', '32': 't-fg-success',
|
||||
'33': 't-fg-warning', '34': 't-fg-accent', '35': 't-fg-purple', '36': 't-fg-cyan',
|
||||
'37': '', '90': 't-fg-muted', '91': 't-fg-danger', '92': 't-fg-success',
|
||||
'93': 't-fg-warning', '94': 't-fg-accent', '95': 't-fg-purple', '96': 't-fg-cyan', '97': '',
|
||||
};
|
||||
const ANSI_BG = {
|
||||
'40': 't-bg-dark', '41': 't-bg-danger', '42': 't-bg-success', '43': 't-bg-warning',
|
||||
'44': 't-bg-accent', '47': '',
|
||||
};
|
||||
// ===== 后台任务标签(agent terminal_execute 挂起任务的实时视图,与交互式终端区分) =====
|
||||
// 数据通道:/api/terminal/tasks/{session_id}/stream SSE(独立于 Electron IPC 的 xterm 终端);
|
||||
// 状态更新 → 头部徽标;输出增量 → 输出区 append(按 seq 去重);终止 → POST kill。
|
||||
const taskTabs = new Map(); // taskId -> { id, tab, pane, out, statusEl, killBtn, lastSeq, truncated }
|
||||
let _taskStreamEv = null;
|
||||
let _taskStreamSid = "";
|
||||
const MAX_TASK_TABS = 8;
|
||||
|
||||
/** 把含 ANSI 序列的文本转为安全的 HTML 片段(SGR 颜色映射,其他 CSI 忽略) */
|
||||
export function ansiToHtml(text) {
|
||||
let html = '';
|
||||
let i = 0;
|
||||
let curCls = '';
|
||||
const n = text.length;
|
||||
while (i < n) {
|
||||
const ch = text[i];
|
||||
if (ch === '\x1b' && text[i + 1] === '[') {
|
||||
// CSI: \x1b[ ... 最终字符(A-Z 或 @-~)
|
||||
let j = i + 2;
|
||||
while (j < n && !/[A-Za-z@-~]/.test(text[j])) j++;
|
||||
if (j < n) {
|
||||
const params = text.slice(i + 2, j);
|
||||
const final = text[j];
|
||||
if (final === 'm') {
|
||||
// SGR:最后一个参数决定样式
|
||||
const codes = params.split(';').filter(Boolean);
|
||||
const last = codes[codes.length - 1] || '0';
|
||||
if (last === '0') {
|
||||
curCls = '';
|
||||
} else if (ANSI_FG[last] !== undefined) {
|
||||
curCls = ANSI_FG[last];
|
||||
} else if (ANSI_BG[last] !== undefined) {
|
||||
curCls = ANSI_BG[last];
|
||||
} else {
|
||||
// 亮度/其他:忽略
|
||||
}
|
||||
}
|
||||
// 其余 CSI(光标移动等)忽略
|
||||
i = j + 1;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\r') {
|
||||
i += 1;
|
||||
continue; // \r\n 中的 \r 忽略,单独 \r 不处理(简化)
|
||||
}
|
||||
if (ch === '\n') {
|
||||
html += '</span>\n';
|
||||
curCls = ''; // 换行后颜色状态简化重置(避免跨行 span 混乱)
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
const esc = ch === '&' ? '&' : ch === '<' ? '<' : ch === '>' ? '>' : ch;
|
||||
html += curCls ? `<span class="${curCls}">${esc}</span>` : esc;
|
||||
i += 1;
|
||||
}
|
||||
if (curCls) html += '</span>';
|
||||
return html;
|
||||
// ---------- 主题 ----------
|
||||
function cssVar(name, fallback) {
|
||||
const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
||||
return v || fallback;
|
||||
}
|
||||
|
||||
/** 主题 CSS 变量 → xterm theme(背景/前景/光标跟随全局主题) */
|
||||
function readTermTheme() {
|
||||
return {
|
||||
background: cssVar('--surface', '#1e1e23'),
|
||||
foreground: cssVar('--text', '#e5e7eb'),
|
||||
cursor: cssVar('--accent', '#007acc'),
|
||||
cursorAccent: cssVar('--surface', '#1e1e23'),
|
||||
selectionBackground: cssVar('--accent', 'rgba(0, 122, 204, 0.4)'),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------- 终端实例管理 ----------
|
||||
@@ -99,55 +60,96 @@ function ensureElectron() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/** 新建一个 PowerShell 终端标签 */
|
||||
/** 新建一个 PowerShell 终端标签(xterm 挂载后按真实尺寸启动 ConPTY 会话) */
|
||||
export async function spawnTerminal() {
|
||||
if (!ensureElectron()) return null;
|
||||
const cwd = currentCwd();
|
||||
|
||||
// 1) 构建标签 + 面板并临时激活(fit 需要容器有真实尺寸)
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'term-tab';
|
||||
tab.innerHTML = `<span class="term-tab-title">PowerShell <span class="term-tab-ws">${escapeHtml(wsName())}</span></span>`;
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'term-tab-close';
|
||||
closeBtn.title = '关闭此终端(终止进程)';
|
||||
closeBtn.textContent = '\u2715';
|
||||
tab.appendChild(closeBtn);
|
||||
// 标签插到 + 号之前,保证 [标签…][+] 在左、[×] 在右的布局
|
||||
const tabsBar = $('terminalTabs');
|
||||
const addBtn = tabsBar.querySelector('.term-tab-add');
|
||||
if (addBtn) tabsBar.insertBefore(tab, addBtn);
|
||||
else tabsBar.appendChild(tab);
|
||||
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'term-pane active'; // 临时 active:fit 需要可见容器;spawn 成功后重新激活
|
||||
const holder = document.createElement('div');
|
||||
holder.className = 'term-holder';
|
||||
pane.appendChild(holder);
|
||||
$('terminalBody').appendChild(pane);
|
||||
|
||||
// 2) xterm 实例(UMD 全局:window.Terminal / window.FitAddon)
|
||||
let term, fit;
|
||||
try {
|
||||
const cwd = currentCwd();
|
||||
const { id, ok, error } = await window.electronAPI.spawnTerminal(cwd);
|
||||
if (!ok) {
|
||||
showToast('终端启动失败: ' + (error || '未知错误'));
|
||||
return null;
|
||||
}
|
||||
// 渲染标签 + 输出面板
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'term-tab';
|
||||
tab.dataset.tid = String(id);
|
||||
tab.innerHTML = `<span class="term-tab-title">PowerShell <span class="term-tab-ws">${escapeHtml(wsName())}</span></span>`;
|
||||
const closeBtn = document.createElement('button');
|
||||
closeBtn.type = 'button';
|
||||
closeBtn.className = 'term-tab-close';
|
||||
closeBtn.title = '关闭此终端(终止进程)';
|
||||
closeBtn.textContent = '\u2715';
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
killTerminal(id);
|
||||
term = new window.Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'Cascadia Mono, Consolas, "Courier New", monospace',
|
||||
fontSize: 13,
|
||||
scrollback: 5000,
|
||||
theme: readTermTheme(),
|
||||
});
|
||||
tab.appendChild(closeBtn);
|
||||
tab.addEventListener('click', () => activateTerminal(id));
|
||||
// 标签插到 + 号之前,保证 [标签…][+] 在左、[×] 在右的布局
|
||||
const tabsBar = $('terminalTabs');
|
||||
const addBtn = tabsBar.querySelector('.term-tab-add');
|
||||
if (addBtn) tabsBar.insertBefore(tab, addBtn);
|
||||
else tabsBar.appendChild(tab);
|
||||
|
||||
const pane = document.createElement('div');
|
||||
pane.className = 'term-pane';
|
||||
pane.dataset.tid = String(id);
|
||||
const outEl = document.createElement('div');
|
||||
outEl.className = 'term-out';
|
||||
pane.appendChild(outEl);
|
||||
$('terminalBody').appendChild(pane);
|
||||
|
||||
terminals.push({ id, tab, pane, outEl, alive: true });
|
||||
activateTerminal(id);
|
||||
// 首次 spawn 时输出欢迎提示
|
||||
appendOutput(id, `PowerShell 已启动${cwd ? `(工作区: ${cwd})` : ''}\r\n`);
|
||||
return id;
|
||||
fit = new window.FitAddon.FitAddon();
|
||||
term.loadAddon(fit);
|
||||
term.open(holder);
|
||||
if (holder.clientWidth > 20 && holder.clientHeight > 20) fit.fit();
|
||||
} catch (e) {
|
||||
showToast('终端启动失败: ' + (e.message || e));
|
||||
tab.remove();
|
||||
pane.remove();
|
||||
showToast('终端初始化失败: ' + (e.message || e));
|
||||
return null;
|
||||
}
|
||||
|
||||
// 3) 主进程 spawn(用 fit 后的真实行列数,避免提示符宽度不对)
|
||||
const { id, ok, error } = await window.electronAPI.spawnTerminal(cwd, term.cols, term.rows)
|
||||
.catch((e) => ({ ok: false, error: (e && e.message) || e }));
|
||||
if (!ok) {
|
||||
tab.remove();
|
||||
pane.remove();
|
||||
try { term.dispose(); } catch { /* ignore */ }
|
||||
showToast('终端启动失败: ' + (error || '未知错误'));
|
||||
return null;
|
||||
}
|
||||
|
||||
const t = { id, tab, pane, holder, term, fit, ro: null, alive: true };
|
||||
terminals.push(t);
|
||||
|
||||
// 4) 事件接线
|
||||
closeBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
killTerminal(id);
|
||||
});
|
||||
tab.addEventListener('click', () => activateTerminal(id));
|
||||
// 键盘进 xterm 状态机 → 直接写 PTY(行编辑/补全交给 PSReadLine)
|
||||
term.onData((data) => {
|
||||
if (t.alive) window.electronAPI.sendTerminalInput(id, data);
|
||||
});
|
||||
// 尺寸变化(fit 触发)→ 同步 ConPTY 行列数
|
||||
term.onResize(({ cols, rows }) => {
|
||||
window.electronAPI.resizeTerminal(id, cols, rows);
|
||||
});
|
||||
// 容器尺寸变化(窗口拉伸/面板展开)→ 重新 fit
|
||||
try {
|
||||
const ro = new ResizeObserver(() => {
|
||||
if (holder.clientWidth < 20 || holder.clientHeight < 20) return;
|
||||
try { t.fit.fit(); } catch { /* ignore */ }
|
||||
});
|
||||
ro.observe(holder);
|
||||
t.ro = ro;
|
||||
} catch { /* ResizeObserver 不可用时忽略 */ }
|
||||
|
||||
activateTerminal(id);
|
||||
term.write(`\r\nPowerShell 已启动${cwd ? `(工作区: ${cwd})` : ''}\r\n`);
|
||||
return id;
|
||||
}
|
||||
|
||||
function getTerm(id) {
|
||||
@@ -158,41 +160,26 @@ function activateTerminal(id) {
|
||||
activeId = id;
|
||||
for (const t of terminals) {
|
||||
const isActive = t.id === id;
|
||||
t.tab.classList.toggle('active', isActive);
|
||||
t.pane.classList.toggle('active', isActive);
|
||||
t.tab.classList.toggle("active", isActive);
|
||||
t.pane.classList.toggle("active", isActive);
|
||||
if (isActive) {
|
||||
// 激活时补一次 fit(面板尺寸可能已变化),onResize 自动同步主进程
|
||||
try {
|
||||
if (t.holder.clientWidth > 20 && t.holder.clientHeight > 20) t.fit.fit();
|
||||
} catch { /* ignore */ }
|
||||
t.term.focus();
|
||||
}
|
||||
}
|
||||
// 命令行(提示符+输入)挂载到活动面板输出末尾,观感等同真实终端的当前行
|
||||
const t = getTerm(id);
|
||||
if (!_cmdlineEl) buildCmdline(); // 兜底:initTerminal 尚未执行时按需构建
|
||||
const cl = _cmdlineEl;
|
||||
if (t && cl) {
|
||||
t.outEl.appendChild(cl);
|
||||
scrollPaneToBottom(t.pane);
|
||||
}
|
||||
const input = _cmdInput;
|
||||
if (input) {
|
||||
input.disabled = !t?.alive;
|
||||
input.focus();
|
||||
// 交互终端激活时取消任务标签的活动态
|
||||
for (const tb of taskTabs.values()) {
|
||||
tb.tab.classList.remove("active");
|
||||
tb.pane.classList.remove("active");
|
||||
}
|
||||
}
|
||||
|
||||
/** 滚动容器是 .term-pane(非 .term-out),输出追加后保持跟随底部 */
|
||||
function scrollPaneToBottom(pane) {
|
||||
if (pane) pane.scrollTop = pane.scrollHeight;
|
||||
}
|
||||
|
||||
function appendOutput(id, chunk) {
|
||||
const t = getTerm(id);
|
||||
if (!t) return;
|
||||
const html = ansiToHtml(chunk);
|
||||
const cl = _cmdlineEl;
|
||||
if (cl && cl.parentNode === t.outEl) {
|
||||
// 命令行保持在输出最后一行:新输出插在它之前
|
||||
cl.insertAdjacentHTML('beforebegin', html);
|
||||
} else {
|
||||
t.outEl.insertAdjacentHTML('beforeend', html);
|
||||
}
|
||||
scrollPaneToBottom(t.pane);
|
||||
function disposeTerm(t) {
|
||||
try { t.ro?.disconnect(); } catch { /* ignore */ }
|
||||
try { t.term.dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/** 单个终端关闭(kill 进程并移除标签) */
|
||||
@@ -200,6 +187,7 @@ export function killTerminal(id) {
|
||||
const t = getTerm(id);
|
||||
if (!t) return;
|
||||
t.alive = false;
|
||||
disposeTerm(t);
|
||||
try { window.electronAPI?.killTerminal(id); } catch { /* ignore */ }
|
||||
t.tab.remove();
|
||||
t.pane.remove();
|
||||
@@ -208,7 +196,7 @@ export function killTerminal(id) {
|
||||
activeId = null;
|
||||
if (terminals.length > 0) activateTerminal(terminals[terminals.length - 1].id);
|
||||
}
|
||||
if (terminals.length === 0) {
|
||||
if (terminals.length === 0 && taskTabs.size === 0) {
|
||||
setDockVisible(false); // 全部关闭后隐藏面板
|
||||
}
|
||||
}
|
||||
@@ -217,12 +205,14 @@ export function killTerminal(id) {
|
||||
export function killAllTerminals() {
|
||||
try { window.electronAPI?.killAllTerminals(); } catch { /* ignore */ }
|
||||
for (const t of terminals) {
|
||||
t.alive = false;
|
||||
disposeTerm(t);
|
||||
t.tab.remove();
|
||||
t.pane.remove();
|
||||
}
|
||||
terminals = [];
|
||||
activeId = null;
|
||||
setDockVisible(false);
|
||||
if (taskTabs.size === 0) setDockVisible(false);
|
||||
}
|
||||
|
||||
/** 显示/折叠终端面板(不 kill,仅隐藏) */
|
||||
@@ -231,8 +221,12 @@ export function setDockVisible(visible) {
|
||||
const dock = $('terminalDock');
|
||||
if (!dock) return;
|
||||
dock.hidden = !_dockVisible;
|
||||
if (_dockVisible && terminals.length > 0 && activeId == null) {
|
||||
activateTerminal(terminals[terminals.length - 1].id);
|
||||
// 重新展开时激活最后一个标签;ResizeObserver/fit 会恢复正确行列数
|
||||
if (_dockVisible) {
|
||||
if (terminals.length > 0 && activeId == null) activateTerminal(terminals[terminals.length - 1].id);
|
||||
// 展开时同步后台任务标签(确保新任务在展开瞬间即可见)
|
||||
_ensureTaskStream();
|
||||
syncTaskTabs().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -246,83 +240,265 @@ export function toggleTerminal() {
|
||||
setDockVisible(true);
|
||||
if (terminals.length === 0) {
|
||||
spawnTerminal();
|
||||
} else if (activeId == null && terminals.length > 0) {
|
||||
} else if (activeId == null) {
|
||||
activateTerminal(terminals[terminals.length - 1].id);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 输入 ----------
|
||||
// 管道模式下 PowerShell 不回显提示符与命令,前端自行回显以还原真实终端观感
|
||||
const _cmdHistory = [];
|
||||
let _histIdx = -1; // -1 = 未在浏览历史
|
||||
// 命令行元素引用:buildCmdline 构建后先处于游离态(未挂到 document),
|
||||
// 不能用 getElementById 查找(查不到游离节点),必须用模块级引用持有
|
||||
let _cmdlineEl = null;
|
||||
let _cmdInput = null;
|
||||
// ===== 后台任务标签实现 =====
|
||||
|
||||
function echoCommand(text) {
|
||||
const t = getTerm(activeId);
|
||||
if (!t) return;
|
||||
const html = `<span class="t-fg-success">PS></span> ${escapeHtml(text)}\n`;
|
||||
const cl = _cmdlineEl;
|
||||
if (cl && cl.parentNode === t.outEl) cl.insertAdjacentHTML('beforebegin', html);
|
||||
else t.outEl.insertAdjacentHTML('beforeend', html);
|
||||
scrollPaneToBottom(t.pane);
|
||||
function _taskStatusMeta(t) {
|
||||
if (!t) return { label: "未知", cls: "done" };
|
||||
if (t.status === "running") return { label: "运行中", cls: "running" };
|
||||
if (t.status === "killed") return { label: "已终止", cls: "done" };
|
||||
if (t.abnormal || (t.exit_code != null && t.exit_code !== 0)) return { label: `异常退出 · exit ${t.exit_code}`, cls: "err" };
|
||||
return { label: "已完成", cls: "done" };
|
||||
}
|
||||
|
||||
function sendInput() {
|
||||
const input = _cmdInput;
|
||||
const t = getTerm(activeId);
|
||||
if (!t || !t.alive || !input) return;
|
||||
const text = input.value;
|
||||
input.value = '';
|
||||
_histIdx = -1;
|
||||
if (!text) return;
|
||||
if (_cmdHistory[_cmdHistory.length - 1] !== text) _cmdHistory.push(text);
|
||||
if (_cmdHistory.length > 200) _cmdHistory.shift();
|
||||
echoCommand(text);
|
||||
try { window.electronAPI.sendTerminalInput(activeId, text + '\r'); } catch { /* ignore */ }
|
||||
function _currentTaskSid() {
|
||||
// cron 子进程任务不在此服务注册表;仅 chat 会话支持任务标签
|
||||
if (state.mode === "cron") return "";
|
||||
return state.sessionId || "";
|
||||
}
|
||||
|
||||
/** ↑/↓ 浏览命令历史 */
|
||||
function historyNav(dir) {
|
||||
const input = _cmdInput;
|
||||
if (!input || _cmdHistory.length === 0) return;
|
||||
if (_histIdx === -1) _histIdx = _cmdHistory.length;
|
||||
_histIdx += dir;
|
||||
if (_histIdx < 0) _histIdx = 0;
|
||||
if (_histIdx >= _cmdHistory.length) { _histIdx = -1; input.value = ''; return; }
|
||||
input.value = _cmdHistory[_histIdx];
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
}
|
||||
/** 打开/刷新指定任务标签(已存在则激活) */
|
||||
export function openTaskTab(task, { activate = true } = {}) {
|
||||
const id = task?.id != null ? Number(task.id) : null;
|
||||
if (id == null) return null;
|
||||
const exist = taskTabs.get(id);
|
||||
if (exist) {
|
||||
_updateTaskTabMeta(exist, task);
|
||||
if (activate) activateTaskTab(id);
|
||||
return exist;
|
||||
}
|
||||
// 数量上限:超出淘汰最旧的已完成任务标签
|
||||
if (taskTabs.size >= MAX_TASK_TABS) {
|
||||
for (const [tid, tb] of taskTabs) {
|
||||
if (tb.task && tb.task.status !== "running") { closeTaskTab(tid); break; }
|
||||
}
|
||||
}
|
||||
|
||||
/** 构建命令行元素(提示符 + 输入框);由 activateTerminal 挂到活动面板输出末尾 */
|
||||
function buildCmdline() {
|
||||
if (_cmdlineEl) return;
|
||||
const row = document.createElement('div');
|
||||
row.className = 'terminal-input-row';
|
||||
row.id = 'terminalCmdline';
|
||||
const prompt = document.createElement('span');
|
||||
prompt.className = 'terminal-prompt';
|
||||
prompt.id = 'terminalPrompt';
|
||||
prompt.textContent = 'PS>';
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.className = 'terminal-input';
|
||||
input.id = 'terminalInput';
|
||||
input.autocomplete = 'off';
|
||||
input.spellcheck = false;
|
||||
input.placeholder = '输入命令,Enter 执行';
|
||||
input.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); sendInput(); }
|
||||
else if (e.key === 'ArrowUp') { e.preventDefault(); historyNav(-1); }
|
||||
else if (e.key === 'ArrowDown') { e.preventDefault(); historyNav(1); }
|
||||
const tab = document.createElement("div");
|
||||
tab.className = "term-tab term-tab--task";
|
||||
tab.title = `后台任务 #${id}`;
|
||||
tab.innerHTML = `<img class="term-tab-icon" src="/image/终端.svg" alt="" /><span class="term-tab-title">任务#${id}</span>`;
|
||||
const closeBtn = document.createElement("button");
|
||||
closeBtn.type = "button";
|
||||
closeBtn.className = "term-tab-close";
|
||||
closeBtn.title = "关闭此标签(不终止任务)";
|
||||
closeBtn.textContent = "\u2715";
|
||||
closeBtn.addEventListener("click", (e) => { e.stopPropagation(); closeTaskTab(id); });
|
||||
tab.appendChild(closeBtn);
|
||||
|
||||
const tabsBar = $("terminalTabs");
|
||||
const addBtn = tabsBar.querySelector(".term-tab-add");
|
||||
if (addBtn) tabsBar.insertBefore(tab, addBtn);
|
||||
else tabsBar.appendChild(tab);
|
||||
|
||||
const pane = document.createElement("div");
|
||||
pane.className = "term-task-pane";
|
||||
pane.innerHTML = `
|
||||
<div class="term-task-head">
|
||||
<span class="term-task-cmd"></span>
|
||||
<span class="term-task-status"></span>
|
||||
<button type="button" class="term-task-kill">终止</button>
|
||||
</div>
|
||||
<pre class="term-task-output"></pre>
|
||||
`;
|
||||
$("terminalBody").appendChild(pane);
|
||||
|
||||
const tb = {
|
||||
id, task: task || null, tab, pane,
|
||||
cmdEl: pane.querySelector(".term-task-cmd"),
|
||||
statusEl: pane.querySelector(".term-task-status"),
|
||||
killBtn: pane.querySelector(".term-task-kill"),
|
||||
out: pane.querySelector(".term-task-output"),
|
||||
lastSeq: 0,
|
||||
truncated: false,
|
||||
nearBottom: true,
|
||||
};
|
||||
tb.killBtn.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
await _killTaskById(id);
|
||||
});
|
||||
row.appendChild(prompt);
|
||||
row.appendChild(input);
|
||||
// 游离态持有引用;activateTerminal 首次挂载后才进入 document
|
||||
_cmdlineEl = row;
|
||||
_cmdInput = input;
|
||||
taskTabs.set(id, tb);
|
||||
_updateTaskTabMeta(tb, task);
|
||||
if (activate) activateTaskTab(id);
|
||||
return tb;
|
||||
}
|
||||
|
||||
function _updateTaskTabMeta(tb, task) {
|
||||
if (!task) return;
|
||||
tb.task = task;
|
||||
const st = _taskStatusMeta(task);
|
||||
const wasRunning = tb.statusEl.dataset.cls === "running";
|
||||
tb.cmdEl.textContent = String(task.command || "").slice(0, 80) || "(无命令)";
|
||||
tb.statusEl.textContent = st.label;
|
||||
tb.statusEl.dataset.cls = st.cls;
|
||||
tb.statusEl.className = "term-task-status" + (st.cls === "running" ? " term-task-status--running" : (st.cls === "err" ? " term-task-status--err" : ""));
|
||||
tb.killBtn.hidden = task.status !== "running";
|
||||
tb.tab.classList.toggle("term-tab--dead", task.status !== "running");
|
||||
if (task.truncated && !tb.truncated) {
|
||||
tb.truncated = true;
|
||||
const warn = document.createElement("div");
|
||||
warn.className = "term-task-truncated";
|
||||
warn.textContent = `(较早输出已截断,仅保留最近 256KB)`;
|
||||
tb.out.appendChild(warn);
|
||||
}
|
||||
// 状态从运行→结束:补一行结束信息(仅在已有输出末尾提示,不重复)
|
||||
if (wasRunning && task.status !== "running") {
|
||||
_appendOutputText(tb, `\n${st.label} · 退出码 ${task.exit_code == null ? "?" : task.exit_code}\n`);
|
||||
}
|
||||
}
|
||||
|
||||
function _appendOutputText(tb, text, source) {
|
||||
const atBottom = tb.out.scrollHeight - tb.out.scrollTop - tb.out.clientHeight < 40;
|
||||
if (source === "err") {
|
||||
const span = document.createElement("span");
|
||||
span.className = "term-task-err";
|
||||
span.textContent = text;
|
||||
tb.out.appendChild(span);
|
||||
} else {
|
||||
tb.out.appendChild(document.createTextNode(text));
|
||||
}
|
||||
if (atBottom) tb.out.scrollTop = tb.out.scrollHeight;
|
||||
}
|
||||
|
||||
function _appendTaskChunks(id, chunks) {
|
||||
const tb = taskTabs.get(id);
|
||||
if (!tb || !Array.isArray(chunks)) return;
|
||||
for (const c of chunks) {
|
||||
if (!c || c.seq == null || c.seq <= tb.lastSeq) continue;
|
||||
tb.lastSeq = c.seq;
|
||||
_appendOutputText(tb, String(c.text || ""), c.source === "err" ? "err" : "out");
|
||||
}
|
||||
}
|
||||
|
||||
function activateTaskTab(id) {
|
||||
// 交互式 xterm 与任务标签共用 dock:激活任务标签时取消交互终端的活动态
|
||||
for (const t of terminals) {
|
||||
t.tab.classList.remove("active");
|
||||
t.pane.classList.remove("active");
|
||||
}
|
||||
activeId = null;
|
||||
for (const [tid, tb] of taskTabs) {
|
||||
const isActive = tid === Number(id);
|
||||
tb.tab.classList.toggle("active", isActive);
|
||||
tb.pane.classList.toggle("active", isActive);
|
||||
if (isActive && tb.task && tb.task.status === "running") {
|
||||
tb.out.scrollTop = tb.out.scrollHeight;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function closeTaskTab(id, { offDock = true } = {}) {
|
||||
const tb = taskTabs.get(Number(id));
|
||||
if (!tb) return;
|
||||
tb.tab.remove();
|
||||
tb.pane.remove();
|
||||
taskTabs.delete(Number(id));
|
||||
if (taskTabs.size === 0 && terminals.length === 0 && offDock) {
|
||||
setDockVisible(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function closeAllTaskTabs() {
|
||||
for (const id of [...taskTabs.keys()]) closeTaskTab(id, { offDock: false });
|
||||
if (terminals.length === 0) setDockVisible(false);
|
||||
}
|
||||
|
||||
async function _killTaskById(id) {
|
||||
const tb = taskTabs.get(Number(id));
|
||||
const cmd = tb?.task?.command || "";
|
||||
const ok = await showConfirm(`确定终止后台终端任务吗?\n任务ID: #${id}\n命令: ${String(cmd).slice(0, 120)}`);
|
||||
if (!ok) return;
|
||||
const fd = new FormData();
|
||||
fd.append("session_id", state.sessionId || "");
|
||||
try {
|
||||
const res = await api(`/api/terminal/tasks/${encodeURIComponent(id)}/kill`, { method: "POST", body: fd });
|
||||
if (res && res.ok) return;
|
||||
showToast("终止失败: " + ((res && (res.error || res.detail)) || "未知错误"));
|
||||
} catch (e) {
|
||||
showToast("终止失败: " + (e.message || String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 拉取全量任务列表并同步标签(dock 展开 / SSE full / 轮询兜底) */
|
||||
async function syncTaskTabs() {
|
||||
const sid = _currentTaskSid();
|
||||
if (!sid) return;
|
||||
try {
|
||||
const data = await api(`/api/terminal/tasks/${encodeURIComponent(sid)}/live`);
|
||||
const tasks = Array.isArray(data && data.tasks) ? data.tasks : [];
|
||||
for (const t of tasks) {
|
||||
openTaskTab(t, { activate: false });
|
||||
}
|
||||
// 已从后端消失的"运行中"标签:标记停止并补输出尾部
|
||||
for (const [id, tb] of taskTabs) {
|
||||
if (tb.task && tb.task.status === "running" && !tasks.find((t) => t.id === id)) {
|
||||
_updateTaskTabMeta(tb, { ...tb.task, status: "killed", exit_code: null, abnormal: false });
|
||||
}
|
||||
}
|
||||
// 输出回放(重连/初次同步)
|
||||
const outputs = Array.isArray(data && data.outputs) ? data.outputs : [];
|
||||
for (const o of outputs) {
|
||||
const tb = taskTabs.get(o.id);
|
||||
if (!tb) continue;
|
||||
const chunks = Array.isArray(o.chunks) ? o.chunks : [];
|
||||
const last = chunks.length ? chunks[chunks.length - 1].seq : 0;
|
||||
if (last < tb.lastSeq) continue; // 已有更新输出
|
||||
tb.out.innerHTML = "";
|
||||
tb.lastSeq = 0;
|
||||
tb.truncated = false;
|
||||
_appendTaskChunks(o.id, chunks);
|
||||
}
|
||||
} catch (e) {
|
||||
showToast("同步后台任务失败: " + (e.message || String(e)));
|
||||
}
|
||||
}
|
||||
|
||||
/** 任务 SSE 流:确保绑定当前会话;会话变化时重建并清空旧标签 */
|
||||
function _ensureTaskStream() {
|
||||
const sid = _currentTaskSid();
|
||||
if (!sid) return;
|
||||
if (_taskStreamSid === sid && _taskStreamEv) return;
|
||||
_stopTaskStream();
|
||||
openTaskTabByIdsReset(); // 会话已变:清空旧标签(仅隐藏,不终止任务)
|
||||
const es = new EventSource(`/api/terminal/tasks/${encodeURIComponent(sid)}/stream`);
|
||||
es.addEventListener("update", (ev) => {
|
||||
let payload = null;
|
||||
try { payload = JSON.parse(ev.data); } catch { return; }
|
||||
if (!payload) return;
|
||||
if (payload.type === "full") {
|
||||
_ensureTaskStreamCheckSid();
|
||||
syncTaskTabs().catch(() => {});
|
||||
} else if (payload.type === "task_update" && payload.task) {
|
||||
_ensureTaskStreamCheckSid();
|
||||
const task = payload.task;
|
||||
const tb = taskTabs.get(task.id);
|
||||
if (tb) _updateTaskTabMeta(tb, task);
|
||||
else if (_dockVisible) openTaskTab(task);
|
||||
} else if (payload.type === "output") {
|
||||
_ensureTaskStreamCheckSid();
|
||||
_appendTaskChunks(payload.task_id, payload.chunks);
|
||||
}
|
||||
});
|
||||
es.onerror = () => { /* EventSource 自动重连,重连后 full 快照重同步 */ };
|
||||
_taskStreamEv = es;
|
||||
_taskStreamSid = sid;
|
||||
}
|
||||
|
||||
function _ensureTaskStreamCheckSid() {
|
||||
if (_taskStreamSid !== _currentTaskSid()) _ensureTaskStream();
|
||||
}
|
||||
|
||||
function openTaskTabByIdsReset() {
|
||||
closeAllTaskTabs();
|
||||
}
|
||||
|
||||
function _stopTaskStream() {
|
||||
if (_taskStreamEv) { try { _taskStreamEv.close(); } catch {} _taskStreamEv = null; }
|
||||
_taskStreamSid = "";
|
||||
}
|
||||
|
||||
// ---------- 事件绑定 ----------
|
||||
@@ -337,15 +513,11 @@ export function initTerminal() {
|
||||
const termBtn = $('terminalBtn');
|
||||
if (termBtn) termBtn.classList.remove('is-placeholder');
|
||||
|
||||
// 命令行(提示符 + 输入)动态构建,挂到活动面板输出末尾,随内容滚动
|
||||
buildCmdline();
|
||||
|
||||
// 点击终端区域任意处直接键入(焦点落到命令行输入框)
|
||||
// 点击终端区域任意处直接键入(焦点落到活动 xterm)
|
||||
const termBody = $('terminalBody');
|
||||
if (termBody) {
|
||||
termBody.addEventListener('click', () => {
|
||||
const input = _cmdInput;
|
||||
if (input && !input.disabled) input.focus();
|
||||
getTerm(activeId)?.term.focus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -365,23 +537,35 @@ export function initTerminal() {
|
||||
dockClose.title = '关闭全部终端(终止所有进程)';
|
||||
dockClose.textContent = '\u2715';
|
||||
dockClose.addEventListener('click', async () => {
|
||||
const ok = await showConfirm('确定关闭全部终端吗?\n将终止所有 PowerShell 进程。');
|
||||
if (ok) killAllTerminals();
|
||||
const hasTask = taskTabs.size > 0;
|
||||
const ok = await showConfirm(hasTask
|
||||
? '确定关闭全部终端吗?\n将终止所有 PowerShell 进程(后台任务仅隐藏标签,不会终止;可在通知条中终止)。'
|
||||
: '确定关闭全部终端吗?\n将终止所有 PowerShell 进程。');
|
||||
if (ok) { killAllTerminals(); closeAllTaskTabs(); }
|
||||
});
|
||||
$('terminalTabs').appendChild(dockClose);
|
||||
|
||||
// 主进程数据推送
|
||||
if (window.electronAPI?.onTerminalData) {
|
||||
window.electronAPI.onTerminalData(({ id, chunk }) => appendOutput(id, chunk || ''));
|
||||
window.electronAPI.onTerminalData(({ id, chunk }) => {
|
||||
const t = getTerm(id);
|
||||
if (t && chunk) t.term.write(chunk);
|
||||
});
|
||||
window.electronAPI.onTerminalExit(({ id, code }) => {
|
||||
const t = getTerm(id);
|
||||
if (!t) return;
|
||||
t.alive = false;
|
||||
t.tab.classList.add('term-tab--dead');
|
||||
appendOutput(id, `\r\n[进程已退出,code=${code == null ? '?' : code}](关闭标签可移除)\r\n`);
|
||||
if (activeId === id && _cmdInput) _cmdInput.disabled = true;
|
||||
try { t.ro?.disconnect(); } catch { /* ignore */ }
|
||||
t.term.write(`\r\n\x1b[90m[进程已退出,code=${code == null ? '?' : code}](关闭标签可移除)\x1b[0m\r\n`);
|
||||
});
|
||||
}
|
||||
|
||||
// 主题切换(themes.js dispatch theme-changed)→ 更新所有 xterm 配色
|
||||
document.addEventListener('theme-changed', () => {
|
||||
const theme = readTermTheme();
|
||||
for (const t of terminals) t.term.options.theme = theme;
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 };
|
||||
@@ -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; }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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}")
|
||||
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 132 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 325 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 188 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 131 KiB |