From 1cc969317d29206f544ca5f5e8fd4797ba09b9de Mon Sep 17 00:00:00 2001 From: lofyer Date: Sun, 9 Aug 2026 21:54:25 +0800 Subject: [PATCH] fix: align channel runtime and settings UI --- FEATURES.md | 19 +- README.md | 26 +- continue-runtime-web-demo/index.html | 308 ------- continue-runtime-web-demo/script.js | 382 --------- continue-runtime-web-demo/styles.css | 782 ------------------ .../wechat-clawbot-channel-project-prd.md | 54 +- src/main/agent/runtime-selection.test.ts | 38 +- src/main/agent/runtime-selection.ts | 20 + src/main/assistant/assistant-database.test.ts | 7 +- src/main/ipc.test.ts | 28 +- src/main/ipc.ts | 19 +- src/renderer/src/App.test.tsx | 45 + src/renderer/src/App.tsx | 9 +- .../src/ChannelSettingsSection.test.tsx | 49 ++ src/renderer/src/ChannelSettingsSection.tsx | 12 +- src/renderer/src/styles.css | 12 +- src/shared/runtime-selection-contracts.ts | 6 + 17 files changed, 288 insertions(+), 1528 deletions(-) delete mode 100644 continue-runtime-web-demo/index.html delete mode 100644 continue-runtime-web-demo/script.js delete mode 100644 continue-runtime-web-demo/styles.css diff --git a/FEATURES.md b/FEATURES.md index 79f9aee..5924277 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -21,6 +21,7 @@ - [x] **直连模型 Runtime**:支持问答、知识总结、受控工具执行和图像生成。 - [x] **OpenCode 与 Continue**:使用隔离子进程、环境变量白名单、取消、超时和活动记录。 +- [x] **统一 Runtime 配置来源**:普通会话和消息通道共用“Agent Runtime”中的 OpenCode/Continue 模型来源、自有配置、程序路径和服务地址;通道只选择 Runtime 类型,每次远程请求动态解析当前全局配置。 - [x] **Ask 与 Execute 工作模式**:Ask 保持只读;Execute 运行已启用且受边界约束的工具。 - [x] **专家与 Subagent**:支持显式专家、团队分析和最多三个只读专家并行分析。 - [x] **角色绑定模型连接**:每个角色可继承默认模型或选择独立文本模型连接,失效连接安全回退默认模型,综合角色始终继承默认模型。 @@ -36,12 +37,12 @@ - [x] **知识图谱**:支持规则、模型和混合抽取,以及实体、关系、别名和证据维护。 - [x] **向量模型配置与检索**:可配置兼容 Embeddings 接口并用于语义检索。 - [x] **向量诊断与索引任务**:提供真实向量生成诊断、按文档重建进度、取消、失败状态与重启后结果恢复;每篇成功文档立即可用于检索。 -- [ ] **魔法笔记 / Magic Notes**(规划中):提供本地优先的结构化笔记工作空间,可摘录选中的对话、知识、文档和网页内容并保留来源追溯;AI 总结、改写、续写、整理和关联知识均由用户明确触发,不会静默修改来源知识。 +- [x] **魔法笔记 / Magic Notes**:提供本地优先的笔记与待办工作台、范围管理、编辑、筛选和受控 AI 评论;创建、保存和评论结果使用统一应用通知。 - [ ] **MCP Server Control Plane**(规划中):扩展 MCP Agent Runtime Broker,统一生命周期、健康检查、重连、Schema 缓存、按项目或任务隔离、审批和审计,并受控接入 OpenCode、Continue。 ### 工作管理、长期协作与工作流 -- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件。 +- [x] **任务、活动与成果**:集中管理任务状态、审计活动和成果文件;活动按会话分组并默认收起,避免长历史占满页面。 - [x] **记忆与智能心跳**:提供周期回顾、建议记忆、洞察、后续任务和可审计运行轨迹。 - [ ] **批量运行与对比实验室**(规划中):对模型、Prompt、角色和工作流配置执行批量对比,汇总质量、耗时、Token、费用、失败率和成果差异。 - [ ] **时态记忆与事实冲突检测**(规划中):为记忆和知识图谱增加有效期、当前事实、过期与矛盾检测、事实核验及证据回溯。 @@ -51,9 +52,12 @@ ### 浏览器、通信、语音与应用维护 - [x] **浏览器和桌面受控工具**:保留范围、取消、超时、输出边界和执行记录。 -- [x] **企业微信与钉钉**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。 +- [x] **远程消息通道项目**:微信 ClawBot、企业微信和钉钉分别拥有系统管理的项目、独立远程会话、工作目录、处理后端、默认 Ask/Execute 模式及任务活动归属。 +- [x] **微信 ClawBot 扫码与媒体**:通过独立 Sidecar 完成本机扫码、验证码、加密凭据和文字收发;支持个人微信私聊图片与文件,单条消息最多 4 个附件、解密后合计不超过 12MB。 +- [x] **微信安全回传**:支持返回当前任务生成的图片,或在用户明确要求时将本次最终文本生成为 Markdown 附件;不自动读取或发送已有工作区文件。 +- [x] **企业微信与钉钉连接**:支持 Main-only 加密设置、环境变量只读覆盖、连接测试、动态启停、发送者范围和状态诊断。 - [x] **可选本地语音模型管理**:应用不内置模型权重;提供校验下载、进度与取消、来源链接、本地目录导入、切换和删除。 -- [ ] **本地录音与离线转写**(开发中):采集麦克风音频并使用已选择的本地模型离线转写,补齐取消、资源释放和 Electron 打包验证。 +- [x] **本地录音与离线转写**:采集麦克风音频并使用已选择的本地模型离线转写,支持停止、取消、状态反馈和资源释放。 - [x] **版本检查**:仅检查固定官方 Release 和当前平台清单,不自动下载或安装。 - [x] **内网兼容模式**:默认开启;允许应用内 HTTP 与无效、自签名或过期的 HTTPS 证书,关闭后恢复严格地址和证书校验。 @@ -80,12 +84,13 @@ - [ ] **Headless Runtime API**:作为可选、本机默认仅监听 loopback 的服务,提供任务提交、流式事件、状态和成果下载;访问令牌必须具有 scope、有效期、速率限制、项目限制和撤销能力。 - [ ] **GoodBuddy Team Hub**:作为独立可选服务提供组织、成员、RBAC、项目共享、远程 Agent 注册、策略下发和租户级审计,不把 Electron Renderer 或云端服务改造成用户凭据持有者。 -安全边界保持不变:Ask/Plan 必须在 Runtime 边界只读;Execute、MCP、网络和 Subagent 工具均经过 Main 进程审批与审计;不得照搬进程内脚本执行、任意 URL 请求、仅以 `created_by` 模拟多租户或共享无隔离 MCP 会话等做法。 +安全边界保持不变:Ask/Plan 必须在 Runtime 边界只读;Execute、MCP、网络和 Subagent 工具均受 Main 进程能力边界、权限策略、取消和审计约束。普通交互按对应策略审批;受信发送者发起的远程 Execute 不逐次弹窗确认,但不得绕过项目目录、Runtime、沙箱、能力开关或直连模型工具安全策略。不得照搬进程内脚本执行、任意 URL 请求、仅以 `created_by` 模拟多租户或共享无隔离 MCP 会话等做法。 ### 知识工作空间与魔法笔记 -- [ ] **魔法笔记 / Magic Notes**:建设本地优先的结构化笔记工作空间,支持将用户选中的对话片段、知识条目、文档摘录和网页摘录收集为可编辑笔记,并持续保留来源、位置和引用关系。 -- [ ] **受控 AI 笔记操作**:提供总结、改写、续写、整理和关联知识等显式操作;操作结果先进入笔记或待确认变更,不静默回写或修改来源知识。 +- [x] **魔法笔记 / Magic Notes 基础工作台**:已提供本地优先的笔记与待办页签、范围管理、编辑、筛选、删除和受控 AI 评论。 +- [ ] **可追溯摘录扩展**(规划中):支持将用户选中的对话片段、知识条目、文档摘录和网页摘录收集为可编辑笔记,并持续保留来源、位置和引用关系。 +- [ ] **扩展受控 AI 笔记操作**(规划中):在现有 AI 评论之外提供总结、改写、续写、整理和关联知识等显式操作;操作结果先进入笔记或待确认变更,不静默回写或修改来源知识。 ### 多云远程沙盒 Agent diff --git a/README.md b/README.md index 07db4f7..2510ad5 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ 面向专业工作与国产化环境的安全桌面智能助手。 -GoodBuddy 将模型连接、Agent Runtime、本地知识库、知识图谱、任务协作和持续成长能力组织在同一个桌面工作空间中。它不是简单的聊天窗口,而是一套可审计、可控制、可长期使用的个人智能工作环境。 +GoodBuddy 将模型连接、Agent Runtime、本地知识库、知识图谱、远程消息通道、任务协作和持续成长能力组织在同一个桌面工作空间中。它不是简单的聊天窗口,而是一套可审计、可控制、可长期使用的个人智能工作环境。 ![GoodBuddy 工作空间](docs/screenshots/workspace-overview.png) @@ -10,7 +10,7 @@ GoodBuddy 将模型连接、Agent Runtime、本地知识库、知识图谱、任 ### 安全可控的 Agent 执行 -GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode 和 Continue。工具不会被直接暴露给界面,所有执行都受到工作模式、权限审批和运行边界约束。 +GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode 和 Continue。工具不会被直接暴露给界面,所有执行都受到工作模式、权限策略和运行边界约束。 - `Ask`:只读问答,不调用工具。 - `Execute`:选择该模式即授权当前交互运行使用已启用的受控工具。 @@ -46,6 +46,19 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode - 支持远程任务委派与持久化结果发件箱。 - Skills 与 MCP 能力按需接入。 +### 远程消息通道与微信 ClawBot + +微信 ClawBot、企业微信和钉钉分别使用系统管理的通道项目。远程发送者拥有独立会话,任务、活动和成果持续归属于对应通道与项目。 + +- 微信 ClawBot 使用本机扫码绑定,支持个人微信私聊文字、图片和文件。 +- 单条微信消息最多 4 个附件,解密后合计不超过 12MB;图片和支持的文档进入现有受控上下文。 +- 通道可选择直连文本模型、OpenCode 或 Continue。OpenCode/Continue 始终跟随“Agent Runtime”中的全局配置,不在通道中维护第二套 Runtime 配置。 +- 远程消息支持 Ask 与 Execute。Execute 不显示通道专属逐次审批,但仍受发送者范围、项目目录、Runtime、沙箱、能力开关和活动审计约束。 +- 当前任务生成的图片可以返回微信;明确要求文件时可将本次最终文本生成为 Markdown 附件,不自动发送已有工作区文件。 +- “断开本机绑定”只停止本机收发并清除本地凭据,不会删除通道项目、远程会话或历史,也不承诺解除微信服务端授权。 + +完整设计、安全边界和联调状态见[远程消息通道项目与微信 ClawBot 集成 PRD](docs/features/wechat-clawbot-channel-project-prd.md)。 + ### 本地知识库与知识图谱 文件、目录和网页内容可以按知识库独立管理。GoodBuddy 会完成解析、索引、检索和图谱构建,并保留可追溯的来源与证据。 @@ -82,6 +95,8 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode | OpenCode | 完整编码与工作区任务 | Execute 不弹 GoodBuddy 审批,保留 Runtime 自身权限、取消和活动记录 | | Continue | Agent 编码与工作区任务 | Execute 不弹 GoodBuddy 审批,使用独立宿主、取消和活动记录 | +消息通道选择 OpenCode 或 Continue 时只选择 Runtime 类型,具体模型来源、自有配置、可执行文件和服务地址统一复用“Agent Runtime”设置,并在每次远程请求开始时解析当前全局配置。 + ## 功能矩阵与路线图 以下为仓库首页的简要路线图;完整能力说明、状态和重大规划统一记录在 [FEATURES.md](FEATURES.md)。 @@ -90,8 +105,9 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode - [x] [多 Runtime、模型连接、Skills 与 MCP](FEATURES.md#agent-runtime-与模型连接) - [x] [本地知识库、向量检索与知识图谱](FEATURES.md#skillsmcp-与知识库) - [x] [任务、成果、记忆与智能心跳](FEATURES.md#工作管理长期协作与工作流) -- [ ] [本地录音与离线转写](FEATURES.md#浏览器通信语音与应用维护) -- [ ] [魔法笔记 / Magic Notes](FEATURES.md#知识工作空间与魔法笔记):本地优先的结构化笔记、可追溯摘录与受控 AI 整理。 +- [x] [微信 ClawBot、企业微信与钉钉消息通道](FEATURES.md#浏览器通信语音与应用维护) +- [x] [本地录音与离线转写](FEATURES.md#浏览器通信语音与应用维护) +- [x] [魔法笔记 / Magic Notes](FEATURES.md#知识工作空间与魔法笔记):本地优先的笔记与待办工作台,支持受控 AI 评论。 - [ ] [Agent 框架、受控工作流与团队协作](FEATURES.md#agent-框架与协作能力) - [ ] [多云远程沙盒 Agent](FEATURES.md#多云远程沙盒-agent) @@ -99,4 +115,4 @@ GoodBuddy 通过统一的 Agent Runtime 控制层接入直连模型、OpenCode ## 隐私说明 -模型请求只会发送到用户选择的模型连接。本地数据保存在当前系统的应用数据目录中;远程委派仅在用户显式配置端点和令牌后启用。面向纯内网部署的“内网兼容模式”默认开启,允许 HTTP 并接受无效、自签名或过期的 HTTPS 证书;可在“安全与数据”中关闭并恢复严格校验。 +模型请求只会发送到用户选择的模型连接。本地数据保存在当前系统的应用数据目录中;远程委派仅在用户显式配置端点和令牌后启用。面向纯内网部署的“内网兼容模式”默认开启,允许 HTTP 并接受无效、自签名或过期的 HTTPS 证书;可在“安全与数据”中关闭并恢复严格校验。微信凭据和媒体端点不受该兼容模式放宽,始终只允许经过校验的腾讯微信 HTTPS 主机与重定向。 diff --git a/continue-runtime-web-demo/index.html b/continue-runtime-web-demo/index.html deleted file mode 100644 index c24e1eb..0000000 --- a/continue-runtime-web-demo/index.html +++ /dev/null @@ -1,308 +0,0 @@ - - - - - - GoodBuddy 控制中心 · AI 运维仪表盘 - - - - - - - - -
-
-
-

今日运维概览

-

实时掌握 AI 代理集群健康状况与任务执行进度

- -
-
-

总任务数

-

128

-

较昨日 +12

-
-
-

运行中代理

-

6

-

全部在线

-
-
-

已完成任务

-

96

-

完成率 75%

-
-
-

平均响应时间

-

340ms

-

较昨日 -18ms

-
-
-
-
- -
-
-

代理状态

-
    -
  • -
    - - 运行中 -
    -

    Agent Alpha

    -

    负责:数据同步 & 报表生成

    -

    最近活跃:2 分钟前

    -
  • -
  • -
    - - 空闲 -
    -

    Agent Beta

    -

    负责:客户工单处理

    -

    最近活跃:15 分钟前

    -
  • -
  • -
    - - 维护中 -
    -

    Agent Gamma

    -

    负责:库存预警检测

    -

    最近活跃:1 小时前

    -
  • -
  • -
    - - 运行中 -
    -

    Agent Delta

    -

    负责:日志汇总分析

    -

    最近活跃:刚刚

    -
  • -
-
-
- -
-
-
-

任务看板

- -
- -
- - - -
- - - -
    -
  • -
    -

    数据同步任务

    - 运行中 -
    -

    负责人:Agent Alpha

    -

    截止时间:今天 18:00

    -
  • -
  • -
    -

    客户工单自动回复

    - 已完成 -
    -

    负责人:Agent Beta

    -

    完成时间:今天 09:24

    -
  • -
  • -
    -

    每日日志汇总

    - 运行中 -
    -

    负责人:Agent Delta

    -

    截止时间:今天 23:00

    -
  • -
  • -
    -

    库存预警检测

    - 已完成 -
    -

    负责人:Agent Gamma

    -

    完成时间:昨天 21:10

    -
  • -
  • -
    -

    周报生成

    - 运行中 -
    -

    负责人:Agent Alpha

    -

    截止时间:本周五 12:00

    -
  • -
-
-
- -
-
-

活动日志

- -
- - -
- - - -
    -
  • - 10:42 - Agent Alpha 完成了「数据同步任务」的第 3 阶段 -
  • -
  • - 10:31 - Agent Beta 自动回复了 12 条客户工单 -
  • -
  • - 09:58 - Agent Gamma 触发了库存预警检测维护流程 -
  • -
  • - 09:20 - Agent Delta 生成了每日日志汇总报告 -
  • -
  • - 08:47 - 系统检测到 Agent Alpha 响应时间恢复正常 -
  • -
  • - 08:02 - Agent Delta 启动,开始监听新任务队列 -
  • -
-
-
-
- - - - - - - - - diff --git a/continue-runtime-web-demo/script.js b/continue-runtime-web-demo/script.js deleted file mode 100644 index 03472e7..0000000 --- a/continue-runtime-web-demo/script.js +++ /dev/null @@ -1,382 +0,0 @@ -/*! - * GoodBuddy Control Center — script.js - * Vanilla JS only. No external dependencies. - * CONTINUE_WEB_DEMO_OK - */ -/* global document, window */ -(function () { - "use strict"; - - /* --------------------------------------------------------- - * Utilities - * ------------------------------------------------------- */ - - /** - * Escapes HTML-sensitive characters in a string so that it is - * safe to insert as text content inside markup. - * @param {string} value - * @returns {string} - */ - function escapeHtml(value) { - return String(value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); - } - - function qs(selector, scope) { - return (scope || document).querySelector(selector); - } - - function qsa(selector, scope) { - return Array.prototype.slice.call((scope || document).querySelectorAll(selector)); - } - - /* --------------------------------------------------------- - * Theme toggle (persisted via localStorage) - * ------------------------------------------------------- */ - - var THEME_STORAGE_KEY = "goodbuddy-theme"; - - function initTheme() { - var themeToggleBtn = qs("#themeToggleBtn"); - var iconEl = qs(".theme-toggle-icon", themeToggleBtn); - var labelEl = qs(".theme-toggle-label", themeToggleBtn); - - function applyTheme(theme) { - var label; - if (theme === "dark") { - document.documentElement.setAttribute("data-theme", "dark"); - themeToggleBtn.setAttribute("aria-pressed", "true"); - if (iconEl) iconEl.textContent = "☀️"; - label = "浅色模式"; - } else { - document.documentElement.removeAttribute("data-theme"); - themeToggleBtn.setAttribute("aria-pressed", "false"); - if (iconEl) iconEl.textContent = "🌙"; - label = "深色模式"; - } - if (labelEl) labelEl.textContent = label; - // .theme-toggle-label is visually hidden on narrow viewports and the - // icon is aria-hidden, so without an explicit aria-label the button - // has no accessible name on mobile. Keep it in sync with the visible - // desktop label on every theme change. - themeToggleBtn.setAttribute("aria-label", label); - } - - var stored = null; - try { - stored = window.localStorage.getItem(THEME_STORAGE_KEY); - } catch { - /* localStorage unavailable, use the default theme */ - } - - var prefersDark = - window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; - var initialTheme = stored || (prefersDark ? "dark" : "light"); - applyTheme(initialTheme); - - themeToggleBtn.addEventListener("click", function () { - var isDark = document.documentElement.getAttribute("data-theme") === "dark"; - var nextTheme = isDark ? "light" : "dark"; - applyTheme(nextTheme); - try { - window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme); - } catch { - /* localStorage unavailable — ignore silently */ - } - }); - } - - /* --------------------------------------------------------- - * Mobile navigation toggle - * ------------------------------------------------------- */ - - function initNavToggle() { - var navToggleBtn = qs("#navToggleBtn"); - var primaryNav = qs("#primaryNav"); - if (!navToggleBtn || !primaryNav) return; - - navToggleBtn.addEventListener("click", function () { - var isOpen = primaryNav.classList.toggle("is-open"); - navToggleBtn.setAttribute("aria-expanded", String(isOpen)); - }); - - qsa("a", primaryNav).forEach(function (link) { - link.addEventListener("click", function () { - primaryNav.classList.remove("is-open"); - navToggleBtn.setAttribute("aria-expanded", "false"); - }); - }); - } - - /* --------------------------------------------------------- - * Task board: filtering + adding tasks - * ------------------------------------------------------- */ - - var STATUS_LABELS = { - running: "运行中", - completed: "已完成" - }; - - var STATUS_BADGE_CLASS = { - running: "status-running", - completed: "status-completed" - }; - - function initTaskBoard() { - var taskList = qs("#taskList"); - var filterButtons = qsa(".filter-btn"); - var emptyState = qs("#taskEmptyState"); - - function currentFilter() { - var activeBtn = qs(".filter-btn.is-active"); - return activeBtn ? activeBtn.getAttribute("data-filter") : "all"; - } - - function applyFilter() { - var filter = currentFilter(); - var cards = qsa(".task-card", taskList); - var visibleCount = 0; - - cards.forEach(function (card) { - var status = card.getAttribute("data-status"); - var matches = filter === "all" || status === filter; - card.hidden = !matches; - if (matches) visibleCount += 1; - }); - - if (emptyState) { - emptyState.hidden = visibleCount !== 0; - } - } - - filterButtons.forEach(function (btn) { - btn.addEventListener("click", function () { - filterButtons.forEach(function (b) { - b.classList.remove("is-active"); - b.setAttribute("aria-pressed", "false"); - }); - btn.classList.add("is-active"); - btn.setAttribute("aria-pressed", "true"); - applyFilter(); - }); - }); - - /** - * Builds a task card list item using safe DOM APIs so that - * user-provided text can never be interpreted as markup. - */ - function createTaskCard(title, assignee, status) { - var li = document.createElement("li"); - li.className = "task-card"; - li.setAttribute("data-status", status); - - var top = document.createElement("div"); - top.className = "task-card-top"; - - var heading = document.createElement("h3"); - heading.className = "task-title"; - // textContent assigns the raw string as plain text — the browser - // never parses it as markup, so no HTML-escaping is needed (or - // wanted) here. Escaping first would make textContent render the - // escaped entities literally (e.g. "<img...") instead of the - // user's exact text. - heading.textContent = title; - - var badge = document.createElement("span"); - badge.className = "status-badge " + STATUS_BADGE_CLASS[status]; - badge.textContent = STATUS_LABELS[status]; - - top.appendChild(heading); - top.appendChild(badge); - - var meta = document.createElement("p"); - meta.className = "task-meta"; - meta.textContent = "负责人:" + assignee; - - var metaTime = document.createElement("p"); - metaTime.className = "task-meta"; - var now = new Date(); - var timeLabel = - status === "completed" - ? "完成时间:刚刚" - : "截止时间:" + - String(now.getHours()).padStart(2, "0") + - ":" + - String(now.getMinutes()).padStart(2, "0"); - metaTime.textContent = timeLabel; - - li.appendChild(top); - li.appendChild(meta); - li.appendChild(metaTime); - - return li; - } - - applyFilter(); - - return { - addTask: function (title, assignee, status) { - // Pass the trimmed, un-escaped user text straight through. - // createTaskCard assigns it via textContent (never innerHTML), - // so it is rendered as inert plain text and can never execute as - // markup/script regardless of its contents. - var card = createTaskCard(title.trim(), assignee, status); - taskList.insertBefore(card, taskList.firstChild); - applyFilter(); - return card; - } - }; - } - - /* --------------------------------------------------------- - * Activity log search - * ------------------------------------------------------- */ - - function initActivitySearch() { - var searchInput = qs("#activitySearch"); - var activityList = qs("#activityList"); - var emptyState = qs("#activityEmptyState"); - if (!searchInput || !activityList) return; - - function runSearch() { - var query = searchInput.value.trim().toLowerCase(); - var items = qsa(".activity-item", activityList); - var visibleCount = 0; - - items.forEach(function (item) { - var text = item.textContent.toLowerCase(); - var matches = query === "" || text.indexOf(query) !== -1; - item.hidden = !matches; - if (matches) visibleCount += 1; - }); - - if (emptyState) { - emptyState.hidden = visibleCount !== 0; - } - } - - searchInput.addEventListener("input", runSearch); - } - - /* --------------------------------------------------------- - * Add-task modal (accessible dialog) - * ------------------------------------------------------- */ - - function initTaskModal(taskBoard) { - var overlay = qs("#taskModalOverlay"); - var modal = qs("#taskModal"); - var openBtn = qs("#addTaskBtn"); - var closeBtn = qs("#modalCloseBtn"); - var cancelBtn = qs("#modalCancelBtn"); - var form = qs("#taskForm"); - var titleInput = qs("#taskTitleInput"); - var assigneeSelect = qs("#taskAssigneeSelect"); - var statusSelect = qs("#taskStatusSelect"); - var errorMsg = qs("#taskFormError"); - - if (!overlay || !modal || !openBtn || !form) return; - - var lastFocusedElement = null; - - function getFocusableElements() { - return qsa( - 'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])', - modal - ).filter(function (el) { - return el.offsetParent !== null; - }); - } - - function openModal() { - lastFocusedElement = document.activeElement; - overlay.hidden = false; - modal.hidden = false; - errorMsg.hidden = true; - form.reset(); - document.body.style.overflow = "hidden"; - titleInput.focus(); - document.addEventListener("keydown", handleKeydown); - } - - function closeModal() { - overlay.hidden = true; - modal.hidden = true; - document.body.style.overflow = ""; - document.removeEventListener("keydown", handleKeydown); - if (lastFocusedElement && typeof lastFocusedElement.focus === "function") { - lastFocusedElement.focus(); - } - } - - function handleKeydown(event) { - if (event.key === "Escape") { - event.preventDefault(); - closeModal(); - return; - } - - if (event.key === "Tab") { - var focusable = getFocusableElements(); - if (focusable.length === 0) return; - - var first = focusable[0]; - var last = focusable[focusable.length - 1]; - - if (event.shiftKey && document.activeElement === first) { - event.preventDefault(); - last.focus(); - } else if (!event.shiftKey && document.activeElement === last) { - event.preventDefault(); - first.focus(); - } - } - } - - openBtn.addEventListener("click", openModal); - closeBtn.addEventListener("click", closeModal); - cancelBtn.addEventListener("click", closeModal); - overlay.addEventListener("click", closeModal); - - form.addEventListener("submit", function (event) { - event.preventDefault(); - - var title = titleInput.value.trim(); - if (title === "") { - errorMsg.hidden = false; - titleInput.focus(); - return; - } - - errorMsg.hidden = true; - taskBoard.addTask(title, assigneeSelect.value, statusSelect.value); - closeModal(); - }); - } - - /* --------------------------------------------------------- - * Bootstrap - * ------------------------------------------------------- */ - - function init() { - initTheme(); - initNavToggle(); - var taskBoard = initTaskBoard(); - initActivitySearch(); - initTaskModal(taskBoard); - } - - if (document.readyState === "loading") { - document.addEventListener("DOMContentLoaded", init); - } else { - init(); - } - - // Expose escapeHtml for testability / verification purposes only. - window.__goodbuddyEscapeHtml = escapeHtml; -})(); - -// CONTINUE_WEB_DEMO_OK diff --git a/continue-runtime-web-demo/styles.css b/continue-runtime-web-demo/styles.css deleted file mode 100644 index b9c8e35..0000000 --- a/continue-runtime-web-demo/styles.css +++ /dev/null @@ -1,782 +0,0 @@ -/* ========================================================= - GoodBuddy Control Center — styles.css - ========================================================= */ - -:root { - color-scheme: light; - - --color-bg: #f4f6fb; - --color-bg-elevated: #ffffff; - --color-bg-header: rgba(255, 255, 255, 0.92); - --color-border: #dde3ee; - --color-text: #1c2333; - --color-text-muted: #5a6479; - --color-primary: #3a5bfd; - --color-primary-hover: #2a45d6; - --color-primary-contrast: #ffffff; - --color-success: #12a454; - --color-success-bg: #e4f8ec; - --color-warning: #b8860b; - --color-warning-bg: #fbf1d9; - --color-info: #2a6df4; - --color-info-bg: #e6edff; - --color-danger: #d43b3b; - --color-danger-bg: #fdeaea; - --shadow-sm: 0 1px 2px rgba(20, 26, 46, 0.06); - --shadow-md: 0 8px 24px rgba(20, 26, 46, 0.08); - --shadow-lg: 0 20px 48px rgba(20, 26, 46, 0.16); - --radius-sm: 8px; - --radius-md: 14px; - --radius-lg: 20px; - --transition-fast: 150ms ease; - --transition-base: 220ms ease; - --focus-ring: 0 0 0 3px rgba(58, 91, 253, 0.35); - --max-width: 1180px; -} - -[data-theme="dark"] { - color-scheme: dark; - - --color-bg: #0f1220; - --color-bg-elevated: #1a1f33; - --color-bg-header: rgba(15, 18, 32, 0.9); - --color-border: #2b3150; - --color-text: #eef1fb; - --color-text-muted: #a3aac4; - --color-primary: #7b93ff; - --color-primary-hover: #93a7ff; - --color-primary-contrast: #0f1220; - --color-success: #35d17d; - --color-success-bg: rgba(53, 209, 125, 0.14); - --color-warning: #e8c15b; - --color-warning-bg: rgba(232, 193, 91, 0.14); - --color-info: #7ea2ff; - --color-info-bg: rgba(126, 162, 255, 0.14); - --color-danger: #f27272; - --color-danger-bg: rgba(242, 114, 114, 0.14); - --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4); - --shadow-md: 0 8px 24px rgba(0, 0, 0, 0.45); - --shadow-lg: 0 20px 48px rgba(0, 0, 0, 0.55); - --focus-ring: 0 0 0 3px rgba(123, 147, 255, 0.45); -} - -/* ---------- Reset & base ---------- */ -*, -*::before, -*::after { - box-sizing: border-box; -} - -html { - scroll-behavior: smooth; -} - -body { - margin: 0; - min-height: 100vh; - background: var(--color-bg); - color: var(--color-text); - font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui, - -apple-system, sans-serif; - line-height: 1.6; - transition: background-color var(--transition-base), color var(--transition-base); -} - -h1, -h2, -h3, -p { - margin: 0 0 0.5em; -} - -ul { - list-style: none; - margin: 0; - padding: 0; -} - -button { - font-family: inherit; -} - -input, -select { - font-family: inherit; -} - -.section-inner { - max-width: var(--max-width); - margin: 0 auto; - padding: 0 1.5rem; -} - -/* ---------- Accessibility helpers ---------- */ -.visually-hidden { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} - -.skip-link { - position: absolute; - top: -100px; - left: 1rem; - background: var(--color-primary); - color: var(--color-primary-contrast); - padding: 0.6rem 1rem; - border-radius: var(--radius-sm); - z-index: 1000; - transition: top var(--transition-fast); -} - -.skip-link:focus { - top: 1rem; -} - -:focus-visible { - outline: none; - box-shadow: var(--focus-ring); - border-radius: var(--radius-sm); -} - -a { - color: var(--color-primary); -} - -/* ---------- Header ---------- */ -.site-header { - position: sticky; - top: 0; - z-index: 100; - background: var(--color-bg-header); - backdrop-filter: blur(10px); - border-bottom: 1px solid var(--color-border); -} - -.header-inner { - max-width: var(--max-width); - margin: 0 auto; - padding: 0.85rem 1.5rem; - display: flex; - align-items: center; - gap: 1rem; -} - -.brand { - display: flex; - align-items: center; - gap: 0.6rem; - font-weight: 700; - font-size: 1.05rem; - white-space: nowrap; -} - -.brand-mark { - display: inline-flex; - align-items: center; - justify-content: center; - width: 36px; - height: 36px; - border-radius: 10px; - background: linear-gradient(135deg, var(--color-primary), #7d5bfd); - color: #fff; - font-size: 0.85rem; - font-weight: 800; -} - -.primary-nav { - flex: 1; -} - -.primary-nav ul { - display: flex; - gap: 1.5rem; -} - -.primary-nav a { - text-decoration: none; - color: var(--color-text-muted); - font-weight: 600; - padding: 0.4rem 0.2rem; - border-radius: var(--radius-sm); - transition: color var(--transition-fast); -} - -.primary-nav a:hover { - color: var(--color-primary); -} - -.header-actions { - display: flex; - align-items: center; - gap: 0.75rem; -} - -.theme-toggle { - display: inline-flex; - align-items: center; - gap: 0.5rem; - padding: 0.5rem 0.9rem; - border-radius: 999px; - border: 1px solid var(--color-border); - background: var(--color-bg-elevated); - color: var(--color-text); - cursor: pointer; - font-weight: 600; - font-size: 0.9rem; - transition: transform var(--transition-fast), background-color var(--transition-fast); -} - -.theme-toggle:hover { - transform: translateY(-1px); -} - -.nav-toggle { - display: none; - flex-direction: column; - gap: 4px; - padding: 0.5rem; - background: transparent; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - cursor: pointer; -} - -.nav-toggle-bar { - display: block; - width: 20px; - height: 2px; - background: var(--color-text); - border-radius: 2px; -} - -/* ---------- Hero ---------- */ -.hero { - padding: 3rem 0 2.5rem; -} - -.hero-subtitle { - color: var(--color-text-muted); - font-size: 1.05rem; - max-width: 46ch; -} - -.metrics { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; - margin-top: 1.75rem; -} - -.metric-card { - background: var(--color-bg-elevated); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: 1.25rem; - box-shadow: var(--shadow-sm); - transition: box-shadow var(--transition-fast), transform var(--transition-fast); -} - -.metric-card:hover { - box-shadow: var(--shadow-md); - transform: translateY(-2px); -} - -.metric-label { - color: var(--color-text-muted); - font-size: 0.85rem; - font-weight: 600; -} - -.metric-value { - font-size: 2rem; - font-weight: 800; -} - -.metric-delta { - font-size: 0.82rem; - font-weight: 700; -} - -.metric-delta--up { - color: var(--color-success); -} - -.metric-delta--down { - color: var(--color-danger); -} - -/* ---------- Sections generic ---------- */ -.agents, -.tasks, -.activity { - padding: 2.5rem 0; -} - -.agents h2, -.tasks h2, -.activity h2 { - font-size: 1.5rem; -} - -/* ---------- Agent grid ---------- */ -.agent-grid { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 1rem; - margin-top: 1.25rem; -} - -.agent-card { - background: var(--color-bg-elevated); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: 1.25rem; - box-shadow: var(--shadow-sm); - transition: transform var(--transition-fast), box-shadow var(--transition-fast); -} - -.agent-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} - -.agent-card-top { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 0.75rem; -} - -.agent-avatar { - display: inline-flex; - align-items: center; - justify-content: center; - width: 42px; - height: 42px; - border-radius: 50%; - background: var(--color-info-bg); - color: var(--color-info); - font-weight: 800; - font-size: 0.85rem; -} - -.agent-name { - font-size: 1.05rem; -} - -.agent-meta { - color: var(--color-text-muted); - font-size: 0.88rem; - margin-bottom: 0.2rem; -} - -/* ---------- Status badges ---------- */ -.status-badge { - display: inline-flex; - align-items: center; - padding: 0.28rem 0.7rem; - border-radius: 999px; - font-size: 0.78rem; - font-weight: 700; -} - -.status-running { - background: var(--color-info-bg); - color: var(--color-info); -} - -.status-completed { - background: var(--color-success-bg); - color: var(--color-success); -} - -.status-idle { - background: var(--color-warning-bg); - color: var(--color-warning); -} - -.status-maintenance { - background: var(--color-danger-bg); - color: var(--color-danger); -} - -/* ---------- Buttons ---------- */ -.btn { - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0.6rem 1.1rem; - border-radius: var(--radius-sm); - border: 1px solid transparent; - font-weight: 700; - font-size: 0.92rem; - cursor: pointer; - transition: background-color var(--transition-fast), transform var(--transition-fast), - box-shadow var(--transition-fast); -} - -.btn:hover { - transform: translateY(-1px); -} - -.btn-primary { - background: var(--color-primary); - color: var(--color-primary-contrast); - box-shadow: var(--shadow-sm); -} - -.btn-primary:hover { - background: var(--color-primary-hover); -} - -.btn-ghost { - background: transparent; - border-color: var(--color-border); - color: var(--color-text); -} - -.btn-ghost:hover { - background: var(--color-bg); -} - -/* ---------- Tasks ---------- */ -.tasks-header { - display: flex; - align-items: center; - justify-content: space-between; - flex-wrap: wrap; - gap: 1rem; -} - -.filter-group { - display: flex; - gap: 0.6rem; - margin: 1.25rem 0 1rem; - flex-wrap: wrap; -} - -.filter-btn { - padding: 0.5rem 1rem; - border-radius: 999px; - border: 1px solid var(--color-border); - background: var(--color-bg-elevated); - color: var(--color-text-muted); - font-weight: 700; - font-size: 0.88rem; - cursor: pointer; - transition: all var(--transition-fast); -} - -.filter-btn:hover { - color: var(--color-primary); - border-color: var(--color-primary); -} - -.filter-btn.is-active { - background: var(--color-primary); - border-color: var(--color-primary); - color: var(--color-primary-contrast); -} - -.task-list { - display: grid; - grid-template-columns: repeat(2, 1fr); - gap: 1rem; -} - -.task-card { - background: var(--color-bg-elevated); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: 1.1rem 1.25rem; - box-shadow: var(--shadow-sm); - transition: transform var(--transition-fast), box-shadow var(--transition-fast); -} - -.task-card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-md); -} - -.task-card-top { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 0.75rem; - margin-bottom: 0.4rem; -} - -.task-title { - font-size: 1rem; - word-break: break-word; -} - -.task-meta { - color: var(--color-text-muted); - font-size: 0.85rem; - margin-bottom: 0.15rem; -} - -.empty-state { - color: var(--color-text-muted); - font-style: italic; - padding: 1rem 0; -} - -/* ---------- Activity ---------- */ -.search-field { - margin: 1.25rem 0; -} - -.search-input { - width: 100%; - max-width: 420px; - padding: 0.65rem 1rem; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border); - background: var(--color-bg-elevated); - color: var(--color-text); - font-size: 0.95rem; -} - -.activity-list { - display: flex; - flex-direction: column; - gap: 0.6rem; -} - -.activity-item { - display: flex; - gap: 1rem; - align-items: baseline; - padding: 0.75rem 1rem; - background: var(--color-bg-elevated); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - box-shadow: var(--shadow-sm); -} - -.activity-time { - flex-shrink: 0; - font-variant-numeric: tabular-nums; - color: var(--color-text-muted); - font-weight: 700; - font-size: 0.85rem; - min-width: 3.2rem; -} - -.activity-text { - color: var(--color-text); -} - -/* ---------- Footer ---------- */ -.site-footer { - border-top: 1px solid var(--color-border); - padding: 1.5rem 0 2.5rem; - color: var(--color-text-muted); - font-size: 0.85rem; -} - -.footer-marker { - opacity: 0.55; - font-size: 0.75rem; - letter-spacing: 0.02em; -} - -/* ---------- Modal ---------- */ -.modal-overlay { - position: fixed; - inset: 0; - background: rgba(10, 12, 24, 0.5); - z-index: 200; -} - -.modal { - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: min(480px, calc(100vw - 2rem)); - max-height: calc(100vh - 3rem); - overflow-y: auto; - background: var(--color-bg-elevated); - border-radius: var(--radius-lg); - box-shadow: var(--shadow-lg); - z-index: 201; - border: 1px solid var(--color-border); -} - -.modal[hidden], -.modal-overlay[hidden] { - display: none; -} - -.modal-form { - display: flex; - flex-direction: column; -} - -.modal-header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 1.25rem 1.5rem; - border-bottom: 1px solid var(--color-border); -} - -.modal-header h2 { - font-size: 1.15rem; - margin: 0; -} - -.modal-close { - background: transparent; - border: none; - font-size: 1.4rem; - line-height: 1; - color: var(--color-text-muted); - cursor: pointer; - padding: 0.25rem 0.5rem; - border-radius: var(--radius-sm); -} - -.modal-close:hover { - color: var(--color-danger); -} - -.modal-body { - padding: 1.25rem 1.5rem; - display: flex; - flex-direction: column; - gap: 1rem; -} - -.form-field { - display: flex; - flex-direction: column; - gap: 0.4rem; -} - -.form-field label { - font-weight: 700; - font-size: 0.9rem; -} - -.form-field input, -.form-field select { - padding: 0.6rem 0.75rem; - border-radius: var(--radius-sm); - border: 1px solid var(--color-border); - background: var(--color-bg); - color: var(--color-text); - font-size: 0.95rem; -} - -.form-error { - color: var(--color-danger); - font-size: 0.85rem; - font-weight: 700; - margin: 0; -} - -.modal-footer { - display: flex; - justify-content: flex-end; - gap: 0.75rem; - padding: 1.1rem 1.5rem; - border-top: 1px solid var(--color-border); -} - -/* ---------- Responsive ---------- */ -@media (max-width: 900px) { - .metrics { - grid-template-columns: repeat(2, 1fr); - } - - .agent-grid { - grid-template-columns: repeat(2, 1fr); - } - - .task-list { - grid-template-columns: 1fr; - } -} - -@media (max-width: 720px) { - .nav-toggle { - display: inline-flex; - } - - .primary-nav { - position: absolute; - top: 100%; - left: 0; - right: 0; - background: var(--color-bg-elevated); - border-bottom: 1px solid var(--color-border); - box-shadow: var(--shadow-md); - display: none; - } - - .primary-nav.is-open { - display: block; - } - - .primary-nav ul { - flex-direction: column; - padding: 1rem 1.5rem; - gap: 0.25rem; - } - - .primary-nav a { - display: block; - padding: 0.6rem 0.2rem; - } - - .header-inner { - flex-wrap: wrap; - position: relative; - } - - .theme-toggle-label { - display: none; - } -} - -@media (max-width: 560px) { - .metrics { - grid-template-columns: 1fr; - } - - .agent-grid { - grid-template-columns: 1fr; - } - - .tasks-header { - flex-direction: column; - align-items: flex-start; - } -} - -/* ---------- Reduced motion ---------- */ -@media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - animation-duration: 0.001ms !important; - animation-iteration-count: 1 !important; - transition-duration: 0.001ms !important; - scroll-behavior: auto !important; - } - - html { - scroll-behavior: auto; - } -} diff --git a/docs/features/wechat-clawbot-channel-project-prd.md b/docs/features/wechat-clawbot-channel-project-prd.md index 40c1f06..c34d7a2 100644 --- a/docs/features/wechat-clawbot-channel-project-prd.md +++ b/docs/features/wechat-clawbot-channel-project-prd.md @@ -5,21 +5,21 @@ | 项目 | 内容 | | --- | --- | | 状态 | 已实施,待真实微信账号联调 | -| 版本 | 1.3 | +| 版本 | 1.4 | | 日期 | 2026-08-09 | | 适用产品 | GoodBuddy 桌面端 | | 首期范围 | 微信 ClawBot、企业微信、钉钉的通道项目;微信文字、图片和文件 Ask 与 Execute | ## 1. 背景 -GoodBuddy 已支持企业微信和钉钉远程消息通道,但目前通道仅存在于设置页和后台服务中。远程消息进入任务系统后,用户无法在项目与会话导航中持续、明确地识别: +GoodBuddy 已将企业微信、钉钉和微信 ClawBot 远程消息通道纳入系统管理的通道项目。该信息架构解决了早期通道仅存在于设置页和后台服务中,远程消息进入任务系统后无法持续、明确识别以下信息的问题: 1. 消息来自哪个平台。 2. 当前正在与哪个发送者或群聊对话。 3. 远程执行任务使用哪个工作目录。 4. 执行请求、处理后端、工具调用和结果归属于哪个范围。 -GoodBuddy 同时计划接入腾讯官方微信 ClawBot。微信 ClawBot 使用扫码授权,不使用 App ID 或 Secret,并需要在本机持续运行通信服务。微信消息既要支持只读对话,也要支持受控执行。 +腾讯官方微信 ClawBot 使用扫码授权,不使用 App ID 或 Secret,并由本机独立 Sidecar 持续运行通信服务。文字、图片和文件收发、只读对话与受控执行已经实现;真实微信账号的认证媒体端到端联调仍作为发布前手动验收。 为避免把通道来源、发送者身份和执行范围混在一起,本功能使用“通道项目 + 远程会话”的两级结构: @@ -38,7 +38,7 @@ GoodBuddy 同时计划接入腾讯官方微信 ClawBot。微信 ClawBot 使用 6. 微信 ClawBot 首期支持个人微信私聊中的文字、图片和文件;语音和视频后续支持。 7. 微信卡片可以配置默认“对话”或“执行”模式。 8. “对话”映射为 GoodBuddy `Ask`;“执行”映射为 `Execute`。 -9. 每个通道项目默认使用“模型连接”中的默认直连文本模型,也可以显式选择其他直连文本模型、OpenCode 或 Continue。 +9. 每个通道项目默认使用“模型连接”中的默认直连文本模型,也可以显式选择其他直连文本模型、OpenCode 或 Continue。选择 OpenCode/Continue 时,通道只保存 Runtime 类型,并在每次远程请求开始时动态跟随“Agent Runtime”中的对应全局配置,不维护第二套模型来源或 Runtime 配置。 10. 远程 Execute 不显示通道专属请求级或逐工具确认;收到合法消息后立即按所选后端运行。 11. 任务仍受工作目录、Runtime 能力、沙箱、能力开关、直连模型工具安全策略和活动审计约束。 12. 停用或断开通道不得删除通道项目、远程会话、任务、活动或成果历史。 @@ -216,7 +216,7 @@ C:\Users\用户名 [选择目录] 通道项目 微信 ClawBot -[断开连接] +[断开本机绑定(危险操作)] ``` “对话 / 执行”是互斥状态,应使用语义化分段控件,不使用两个独立复选框。 @@ -243,14 +243,21 @@ C:\Users\用户名 每个通道项目提供相同的后端选择,不提供含义不明确的“自动”选项: - 直连模型:默认选择“模型连接”中的默认文本连接;列出已配置的文本模型连接,不列出仅支持图像生成的连接。 -- OpenCode:使用当前 OpenCode Runtime 及其模型来源。 -- Continue:使用当前 Continue Runtime 及其模型来源。 +- OpenCode:使用当前 OpenCode Runtime,并动态跟随“Agent Runtime”中的全局 OpenCode 模型来源、自有配置、程序路径和服务地址。 +- Continue:使用当前 Continue Runtime,并动态跟随“Agent Runtime”中的全局 Continue 模型来源、自有配置和程序路径。 选择持久化在通道项目上。旧版本保存的 `auto` 选择在启动时迁移为当前默认 直连文本模型。模型连接删除、改为图片模型或凭据失效后,优先修复为默认或 首个可用文本模型;没有可用文本模型时,UI 明确提示用户完成模型配置或改选 -Agent Runtime。远程会话每次收到新消息时记录本次项目选择。Execute 启动前 -检查所选后端是否支持工具执行,并返回可处理的配置错误。 +Agent Runtime。OpenCode/Continue 的通道项目只持久化 `provider`,旧版残留的 +通道级 `profileId` 在修复时移除;全局 Agent Runtime 配置变更从下一条远程 +请求开始生效。远程会话记录通道项目的逻辑后端选择,不复制全局配置或凭据。 +Execute 启动前检查解析后的后端是否支持工具执行,并返回可处理的配置错误。 + +通道面板的后端说明必须明确显示“跟随 Agent Runtime 全局配置”,不得在消息 +通道设置中重复展示 OpenCode/Continue 的模型来源、自有配置文件或程序路径。 +“Agent Runtime > 高级设置”中的来源选项、条件配置卡和后续路径字段保持 +`12px` 区块间距,不能出现卡片边框贴合或内容归属不清。 ### 7.5 通道项目会话界面 @@ -261,6 +268,7 @@ Agent Runtime。远程会话每次收到新消息时记录本次项目选择。E - 尚无远程会话时显示等待首条客户端消息的空状态和设置入口。 - 旧版本误建在通道项目中的普通本地会话不参与通道会话列表,但保留其数据。 - 远程会话底部说明客户端联动方式,只显示历史、任务和执行结果,不再提及已移除的审批流程。 +- “任务与活动”页面按会话分组显示远程任务;所有分组首次进入时默认收起,包括进行中、失败和已完成状态,用户可通过原生展开控件查看明细。 ### 7.6 微信扫码绑定 @@ -276,6 +284,10 @@ Agent Runtime。远程会话每次收到新消息时记录本次项目选择。E 需要配对数字时,在同一对话框中显示验证码输入。验证码不得写入日志或持久化。 +绑定完成后显示“重新绑定”和“断开本机绑定”。“断开本机绑定”使用共享红色 +危险操作样式,并持续说明该操作只清除本机凭据、不保证解除微信服务端授权, +也不删除通道项目、远程会话、任务、活动或成果历史。 + ### 7.7 企业微信和钉钉 企业微信和钉钉继续使用现有凭据表单、环境变量只读覆盖和连接测试,但增加: @@ -306,12 +318,10 @@ channel + accountId + externalConversationId ### 8.2 新建上下文 -首期支持以下方式创建新上下文: - -- 微信发送 `/new`。 -- GoodBuddy 会话页面点击“新建远程会话”。 - -新会话仍属于相同通道项目。旧会话保留并可搜索。 +首期不提供手动“新建远程会话”入口,也不把通道项目中的全局“新建对话” +命令转为本地会话。客户端首条合法消息按稳定键自动创建远程会话,后续消息 +继续复用该会话;未来如增加远程上下文重置命令,需要单独定义协议、去重和 +历史保留语义。 ### 8.3 展示 @@ -357,7 +367,7 @@ channel + accountId + externalConversationId Execute 消息通过身份、长度、去重和并发检查后: 1. 创建并立即启动远程执行任务。 -2. 使用通道项目当前保存的消息处理后端。 +2. 使用通道项目当前保存的逻辑处理后端;OpenCode/Continue 在此时解析“Agent Runtime”中的对应全局配置。 3. 将项目根目录作为本次 Runtime 工作目录。 4. 所选后端不支持工具执行时,不启动任务,并返回设置修复说明。 @@ -491,10 +501,11 @@ Main 到 Sidecar: 产品区分: - 停用:停止收发,保留本地绑定凭据。 -- 断开本地连接:停止收发并清除本地凭据。 +- 断开本机绑定:停止收发并清除本地凭据;入口使用红色危险操作样式。 - 微信端解除绑定:只有腾讯提供并验证服务端撤销能力后才可承诺。 -当前不得把本地清除描述为“已在微信端彻底解绑”。 +当前不得把本地清除描述为“已在微信端彻底解绑”。断开后保留通道项目、 +远程会话、任务、活动和成果历史。 ## 12. 数据与契约建议 @@ -643,8 +654,11 @@ Renderer 快照只返回是否已配置和脱敏标识。 - [ ] 三个通道使用与模型设置一致的分段外观页签,并保留完整页签键盘语义。 - [ ] 三个面板均显示项目、根目录、消息处理后端、默认模式和连接状态。 - [ ] 微信卡片可以完成扫码、过期刷新、验证码和连接状态展示。 +- [ ] “断开本机绑定”使用共享红色危险操作样式,并明确说明只清除本机凭据、不删除历史或承诺服务端解绑。 - [ ] 不显示“自动”后端;首次创建和旧版 `auto` 配置均落到默认直连文本模型。 - [ ] 直连模型只列出文本连接,OpenCode 与 Continue 可直接选择。 +- [ ] 选择 OpenCode/Continue 时只保存 Runtime 类型,每次远程请求动态跟随“Agent Runtime”中的对应全局配置,通道页不出现第二套 Runtime 配置。 +- [ ] Agent Runtime 高级设置的来源选项、条件配置卡和路径字段之间保持 `12px` 间距且无横向溢出。 - [ ] 默认 Execute 时持续显示目录范围和无逐次确认的风险说明。 - [ ] Renderer 无法读取任何微信 Token 或上下文令牌。 @@ -658,6 +672,7 @@ Renderer 快照只返回是否已配置和脱敏标识。 - [ ] 切换到通道项目不会创建普通本地会话。 - [ ] 通道项目隐藏“新建对话”和 `Ctrl+N`,全局快捷命令也不创建会话。 - [ ] 没有远程会话时显示等待客户端首条消息的空状态。 +- [ ] “任务与活动”中的会话分组默认收起,进行中、失败和已完成状态行为一致。 - [ ] 微信图片和文件显示在对应远程消息中,附件消息无需附带文字。 - [ ] 支持的附件进入所选后端现有图片或文档上下文;不支持和超限附件返回明确提示。 @@ -709,6 +724,7 @@ Renderer 快照只返回是否已配置和脱敏标识。 ### 阶段三:受控 Execute - 通道项目处理后端选择与失效修复。 +- OpenCode/Continue 通道后端动态跟随全局 Agent Runtime 配置。 - Execute Runtime 接入。 - 直连模型工具安全策略和活动关联。 - 结果回传、取消、超时和失败恢复。 @@ -741,10 +757,12 @@ Renderer 快照只返回是否已配置和脱敏标识。 - 消息去重、会话映射、并发和取消。 - Ask 工具拒绝。 - Execute 使用直连模型、OpenCode 和 Continue 的路由。 +- OpenCode/Continue 通道只保存 Runtime 类型,并在 Ask 与 Execute 开始时解析当前全局 Agent Runtime 配置。 - Execute 不创建通道专属审批,直连模型禁止工具策略仍然生效。 - 模型连接删除后的通道后端选择修复。 - 发件箱投递失败不重复执行。 - 项目选择器、分段页签、后端选择、设置面板和扫码对话框的键盘与无障碍行为。 +- Runtime 高级设置区块间距、微信断开危险按钮和任务活动默认折叠的 UI 回归。 - Windows、macOS、Linux 的默认用户目录和 Sidecar 关闭行为。 实现完成后运行: diff --git a/src/main/agent/runtime-selection.test.ts b/src/main/agent/runtime-selection.test.ts index f3b15e7..1608afc 100644 --- a/src/main/agent/runtime-selection.test.ts +++ b/src/main/agent/runtime-selection.test.ts @@ -2,7 +2,8 @@ import type { ResolvedRuntimeSettings } from '../runtime-settings-store' import { describe, expect, it } from 'vitest' import { applyRuntimeSelection, - getConfiguredRuntimeTarget + getConfiguredRuntimeTarget, + resolveConfiguredAgentRuntimeSelection } from './runtime-selection' const defaultProfileId = '00000000-0000-4000-8000-000000000001' @@ -148,6 +149,41 @@ describe('runtime selection', () => { ).toThrow('自动启动') }) + it('resolves Agent Runtime backends from the global Runtime configuration', () => { + const base = settings() + const configured = settings({ + opencodeModelProfile: base.modelProfiles[1], + continueModelProfile: base.modelProfiles[2] + }) + + expect( + resolveConfiguredAgentRuntimeSelection(configured, { + provider: 'opencode', + profileId: defaultProfileId + }) + ).toEqual({ + provider: 'opencode', + profileId: secondProfileId + }) + expect( + resolveConfiguredAgentRuntimeSelection(configured, { + provider: 'continue' + }) + ).toEqual({ + provider: 'continue', + profileId: responsesProfileId + }) + expect( + resolveConfiguredAgentRuntimeSelection(configured, { + provider: 'model', + profileId: defaultProfileId + }) + ).toEqual({ + provider: 'model', + profileId: defaultProfileId + }) + }) + it('routes legacy automatic settings through local OpenCode when the Server is blank', () => { expect(getConfiguredRuntimeTarget(settings())).toBe('opencode') expect( diff --git a/src/main/agent/runtime-selection.ts b/src/main/agent/runtime-selection.ts index 57d407a..5f7c95d 100644 --- a/src/main/agent/runtime-selection.ts +++ b/src/main/agent/runtime-selection.ts @@ -35,6 +35,26 @@ export function getConfiguredRuntimeTarget( return 'model' } +export function resolveConfiguredAgentRuntimeSelection( + settings: ResolvedRuntimeSettings, + selection: AgentRuntimeSelection +): AgentRuntimeSelection { + if ( + selection.provider !== 'opencode' && + selection.provider !== 'continue' + ) { + return selection + } + const profile = + selection.provider === 'opencode' + ? settings.opencodeModelProfile + : settings.continueModelProfile + return { + provider: selection.provider, + ...(profile ? { profileId: profile.id } : {}) + } +} + export function applyRuntimeSelection( settings: ResolvedRuntimeSettings, selection: AgentRuntimeSelection diff --git a/src/main/assistant/assistant-database.test.ts b/src/main/assistant/assistant-database.test.ts index aca9d3e..a41dbf9 100644 --- a/src/main/assistant/assistant-database.test.ts +++ b/src/main/assistant/assistant-database.test.ts @@ -1185,8 +1185,8 @@ describe('AssistantDatabase', () => { rootPath: channelProject.rootPath, defaultWorkMode: channelProject.defaultWorkMode, runtimeSelection: { - provider: 'model', - profileId: removedProfileId + provider: 'opencode', + profileId: runtimeProfileId } }) const imageChannelProject = database.ensureChannelProjects( @@ -1257,8 +1257,7 @@ describe('AssistantDatabase', () => { { provider: 'model', profileId: runtimeProfileId } ]) expect(database.getProject(channelProject.id).runtimeSelection).toEqual({ - provider: 'model', - profileId: defaultProfileId + provider: 'opencode' }) expect( database.getProject(imageChannelProject.id).runtimeSelection diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index ed81d58..89c0c60 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -973,15 +973,18 @@ describe('registerIpcHandlers agent terminal state', () => { respond: vi.fn(), clear: vi.fn() } + const getResolvedSettings = vi.fn( + async (): Promise> => ({ + toolApproval, + subagentSmartRoutingEnabled: smartRoutingEnabled + }) + ) const dispose = registerIpcHandlers( window as never, runtime as never, 'CommandOrControl+Shift+Space', { - getResolvedSettings: vi.fn(async () => ({ - toolApproval, - subagentSmartRoutingEnabled: smartRoutingEnabled - })) + getResolvedSettings } as never, {} as never, contextManager as never, @@ -1009,6 +1012,7 @@ describe('registerIpcHandlers agent terminal state', () => { assistantDatabase, contextManager, dispose, + getResolvedSettings, clearHandler: electronMocks.handlers.get( ipcChannels.appClearLocalData ), @@ -2240,6 +2244,8 @@ describe('registerIpcHandlers agent terminal state', () => { it('routes remote Execute to a configured Agent Runtime without a GoodBuddy approval callback', async () => { let receivedAuthorize: unknown = 'not-called' + const configuredProfileId = + '00000000-0000-4000-8000-000000000019' const selectedRuntime = { runtimeId: 'continue', capability: 'chat', @@ -2282,6 +2288,11 @@ describe('registerIpcHandlers agent terminal state', () => { false, selectedRuntimes ) + harness.getResolvedSettings.mockResolvedValue({ + toolApproval: 'always', + subagentSmartRoutingEnabled: false, + continueModelProfile: { id: configuredProfileId } + }) vi.mocked( harness.assistantDatabase.listProjects ).mockReturnValue([ @@ -2323,9 +2334,16 @@ describe('registerIpcHandlers agent terminal state', () => { output: 'Continue 已执行' }) expect(selectedRuntimes.getRuntime).toHaveBeenCalledWith( - { provider: 'continue' }, + { provider: 'continue', profileId: configuredProfileId }, 'C:\\ProjectWorkspace' ) + expect( + harness.assistantDatabase.getOrCreateRemoteConversation + ).toHaveBeenCalledWith( + expect.objectContaining({ + runtimeSelection: { provider: 'continue' } + }) + ) expect(receivedAuthorize).toBeUndefined() expect(harness.approvalBroker.request).not.toHaveBeenCalled() await harness.dispose() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index 9432c50..5244579 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -121,6 +121,7 @@ import { createDefaultModelRuntime, createModelProfileRuntime } from './agent/create-runtime' +import { resolveConfiguredAgentRuntimeSelection } from './agent/runtime-selection' import { safeToolErrorDetail } from './agent/approval-summary' import { ReasoningTagStreamParser } from './agent/reasoning-stream' import type { BundledRuntimePaths } from './agent/bundled-runtimes' @@ -576,6 +577,7 @@ export function registerIpcHandlers( const resolveRequestRuntime = async ( request: Pick & { workspaceOverride?: string + followConfiguredAgentRuntime?: boolean } ): Promise => { const projectWorkspace = @@ -586,8 +588,14 @@ export function registerIpcHandlers( if (!selectedRuntimes || (!request.runtimeSelection && !projectWorkspace)) { return runtime } - const selection = + let selection = request.runtimeSelection ?? ({ provider: 'auto' } as const) + if (request.followConfiguredAgentRuntime) { + selection = resolveConfiguredAgentRuntimeSelection( + await settingsStore.getResolvedSettings(), + selection + ) + } return projectWorkspace ? selectedRuntimes.getRuntime(selection, projectWorkspace) : selectedRuntimes.getRuntime(selection) @@ -830,6 +838,7 @@ export function registerIpcHandlers( rootPath: string conversationId: string runtimeSelection: AgentRuntimeSelection + followConfiguredAgentRuntime?: boolean runtime?: AgentRuntime taskId?: string contextIds?: string[] @@ -888,7 +897,9 @@ export function registerIpcHandlers( (await resolveRequestRuntime({ projectId: schedule.projectId, runtimeSelection: remoteContext?.runtimeSelection, - workspaceOverride: remoteContext?.rootPath + workspaceOverride: remoteContext?.rootPath, + followConfiguredAgentRuntime: + remoteContext?.followConfiguredAgentRuntime })) const agentRuntimeSelected = isAgentRuntime(requestRuntime) const channelToolPolicy = @@ -1455,7 +1466,8 @@ export function registerIpcHandlers( executionRuntime = await resolveRequestRuntime({ projectId: project.id, runtimeSelection, - workspaceOverride: project.rootPath + workspaceOverride: project.rootPath, + followConfiguredAgentRuntime: true }) executionStatus = await executionRuntime.getStatus() } catch (error) { @@ -1546,6 +1558,7 @@ export function registerIpcHandlers( rootPath: project.rootPath, conversationId: remoteConversation.id, runtimeSelection, + followConfiguredAgentRuntime: true, runtime: executionRuntime, taskId: remoteTaskId, contextIds, diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 56b43d7..bb381ab 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -863,6 +863,11 @@ describe('App', () => { provider: 'model', profileId: modelProfileId }) + expect( + screen + .getByText('正在连接 Agent Runtime') + .querySelector('.message__status-dot') + ).toHaveClass('message__status-dot--active') const userMessage = screen .getAllByText('帮我分析项目') .map((element) => element.closest('article')) @@ -2179,6 +2184,15 @@ describe('App', () => { }) expect(await screen.findByText('已取消')).toBeInTheDocument() + const cancelledStatus = screen + .getAllByText('请求已取消') + .find((element) => element.classList.contains('message__status')) + expect(cancelledStatus).toBeDefined() + const cancelledDot = cancelledStatus?.querySelector( + '.message__status-dot' + ) + expect(cancelledDot).toHaveClass('message__status-dot') + expect(cancelledDot).not.toHaveClass('message__status-dot--active') fireEvent.click(screen.getByText('任务与活动')) expect((await screen.findAllByText('已取消')).length).toBeGreaterThan(0) fireEvent.click(screen.getByRole('button', { name: '进行中' })) @@ -2187,6 +2201,37 @@ describe('App', () => { ).toBeInTheDocument() }) + it('keeps persisted completed message statuses static', async () => { + vi.mocked(api.conversations.list).mockResolvedValueOnce([ + { + id: '00000000-0000-4000-8000-000000000210', + projectId, + title: '已完成会话', + updatedAt: 1_775_000_000_000, + messages: [ + { + id: '00000000-0000-4000-8000-000000000211', + role: 'assistant', + content: '任务结果', + createdAt: 1_775_000_000_000, + state: 'complete', + status: '任务已完成' + } + ] + } + ]) + + render() + + const completedStatus = await screen.findByText('任务已完成') + expect( + completedStatus.querySelector('.message__status-dot') + ).toHaveClass('message__status-dot') + expect( + completedStatus.querySelector('.message__status-dot') + ).not.toHaveClass('message__status-dot--active') + }) + it('switches runtime profiles from the composer dropdown', async () => { render() diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4e95a93..ac3cbe4 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -4925,7 +4925,14 @@ function App(): React.JSX.Element { : 'message__status' } > - +