From 80b0b187125ba73228d2edb0808c4dd096493315 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Thu, 10 Sep 2026 01:26:56 +0800 Subject: [PATCH] feat: add persistent workbar browser tabs and scoped MCP routing --- UI-DESIGN.md | 29 +- build/run-browser-tabs-electron-e2e.cjs | 31 + .../logic-design.md | 11 +- .../application-tool-navigation/prd.md | 18 +- .../technical-design.md | 18 +- .../application-tool-navigation/ui-design.md | 9 +- .../user-stories.md | 26 +- docs/features/assistant-workbar/README.md | 7 +- .../browser-tabs-technical-design.md | 177 ++ docs/features/assistant-workbar/prd.md | 70 +- docs/features/assistant-workbar/progress.md | 24 + .../assistant-workbar/terminal-tabs-prd.md | 135 +- docs/features/task-and-job/README.md | 2 +- docs/features/task-and-job/task-center-prd.md | 39 +- .../direct-model-process-service.test.ts | 54 +- .../agent/direct-model-process-service.ts | 160 +- src/main/agent/knowledge-mcp-gateway.test.ts | 111 +- src/main/agent/knowledge-mcp-gateway.ts | 129 +- src/main/agent/model-runtime.test.ts | 34 + src/main/agent/model-runtime.ts | 23 +- src/main/agent/model-tool-provider.test.ts | 373 +++- src/main/agent/model-tool-provider.ts | 75 +- src/main/agent/paged-output-store.test.ts | 104 + src/main/agent/paged-output-store.ts | 296 +++ src/main/agent/runtime-selection.test.ts | 2 +- src/main/agent/runtime-selection.ts | 9 +- src/main/agent/runtime.ts | 3 + src/main/browser/browser-model-tools.test.ts | 49 +- src/main/browser/browser-model-tools.ts | 41 +- src/main/browser/browser-service.test.ts | 317 ++- src/main/browser/browser-service.ts | 1739 +++++++++-------- .../browser/electron-browser-session.test.ts | 65 +- src/main/browser/electron-browser-session.ts | 148 +- src/main/ipc.test.ts | 439 ++++- src/main/ipc.ts | 433 +++- src/main/runtime-settings-store.ts | 26 +- src/preload/index.ts | 106 +- src/preload/preload-sandbox.test.ts | 26 +- .../src/RightAssistantSidebar.resize.test.tsx | 555 +++++- src/renderer/src/WorkbarShell.test.tsx | 119 +- src/renderer/src/WorkbarShell.tsx | 57 +- src/renderer/src/i18n/locales/en-US/app.ts | 3 + .../src/i18n/locales/en-US/workspace.ts | 11 + src/renderer/src/i18n/locales/zh-CN/app.ts | 3 + .../src/i18n/locales/zh-CN/workspace.ts | 10 + src/renderer/src/runtime-selection.test.ts | 5 +- src/shared/contracts.test.ts | 52 + src/shared/contracts.ts | 222 ++- src/shared/ipc-channels.ts | 8 + src/shared/runtime-selection-contracts.ts | 77 +- src/shared/terminal-contracts.ts | 4 +- src/shared/workbar-contracts.test.ts | 125 +- src/shared/workbar-contracts.ts | 160 +- tests/browser-tabs-electron-e2e.ts | 145 ++ 54 files changed, 5446 insertions(+), 1468 deletions(-) create mode 100644 build/run-browser-tabs-electron-e2e.cjs create mode 100644 docs/features/assistant-workbar/browser-tabs-technical-design.md create mode 100644 src/main/agent/paged-output-store.test.ts create mode 100644 src/main/agent/paged-output-store.ts create mode 100644 tests/browser-tabs-electron-e2e.ts diff --git a/UI-DESIGN.md b/UI-DESIGN.md index 90f62427..3ad0a43c 100644 --- a/UI-DESIGN.md +++ b/UI-DESIGN.md @@ -321,23 +321,27 @@ 动态 Tab、“+”和终端细节见 [助手工作栏多终端页签 PRD](./docs/features/assistant-workbar/terminal-tabs-prd.md)。 -- 升级后默认打开任务中心、工作区、浏览器和成果四个单实例应用,不根据当前页面或 Runtime - 能力自动增删稳定应用目录入口。 +- 升级后默认打开任务中心、工作区、一个浏览器实例和成果。任务中心与工作区是不可关闭的 + 单实例基础应用;浏览器是可关闭的多实例应用;成果保持可关闭的单实例应用。不可关闭不 + 等于锁定顺序。 - 资源监控作为本机单实例应用保留在能力目录,但不加入升级后的默认打开实例。面板只在可见 活动时采样,首期不持久化历史或提供后台负载告警。 - Tab 行只显示已打开实例,行末始终提供“+”普通按钮;点击后在内容区打开应用选择页。 “+”不使用 `tab` 语义,也不能因为终端或其他应用已经打开而消失。 -- 应用注册表声明单实例或多实例。在工作栏“+”目录中,单实例重复选择时聚焦已有 Tab;终端 - 从首期开始支持多实例,每次选择创建独立 Tab 和 Shell Session。主侧栏系统工具快捷入口 - 可以聚焦当前执行空间中最近使用的终端,额外终端仍从“+”目录创建。 -- Task Center 是 Task 的单例应用级索引,不使用“跟随 / 固定目标”多实例模式。每个 Task - 只关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容, - 也不得把 Job/Run 提升为可导航 UI 对象。 +- 应用注册表分别声明单实例或多实例、默认打开、必须存在、可关闭和排序策略。在工作栏“+”目录中, + 单实例重复选择时聚焦已有 Tab;浏览器和终端每次选择创建独立实例。主侧栏系统工具快捷 + 入口按当前上下文聚焦最近使用的匹配实例,额外实例仍从“+”目录创建。 +- Task Center 是默认跟随当前项目的单实例索引,不使用多实例模式。用户可在同一 Tab 内切换 + “当前项目”“全局任务”和“全部项目”;范围变化不得创建第二个 Task Center。每个 Task 只 + 关联一条 Conversation,一条 Conversation 可以关联多个 Task;列表不得复制会话内容,也 + 不得把 Job/Run 提升为可导航 UI 对象。 - 各栏目读取当前会话或项目的对应内容;当前内容不可用时仍保留栏目入口并说明原因。 - 能力、连接和内容可以动态变化,栏目入口不能随之自动隐藏。不可用状态必须说明原因、影响和可执行入口。 -- 用户可以主动排序或隐藏栏目,并可恢复默认布局;应用不能用用户偏好机制实现自动能力裁剪。 +- 用户可以主动排序栏目,并可关闭可关闭实例或恢复默认布局;任务中心和工作区不能隐藏或 + 关闭。应用不能用用户偏好机制实现自动能力裁剪。 - 已打开实例保留 `tablist`、`tab`、`tabpanel`、方向键、Home、End 和焦点恢复语义。 - 实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。 + 实例过多时 Tab 区单行横向滚动,“+”保持可见,不换行或改成下拉框。不可关闭实例不渲染 + 关闭按钮,也不响应关闭快捷命令。 - 当前工作栏 Tab 必须同时使用连续内容表面、可见边框、顶部指示、图标或较高字重中的至少 两种信号;悬停反馈不得强于当前选中态。Tab 关闭按钮保留固定布局空间,默认仅在对应 Tab 悬停或 `focus-within` 时显示;无悬停或粗指针设备始终显示,键盘与辅助技术始终可访问。 @@ -354,8 +358,9 @@ 含义模糊的“停止浏览器”。地址输入显示浏览器权威提交 URL,但用户编辑期间保留本地草稿, 不被画面或状态刷新覆盖;提交裸域名时自动添加 `http://`,提交、失焦后的同步规则不得让 尚未提交的文字改变 Agent 看到的页面。 -- 浏览器工具栏操作的是当前 Conversation 中 Agent 使用的同一隔离浏览器会话,用户导航会 - 有意改变 Agent 下一步看到的页面。创建中、Agent 操作中或已进入完整交互窗口时禁用其余 +- 浏览器工具栏操作的是该实例绑定的 Conversation 和 Browser Tab。同一 Conversation 的多个 + Tab 共享隔离登录上下文,但页面、导航、操作队列和元素引用相互独立。模型请求开始时冻结 + 工具目标,用户切换工作栏 Tab 不改变进行中的 Agent 操作。创建中、Agent 操作中或已进入完整交互窗口时禁用其余 工具栏控件;仅当前导航仍在加载时保留“停止加载”,且该操作只中止页面加载,不关闭浏览器、 不释放 Runtime 上下文。没有会话的活动 Conversation 可以直接在地址栏输入 URL 并“前往” 创建会话,但必须复用 Agent 浏览器相同的 URL 策略与能力路径。 diff --git a/build/run-browser-tabs-electron-e2e.cjs b/build/run-browser-tabs-electron-e2e.cjs new file mode 100644 index 00000000..b96883ab --- /dev/null +++ b/build/run-browser-tabs-electron-e2e.cjs @@ -0,0 +1,31 @@ +const { spawnSync } = require('node:child_process') +const { rmSync } = require('node:fs') +const { tmpdir } = require('node:os') +const { join } = require('node:path') +const { buildSync } = require('esbuild') + +const output = join(tmpdir(), `goodbuddy-browser-tabs-e2e-${process.pid}.cjs`) + +try { + buildSync({ + entryPoints: [join(process.cwd(), 'tests', 'browser-tabs-electron-e2e.ts')], + outfile: output, + bundle: true, + platform: 'node', + format: 'cjs', + target: 'node22', + external: ['electron'] + }) + const environment = { ...process.env } + delete environment.ELECTRON_RUN_AS_NODE + const result = spawnSync(require('electron'), [output], { + cwd: process.cwd(), + env: environment, + stdio: 'inherit', + timeout: 120_000 + }) + if (result.error) throw result.error + process.exitCode = result.status ?? 1 +} finally { + rmSync(output, { force: true }) +} diff --git a/docs/features/application-tool-navigation/logic-design.md b/docs/features/application-tool-navigation/logic-design.md index 8341b929..74f2affa 100644 --- a/docs/features/application-tool-navigation/logic-design.md +++ b/docs/features/application-tool-navigation/logic-design.md @@ -87,13 +87,14 @@ type NavigationContribution = | 工具 | 匹配实例存在 | 匹配实例不存在 | 上下文不可用 | | --- | --- | --- | --- | -| 文件 | 激活单实例并保留其跟随或固定状态 | 创建跟随当前上下文的 `workspace` 单实例 | 打开面板并显示目录修复入口 | +| 文件 | 激活不可关闭的单实例并保留其跟随或固定状态 | 仅在迁移或布局修复时补回 `workspace` | 打开面板并显示目录修复入口 | | 终端 | 激活当前执行空间最近的 `starting` 或 `running` 实例 | 创建一个绑定当前执行空间的实例 | 没有项目时使用本机 Home | -| 浏览器 | 激活单实例并保留其目标状态 | 创建跟随当前会话的 `browser` 单实例 | 打开面板并提示选择或创建对话 | +| 浏览器 | 激活绑定当前 Conversation 的最近使用实例 | 创建绑定当前 Conversation 的新实例 | 激活最近使用实例;不存在时创建待绑定实例 | | 资源监控 | 激活 `resources` 单实例 | 创建本机 `resources` 单实例 | 部分指标不可用时显示原因并保留其余指标 | -文件和浏览器按以下优先级处理目标:已有有效固定目标、跟随模式的当前上下文、新实例的默认 -上下文。固定目标失效时显示该目标的修复状态,不回退到当前项目、默认工作区或当前会话。 +文件保持单一实例原有绑定。浏览器快捷入口按当前 Conversation 匹配最近使用实例,而工作栏 +“+”始终新建浏览器实例。固定目标失效时显示该目标的修复状态,不回退到当前项目、默认 +工作区或当前会话。 `closing`、`exited`、`interrupted` 和 `failed` 终端不属于可复用实例。当前执行空间只有这些 终端时,快捷按钮创建新实例并保留原 Tab 的历史状态。用户需要额外的并行终端时使用工作栏 @@ -154,7 +155,7 @@ warming/loading/live/partial/error -> inactive ## 7. 逻辑完整性评估 -首期规则已覆盖固定入口、应用显隐、保存失败、系统工具单实例与多实例差异、本机 Home、 +首期规则已覆盖固定入口、应用显隐、保存失败、系统工具匹配与显式多实例差异、本机 Home、 托管 SSH、无活动会话、目录失效、资源部分可用、采样停止、权限边界、键盘和窄窗口。其他 工具面板内部的取消、超时、 连接恢复和固定目标失效继续由助手工作栏负责,本功能不复制这些状态机。 diff --git a/docs/features/application-tool-navigation/prd.md b/docs/features/application-tool-navigation/prd.md index a9c1d39a..e733cb86 100644 --- a/docs/features/application-tool-navigation/prd.md +++ b/docs/features/application-tool-navigation/prd.md @@ -86,10 +86,10 @@ Switch 更新成功后主导航立即生效;更新失败时恢复原状态并 ### FR-6 文件浏览器入口 -点击文件按钮时展开助手工作栏,并打开或聚焦单实例的“工作区”面板。已有实例保持其“跟随 -当前上下文”或“固定到指定对象”状态;创建实例时默认跟随当前项目,没有项目时使用已配置 -的默认工作区。跟随上下文或新建实例所需的路径不可用时,面板显示修复入口;已有有效固定 -目标不受当前项目和默认工作区状态影响。 +点击文件按钮时展开助手工作栏,并聚焦默认存在且不可关闭的“工作区”单实例。该实例保持 +“跟随当前上下文”或“固定到指定对象”状态;迁移或布局修复发现实例缺失时补回,并默认跟随 +当前项目。没有项目时使用已配置的默认工作区。跟随上下文所需的路径不可用时,面板显示修复 +入口;已有有效固定目标不受当前项目和默认工作区状态影响。 ### FR-7 终端入口 @@ -99,9 +99,10 @@ Switch 更新成功后主导航立即生效;更新失败时恢复原状态并 ### FR-8 浏览器入口 -点击浏览器按钮时展开助手工作栏,并打开或聚焦单实例的受控浏览器面板。已有实例保持其目标 -状态;新实例默认跟随当前会话。新建或跟随模式下没有活动会话时,面板显示需要选择或创建 -会话的空状态;已有有效固定目标继续显示。入口不打开系统默认浏览器,也不创建旁路 WebView。 +点击浏览器按钮时展开助手工作栏,并优先聚焦绑定当前 Conversation 的最近使用浏览器实例; +没有匹配实例时创建一个。没有活动 Conversation 时聚焦最近使用的浏览器实例;完全不存在时 +创建待绑定空实例。该快捷入口不会因连续点击创建重复实例;用户通过工作栏“+”选择浏览器时 +则始终创建新实例。入口不打开系统默认浏览器,也不创建旁路 WebView。 ### FR-9 资源监控入口 @@ -150,7 +151,8 @@ Switch,切换后立即持久化。系统工具区域列出四项内置固定 - 运行记录在所有应用显隐组合下保持可见并可打开。 - 左下角不再用“本地工作区”与系统架构占据主要空间。 - 文件、终端、浏览器和资源监控均可从左下角打开正确的工作栏面板,且不产生第二套内容界面。 -- 连续点击系统工具不会无意创建重复的文件或浏览器实例;终端遵守多实例规则。 +- 连续点击系统工具不会无意创建重复的浏览器或终端实例;工作区始终复用不可关闭的固定实例。 + 用户通过工作栏“+”可以显式创建额外浏览器或终端实例。 - 资源监控以 2 秒间隔更新,保留不超过 150 个样本;面板不活动、工作栏收起或窗口隐藏、 最小化后停止请求新样本。 - CPU、内存、磁盘 IO 或 GoodBuddy 指标不可用时,页面区分部分可用与整体失败。 diff --git a/docs/features/application-tool-navigation/technical-design.md b/docs/features/application-tool-navigation/technical-design.md index a3204cbf..67b35439 100644 --- a/docs/features/application-tool-navigation/technical-design.md +++ b/docs/features/application-tool-navigation/technical-design.md @@ -26,7 +26,8 @@ type WorkspaceView = ``` 文件、浏览器和终端已经由 `RightAssistantSidebar`、`WorkbarShell` 与 `workbar-contracts.ts` -承载。工作区与浏览器是单实例应用,终端是多实例应用。代码中尚无资源指标采集和资源监控 +承载。工作区是不可关闭的单实例应用,浏览器和终端是多实例应用。浏览器多实例的跨进程 +实现以[浏览器多实例技术设计](../assistant-workbar/browser-tabs-technical-design.md)为准。代码中尚无资源指标采集和资源监控 面板。现有侧栏底部 `user-card` 整行打开设置,并显示“本地工作区”和平台架构。 本次改动增加入口编排、从主侧栏到工作栏的命令通道,以及本机资源监控的 Main/Preload/ @@ -78,23 +79,28 @@ type WorkbarOpenIntent = { requestId: number appId: 'workspace' | 'terminal' | 'browser' | 'resources' reason: 'sidebar-system-tool' + context?: { conversationId?: string; projectId?: string } } ``` `requestId` 使连续点击同一工具仍能被消费。不要仅设置 `assistantSidebarTab`,因为用户可能已 关闭对应实例,且终端需要按执行空间寻找实例。 -建议由 `RightAssistantSidebar` 在消费意图后通过现有 `WorkbarShell` 控制接口完成。文件和浏览器 -已有实例时只激活实例,保留其“跟随当前上下文”或“固定到指定对象”状态: +建议由 `RightAssistantSidebar` 在消费意图后通过现有 `WorkbarShell` 控制接口完成。控制器必须 +接收上下文和调用原因,不能只按 `appId` 查找第一个实例: ```ts type WorkbarController = { - openOrActivate(appId: WorkbarAppId): Promise + openOrActivate(intent: WorkbarOpenIntent): Promise } ``` 若当前 `WorkbarShell` 没有命令式入口,先增加窄的回调或受控请求 Props,不引入全局事件总线。 +工作区意图始终激活注册表中的不可关闭单实例;仅在迁移或布局损坏时补回。浏览器意图按 +`conversationId` 激活最近使用的匹配实例,没有匹配实例时创建一个。工作栏“+”不走该去重 +路径,选择浏览器时始终创建新实例。 + ### 3.3 终端匹配 工作栏“+”目录选择终端时每次创建新实例;侧栏系统工具按钮执行“进入当前终端”,采用: @@ -330,7 +336,7 @@ resourceMonitor.getLocalSnapshot(input: { - 固定入口顺序不受三个应用显隐组合影响。 - 运行记录和设置没有显隐控件。 - 四个系统工具按钮具有名称、工具提示、键盘行为和选中状态。 -- 文件和浏览器复用单实例;终端优先聚焦当前执行空间实例。 +- 文件复用不可关闭单实例;浏览器和终端按当前上下文聚焦匹配实例,工作栏“+”允许显式多实例。 - 工具按钮展开工作栏但不改变当前主页面。 - 显隐更新成功后主导航立即变化,失败时 Switch 和主导航恢复原状态。 - 窄窗口下工具按钮不重叠,焦点恢复正确。 @@ -370,7 +376,7 @@ resourceMonitor.getLocalSnapshot(input: { 3. 增加资源快照契约、Main 采集服务和 Preload API。 4. 为工作栏增加 `resources` 单实例和 `ResourceMonitorPanel`。 5. 拆分侧栏底部设置按钮,加入四个系统工具按钮。 -6. 为工作栏增加 `openOrActivate` 意图接口和终端匹配规则。 +6. 为工作栏增加带上下文的 `openOrActivate` 意图接口,以及浏览器和终端匹配规则。 7. 接入选中状态、焦点恢复、无障碍文案和响应式样式。 8. 完成 Renderer、契约、迁移和跨平台回归测试。 diff --git a/docs/features/application-tool-navigation/ui-design.md b/docs/features/application-tool-navigation/ui-design.md index fec4bf98..59ded961 100644 --- a/docs/features/application-tool-navigation/ui-design.md +++ b/docs/features/application-tool-navigation/ui-design.md @@ -44,9 +44,9 @@ | 位置 | 图标 | 可见工具提示 | 点击结果 | | --- | --- | --- | --- | -| 1 | `FolderOpen` | 文件 | 打开或聚焦工作区面板 | +| 1 | `FolderOpen` | 文件 | 聚焦不可关闭的工作区面板 | | 2 | `SquareTerminal` | 终端 | 打开或聚焦当前执行空间终端 | -| 3 | `Globe` | 浏览器 | 打开或聚焦受控浏览器面板 | +| 3 | `Globe` | 浏览器 | 聚焦当前 Conversation 的匹配浏览器实例 | | 4 | `Activity` | 资源监控 | 打开或聚焦本机资源监控面板 | | 末位 | `Settings` | 设置 | 打开设置页面 | @@ -119,9 +119,10 @@ 点击系统工具后,右侧工作栏按现有布局回流打开。主内容页面保持不变,侧栏不渲染工具内容。 -- 文件:激活“工作区”Tab,焦点进入目录区域或错误修复入口。 +- 文件:激活默认存在且不可关闭的“工作区”Tab,焦点进入目录区域或错误修复入口。 - 终端:激活匹配终端,焦点进入终端输入区;新建期间保留按钮宽度和进度状态。 -- 浏览器:激活“浏览器”Tab,焦点进入工具栏;没有会话时进入空状态主操作。 +- 浏览器:激活绑定当前 Conversation 的最近使用“浏览器”Tab;没有匹配实例时创建一个, + 没有 Conversation 时按产品规则复用最近实例或进入待绑定空状态。 - 资源监控:激活“资源监控”Tab,焦点进入指标摘要;首个样本加载后开始绘制历史。 后台事件可以更新徽标,不能自动展开工作栏或切换当前 Tab。由用户点击快捷按钮触发的打开 diff --git a/docs/features/application-tool-navigation/user-stories.md b/docs/features/application-tool-navigation/user-stories.md index 48f03308..e3051354 100644 --- a/docs/features/application-tool-navigation/user-stories.md +++ b/docs/features/application-tool-navigation/user-stories.md @@ -105,7 +105,7 @@ - Given 当前项目有有效根目录 - When 用户点击文件按钮 - Then 助手工作栏展开 -- And 已有工作区面板时聚焦该实例,没有时创建单实例 +- And 聚焦默认存在且不可关闭的工作区单实例 - And 文件按钮通过选中状态表明该面板正在显示 ### US-C2 处理无效工作区 @@ -114,7 +114,7 @@ 验收: -- Given 工作区面板尚未创建或处于跟随模式 +- Given 工作区面板处于跟随模式 - And 当前项目和默认工作区均不可用 - When 用户点击文件按钮 - Then 工作区面板仍打开 @@ -160,15 +160,23 @@ - Given 浏览器面板跟随当前上下文且当前会话已有浏览器会话 - When 用户点击浏览器按钮 -- Then 工作栏聚焦同一个浏览器会话 -- And 不打开系统默认浏览器或第二个 WebView -- Given 浏览器面板已固定到其他会话 +- Then 工作栏聚焦绑定当前 Conversation 的最近使用浏览器实例 +- And 不打开系统默认浏览器或无意创建重复实例 +- Given 现有浏览器实例都绑定到其他 Conversation - When 用户点击浏览器按钮 -- Then 工作栏聚焦该面板并保持固定目标 -- Given 浏览器面板尚未创建或处于跟随模式 -- And 当前没有活动对话 +- Then 工作栏创建并聚焦绑定当前 Conversation 的新浏览器实例 +- Given 当前没有活动 Conversation +- And 已存在浏览器实例 - When 用户点击浏览器按钮 -- Then 面板说明需要选择或创建对话 +- Then 工作栏聚焦最近使用的浏览器实例并保持其目标 +- Given 当前没有活动 Conversation +- And 不存在浏览器实例 +- When 用户点击浏览器按钮 +- Then 创建待选择目标的空实例 +- Given 用户需要同一 Conversation 的另一个独立页面 +- When 用户从工作栏“+”选择浏览器 +- Then 每次创建拥有独立页面和导航状态的新实例 +- And 同一 Conversation 内共享隔离登录上下文 ### US-C6 查看本机资源 diff --git a/docs/features/assistant-workbar/README.md b/docs/features/assistant-workbar/README.md index 04a57b75..985bb903 100644 --- a/docs/features/assistant-workbar/README.md +++ b/docs/features/assistant-workbar/README.md @@ -7,9 +7,10 @@ | 文档 | 权威职责 | | --- | --- | | [通用助手工作栏与执行空间 PRD](./prd.md) | 工作栏整体产品范围、执行空间和各工作面板归属 | -| [多终端页签 PRD](./terminal-tabs-prd.md) | 多终端 Tab、目标选择、关闭确认和终端生命周期 | +| [多终端页签 PRD](./terminal-tabs-prd.md) | 工作栏应用的固定、可关闭、单实例和多实例策略,以及多终端生命周期 | +| [浏览器多实例技术设计](./browser-tabs-technical-design.md) | 浏览器 Tab 身份、Conversation 绑定、IPC、BrowserService 和 MCP 路由 | | [实现与验证进度](./progress.md) | 工作区与 Runtime 相关改动的实现事实、验证证据与剩余阻塞 | | [Runtime 交互边界](./runtime-interactions.md) | 原生权限、结构化问答、后台拒绝、终端交互、托管 Runtime 并行与输出及消息底部请求状态的适配范围 | -当前尚未拆出独立 User Stories、功能逻辑、UI 设计和技术设计文档;相关内容仍保留在 -两份现有 PRD 中,后续实际维护该功能时按职责拆分。 +当前尚未拆出独立 User Stories、功能逻辑和 UI 设计文档;相关内容仍保留在两份现有 PRD +中。浏览器多实例涉及跨进程身份和工具路由,单独使用技术设计文档维护。 diff --git a/docs/features/assistant-workbar/browser-tabs-technical-design.md b/docs/features/assistant-workbar/browser-tabs-technical-design.md new file mode 100644 index 00000000..4c890cb5 --- /dev/null +++ b/docs/features/assistant-workbar/browser-tabs-technical-design.md @@ -0,0 +1,177 @@ +# 浏览器多实例技术设计 + +## 文档信息 + +| 项目 | 内容 | +| --- | --- | +| 状态 | 基础多实例已实施,Agent 多 Tab 工具待需求 | +| 版本 | 1.0 | +| 日期 | 2026-09-10 | +| 关联 PRD | [通用助手工作栏与执行空间 PRD](./prd.md) | +| 页签策略 | [助手工作栏多终端页签 PRD](./terminal-tabs-prd.md) | + +## 1. 目标与边界 + +浏览器改为可关闭的多实例工作栏应用。每个工作栏浏览器实例对应一个独立 Browser Tab,拥有 +独立页面、导航状态、操作队列、画面状态和元素引用空间。同一 Conversation 下的多个 Tab +共享隔离存储与代理上下文,使登录状态符合常规浏览器预期。 + +本设计不把多个工作栏 Tab 实现为同一页面的重复入口,不允许用户切换 Tab 后改变正在执行的 +Agent 工具目标,也不在首期要求模型主动管理多个 Tab。 + +## 2. 改造前限制 + +改造前的链路以 `conversationId` 作为唯一浏览器身份: + +- Renderer 只保存每个 Conversation 的一份 `BrowserLiveState`。 +- Browser IPC、Preload API 和 `BrowserService` 操作只携带 `conversationId`。 +- Main 每个 Conversation 只创建一个 Session、一个 `WebContentsView` 和一个 CDP Driver。 +- 请求级 MCP 能力只保存 `browserConversationId`。 +- snapshot 元素 `ref` 只在一个 Driver 的引用空间内有效。 + +因此只把工作栏注册策略改成 `multiple` 会导致多个 Tab 控制同一页面,并产生 viewport、清理和 +元素引用竞态,不能作为可交付实现。 + +## 3. 身份与所有权 + +```ts +type BrowserTabId = string + +type ConversationBrowserContext = { + conversationId: string + partitionId: string + tabs: Map + primaryTabId?: BrowserTabId +} + +type BrowserTab = { + id: BrowserTabId + conversationId: string + view: WebContentsView + driver: CdpBrowserDriver + state: BrowserLiveState +} +``` + +- Conversation Browser Context 拥有隔离 partition、代理配置和全部 Tab。 +- Browser Tab 拥有页面、Driver、串行操作队列、snapshot 代次、idle 状态和可见 viewport。 +- `BrowserTabId` 是 Main 签发的不透明 ID。Renderer 和 Runtime 不能自行选择或复用。 +- 工作栏实例保存公开的逻辑实例 ID 和 Conversation 绑定;进程重启后不恢复旧 + `WebContentsView`,首次使用时由 Main 为该逻辑实例创建新的 Browser Tab。 +- 删除 Conversation 时关闭该 Context 下全部 Tab;关闭一个工作栏浏览器实例只关闭对应 + Browser Tab,不得调用 Conversation 级 Runtime 释放路径。 + +## 4. Renderer 与 viewport + +Renderer 状态改为: + +```ts +Record> +``` + +每个浏览器工作栏实例持有自己的 `browserTabId`。只有当前可见实例可以提交 viewport。每次 +获得可见权时生成新的 UUID `leaseToken`;提交请求携带 `browserTabId` 和 token,清理请求必须 +回传同一个 token。Main 只接受当前 token 的清理,避免已隐藏实例的延迟 cleanup 隐藏新实例, +也避免 Renderer 重载后从零开始的数字序列无法覆盖旧租约。 + +非活动 Browser Tab 保持页面和状态,但隐藏 `WebContentsView` 并停止非必要画面采集。工作栏 +收起、切换主页面和窗口隐藏时采用相同规则,不销毁 Tab。 + +浏览器应用声明 `visibleAcrossContextSwitches: true`,打开的 Tab 跨项目、会话切换继续显示。 +切换活动 Conversation 不改变当前选中的浏览器或其 `targetRef.conversationId`;用户在会话 B +查看会话 A 的浏览器时,viewport 和手动导航仍指向 A 的原 Tab。新建实例绑定 B。仅未绑定的 +默认或恢复实例可在首次使用时取得当前会话绑定。 + +Tab 的跨会话可见性与 MCP 授权分开处理。B 的请求不能选择属于 A 的可见 Tab;A 已取得的 +capability 在用户切到 B 后仍固定路由至 A 的原 Tab,使用租约也继续阻止关闭该 Tab。 + +## 5. IPC 与服务接口 + +共享契约增加有界操作: + +```ts +browser.createTab({ conversationId, workbarInstanceId }) +browser.listTabs({ conversationId }) +browser.closeTab({ conversationId, tabId }) +browser.setViewport({ conversationId, tabId, leaseToken, bounds }) +browser.navigate({ conversationId, tabId, url }) +browser.back({ conversationId, tabId }) +browser.reload({ conversationId, tabId }) +browser.stopLoading({ conversationId, tabId }) +``` + +Main 必须验证 `tabId` 属于同一 `conversationId` 和当前窗口。BrowserService 将现有 +Conversation 级 slot 拆为 Context 与 Tab 两层;同一 Tab 内操作继续串行,不同 Tab 可以并行, +但仍受窗口级和 Conversation 级有界数量限制。 + +现有 Conversation 级 `stop/release` 保留为销毁完整浏览器上下文的操作,不能用作关闭单个 +Browser Tab。 + +## 6. Agent 与 MCP 路由 + +### 6.1 兼容阶段 + +第一阶段不修改模型可见的现有浏览器工具参数。模型请求开始时,Main 按以下顺序选择并冻结 +`boundBrowserTabId`: + +1. 当前可见且绑定该 Conversation 的 Browser Tab。 +2. 该 Conversation 的 `primaryTabId`。 +3. 新建一个 primary Browser Tab。 + +直连模型上下文和请求级 MCP capability 同时保存 `conversationId` 与 +`boundBrowserTabId`。请求执行期间,用户切换、创建或关闭其他工作栏 Tab 不改变工具目标; +模型请求使用某个 Tab 时禁用该 Tab 的工作栏关闭操作,并说明请求仍在执行。用户先取消请求 +后才能关闭;系统不得把进行中的操作改投其他 Tab。 + +该阶段 MCP transport、endpoint 和 token 生命周期不变,只扩展服务端 capability 内容和内部 +路由。旧工具调用继续使用被冻结的 Tab。 + +### 6.2 Agent 多 Tab 阶段 + +只有明确需要 Agent 管理多个页面时,才增加: + +- `browser_tab_list` +- `browser_tab_open` +- `browser_tab_close` + +导航、snapshot、back 和 screenshot 增加可选 `tabId`;省略时使用请求绑定的默认 Tab。 +`browser_snapshot` 返回 `tabId` 和 snapshot 代次。click、type 和 select 必须校验 capability、 +`tabId`、引用代次和元素 `ref` 属于同一 Driver,禁止跨 Tab 猜测或解析引用。 + +## 7. 工作栏创建与快捷入口 + +- 从“+”选择浏览器时始终创建新工作栏实例,并默认绑定当前 Conversation。 +- 没有活动 Conversation 时创建待绑定实例,不创建无归属 Main Browser Context。 +- 主侧栏浏览器快捷按钮优先激活绑定当前 Conversation 的最近使用实例;没有匹配实例时创建 + 一个。没有活动 Conversation 时激活最近使用的浏览器实例;完全不存在时创建待绑定实例。 +- 每个实例标题至少区分 Conversation 和当前页面;完整目标通过提示和可访问名称提供。 +- 关闭工作栏 Browser Tab 只释放对应 Browser Tab。关闭浏览器上下文是独立危险操作,需要 + 明确说明会影响该 Conversation 下的全部 Tab 和 Agent 浏览器状态。 + +## 8. 持久化与恢复 + +持久化工作栏实例 ID、顺序、自定义标题和公开 Conversation 绑定,不持久化页面进程、CDP +引用、snapshot、viewport lease、Cookie、Token 或 Main `BrowserTabId`。应用重启后实例显示 +未启动状态,首次导航或 Agent 使用时按原绑定创建新 Tab。 + +绑定的 Conversation 已删除或不可见时保留实例并显示目标失效,不自动绑定当前 Conversation。 + +## 9. 限制与验收 + +- 分别限制每个 Conversation 的 Tab 数和每个窗口的总活动 Tab 数;达到上限时在创建前反馈。 +- 两个 Tab 的地址、历史、加载状态、截图和元素引用互不覆盖。 +- 同一 Conversation 的 Tab 共享登录状态,不同 Conversation 保持隔离。 +- 延迟 viewport cleanup 不能隐藏另一个已激活 Tab。 +- 一个 Tab 的元素 `ref` 不能在另一个 Tab 中执行。 +- 用户切换工作栏 Tab 不改变进行中模型请求的 MCP 浏览器目标。 +- 关闭一个 Tab 不释放 Conversation Runtime,也不关闭同一 Conversation 的其他 Tab。 +- 旧版无 `tabId` 的模型工具调用仍操作请求绑定的默认 Tab。 + +## 10. 实施顺序 + +1. 引入 Browser Context 与 Browser Tab 两层身份,拆分 partition 和页面生命周期。 +2. 扩展 Shared、Preload、IPC 与 BrowserService 的 `tabId` 和 viewport `leaseToken`。 +3. 改造 Renderer 状态与工作栏实例,开放浏览器 `multiple` 策略和单 Tab 关闭。 +4. 将直连模型与 MCP capability 绑定到请求开始时确定的 Browser Tab。 +5. 完成跨 Tab 引用隔离、并行队列、共享 Cookie、关闭和恢复测试。 +6. 有明确 Agent 多页面需求后再开放模型可见的 Tab 管理工具。 diff --git a/docs/features/assistant-workbar/prd.md b/docs/features/assistant-workbar/prd.md index 60256dac..b0d68b08 100644 --- a/docs/features/assistant-workbar/prd.md +++ b/docs/features/assistant-workbar/prd.md @@ -89,18 +89,20 @@ Runtime ``` - 应用不得根据当前项目、会话、Runtime、主机或探测结果无提示地增删能力目录项。 -- 用户主动打开、关闭、排序和停靠面板实例;应用不默认同时挂载全部能力。 -- Task Center 是 Task 的单例应用级索引。每个 Task 只关联一条 Conversation,一条 - Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通 Conversation、 - Job、Run 或心跳事项。 +- 用户主动打开、关闭、排序和停靠可关闭的面板实例;任务中心和工作区作为基础工作入口默认 + 存在且不可关闭。不可关闭不等于锁定顺序,排序能力由独立属性控制。 +- Task Center 是默认跟随当前项目的单实例 Task 索引。每个 Task 只关联一条 Conversation, + 一条 Conversation 可以关联多个 Task;Task Center 不复制会话内容,也不显示普通 + Conversation、Job、Run 或心跳事项。 - 当前能力、连接、数据和空状态可以动态变化。 - 能力不可用时,目录项或已打开面板显示原因、影响和可执行的配置或切换入口,不能只通过隐藏表示。 - 用户可以在设置中调整目录顺序;恢复默认布局恢复标准目录与默认打开面板,不强制打开全部能力。 -- 同一能力需要并排比较不同目标时可以创建多个实例,每个实例拥有独立身份和范围绑定。 +- 浏览器、终端及其他声明为多实例的能力可以创建多个实例,每个实例拥有独立身份和范围绑定。 ### 3.2 当前上下文只提供默认值 -任务中心作为全局索引不跟随当前会话,也不支持为同一列表打开多个目标实例。其他可绑定 +任务中心只有一个工作栏实例,默认跟随当前项目,不跟随当前 Conversation。用户可以在同一 +实例内显式切换到全局任务或全部项目,但不能为不同项目打开多个任务中心 Tab。其他可绑定 目标的能力使用当前会话、项目、Runtime 和主机帮助新面板实例初始定位,但这些上下文不是 使用门槛: @@ -212,17 +214,22 @@ Runtime - 每项始终显示稳定图标,并提供可见标签或可持续查看的工具提示。 - 已打开实例使用 `tablist`、`tab`、`tabpanel`;“+”是位于 `tablist` 外的普通命令按钮。 -- 应用注册表声明 `single` 或 `multiple`。在工作栏“+”目录中重复选择单实例应用时聚焦已有 - 实例;选择多实例应用时每次创建新实例。主侧栏系统工具快捷入口可以聚焦当前执行空间中 - 最近使用的终端,额外终端仍从“+”目录创建。 -- 支持方向键、Home、End、Enter、Space、关闭面板和正确焦点恢复。 +- 应用注册表分别声明实例策略、默认打开、必须存在、是否可关闭和是否允许排序。`single` 或 + `multiple` 只控制实例数量;`required` 控制布局修复,不能兼作关闭或排序策略。 +- 在工作栏“+”目录中重复选择单实例应用时聚焦已有实例;选择浏览器、终端等多实例应用时 + 每次创建新实例。主侧栏系统工具快捷入口按当前上下文聚焦匹配实例,不能因连续点击无意 + 创建重复实例。 +- 支持方向键、Home、End、Enter、Space、可关闭面板的关闭命令和正确焦点恢复。不可关闭 + 实例不渲染关闭按钮,也不响应关闭快捷命令。 - 徽标显示未解决数量、等待审批或失败状态,并同时提供文字或可访问名称。 -- 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭实例后能力仍可从目录重新打开。 +- 用户调整目录顺序、打开实例、停靠位置和尺寸后持久化;关闭可关闭实例后能力仍可从目录 + 重新打开。布局加载和恢复默认布局必须补回缺失的任务中心与工作区实例。 - 默认布局只恢复经过产品确认的少量常用面板,不自动打开全部标准能力。 - 每个实例显示稳定实例 ID、能力名称、跟随或固定状态与当前目标;同能力多实例不能只靠位置区分。 -从当前聊天右栏迁移时,Task Center、工作区、浏览器和成果作为默认打开的单实例应用保留; -终端不默认打开,由用户通过“+”创建,并从首期开始支持多实例。Tab 必须单行横向滚动, +从当前聊天右栏迁移时,Task Center 和工作区作为默认打开、单实例且不可关闭的基础应用保留; +浏览器保留一个默认打开实例,但注册为可关闭的多实例应用;成果保持默认打开、单实例且可 +关闭;终端不默认打开,由用户通过“+”创建,并从首期开始支持多实例。Tab 必须单行横向滚动, 不能自动隐藏或缩写到不可辨认,“+”不能随滚动消失。Task Center 继续作为 Task 的现有 入口;审批定位到所属 Task 或 Runtime。智能心跳不作为工作栏页签,其报告、建议、历史和 完整配置统一归属“智能心跳”菜单入口。当前阶段不新增独立自动化中心。 @@ -260,8 +267,13 @@ Task Center 保留为工作栏中的稳定入口,并在现有基础上适度 活动记录和成果查看器中,不在窄栏复制,也不呈现 Job/Run 树。 - 智能心跳的报告、建议、历史和配置不进入任务中心。 -任务中心是单例索引,不使用其他能力的“跟随 / 固定目标”多实例模型。后台状态可以更新 -徽标和排序,但不能自动打开面板或抢占用户当前工作。 +任务中心是应用级单例,默认存在且不可关闭。它的数据范围默认跟随当前项目,并在标题或范围 +控件中明确显示项目;用户可以切换到“全局任务”或“全部项目”,该选择只改变同一实例的列表 +范围,不创建第二个任务中心 Tab。切换回“跟随当前项目”后,项目变化立即刷新列表。后台状态 +可以更新徽标和排序,但不能抢占用户当前工作。 + +当前实现已按活动项目过滤 Scheduled Task 和审批,并提供“当前项目”“全局任务”“全部项目” +范围控件。 主侧栏最近会话为关联 Task 提供轻量入口:父会话行只显示行首展开按钮,Task 子项使用 任务图标和本地化摘要,展开层级只到 Task;点击 Task 子项打开同一 Conversation 并定位。 @@ -356,6 +368,10 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 ### 7.6 工作区 +工作区是默认存在、单实例且不可关闭的基础应用。它默认跟随当前项目;项目或目录不可用时 +保留同一 Tab 并显示修复入口,不能通过关闭 Tab 隐藏范围错误。用户仍可调整该 Tab 的顺序, +除非后续单独引入顺序锁定属性。 + 工作区面板允许用户选择: - 当前项目目录。 @@ -429,10 +445,14 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 ### 7.7 浏览器 +浏览器是可关闭的多实例应用。升级后的默认布局保留一个浏览器实例;用户从“+”目录选择 +浏览器时每次创建新的 Browser Tab。每个实例必须显示可区分的标题和绑定 Conversation, +不能让多个工作栏 Tab 只是同一页面的重复控制入口。 + 浏览器能力在目录中稳定可发现,打开面板后允许用户: -- 创建新的 GoodBuddy 隔离浏览器会话。 -- 选择当前会话或其他受控浏览器会话。 +- 在当前 Conversation 的隔离浏览器上下文中创建独立 Browser Tab。 +- 选择当前 Conversation 或其他允许访问的 Conversation 作为实例目标。 - 查看状态、当前 URL、有界画面和错误。 - 通过顶部浏览器工具栏返回、刷新或停止加载、编辑地址并前往。 - 进入明确的交互模式或关闭浏览器。 @@ -441,10 +461,11 @@ Renderer 不接收任意系统 PID 控制能力。控制动作引用 Main 签发 页面加载期间原位切换为“停止加载”;“关闭浏览器”是清楚独立于导航的会话生命周期操作, 必须与“停止加载”在文案和视觉上明确区分。现有“停止浏览器”语义统一改名为“关闭浏览器”。 -浏览器面板和 Agent 操作同一条按 Conversation 归属的浏览器会话,不创建用户预览专用的 -第二个页面。用户执行前往、返回或刷新会有意改变 Agent 下一步读取和操作的页面。所有 -前往、返回和刷新请求都进入 `BrowserService`,并与 Agent 发起的浏览器操作串行执行;不在 -Renderer 建立第二套导航队列、抢占规则或恢复状态机。 +同一 Conversation 下的 Browser Tab 共享隔离存储和代理上下文,但每个 Tab 拥有独立页面、 +导航状态、操作队列和元素引用空间。模型请求开始时绑定一个确定的 Browser Tab;用户切换 +工作栏实例不能在请求途中改变 Agent 的工具目标。用户在该实例执行前往、返回或刷新会有意 +改变绑定到同一 Tab 的 Agent 后续操作。所有请求都进入 `BrowserService`;Renderer 不建立 +第二套导航队列、抢占规则或恢复状态机。 浏览器工具栏读取的权威状态至少包含: @@ -472,7 +493,10 @@ type BrowserToolbarState = { 没有浏览器会话时仍显示工具栏。活动 Conversation 可以直接在地址栏输入非空 URL 并“前往”, 由 `BrowserService` 通过 Agent 浏览器相同的能力检查和 URL 策略创建会话并导航;不得为这一 空状态增加旁路 WebView 或宽松 URL 入口。没有活动 Conversation 时说明需要先选择或创建会话。 -模型或后台浏览器活动可以更新徽标,但不得无条件打开面板或切换用户当前面板。 +模型或后台浏览器活动可以更新对应实例的徽标,但不得无条件打开面板或切换用户当前面板。 + +浏览器多实例的跨进程身份、可见 viewport 所有权、关闭语义和 MCP 兼容方案以 +[浏览器多实例技术设计](./browser-tabs-technical-design.md)为准。 浏览器面板只管理 GoodBuddy 受控浏览器,不表示可以控制用户已安装的浏览器。 @@ -1065,7 +1089,7 @@ Electron、ChildProcess、PTY、SSH Client、Socket 或文件句柄。 动态 Tab 以及本机与直接 SSH 多终端。 - [Task 与 Job 统一领域模型](../task-and-job/task-and-job-model.md) 定义 Task、Conversation、 Job、Subjob、Run 与 Subagent。 -- [Task Center PRD](../task-and-job/task-center-prd.md) 定义应用级 Task 索引。 +- [Task Center PRD](../task-and-job/task-center-prd.md) 定义单实例、项目跟随和显式范围切换。 - [智能心跳 PRD](../smart-heartbeat/prd.md) 定义心跳入口、范围和长期边界。 - [会话监督 PRD](../conversation-supervision/prd.md) 定义监督判断、证据、预算和介入边界。 - [自动化平台总体设计](../../architecture/automation-platform-architecture.md) 定义 Plan、Job、Run、监督、预算和记忆。 diff --git a/docs/features/assistant-workbar/progress.md b/docs/features/assistant-workbar/progress.md index e1b7293d..1d638c99 100644 --- a/docs/features/assistant-workbar/progress.md +++ b/docs/features/assistant-workbar/progress.md @@ -1,5 +1,29 @@ # 工作栏实现与验证进度 +## 2026-09-10 固定基础页签与浏览器多实例 + +- 工作栏注册表已分别声明实例策略、默认上下文、默认打开、必须存在、可关闭和排序属性。 + 任务中心与工作区保持默认存在且不可关闭;浏览器改为当前 Conversation 上下文的多实例。 + 布局加载按 `required` 补回缺失的基础页签,并修复失效活动实例。 +- 任务中心增加“当前项目”“全局任务”“全部项目”范围,默认跟随活动项目;任务和审批按 + Conversation 所属项目过滤,范围模式随工作栏布局持久化。 +- 浏览器服务已拆分 Conversation Context 与 Browser Tab。每个 Tab 拥有独立页面、Driver、 + 操作队列、状态和元素引用空间,同一 Conversation 的 Tab 共享 partition、代理和 Cookie。 +- Renderer 使用工作栏实例 UUID 原子创建或恢复 Browser Tab,并以 UUID viewport token 防止 + 延迟 cleanup 隐藏新活动 Tab。关闭单个实例只释放对应 Tab。 +- 直连模型和请求级 MCP capability 在请求开始时优先绑定可见 Tab,其次绑定 primary Tab, + 并持有使用租约。请求结束或 capability 撤销前,绑定 Tab 拒绝关闭;工具参数未增加模型可见 + `tabId`,已有浏览器工具 schema 保持不变。 +- 聚焦回归命令覆盖 Shared、BrowserService、Electron Session、IPC、Preload、MCP、Renderer + 和 Workbar,13 个测试文件共 453 项通过;`npm run lint` 与 Web TypeScript 检查通过。 +- `node build/run-browser-tabs-electron-e2e.cjs` 通过。该测试启动真实 Electron/Chromium 和本地 + HTTP 页面,创建两个 `WebContentsView`,验证页面隔离、同 Conversation Cookie 共享、真实 + loopback MCP 的固定 Tab 导航与快照、使用租约关闭保护,以及单 Tab 和最终 Context 清理。 +- `npm run typecheck`、`npm run lint`、`npm run build:bundle` 和 `git diff --check` 通过。 +- 本轮整库 `npm test` 为 3686 项通过、61 项跳过、4 项失败。失败位于 portable/release 的 + ASAR 元数据 fixture、Runtime 版本探测和 DeepSeek Harness 的本地 MCP fixture;定向复跑仍 + 失败。本次工作栏、浏览器、IPC 和 MCP 聚焦回归均通过,不将整库结果记为通过。 + ## 2026-09-09 移动目录选择与 Git 控件修正 - 本轮在已有文件管理改动上修正 [FR-W3、FR-W4、FR-W6](./prd.md#76-工作区): diff --git a/docs/features/assistant-workbar/terminal-tabs-prd.md b/docs/features/assistant-workbar/terminal-tabs-prd.md index 62ac8f53..9f297ad0 100644 --- a/docs/features/assistant-workbar/terminal-tabs-prd.md +++ b/docs/features/assistant-workbar/terminal-tabs-prd.md @@ -5,8 +5,8 @@ | 项目 | 内容 | | --- | --- | | 状态 | 已实施,跨平台验证中 | -| 版本 | 1.1 | -| 日期 | 2026-08-31 | +| 版本 | 1.2 | +| 日期 | 2026-09-10 | | 适用产品 | GoodBuddy 桌面端 | | 界面归属 | [通用助手工作栏与执行空间](./prd.md) | | 相关设计 | [统一界面设计系统](../../../UI-DESIGN.md) | @@ -18,7 +18,7 @@ - 已打开应用以同级 Tab 呈现。 - Tab 行始终提供“+”入口,用户按需打开应用。 -- 应用通过统一注册表声明单实例或多实例策略。 +- 应用通过统一注册表分别声明单实例或多实例、默认打开、可关闭和排序策略。 - “终端”作为首个多实例应用,自动使用当前项目的本机或托管 SSH 执行空间。 - 本机与远程交互式终端的生命周期、状态、性能和验收要求。 @@ -31,7 +31,7 @@ 当前工作栏在聊天页面右侧固定显示“任务中心、工作区、浏览器、成果”四个 Tab。用户不能: - 通过统一入口按需打开新的工作栏应用。 -- 关闭并重新打开一个工作栏应用。 +- 按应用策略关闭并重新打开可关闭的工作栏应用。 - 为支持多实例的应用创建第二个独立实例。 - 在 GoodBuddy 内使用本机或远程 Host 的交互式 Shell。 @@ -49,17 +49,51 @@ SSH 连接池,但尚不存在工作栏应用实例模型、终端 IPC、终端 - 稳定应用 ID。 - 用户可见名称、图标和说明。 - `single` 或 `multiple` 实例策略。 +- 是否默认打开、是否必须存在、是否可关闭和是否允许排序;这些属性彼此独立。 - 创建实例时可用的默认上下文。 - 当前不可用时的原因和修复入口。 目录中的应用可发现性与实例是否已经打开相互独立。应用不可用时保留入口并说明原因,不以 隐藏代替状态。 +注册表字段如下: + +| 字段 | 类型或允许值 | 含义 | +| --- | --- | --- | +| `id` | 稳定应用 ID | 关联图标、面板实现和持久化实例 | +| `label` | 有界文本 | 默认 Tab 名称和目录名称 | +| `icon` | 稳定图标 ID | 应用目录和 Tab 图标 | +| `description` | 有界文本 | 目录说明和完整提示 | +| `instancePolicy` | `single`、`multiple` | 同一应用允许一个还是多个实例 | +| `defaultContext` | `application`、`current-project`、`current-conversation` | 创建实例时使用的默认范围,不代表实例随后必须跟随 | +| `visibleAcrossContextSwitches` | boolean | 切换项目或会话后,已打开的 Tab 是否继续留在工作栏中 | +| `defaultOpen` | boolean | 新布局是否创建默认实例 | +| `required` | boolean | 布局加载时实例缺失是否必须补回 | +| `closable` | boolean | 是否渲染关闭入口并接受关闭命令 | +| `reorderable` | boolean | 是否允许用户改变 Tab 顺序 | +| `availability` | `available` 或带原因和修复入口的 `unavailable` | 当前能力状态 | + +`defaultOpen`、`required` 和 `closable` 不互相推导。例如浏览器默认打开但不是必需实例;任务 +中心和工作区既默认打开又必须存在。`required` 不表示锁定顺序,顺序只由 `reorderable` 控制。 + +当前五个应用的 `visibleAcrossContextSwitches` 均为 `true`。该属性记录应用目录的显示策略, +不改变内容归属,也不授予其他会话的 MCP 访问权限;当前没有使用 `false` 的应用,尚未实现 +按上下文隐藏 Tab 的策略。 + +浏览器在创建时绑定当前 Conversation;切换会话后仍保留原 Tab、选中状态和页面,导航操作 +继续使用原 `targetRef.conversationId`。此时新建浏览器才绑定新会话。默认或旧布局中尚未绑定 +的浏览器在首次使用时绑定活动会话,此后不再随会话切换改绑。终端同样保留创建时执行目标; +任务中心、工作区和成果的内容继续跟随当前项目。 + ### 3.2 工作栏 Tab 实例 工作栏 Tab 是应用的一次打开实例。每个实例具有独立 ID、标题、应用类型和状态。Tab 行只 显示已经打开的实例,不把全部能力同时渲染为 Tab。 +实例持久化字段只有 `id`、`appId`、`title` 和可选 `targetRef`。`targetRef` 使用公开引用: +终端接受 `local` 或 `projectId`,浏览器接受 `conversationId`。Main 签发的 Browser Tab ID、 +Terminal Session ID、元素引用、凭据、路径和运行状态不进入工作栏布局。 + ### 3.3 终端实例 每个终端 Tab 对应一个独立交互式 Shell Session: @@ -81,10 +115,10 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或 5. 单实例应用已经打开时,再次选择只聚焦已有实例,不创建重复 Tab。 6. 多实例应用每次选择都创建新实例。 7. 终端从首期开始就是多实例应用。 -8. 任务中心、工作区、浏览器和成果首期保持单实例;后续只需修改应用注册表策略即可开放 - 多实例,不重写 Tab 壳层。 -9. 现有四个应用在升级后仍按当前默认布局打开,避免用户入口突然消失。 -10. 用户关闭、切换或重排应用 Tab 后,“+”仍保持可见。 +8. 任务中心和工作区保持单实例、默认打开且不可关闭;不可关闭不自动禁止排序。 +9. 浏览器注册为可关闭的多实例应用,升级后保留一个默认实例;成果保持单实例、默认打开且 + 可关闭。 +10. 用户关闭可关闭实例、切换或重排应用 Tab 后,“+”仍保持可见。 11. 选择“终端”后不再显示第二层目标选择器。存在当前项目时直接绑定该项目,由 Main 根据项目执行空间自动打开本机或托管 SSH Shell;没有当前项目时打开本机 Home Shell。 12. 远程首期使用直接 SSH PTY。SSH 断线后终端进入“已中断”,重新连接会创建一个新的 @@ -109,7 +143,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或 ### 5.2 产品目标 - 用一个可扩展的应用注册表统一单实例与多实例行为。 -- 把现有固定四 Tab 迁移为用户实例,而不回归现有任务中心、工作区、浏览器和成果能力。 +- 把现有 Tab 迁移到统一实例模型,同时保持任务中心和工作区始终存在,并允许浏览器和终端 + 创建多个实例。 - 建立真实的 Main/Preload/Renderer 终端生产链路,而不是用普通文本框模拟终端。 - 复用现有 SSH Host Key、凭据和连接池,不建立第二套 Host 管理。 - 保持终端输入输出有序、响应流畅、内存有界,并可靠清理本机和远程资源。 @@ -149,7 +184,8 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或 - 当前 Tab 同时使用连续内容表面、选中边框、顶部强调线、图标和较高字重,不能只靠文字颜色。 - Tab、关闭按钮和“+”入口沿用共享控件圆角;终端面板外框和终端顶部工具栏按钮使用直角, 保持紧凑的停靠式终端界面。 -- Tab 关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘焦点进入对应 Tab 时出现; +- 只有可关闭实例渲染 Tab 关闭按钮。关闭按钮默认不争夺视觉注意力,仅在鼠标悬停或键盘 + 焦点进入对应 Tab 时出现; 粗指针或无悬停设备始终显示,出现与隐藏不得引发布局位移。 ### 7.2 新建应用选择页 @@ -160,31 +196,34 @@ stdio fallback。首期远程终端随 SSH Channel 结束,不提供跨 SSH 或 2. 在工作栏内容区显示“新建工作栏应用”。 3. 列出稳定应用目录,每项显示名称、说明、单/多实例提示和当前可用状态。 4. 初始焦点进入第一个可用应用;Escape 或“取消”返回原活动实例和“+”按钮。 -5. 选择单实例应用时,若实例已存在,直接聚焦该 Tab。 -6. 选择终端时直接解析当前项目执行空间,创建新 Tab 并聚焦终端输入,不再要求用户 +5. 选择单实例应用时,若实例已存在,直接聚焦该 Tab;任务中心和工作区始终走此路径。 +6. 选择浏览器时创建绑定当前 Conversation 的新 Browser Tab;没有活动 Conversation 时创建 + 待选择目标的空实例。 +7. 选择终端时直接解析当前项目执行空间,创建新 Tab 并聚焦终端输入,不再要求用户 重复选择本机或远程目标。 选择页是“+”触发的临时界面,不进入实例顺序,不持久化,也不占用一个普通 Tab。 ### 7.3 首期应用注册表 -| 应用 | 实例策略 | 升级后默认状态 | 再次从“+”选择 | -| --- | --- | --- | --- | -| 任务中心 | `single` | 打开 | 聚焦已有 Tab | -| 工作区 | `single` | 打开 | 聚焦已有 Tab | -| 浏览器 | `single` | 打开 | 聚焦已有 Tab | -| 成果 | `single` | 打开 | 聚焦已有 Tab | -| 终端 | `multiple` | 不打开 | 每次创建新 Tab | +| 应用 | 实例策略 | 默认上下文 | 默认打开 | 必须存在 | 可关闭 | 可排序 | 再次从“+”选择 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 任务中心 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab | +| 工作区 | `single` | 当前项目 | 是 | 是 | 否 | 是 | 聚焦已有 Tab | +| 浏览器 | `multiple` | 当前 Conversation | 是,一个实例 | 否 | 是 | 是 | 每次创建新 Tab | +| 成果 | `single` | 当前项目 | 是 | 否 | 是 | 是 | 聚焦已有 Tab | +| 终端 | `multiple` | 当前项目 | 否 | 否 | 是 | 是 | 每次创建新 Tab | -实例策略是应用注册数据,不能分散硬编码在多个点击处理器中。 +实例策略、默认状态和可关闭策略都是应用注册数据,不能分散硬编码在多个点击处理器中。 ### 7.4 Tab 操作 - 点击或使用方向键切换实例不会暂停或终止后台实例。 - 支持左右方向键、Home、End 和 roving `tabindex`。 -- 关闭非活动 Tab 后焦点保持在当前活动 Tab。 -- 关闭活动 Tab 后激活右侧相邻实例;没有右侧实例时激活左侧实例。 -- 关闭最后一个实例后显示明确空状态和“打开应用”,Tab 行仍保留“+”。 +- 任务中心和工作区没有关闭按钮,也不响应关闭快捷命令。 +- 关闭可关闭的非活动 Tab 后焦点保持在当前活动 Tab。 +- 关闭可关闭的活动 Tab 后激活右侧相邻实例;没有右侧实例时激活左侧实例。任务中心和工作区 + 始终存在,因此正常布局不会进入零实例空状态。 - 关闭正在运行的终端需要确认“关闭终端将结束 Shell 及其子进程”;取消是初始焦点。 - 已退出或已中断的终端可以直接关闭,不重复确认。 @@ -284,21 +323,45 @@ type TerminalSessionState = ```ts type WorkbarAppDefinition = { id: 'tasks' | 'workspace' | 'browser' | 'results' | 'terminal' + label: string + icon: WorkbarAppDefinition['id'] + description: string instancePolicy: 'single' | 'multiple' + defaultContext: 'application' | 'current-project' | 'current-conversation' + defaultOpen: boolean + required: boolean + closable: boolean + reorderable: boolean + availability: + | { state: 'available' } + | { state: 'unavailable'; reason: string; remedy: string } } type WorkbarTabInstance = { id: string appId: WorkbarAppDefinition['id'] title: string - targetRef?: { - type: 'project' | 'local' - id?: string - } + targetRef?: + | { type: 'local' } + | { type: 'project'; projectId: string } + | { type: 'conversation'; conversationId: string } +} + +type WorkbarLayoutPreferences = { + instances: WorkbarTabInstance[] + activeInstanceId: string | null + expanded: boolean + dock: 'right' + widthRatio: number + taskScope: 'current-project' | 'global' | 'all-projects' } ``` - 持久化工作栏打开状态、Tab 顺序、活动实例、应用 ID、自定义标题、公开目标引用和宽度比例。 +- 加载布局时根据 `required` 补回缺失实例,并拒绝重复的单实例应用;应用策略不写入实例 + 数据,避免持久化状态覆盖注册表。 +- `reorderable` 已进入共享契约;当前 Tab 壳层尚未提供拖放或键盘重排,不能把声明属性视为 + 已完成的排序功能。 - 不持久化终端正文、未执行输入、凭据、环境变量、PTY PID、SSH Channel 或派生 Host identity。 - Main 创建终端后返回新的不透明 Session ID;Renderer 不能选择 PID 或 Channel。 @@ -474,16 +537,19 @@ Schema、假数据终端或不可用入口的中间产品。 ### 18.1 工作栏应用与实例 -- [ ] Tab 行末始终显示“+”,包括没有任何打开实例时。 +- [ ] Tab 行末始终显示“+”;任务中心和工作区始终存在,因此正常布局不存在零实例状态。 - [ ] 点击“+”显示稳定应用目录,取消后返回原实例并恢复焦点。 - [ ] 选择已打开单实例应用只聚焦已有 Tab。 +- [x] 任务中心和工作区默认存在且不可关闭,不渲染关闭按钮,也不响应关闭快捷命令。 +- [x] 连续选择浏览器会创建页面、导航状态和元素引用相互独立的浏览器 Tab。 - [x] 连续选择终端会创建多个互相独立的终端 Tab。 -- [ ] 关闭、切换和重排实例后,“+”仍保持可用。 +- [ ] 关闭可关闭实例、切换和重排实例后,“+”仍保持可用。 - [ ] Tab 溢出时单行横向滚动,不隐藏“+”或把 Tab 换成下拉框。 -- [x] 关闭活动 Tab 后激活正确的相邻 Tab 并恢复焦点。 +- [x] 关闭可关闭的活动 Tab 后激活正确的相邻 Tab 并恢复焦点。 - [x] 当前 Tab 的背景、边框、顶部指示和字重清晰可辨;关闭按钮仅在悬停、焦点或无悬停 设备上出现,且不引发布局位移。 -- [ ] 升级后现有任务中心、工作区、浏览器和成果仍按默认布局打开且行为不回归。 +- [x] 升级后任务中心、工作区、一个浏览器实例和成果按默认布局打开;布局恢复会补回缺失的 + 任务中心与工作区。 - [ ] 在所有主要页面都能打开工作栏;切换主要页面不会销毁 Tab 实例或终端 Session。 ### 18.2 本机终端 @@ -522,7 +588,8 @@ Schema、假数据终端或不可用入口的中间产品。 ### 18.5 设计、无障碍和跨平台 -- [ ] 仅使用键盘可以打开应用、创建终端、切换和关闭 Tab、复制、粘贴及重新连接。 +- [ ] 仅使用键盘可以打开应用、创建浏览器和终端、切换 Tab、关闭可关闭 Tab、复制、粘贴及 + 重新连接。 - [ ] Tab、“+”、选择页和关闭确认使用正确语义并有可见焦点。 - [ ] 终端输出不逐行进入 live region,关键状态变化可被辅助技术感知。 - [ ] 浅色、深色、200% 文字缩放和窄窗口下可以完成核心流程。 @@ -535,7 +602,9 @@ Schema、假数据终端或不可用入口的中间产品。 ### 19.1 自动验证 - 共享 Zod Schema 和边界值测试。 -- 单/多实例注册策略、Tab 顺序、关闭焦点和“+”选择页组件测试。 +- 单/多实例、默认打开、可关闭和排序策略,以及 Tab 顺序、关闭焦点和“+”选择页组件测试。 +- 任务中心与工作区缺失时的布局修复、关闭命令拒绝和焦点保持测试。 +- 浏览器多实例的页面隔离、Conversation 匹配和快捷入口去重测试。 - 本机 PTY Session Manager 的输入、resize、退出、幂等关闭和进程树测试。 - SSH PTY 的 Host identity、lease、断线、resize 和关闭测试。 - Main trusted sender、Session 所有权、IPC 输入边界和事件路由测试。 diff --git a/docs/features/task-and-job/README.md b/docs/features/task-and-job/README.md index ba560dfa..99015835 100644 --- a/docs/features/task-and-job/README.md +++ b/docs/features/task-and-job/README.md @@ -5,7 +5,7 @@ ## 权威文档 1. [Task 与 Job 统一领域模型](./task-and-job-model.md):术语、身份和对象关系。 -2. [Task Center PRD](./task-center-prd.md):Task 的应用级索引。 +2. [Task Center PRD](./task-center-prd.md):Task 的单实例项目跟随索引与范围切换。 3. [Scheduled Task PRD](./scheduled-task-prd.md):时间或事件触发的 Task。 4. [Goal Task PRD](./goal-task-prd.md):围绕可验证结果有界推进的 Task。 5. [Job 与 Subjob PRD](./job-and-subjob-prd.md):Task 内部串行、并行和委派执行。 diff --git a/docs/features/task-and-job/task-center-prd.md b/docs/features/task-and-job/task-center-prd.md index 60b5c293..d4d6ac73 100644 --- a/docs/features/task-and-job/task-center-prd.md +++ b/docs/features/task-and-job/task-center-prd.md @@ -4,20 +4,24 @@ | 项目 | 内容 | | --- | --- | -| 状态 | Scheduled Task 首期已实现;Goal/Event Task 与完整操作待实施 | -| 版本 | 0.3 | -| 日期 | 2026-08-19 | +| 状态 | Scheduled Task 与项目范围跟随已实现;Goal/Event Task 与完整操作待实施 | +| 版本 | 0.4 | +| 日期 | 2026-09-10 | | 依赖 | [Task 与 Job 统一领域模型](./task-and-job-model.md) | | 界面归属 | [通用助手工作栏与执行空间](../assistant-workbar/prd.md) | ## 1. 产品定义 -Task Center 是所有产品级 Task 的应用级单例索引,不是 Automation Center,也不复制 -Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。 +Task Center 是工作栏中的单实例 Task 索引,默认跟随当前项目。它不是 Automation Center, +也不复制 Conversation 内容。点击条目打开其关联 Conversation,并定位或展开对应 Task。 每个 Task 只关联一条 Conversation;一条 Conversation 可以关联零个、一个或多个 Task。 Conversation 不因为关联 Task 而改变对象类型。 +“单实例”和“项目范围”是两个独立属性:应用内只有一个 Task Center Tab,但该 Tab 默认展示 +当前项目的 Task。用户可以在同一 Tab 内切换到“全局任务”或“全部项目”,不能为每个项目创建 +独立 Task Center 实例。 + ## 2. 收录边界 收录: @@ -53,7 +57,19 @@ Job/Subjob/Run 树、独立详情或路由。 Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run。 -## 4. 交互 +## 4. 范围 + +范围控件提供: + +- `当前项目`:默认值,跟随活动项目变化,只显示 `projectId` 匹配的 Task。 +- `全局任务`:只显示没有 `projectId` 的 Task。 +- `全部项目`:显示当前用户可见的项目 Task 和全局 Task,并保留每行的项目或 Global 标识。 + +没有活动项目时,“当前项目”显示明确空状态,并允许切换到“全局任务”或“全部项目”;系统不 +自动改变用户已经选择的范围。范围模式作为工作栏公开偏好持久化,但“当前项目”模式不保存 +具体项目 ID;重启后继续跟随届时的活动项目。 + +## 5. 交互 - 点击条目打开关联 Conversation,并定位到该 Task。 - 支持按需要关注、进行中、已暂停、已结束筛选。 @@ -63,7 +79,7 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run - 完整消息留在 Conversation;工具、审批和错误可以在活动或 Runtime 中按 Task 查看; 独立交付物在成果中查看。 -## 5. 左侧 Conversation Task 列表 +## 6. 左侧 Conversation Task 列表 左侧最近会话列表承担轻量 Task 发现,不替代 Task Center: @@ -88,7 +104,7 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run - 后台状态变化不强制展开;Task 状态持续显示在展开后的子项和 Task Center 中。 - 默认最多显示 3 个 Task;“查看全部 N 个任务”打开该 Conversation 的完整任务区。 -## 6. Conversation 任务区 +## 7. Conversation 任务区 包含 Task 的 Conversation 顶部显示可折叠任务区: @@ -100,14 +116,14 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run 选择 Task 后显示名称、模式、聚合状态、计划、下次执行、最近结果和 Task 级操作。工具、审批、 错误和成果通过 Task 关联显示,但不暴露 Job/Run 层级。 -## 7. 删除关系 +## 8. 删除关系 - 删除 Schedule 只停止后续触发,不删除 Task、Conversation 或历史。 - 删除 Task 停止其计划并移除关联,默认保留 Conversation 和既有消息。 - 删除最后一个 Task 后,左侧 Conversation 的展开按钮消失。 - 删除 Conversation 前必须显示关联 Task 数量,并先停止或结算活动执行。 -## 8. 验收标准 +## 9. 验收标准 - [x] Task Center 只展示产品级 Task。 - [x] 一条 Conversation 可以关联并展开多个 Task。 @@ -118,3 +134,6 @@ Task 行只显示聚合后的用户状态,不要求用户理解内部 Job/Run - [x] 普通模型请求和工具调用不会误显示为 Task。 - [ ] 删除 Task 默认保留 Conversation 和既有消息。 - [x] Smart Heartbeat 不进入 Task Center。 +- [x] Task Center 默认只显示当前项目的 Task,切换项目后在同一 Tab 更新列表。 +- [x] “全局任务”只显示无项目 Task,“全部项目”显示所有可见 Task 并标注所属范围。 +- [x] 范围切换不创建第二个 Task Center Tab;任务中心保持默认存在且不可关闭。 diff --git a/src/main/agent/direct-model-process-service.test.ts b/src/main/agent/direct-model-process-service.test.ts index 22113864..173c5717 100644 --- a/src/main/agent/direct-model-process-service.test.ts +++ b/src/main/agent/direct-model-process-service.test.ts @@ -5,7 +5,6 @@ import { join, relative } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { LocalWorkspaceAccess } from '../workspace' import { - DIRECT_MODEL_PROCESS_TRUNCATION_MARKER, LocalDirectModelProcessService, buildDirectModelProcessEnvironment, type DirectModelProcessChild, @@ -277,7 +276,7 @@ describe('LocalDirectModelProcessService', () => { await service.dispose() }) - it('returns nonzero exits normally and bounds stdout and stderr by retaining both edges', async () => { + it('returns nonzero exits with bounded previews and retains full output for paging', async () => { const { access } = await workspace() const child = new FakeChild() let now = 1_000 @@ -310,12 +309,19 @@ describe('LocalDirectModelProcessService', () => { expect(result.durationMs).toBe(125) expect(result.stdoutTruncated).toBe(true) expect(result.stdout).toBe( - `${'A'.repeat(48 * 1024)}` + - DIRECT_MODEL_PROCESS_TRUNCATION_MARKER + - 'Z'.repeat(48 * 1024) + 'A'.repeat(48 * 1024) + 'M'.repeat(10) + 'Z'.repeat(48 * 1024 - 10) ) expect(result.stderr).toBe('ordinary error output') expect(result.stderrTruncated).toBe(false) + expect(result.stderrReference).toBeUndefined() + const reference = result.stdoutReference! + expect(reference.totalBytes).toBe(96 * 1024 + 10) + await expect(service.readOutput('output', reference.handle, reference.nextCursor)).resolves.toMatchObject({ + content: 'Z'.repeat(10), eof: true, nextCursor: reference.totalBytes + }) + await expect(service.readOutput('other', reference.handle)).rejects.toThrow() + await service.releaseConversation('output') + await expect(service.readOutput('output', reference.handle)).rejects.toThrow() await service.dispose() }) @@ -439,14 +445,18 @@ describe('LocalDirectModelProcessService', () => { } ) const first = run('first') + await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledOnce()) const second = run('second') await vi.waitFor(() => expect(spawnProcess).toHaveBeenCalledTimes(2)) + firstChild.stdout.emit('data', 'x'.repeat(120000)) + secondChild.stderr.emit('data', 'y'.repeat(120000)) await service.releaseConversation('first') expect(terminated).toHaveLength(1) await expect(first).resolves.toMatchObject({ terminationReason: 'cancelled' }) + await expect(service.readOutput('first', (await first).stdoutReference!.handle)).rejects.toThrow() const replacementChild = new FakeChild() children.push(replacementChild) const replacement = run('first') @@ -464,6 +474,7 @@ describe('LocalDirectModelProcessService', () => { await expect(second).resolves.toMatchObject({ terminationReason: 'cancelled' }) + await expect(service.readOutput('second', (await second).stderrReference!.handle)).rejects.toThrow() await service.dispose() }) @@ -496,4 +507,37 @@ describe('LocalDirectModelProcessService', () => { expect(result.stderr).toContain('warn') await service.dispose() }) + + it('pages complete stdout and stderr from a real shell', async () => { + const { access } = await workspace() + const service = new LocalDirectModelProcessService() + try { + expect((await service.getCapability()).available).toBe(true) + const command = process.platform === 'win32' + ? "[Console]::Out.Write(('x' * 120000) + 'stdout-end'); [Console]::Error.Write(('y' * 120000) + 'stderr-end')" + : "printf '%120000sstdout-end' '' | tr ' ' x; printf '%120000sstderr-end' '' | tr ' ' y >&2" + const result = await service.execute({ command }, { + conversationId: 'real-pages', workspace: access, + signal: new AbortController().signal + }) + for (const [preview, reference, expected] of [ + [result.stdout, result.stdoutReference, 'x'.repeat(120000) + 'stdout-end'], + [result.stderr, result.stderrReference, 'y'.repeat(120000) + 'stderr-end'] + ] as const) { + expect(reference).toBeDefined() + let output = preview + let cursor = reference!.nextCursor + while (cursor < reference!.totalBytes) { + const page = await service.readOutput('real-pages', reference!.handle, cursor, 4096) + output += page.content + cursor = page.nextCursor + } + expect(output).toBe(expected) + } + await service.dispose() + await expect(service.readOutput('real-pages', result.stdoutReference!.handle)).rejects.toThrow() + } finally { + await service.dispose() + } + }) }) diff --git a/src/main/agent/direct-model-process-service.ts b/src/main/agent/direct-model-process-service.ts index 97a01b3c..bc44e386 100644 --- a/src/main/agent/direct-model-process-service.ts +++ b/src/main/agent/direct-model-process-service.ts @@ -15,16 +15,18 @@ import { type WaitableProcessTreeChild } from './child-process-termination' import { buildCredentialFilteredUserEnvironment } from './process-environment' +import { + PagedOutputStore, + type PagedOutputPage, + type PagedOutputReference, + type PagedOutputWriter +} from './paged-output-store' const DEFAULT_TIMEOUT_MS = 120_000 const MAXIMUM_TIMEOUT_MS = 10 * 60_000 const MAXIMUM_OUTPUT_BYTES = 96 * 1024 -const RETAINED_OUTPUT_EDGE_BYTES = MAXIMUM_OUTPUT_BYTES / 2 const TERMINATION_WAIT_MS = 2_000 -export const DIRECT_MODEL_PROCESS_TRUNCATION_MARKER = - '\n...[GoodBuddy output truncated]...\n' - export const processExecuteInputSchema = z .object({ command: z.string().trim().min(1).max(100_000), @@ -70,6 +72,8 @@ export type ProcessExecuteResult = { stderr: string stdoutTruncated: boolean stderrTruncated: boolean + stdoutReference?: PagedOutputReference + stderrReference?: PagedOutputReference } export type DirectModelProcessCapability = @@ -94,6 +98,7 @@ export interface DirectModelProcessService { input: ProcessExecuteInput, context: DirectModelProcessExecutionContext ): Promise + readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise releaseConversation(conversationId: string): Promise dispose(): Promise } @@ -271,67 +276,6 @@ async function resolveProcessShell( return undefined } -class BoundedProcessOutput { - private prefix = Buffer.alloc(0) - private suffix = Buffer.alloc(0) - private totalBytes = 0 - - append(chunk: Buffer | string): void { - const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) - this.totalBytes += value.byteLength - - if (this.prefix.byteLength < RETAINED_OUTPUT_EDGE_BYTES) { - const remaining = - RETAINED_OUTPUT_EDGE_BYTES - this.prefix.byteLength - this.prefix = Buffer.concat([ - this.prefix, - value.subarray(0, remaining) - ]) - } - - if (value.byteLength >= RETAINED_OUTPUT_EDGE_BYTES) { - this.suffix = Buffer.from( - value.subarray(value.byteLength - RETAINED_OUTPUT_EDGE_BYTES) - ) - return - } - const combined = Buffer.concat([this.suffix, value]) - this.suffix = - combined.byteLength <= RETAINED_OUTPUT_EDGE_BYTES - ? combined - : Buffer.from( - combined.subarray( - combined.byteLength - RETAINED_OUTPUT_EDGE_BYTES - ) - ) - } - - result(): { text: string; truncated: boolean } { - if (this.totalBytes > MAXIMUM_OUTPUT_BYTES) { - return { - text: Buffer.concat([ - this.prefix, - Buffer.from(DIRECT_MODEL_PROCESS_TRUNCATION_MARKER), - this.suffix - ]).toString('utf8'), - truncated: true - } - } - const overlap = Math.max( - 0, - this.prefix.byteLength + - this.suffix.byteLength - - this.totalBytes - ) - return { - text: Buffer.concat([ - this.prefix, - this.suffix.subarray(overlap) - ]).toString('utf8'), - truncated: false - } - } -} function shellArguments( shell: ResolvedProcessShell, command: string @@ -399,6 +343,7 @@ export class LocalDirectModelProcessService implements DirectModelProcessService { private readonly activeCalls = new Map>() + private readonly outputs = new PagedOutputStore('process') private readonly shell: Promise private disposed = false private disposePromise?: Promise @@ -496,31 +441,46 @@ export class LocalDirectModelProcessService platform ) const startedAt = (this.options.now ?? Date.now)() - let child: DirectModelProcessChild - try { - child = ( - this.options.spawnProcess ?? - (spawn as unknown as DirectModelProcessSpawn) - )(shell.executable, shellArguments(shell, input.command), { - cwd: workingDirectory.canonicalPath, - detached: platform !== 'win32', - env: environment, - shell: false, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true - }) - } catch (error) { - throw new Error('无法启动命令 Shell', { cause: error }) - } - - return await this.waitForProcess( - child, - shell, - workingDirectory.displayPath, - input.timeoutMs, - signal, - startedAt + const stdout = await this.outputs.create( + context.conversationId, MAXIMUM_OUTPUT_BYTES ) + let stderr: PagedOutputWriter | undefined + try { + stderr = await this.outputs.create( + context.conversationId, MAXIMUM_OUTPUT_BYTES + ) + signal.throwIfAborted() + let child: DirectModelProcessChild + try { + child = ( + this.options.spawnProcess ?? + (spawn as unknown as DirectModelProcessSpawn) + )(shell.executable, shellArguments(shell, input.command), { + cwd: workingDirectory.canonicalPath, + detached: platform !== 'win32', + env: environment, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true + }) + } catch (error) { + throw new Error('无法启动命令 Shell', { cause: error }) + } + + return await this.waitForProcess( + child, + shell, + workingDirectory.displayPath, + input.timeoutMs, + signal, + startedAt, + stdout, + stderr + ) + } catch (error) { + await Promise.all([stdout.abort(), stderr?.abort()]) + throw error + } } private waitForProcess( @@ -529,10 +489,10 @@ export class LocalDirectModelProcessService cwd: string, timeoutMs: number, cancellationSignal: AbortSignal, - startedAt: number + startedAt: number, + stdout: PagedOutputWriter, + stderr: PagedOutputWriter ): Promise { - const stdout = new BoundedProcessOutput() - const stderr = new BoundedProcessOutput() child.stdout?.on('data', (chunk) => stdout.append(chunk)) child.stderr?.on('data', (chunk) => stderr.append(chunk)) @@ -550,9 +510,9 @@ export class LocalDirectModelProcessService child.stdout?.removeListener?.('error', failFromOutput) child.stderr?.removeListener?.('error', failFromOutput) } - const result = (): ProcessExecuteResult => { - const stdoutResult = stdout.result() - const stderrResult = stderr.result() + const result = async (): Promise => { + const stdoutResult = await stdout.finish() + const stderrResult = await stderr.finish() return { shell: { kind: shell.kind, label: shell.label }, cwd, @@ -566,7 +526,9 @@ export class LocalDirectModelProcessService stdout: stdoutResult.text, stderr: stderrResult.text, stdoutTruncated: stdoutResult.truncated, - stderrTruncated: stderrResult.truncated + stderrTruncated: stderrResult.truncated, + ...(stdoutResult.reference ? { stdoutReference: stdoutResult.reference } : {}), + ...(stderrResult.reference ? { stderrReference: stderrResult.reference } : {}) } } const finishResult = (): void => { @@ -682,6 +644,11 @@ export class LocalDirectModelProcessService call.controller.abort(abortError('对话进程资源已释放')) } await Promise.all(calls.map((call) => call.done)) + await this.outputs.releaseOwner(conversationId) + } + + readOutput(ownerId: string, handle: string, cursor?: number, limitBytes?: number): Promise { + return this.outputs.read(ownerId, handle, cursor, limitBytes) } dispose(): Promise { @@ -698,5 +665,6 @@ export class LocalDirectModelProcessService call.controller.abort(abortError('进程执行服务已关闭')) } await Promise.all(calls.map((call) => call.done)) + await this.outputs.dispose() } } diff --git a/src/main/agent/knowledge-mcp-gateway.test.ts b/src/main/agent/knowledge-mcp-gateway.test.ts index 2c60430a..82e2918c 100644 --- a/src/main/agent/knowledge-mcp-gateway.test.ts +++ b/src/main/agent/knowledge-mcp-gateway.test.ts @@ -22,6 +22,7 @@ import { KnowledgeMcpGateway, type MagicNotesDatabase } from './knowledge-mcp-gateway' +import { browserTabIdSchema } from '../../shared/contracts' const firstLibraryId = '11111111-1111-4111-8111-111111111111' const secondLibraryId = '22222222-2222-4222-8222-222222222222' @@ -185,6 +186,28 @@ function testTool(name: string): Tool { } const gateways: KnowledgeMcpGateway[] = [] + +it('honors a requested knowledge result count above eight', async () => { + const { service, searchHybridMany } = createService() + const [sample] = await searchHybridMany([firstLibraryId]) + searchHybridMany.mockClear() + searchHybridMany.mockResolvedValue(Array.from({ length: 12 }, (_, index) => ({ + ...sample!, + result: { + ...sample!.result, + chunk: { ...sample!.result.chunk, id: crypto.randomUUID() }, + rank: index + 1 + } + }))) + const gateway = new KnowledgeMcpGateway(service) + gateways.push(gateway) + const controller = new AbortController() + const token = gateway.grant('large-search', [firstLibraryId], controller.signal)! + const references = await gateway.search(token, { query: 'evidence', limit: 12 }) + expect(references).toHaveLength(12) + expect(searchHybridMany).toHaveBeenCalledWith([firstLibraryId], 'evidence', 12, controller.signal) + await expect(gateway.search(token, { query: 'evidence', limit: 101 })).rejects.toThrow() +}) const databases: AssistantDatabase[] = [] const temporaryDirectories: string[] = [] const httpServers: Server[] = [] @@ -210,6 +233,22 @@ afterEach(async () => { describe('KnowledgeMcpGateway', () => { it('exposes the assigned browser through a request-scoped conversation', async () => { const { service } = createService() + const browserTabId = browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000301' + ) + const primaryBrowserTabId = browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000302' + ) + const releaseBrowserUsage = vi.fn() + const acquireTabUsage = vi.fn( + (conversationId: string, tabId: typeof browserTabId, owner: string) => ({ + conversationId, + tabId, + owner, + signal: new AbortController().signal, + release: releaseBrowserUsage + }) + ) const browserService = { getOrigin: vi.fn(() => undefined), navigate: vi.fn(async () => ({ @@ -222,21 +261,63 @@ describe('KnowledgeMcpGateway', () => { select: vi.fn(), back: vi.fn(), screenshot: vi.fn(), - releaseConversation: vi.fn(async () => undefined) + releaseConversation: vi.fn(async () => undefined), + acquireTabUsage, + listTabs: vi.fn(() => [ + { + conversationId: 'browser-conversation', + tabId: primaryBrowserTabId, + primary: true, + status: 'ready' as const, + isLoading: false, + canGoBack: false, + createdAt: 1, + updatedAt: 1 + }, + { + conversationId: 'browser-conversation', + tabId: browserTabId, + primary: false, + status: 'ready' as const, + isLoading: false, + canGoBack: false, + createdAt: 1, + updatedAt: 1 + } + ]) } const gateway = new KnowledgeMcpGateway(service, { browserService }) gateways.push(gateway) await gateway.start() + expect(() => + gateway.grant( + 'wrong-browser-tab', + [], + new AbortController().signal, + 'none', + undefined, + 'browser-conversation', + browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000399' + ) + ) + ).toThrow('浏览器标签页不存在或不属于当前对话') const token = gateway.grant( 'browser-request', [], new AbortController().signal, 'none', undefined, - 'browser-conversation' + 'browser-conversation', + browserTabId )! + expect(acquireTabUsage).toHaveBeenCalledWith( + 'browser-conversation', + browserTabId, + 'browser-request' + ) expect(gateway.getAvailableToolNames(token)).toEqual(browserToolNames) const client = new Client({ name: 'browser-loopback-test', @@ -276,8 +357,32 @@ describe('KnowledgeMcpGateway', () => { expect(browserService.navigate).toHaveBeenCalledWith( 'browser-conversation', 'https://example.com', - expect.any(AbortSignal) + expect.any(AbortSignal), + browserTabId ) + expect(browserService.listTabs).toHaveBeenCalledTimes(2) + vi.mocked(browserService.click).mockRejectedValueOnce( + new Error('浏览器标签页不存在或不属于当前对话') + ) + await expect( + client.callTool({ + name: 'browser_click', + arguments: { ref: 'b_boundTabReference' } + }) + ).resolves.toMatchObject({ isError: true }) + expect(browserService.click).toHaveBeenCalledWith( + 'browser-conversation', + 'b_boundTabReference', + expect.any(AbortSignal), + browserTabId + ) + expect(browserService.listTabs).toHaveBeenCalledTimes(2) + gateway.revoke(token) + gateway.revoke(token) + expect(releaseBrowserUsage).toHaveBeenCalledOnce() + await expect( + client.callTool({ name: 'browser_snapshot', arguments: {} }) + ).rejects.toThrow() } finally { await client.close() } diff --git a/src/main/agent/knowledge-mcp-gateway.ts b/src/main/agent/knowledge-mcp-gateway.ts index f5ed82c4..469f3061 100644 --- a/src/main/agent/knowledge-mcp-gateway.ts +++ b/src/main/agent/knowledge-mcp-gateway.ts @@ -19,7 +19,11 @@ import { type Tool } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' -import type { KnowledgeSearchReference } from '../../shared/contracts' +import type { + BrowserTabId, + BrowserTabSummary, + KnowledgeSearchReference +} from '../../shared/contracts' import { stripKnowledgeHighlightTags } from '../../shared/knowledge-text' import { knowledgeToolNames, @@ -70,6 +74,7 @@ import { type BrowserToolName, type BrowserToolService } from '../browser/browser-model-tools' +import type { BrowserTabUsageLease } from '../browser/browser-service' import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver' import { safeToolErrorDetail } from './approval-summary' @@ -185,6 +190,8 @@ type Capability = { configWorkspacePath?: string authorizeConfigApply?: GoodBuddyConfigApplyAuthorizer browserConversationId?: string + browserTabId?: BrowserTabId + browserUsageLease?: BrowserTabUsageLease expiresAt: number signal: AbortSignal brokerController: AbortController @@ -230,7 +237,14 @@ export type KnowledgeMcpGatewayOptions = { now?: () => number magicNotesDatabase?: MagicNotesDatabase configService?: GoodBuddyConfigService - browserService?: BrowserToolService + browserService?: BrowserToolService & { + listTabs(conversationId: string): BrowserTabSummary[] + acquireTabUsage( + conversationId: string, + tabId: BrowserTabId, + owner: string + ): BrowserTabUsageLease + } launchEnvironmentProvider?: LaunchEnvironmentProvider } @@ -356,7 +370,14 @@ export class KnowledgeMcpGateway { private readonly maximumBodyBytes: number private readonly magicNotesDatabase?: MagicNotesDatabase private readonly configService?: GoodBuddyConfigService - private readonly browserService?: BrowserToolService + private readonly browserService?: BrowserToolService & { + listTabs(conversationId: string): BrowserTabSummary[] + acquireTabUsage( + conversationId: string, + tabId: BrowserTabId, + owner: string + ): BrowserTabUsageLease + } private readonly launchEnvironmentProvider?: LaunchEnvironmentProvider private server?: Server private endpoint?: string @@ -432,7 +453,9 @@ export class KnowledgeMcpGateway { workspacePath: string authorizeApply?: GoodBuddyConfigApplyAuthorizer }, - browserConversationId?: string + browserConversationId?: string, + browserTabId?: BrowserTabId, + browserUsageLease?: BrowserTabUsageLease ): string | undefined { const effectiveMagicNotesAccess = this.magicNotesDatabase ? magicNotesAccess @@ -443,6 +466,39 @@ export class KnowledgeMcpGateway { const effectiveBrowserConversationId = this.browserService ? browserConversationId : undefined + const effectiveBrowserTabId = effectiveBrowserConversationId + ? browserTabId + : undefined + if ( + effectiveBrowserConversationId && + (!effectiveBrowserTabId || + !this.browserService! + .listTabs(effectiveBrowserConversationId) + .some((tab) => tab.tabId === effectiveBrowserTabId)) + ) { + throw new Error('浏览器标签页不存在或不属于当前对话') + } + let effectiveBrowserUsageLease: BrowserTabUsageLease | undefined + if (effectiveBrowserConversationId && effectiveBrowserTabId) { + effectiveBrowserUsageLease = browserUsageLease ?? + this.browserService!.acquireTabUsage( + effectiveBrowserConversationId, + effectiveBrowserTabId, + requestId + ) + if ( + effectiveBrowserUsageLease.conversationId !== + effectiveBrowserConversationId || + effectiveBrowserUsageLease.tabId !== effectiveBrowserTabId || + effectiveBrowserUsageLease.owner !== requestId + ) { + effectiveBrowserUsageLease.release() + throw new Error('浏览器标签页使用租约与当前请求不匹配') + } + } else if (browserUsageLease) { + browserUsageLease.release() + throw new Error('浏览器标签页使用租约与当前请求不匹配') + } if ( authorizedLibraryIds.length === 0 && effectiveMagicNotesAccess === 'none' && @@ -452,21 +508,28 @@ export class KnowledgeMcpGateway { return undefined } signal.throwIfAborted() - return this.storeCapability({ - requestId, - libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]), - magicNotesAccess: effectiveMagicNotesAccess, - configAccess: effectiveConfigAccess, - browserConversationId: effectiveBrowserConversationId, - ...(effectiveConfigAccess !== 'none' - ? { - configWorkspacePath: config?.workspacePath, - authorizeConfigApply: config?.authorizeApply - } - : {}), - signal, - customMcpServers: [] - }) + try { + return this.storeCapability({ + requestId, + libraryIds: Object.freeze([...new Set(authorizedLibraryIds)]), + magicNotesAccess: effectiveMagicNotesAccess, + configAccess: effectiveConfigAccess, + browserConversationId: effectiveBrowserConversationId, + browserTabId: effectiveBrowserTabId, + browserUsageLease: effectiveBrowserUsageLease, + ...(effectiveConfigAccess !== 'none' + ? { + configWorkspacePath: config?.workspacePath, + authorizeConfigApply: config?.authorizeApply + } + : {}), + signal, + customMcpServers: [] + }) + } catch (error) { + effectiveBrowserUsageLease?.release() + throw error + } } grantCustomMcp( @@ -517,14 +580,27 @@ export class KnowledgeMcpGateway { const abort = (): void => { this.revoke(token) } + const abortFromBrowser = (): void => { + this.revoke(token) + } value.signal.addEventListener('abort', abort, { once: true }) + value.browserUsageLease?.signal.addEventListener( + 'abort', + abortFromBrowser, + { once: true } + ) this.capabilities.set(token, { ...value, expiresAt: this.now() + this.capabilityTtlMs, brokerController, references: new Map(), - removeAbortListener: () => + removeAbortListener: () => { value.signal.removeEventListener('abort', abort) + value.browserUsageLease?.signal.removeEventListener( + 'abort', + abortFromBrowser + ) + } }) return token } @@ -542,6 +618,7 @@ export class KnowledgeMcpGateway { capability.brokerController.abort( new Error('MCP capability was revoked') ) + capability.browserUsageLease?.release() for (const session of this.downstreamMcpSessions.values()) { if (session.token === token) { void this.closeDownstreamMcpSession(session) @@ -724,7 +801,7 @@ export class KnowledgeMcpGateway { ...(capability.configAccess === 'write' ? goodbuddyConfigWriteToolNames : []), - ...(capability.browserConversationId + ...(capability.browserConversationId && capability.browserTabId ? browserToolNames : []) ] @@ -1518,10 +1595,11 @@ export class KnowledgeMcpGateway { } ) const capability = this.getCapability(token) - const browserTools = capability.browserConversationId + const browserTools = capability.browserConversationId && capability.browserTabId ? new BrowserModelTools({ service: this.browserService!, - conversationId: capability.browserConversationId + conversationId: capability.browserConversationId, + browserTabId: capability.browserTabId }) : undefined const browserDefinitions = browserTools @@ -1579,10 +1657,11 @@ export class KnowledgeMcpGateway { } } const capability = this.getCapability(token) - const browserTools = capability.browserConversationId + const browserTools = capability.browserConversationId && capability.browserTabId ? new BrowserModelTools({ service: this.browserService!, - conversationId: capability.browserConversationId + conversationId: capability.browserConversationId, + browserTabId: capability.browserTabId }) : undefined if (browserTools?.ownsTool(name)) { diff --git a/src/main/agent/model-runtime.test.ts b/src/main/agent/model-runtime.test.ts index 418f828f..675e85c5 100644 --- a/src/main/agent/model-runtime.test.ts +++ b/src/main/agent/model-runtime.test.ts @@ -18,6 +18,40 @@ const toolPng = Buffer.from([ 0x0d, 0x0a, 0x1a, 0x0a ]).toString('base64') +it('preserves full supplied and cached history beyond message and byte caps', async () => { + const fetcher = vi.fn(async () => new Response( + createEventStream('reply'), + { headers: { 'content-type': 'text/event-stream' } } + )) + const runtime = new ModelAgentRuntime({ + apiKey: 'test-key', + baseUrl: 'https://example.test', + model: 'test-model', + protocol: 'anthropic-messages', + authentication: 'api-key', + fetcher + }) + const history = Array.from({ length: 502 }, (_, index) => ({ + role: index % 2 ? 'assistant' as const : 'user' as const, + content: `${index}: ${'x'.repeat(4_200)}` + })) + for (const suppliedHistory of [history, undefined]) { + for await (const event of runtime.run({ + requestId: crypto.randomUUID(), + conversationId: 'full-history', + prompt: 'next', + history: suppliedHistory + }, new AbortController().signal)) { + expect(event).toBeDefined() + } + } + for (const [, init] of fetcher.mock.calls) { + const body = JSON.parse(String(init?.body)) + expect(body.messages.slice(0, history.length)).toEqual(history) + } + expect(fetcher).toHaveBeenCalledTimes(2) +}) + function createTextToolResult(text: string): ModelToolResult { return { parts: [{ type: 'text', text }], diff --git a/src/main/agent/model-runtime.ts b/src/main/agent/model-runtime.ts index f461f259..6711cbcb 100644 --- a/src/main/agent/model-runtime.ts +++ b/src/main/agent/model-runtime.ts @@ -2625,24 +2625,8 @@ export class ModelAgentRuntime implements AgentRuntime { conversationId: string, messages: ConversationMessage[] ): void { - const retained: ConversationMessage[] = [] - let bytes = 0 - const compressionEnabled = - this.options.contextCompression?.settings.enabled === true - const maximumMessages = compressionEnabled ? 500 : 20 - const maximumBytes = compressionEnabled - ? 2 * 1024 * 1024 - : 512 * 1024 - for (const message of messages.slice(-maximumMessages).reverse()) { - const messageBytes = Buffer.byteLength(message.content) - if (bytes + messageBytes > maximumBytes) { - break - } - retained.unshift(message) - bytes += messageBytes - } this.conversations.delete(conversationId) - this.conversations.set(conversationId, retained) + this.conversations.set(conversationId, [...messages]) while (this.conversations.size > 50) { const oldest = this.conversations.keys().next().value if (oldest) { @@ -2658,9 +2642,7 @@ export class ModelAgentRuntime implements AgentRuntime { request.history && request.history.length > 0 ? request.history : this.conversations.get(request.conversationId) ?? [] - return this.options.contextCompression?.settings.enabled - ? history - : history.slice(-20) + return history } private async *runImageGeneration( @@ -3381,6 +3363,7 @@ export class ModelAgentRuntime implements AgentRuntime { } const toolContext: ModelToolCallContext = { conversationId: request.conversationId, + browserTabId: request.browserTabId, workMode: request.workMode ?? 'ask', requestId: request.requestId, runtimeTarget: 'model', diff --git a/src/main/agent/model-tool-provider.test.ts b/src/main/agent/model-tool-provider.test.ts index 9c962e9f..6aee8670 100644 --- a/src/main/agent/model-tool-provider.test.ts +++ b/src/main/agent/model-tool-provider.test.ts @@ -7,9 +7,11 @@ import { } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { rgPath } from '@vscode/ripgrep' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ResolvedMcpServer } from '../capabilities/capability-service' import type { BrowserToolService } from '../browser/browser-model-tools' +import { browserTabIdSchema } from '../../shared/contracts' import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver' import { LocalWorkspaceAccess, @@ -64,8 +66,15 @@ const png = Buffer.from([ 0x0d, 0x0a, 0x1a, 0x0a ]).toString('base64') const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xd9]).toString('base64') +const firstBrowserTabId = browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000201' +) +const secondBrowserTabId = browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000202' +) const toolContext = { conversationId: 'provider-test-conversation', + browserTabId: firstBrowserTabId, workMode: 'execute' } satisfies ModelToolCallContext @@ -177,18 +186,159 @@ describe('ModelToolProvider', () => { await provider.dispose() }) - it('provides bounded workspace read, list, and atomic write tools', async () => { + it.each(['connect', 'listTools'] as const)( + 'isolates MCP %s failures and retries without reconnecting healthy servers', + async (failureMethod) => { + const workspace = await createWorkspace() + const failure = new Error('server unavailable') + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const failedClient = { + ...mocks.client, + connect: vi.fn().mockResolvedValue(undefined), + listTools: vi.fn().mockResolvedValue({ tools: [] }), + close: vi.fn().mockResolvedValue(undefined) + } + failedClient[failureMethod].mockRejectedValueOnce(failure) + mocks.Client.mockImplementationOnce(function () { return failedClient }) + mocks.client.listTools.mockResolvedValue({ + tools: [{ name: 'search', inputSchema: { type: 'object' } }] + }) + const provider = new ModelToolProvider(workspace, [ + createMcpServer(), + { ...createMcpServer(), id: 'healthy', name: 'Healthy' } + ], createBrowserService()) + const signal = new AbortController().signal + + try { + const tools = await provider.listTools(toolContext, signal) + expect(tools).toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'workspace_read_text' }), + expect.objectContaining({ name: 'browser_snapshot' }), + expect.objectContaining({ serverName: 'Healthy' }) + ])) + expect(tools.filter((tool) => tool.source === 'mcp')).toHaveLength(1) + expect(failedClient.close).toHaveBeenCalledOnce() + expect(mocks.client.close).not.toHaveBeenCalled() + expect(warning).toHaveBeenCalledWith( + expect.stringContaining('Search MCP'), expect.any(Error) + ) + + const recovered = await provider.listTools(toolContext, signal) + expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2) + expect(mocks.Client).toHaveBeenCalledTimes(3) + expect(mocks.client.connect).toHaveBeenCalledTimes(2) + await provider.callTool( + tools.find((tool) => tool.serverName === 'Healthy')!.name, + {}, signal, toolContext + ) + expect(mocks.client.callTool).toHaveBeenCalledOnce() + } finally { + await provider.dispose() + warning.mockRestore() + } + } + ) + + it('loads tools from more than sixteen MCP servers', async () => { + mocks.client.listTools.mockResolvedValue({ + tools: [{ name: 'search', inputSchema: { type: 'object' } }] + }) + const provider = new ModelToolProvider(await createWorkspace(), + Array.from({ length: 17 }, (_, index) => ({ + ...createMcpServer(), id: `server-${index}`, name: `Server ${index}` + })) + ) + try { + const tools = await provider.listTools(toolContext, new AbortController().signal) + expect(tools.filter((tool) => tool.source === 'mcp')).toHaveLength(17) + expect(mocks.client.connect).toHaveBeenCalledTimes(17) + } finally { + await provider.dispose() + } + }) + + it('preserves cancellation during MCP discovery and permits a later retry', async () => { + const controller = new AbortController() + const reason = new Error('cancel discovery') + mocks.client.connect.mockImplementationOnce(async () => { + controller.abort(reason) + throw reason + }) + const provider = new ModelToolProvider(await createWorkspace(), [createMcpServer()]) + try { + await expect(provider.listTools(toolContext, controller.signal)).rejects.toBe(reason) + expect(mocks.client.close).toHaveBeenCalledOnce() + await expect(provider.listTools(toolContext, new AbortController().signal)) + .resolves.toEqual(expect.arrayContaining([ + expect.objectContaining({ name: 'workspace_read_text' }) + ])) + expect(mocks.client.connect).toHaveBeenCalledTimes(2) + } finally { + await provider.dispose() + } + }) + + it('omits failed dynamic catalogs and retries refresh while healthy tools remain usable', async () => { + const warning = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + mocks.client.getServerCapabilities.mockReturnValue({ tools: { listChanged: true } }) + mocks.client.listTools.mockResolvedValue({ + tools: [{ name: 'search', inputSchema: { type: 'object' } }] + }) + const provider = new ModelToolProvider(await createWorkspace(), [ + createMcpServer(true), + { ...createMcpServer(), id: 'healthy', name: 'Healthy' } + ]) + const signal = new AbortController().signal + try { + await provider.listTools(toolContext, signal) + const options = mocks.Client.mock.calls[0]![1] as { + listChanged: { tools: { onChanged(error: Error | null): void } } + } + options.listChanged.tools.onChanged(null) + mocks.client.listTools.mockRejectedValueOnce(new Error('refresh unavailable')) + const tools = await provider.listTools(toolContext, signal) + expect(tools.filter((tool) => tool.source === 'mcp')).toEqual([ + expect.objectContaining({ serverName: 'Healthy' }) + ]) + expect(mocks.client.close).not.toHaveBeenCalled() + const recovered = await provider.listTools(toolContext, signal) + expect(recovered.filter((tool) => tool.source === 'mcp')).toHaveLength(2) + expect(mocks.client.connect).toHaveBeenCalledTimes(2) + + options.listChanged.tools.onChanged(null) + const controller = new AbortController() + const reason = new Error('cancel refresh') + mocks.client.listTools.mockImplementationOnce(async () => { + controller.abort(reason) + throw reason + }) + await expect(provider.listTools(toolContext, controller.signal)).rejects.toBe(reason) + } finally { + await provider.dispose() + warning.mockRestore() + } + }) + + it('provides ripgrep, paged reads, and patch tools', async () => { const workspace = await createWorkspace() await mkdir(join(workspace, 'docs')) await writeFile(join(workspace, 'docs', 'note.txt'), 'hello', 'utf8') - const provider = new ModelToolProvider(workspace) + await writeFile(join(workspace, 'docs', 'search.txt'), '中target\n', 'utf8') + const provider = new ModelToolProvider( + workspace, + [], + undefined, + undefined, + false, + { ripgrepExecutablePath: rgPath } + ) const signal = new AbortController().signal await expect(provider.listTools(toolContext, signal)).resolves.toEqual( expect.arrayContaining([ + expect.objectContaining({ name: 'workspace_rg' }), expect.objectContaining({ name: 'workspace_read_text' }), - expect.objectContaining({ name: 'workspace_list_directory' }), - expect.objectContaining({ name: 'workspace_write_text' }) + expect.objectContaining({ name: 'workspace_apply_patch' }) ]) ) await expect( @@ -198,32 +348,50 @@ describe('ModelToolProvider', () => { signal, toolContext ) - ).resolves.toEqual({ - parts: [{ type: 'text', text: 'hello' }], - contextBytes: 5 + ).resolves.toMatchObject({ + parts: [{ type: 'text', text: expect.stringContaining('1: hello') }] }) const listing = await provider.callTool( - 'workspace_list_directory', - { path: 'docs' }, + 'workspace_rg', + { path: 'docs', filesOnly: true }, signal, toolContext ) expect(listing.parts).toEqual([ expect.objectContaining({ type: 'text', - text: expect.stringContaining('"note.txt"') + text: expect.stringContaining('docs/note.txt') + }) + ]) + const matches = await provider.callTool( + 'workspace_rg', + { path: 'docs', pattern: 'target' }, + signal, + toolContext + ) + expect(matches.parts).toEqual([ + expect.objectContaining({ + type: 'text', + text: expect.stringContaining('docs/search.txt:1:2:中target') }) ]) const written = await provider.callTool( - 'workspace_write_text', - { path: 'docs/output.txt', content: 'saved' }, + 'workspace_apply_patch', + { + patch: [ + '*** Begin Patch', + '*** Add File: docs/output.txt', + '+saved', + '*** End Patch' + ].join('\n') + }, signal, toolContext ) expect(written.parts).toEqual([ expect.objectContaining({ type: 'text', - text: expect.stringContaining('"bytesWritten":5') + text: expect.stringContaining('docs/output.txt') }) ]) await expect( @@ -231,12 +399,15 @@ describe('ModelToolProvider', () => { ).resolves.toBe('saved') }) - it('delegates workspace tools and disposal to WorkspaceAccess', async () => { + it('delegates paged workspace reads and disposal to WorkspaceAccess', async () => { const readText = vi.fn(async () => ({ path: 'remote.txt', name: 'remote.txt', content: 'remote content', - size: 14 + size: 14, + offsetBytes: 0, + bytesRead: 14, + truncated: false })) const listDirectory = vi.fn(async () => ({ path: '', @@ -270,43 +441,19 @@ describe('ModelToolProvider', () => { signal, toolContext ) - ).resolves.toEqual({ - parts: [{ type: 'text', text: 'remote content' }], - contextBytes: 14 + ).resolves.toMatchObject({ + parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }] }) - await provider.callTool( - 'workspace_list_directory', - {}, - signal, - toolContext - ) - await provider.callTool( - 'workspace_write_text', - { path: 'output.txt', content: 'saved' }, - signal, - toolContext - ) expect(readText).toHaveBeenCalledWith( expect.objectContaining({ path: 'remote.txt', - maximumBytes: 256 * 1024, + maximumBytes: 128 * 1024, + allowTruncated: true, signal }) ) - expect(listDirectory).toHaveBeenCalledWith( - expect.objectContaining({ - path: '.', - maximumEntries: 200, - includeOther: true, - signal - }) - ) - expect(writeTextAtomic).toHaveBeenCalledWith({ - path: 'output.txt', - content: 'saved', - maximumBytes: 512 * 1024, - signal - }) + expect(listDirectory).not.toHaveBeenCalled() + expect(writeTextAtomic).not.toHaveBeenCalled() await provider.dispose() expect(dispose).toHaveBeenCalledOnce() }) @@ -361,8 +508,8 @@ describe('ModelToolProvider', () => { for (const [name, argumentsValue] of [ [ - 'workspace_write_text', - { path: 'output.txt', content: 'blocked' } + 'workspace_apply_patch', + { patch: '*** Begin Patch\n*** End Patch' } ], ['browser_click', { ref: 'b_target' }], ['note_create', { title: 'blocked' }], @@ -389,8 +536,8 @@ describe('ModelToolProvider', () => { expect(mocks.Client).not.toHaveBeenCalled() const writeTool = { - name: 'workspace_write_text', - displayName: '写入工作区文本', + name: 'workspace_apply_patch', + displayName: '应用工作区补丁', description: 'write', inputSchema: {}, source: 'builtin' @@ -398,7 +545,7 @@ describe('ModelToolProvider', () => { expect(() => provider.getApproval( writeTool, - { path: 'output.txt', content: 'blocked' }, + { patch: '*** Begin Patch\n*** End Patch' }, 'output.txt', askContext ) @@ -425,20 +572,10 @@ describe('ModelToolProvider', () => { askContext ) ).resolves.toMatchObject({ - parts: [{ type: 'text', text: 'remote content' }] - }) - await expect( - provider.callTool( - 'workspace_list_directory', - {}, - signal, - askContext - ) - ).resolves.toMatchObject({ - parts: [{ type: 'text', text: expect.any(String) }] + parts: [{ type: 'text', text: expect.stringContaining('1: remote content') }] }) expect(readText).toHaveBeenCalledOnce() - expect(listDirectory).toHaveBeenCalledOnce() + expect(listDirectory).not.toHaveBeenCalled() }) it('exposes scoped reads in Ask and Magic Notes writes only in Execute', async () => { @@ -499,6 +636,7 @@ describe('ModelToolProvider', () => { const askTools = await provider.listTools(askContext, signal) expect(askTools.map((tool) => tool.name)).toEqual([ + 'workspace_read_text', 'knowledge_list', 'knowledge_search', 'note_list', @@ -578,7 +716,11 @@ describe('ModelToolProvider', () => { }, signal ) - ).resolves.toEqual([]) + ).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ name: 'workspace_read_text' }) + ]) + ) const executeTools = await provider.listTools( { ...askContext, workMode: 'execute' }, signal @@ -586,8 +728,7 @@ describe('ModelToolProvider', () => { expect(executeTools.map((tool) => tool.name)).toEqual( expect.arrayContaining([ 'workspace_read_text', - 'workspace_list_directory', - 'workspace_write_text', + 'workspace_apply_patch', 'knowledge_list', 'knowledge_search', 'note_search', @@ -705,7 +846,9 @@ describe('ModelToolProvider', () => { workspace, [createMcpServer()], undefined, - gateway + gateway, + false, + { ripgrepExecutablePath: rgPath } ) await expect( validProvider.listTools(context, new AbortController().signal) @@ -719,14 +862,16 @@ describe('ModelToolProvider', () => { workspace, [createMcpServer()], undefined, - gateway + gateway, + false, + { ripgrepExecutablePath: rgPath } ) await expect( overflowingProvider.listTools( context, new AbortController().signal ) - ).rejects.toThrow('无法加载 MCP Server') + ).resolves.toHaveLength(18) await overflowingProvider.dispose() }) @@ -799,6 +944,7 @@ describe('ModelToolProvider', () => { stdoutTruncated: false, stderrTruncated: false })), + readOutput: vi.fn(), releaseConversation: vi.fn(async () => undefined), dispose: vi.fn(async () => undefined) } satisfies DirectModelProcessService @@ -994,10 +1140,12 @@ describe('ModelToolProvider', () => { const provider = new ModelToolProvider(workspace, [], browserService) const firstContext = { conversationId: 'browser-conversation-one', + browserTabId: firstBrowserTabId, workMode: 'execute' } satisfies ModelToolCallContext const secondContext = { conversationId: 'browser-conversation-two', + browserTabId: secondBrowserTabId, workMode: 'execute' } satisfies ModelToolCallContext const signal = new AbortController().signal @@ -1051,12 +1199,14 @@ describe('ModelToolProvider', () => { expect(browserService.screenshot).toHaveBeenNthCalledWith( 1, firstContext.conversationId, - signal + signal, + firstBrowserTabId ) expect(browserService.screenshot).toHaveBeenNthCalledWith( 2, secondContext.conversationId, - signal + signal, + secondBrowserTabId ) await provider.releaseConversation(firstContext.conversationId) @@ -1090,6 +1240,63 @@ describe('ModelToolProvider', () => { }) }) + it('keeps a request on its bound tab when another tab changes or closes', async () => { + const workspace = await createWorkspace() + const browserService = createBrowserService() + const provider = new ModelToolProvider(workspace, [], browserService) + const conversationId = 'two-tab-conversation' + const firstRequest = { + conversationId, + browserTabId: firstBrowserTabId, + workMode: 'execute', + requestId: 'first-tab-request' + } satisfies ModelToolCallContext + const secondRequest = { + conversationId, + browserTabId: secondBrowserTabId, + workMode: 'execute', + requestId: 'second-tab-request' + } satisfies ModelToolCallContext + const signal = new AbortController().signal + + await provider.callTool('browser_snapshot', {}, signal, firstRequest) + await provider.callTool('browser_snapshot', {}, signal, secondRequest) + expect(browserService.snapshot).toHaveBeenNthCalledWith( + 1, + conversationId, + signal, + firstBrowserTabId + ) + expect(browserService.snapshot).toHaveBeenNthCalledWith( + 2, + conversationId, + signal, + secondBrowserTabId + ) + + vi.mocked(browserService.click).mockImplementation( + async (_conversationId, _ref, _signal, tabId) => { + if (tabId === firstBrowserTabId) { + throw new Error('浏览器标签页不存在或不属于当前对话') + } + } + ) + await expect( + provider.callTool( + 'browser_click', + { ref: 'b_closedTabReference' }, + signal, + firstRequest + ) + ).rejects.toThrow('浏览器标签页不存在或不属于当前对话') + expect(browserService.click).toHaveBeenCalledWith( + conversationId, + 'b_closedTabReference', + signal, + firstBrowserTabId + ) + }) + it('exposes only allowlisted read-only Exa tools in Ask and Execute', async () => { const workspace = await createWorkspace() mocks.client.listTools.mockResolvedValue({ @@ -1130,18 +1337,20 @@ describe('ModelToolProvider', () => { workMode: 'ask' } satisfies ModelToolCallContext - await expect(provider.listTools(askContext, signal)).resolves.toEqual([ - expect.objectContaining({ - name: 'web_search', - displayName: '联网搜索', - source: 'builtin' - }), - expect.objectContaining({ - name: 'web_fetch', - displayName: '读取网页', - source: 'builtin' - }) - ]) + await expect(provider.listTools(askContext, signal)).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: 'web_search', + displayName: '联网搜索', + source: 'builtin' + }), + expect.objectContaining({ + name: 'web_fetch', + displayName: '读取网页', + source: 'builtin' + }) + ]) + ) await provider.callTool( 'web_search', { query: 'GoodBuddy current release', numResults: 3 }, diff --git a/src/main/agent/model-tool-provider.ts b/src/main/agent/model-tool-provider.ts index 5009de40..07ad844f 100644 --- a/src/main/agent/model-tool-provider.ts +++ b/src/main/agent/model-tool-provider.ts @@ -24,6 +24,7 @@ import type { } from './runtime' import { BrowserModelTools, + browserToolNames, type BrowserToolService } from '../browser/browser-model-tools' import { BrowserStaleReferenceError } from '../browser/cdp-browser-driver' @@ -45,12 +46,12 @@ import { directModelSubagentInputSchema } from '../assistant/direct-model-subagent-service' import type { LaunchEnvironmentProvider } from '../local-tool-environment' +import type { BrowserTabId } from '../../shared/contracts' import { searchWorkspaceWithRipgrep } from './direct-model-ripgrep' import { applyWorkspacePatch } from './workspace-apply-patch' import { readWorkspaceLines } from './workspace-read-lines' const MAX_MODEL_TOOLS = 100 -const MAX_MCP_SERVERS = 16 const MAX_TOOL_RESULT_BYTES = 256 * 1024 const MCP_TIMEOUT_MS = 30_000 const MCP_CALL_MAX_TOTAL_TIMEOUT_MS = 5 * 60_000 @@ -269,6 +270,7 @@ export type ModelToolResult = { export type ModelToolCallContext = { conversationId: string + browserTabId?: BrowserTabId workMode: 'ask' | 'execute' requestId?: string runtimeTarget?: 'model' @@ -635,7 +637,10 @@ function normalizeMcpResult(result: unknown): ModelToolResult { } export class ModelToolProvider implements ModelToolProviderLike { - private mcpConnections?: Promise + private readonly mcpConnections = new Map< + ResolvedMcpServer, + Promise + >() private webSearchBindings?: Promise> private readonly clients = new Set() private readonly customMcpClients = new Set() @@ -708,10 +713,13 @@ export class ModelToolProvider implements ModelToolProviderLike { private getBrowserTools( context: ModelToolCallContext ): BrowserModelTools | undefined { - return this.browserService && context.workMode === 'execute' + return this.browserService && + context.workMode === 'execute' && + context.browserTabId ? new BrowserModelTools({ service: this.browserService, - conversationId: context.conversationId + conversationId: context.conversationId, + browserTabId: context.browserTabId }) : undefined } @@ -719,7 +727,7 @@ export class ModelToolProvider implements ModelToolProviderLike { private getReservedToolCount(): number { return ( this.getBuiltinTools().length + - (this.browserService ? 7 : 0) + + (this.browserService ? browserToolNames.length : 0) + (this.webSearchEnabled ? 2 : 0) + (this.programming.processService ? 1 : 0) + (this.programming.subagentService ? 1 : 0) + @@ -1077,23 +1085,27 @@ export class ModelToolProvider implements ModelToolProviderLike { signal: AbortSignal, refreshDynamic = false ): Promise> { - if (this.mcpServers.length > MAX_MCP_SERVERS) { - throw new Error('直连模型最多可加载 16 个 MCP Server') - } - this.mcpConnections ??= Promise.all( - this.mcpServers.map((server) => this.connectMcpServer(server, signal)) - ) - .catch(async (error) => { - this.mcpConnections = undefined - const clients = [...this.customMcpClients] - this.customMcpClients.clear() - clients.forEach((client) => this.clients.delete(client)) - await Promise.allSettled( - clients.map((client) => client.close()) - ) - throw error + signal.throwIfAborted() + const loaded = await Promise.all( + this.mcpServers.map((server) => { + let pending = this.mcpConnections.get(server) + if (!pending) { + pending = this.connectMcpServer(server, signal).catch((error) => { + this.mcpConnections.delete(server) + if (!signal.aborted) { + console.warn(`MCP discovery failed: ${server.name}`, error) + } + return undefined + }) + this.mcpConnections.set(server, pending) + } + return pending }) - const connections = await this.mcpConnections + ) + signal.throwIfAborted() + const connections = loaded.filter( + (connection): connection is ConnectedMcp => connection !== undefined + ) if (refreshDynamic) { await Promise.all( connections.map(async (connection) => { @@ -1123,14 +1135,18 @@ export class ModelToolProvider implements ModelToolProviderLike { ) } catch (error) { connection.dynamicToolsChanged = true - throw new Error( - `无法刷新 MCP Server「${connection.server.name}」的工具`, - { cause: error } - ) + connection.tools = [] + if (!signal.aborted) { + console.warn( + `MCP refresh failed: ${connection.server.name}`, + error + ) + } } }) ) } + signal.throwIfAborted() const bindings = new Map() const reservedToolCount = this.getReservedToolCount() for (const connection of connections) { @@ -1782,7 +1798,7 @@ export class ModelToolProvider implements ModelToolProviderLike { this.clients.clear() this.customMcpClients.clear() this.webSearchClients.clear() - this.mcpConnections = undefined + this.mcpConnections.clear() this.webSearchBindings = undefined await Promise.allSettled([ ...clients.map((client) => client.close()), @@ -1794,12 +1810,7 @@ export class ModelToolProvider implements ModelToolProviderLike { async releaseConversation(conversationId: string): Promise { await Promise.allSettled([ - this.browserService - ? new BrowserModelTools({ - service: this.browserService, - conversationId - }).release() - : undefined, + this.browserService?.releaseConversation(conversationId), this.programming.processService?.releaseConversation( conversationId ), diff --git a/src/main/agent/paged-output-store.test.ts b/src/main/agent/paged-output-store.test.ts new file mode 100644 index 00000000..c8bad91e --- /dev/null +++ b/src/main/agent/paged-output-store.test.ts @@ -0,0 +1,104 @@ +import { mkdtemp, readdir, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { PagedOutputStore, PAGED_OUTPUT_MAX_PAGE_BYTES } from './paged-output-store' + +const stores: PagedOutputStore[] = [] +const directories: string[] = [] +afterEach(async () => { + await Promise.all(stores.splice(0).map((store) => store.dispose())) + await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true, force: true }))) +}) + +async function createStore() { + const directory = await mkdtemp(join(tmpdir(), 'goodbuddy-output-test-')) + directories.push(directory) + const store = new PagedOutputStore('test', { temporaryParentDirectory: directory }) + stores.push(store) + return { store, directory } +} + +describe('PagedOutputStore', () => { + it.each([1, 2, 3, 4, 7])('round trips split UTF-8 chunks with a %i byte page', async (limit) => { + const { store } = await createStore() + const text = 'a\u4f60\ud83d\ude00\ufffd\u597dZ' + const writer = await store.create('owner', 2) + for (const byte of Buffer.from(text)) writer.append(Buffer.from([byte])) + const saved = await writer.finish() + expect(saved.text).toBe('a') + let output = saved.text + let cursor = saved.reference!.nextCursor + while (cursor < saved.reference!.totalBytes) { + const page = await store.read('owner', writer.handle, cursor, limit) + expect(page.nextCursor).toBeGreaterThan(cursor) + expect(Buffer.byteLength(page.content)).toBeLessThanOrEqual(Math.max(limit, 4)) + output += page.content + cursor = page.nextCursor + } + expect(output).toBe(text) + await expect(store.read('owner', writer.handle, cursor, limit)).resolves.toMatchObject({ content: '', eof: true, nextCursor: cursor }) + await expect(store.read('owner', writer.handle, 2)).rejects.toThrow('UTF-8') + await expect(store.read('other', writer.handle)).rejects.toThrow() + for (const invalid of [-1, 0.5, cursor + 1]) { + await expect(store.read('owner', writer.handle, invalid)).rejects.toThrow() + } + for (const invalid of [0, 0.5, PAGED_OUTPUT_MAX_PAGE_BYTES + 1]) { + await expect(store.read('owner', writer.handle, 0, invalid)).rejects.toThrow() + } + }) + + it('keeps literal replacement characters in previews', async () => { + const { store } = await createStore() + const writer = await store.create('owner', 3) + writer.append('\ufffdrest') + const saved = await writer.finish() + expect(saved.text).toBe('\ufffd') + expect(saved.reference?.nextCursor).toBe(3) + }) + + it('retains output omitted to keep JSON-escaped previews bounded', async () => { + const { store } = await createStore() + const writer = await store.create('owner', 12) + writer.append('\u0000'.repeat(12)) + const saved = await writer.finish() + expect(saved.text).toBe('\u0000\u0000') + expect(saved.reference?.nextCursor).toBe(2) + const page = await store.read('owner', writer.handle, saved.reference!.nextCursor) + expect(saved.text + page.content).toBe('\u0000'.repeat(12)) + }) + + it('removes small, aborted, released and disposed output files', async () => { + const { store, directory } = await createStore() + const small = await store.create('owner', 10) + small.append('small') + expect(await small.finish()).toEqual({ text: 'small', truncated: false }) + const retained = await store.create('owner', 1) + retained.append('retained') + await retained.finish() + const active = await store.create('owner', 1) + active.append('active') + const other = await store.create('other', 1) + other.append('other') + await other.finish() + const [childDirectory] = await readdir(directory) + await store.releaseOwner('owner') + expect(await readdir(join(directory, childDirectory!))).toHaveLength(1) + await expect(active.finish()).rejects.toThrow() + await expect(store.read('owner', retained.handle)).rejects.toThrow() + await expect(store.read('other', other.handle)).resolves.toMatchObject({ content: 'other' }) + await store.create('other', 1) + await store.dispose() + expect(await readdir(directory)).toEqual([]) + await expect(store.create('owner', 1)).rejects.toThrow() + }) + + it('cleans up when disposed during directory creation', async () => { + const { store, directory } = await createStore() + const pending = store.create('owner', 1) + const rejected = expect(pending).rejects.toThrow() + await store.dispose() + await rejected + expect(await readdir(directory)).toEqual([]) + }) +}) diff --git a/src/main/agent/paged-output-store.ts b/src/main/agent/paged-output-store.ts new file mode 100644 index 00000000..d72391a0 --- /dev/null +++ b/src/main/agent/paged-output-store.ts @@ -0,0 +1,296 @@ +import { randomUUID } from 'node:crypto' +import { + closeSync, + openSync, + readSync, + writeSync +} from 'node:fs' +import { mkdir, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +export const PAGED_OUTPUT_DEFAULT_PAGE_BYTES = 32 * 1024 +export const PAGED_OUTPUT_MAX_PAGE_BYTES = 32 * 1024 + +export type PagedOutputReference = { + handle: string + nextCursor: number + totalBytes: number +} + +export type PagedOutputPage = { + handle: string + content: string + cursor: number + nextCursor: number + totalBytes: number + eof: boolean +} + +type OutputEntry = { + ownerId: string + path: string + totalBytes: number +} + +export type PagedOutputStoreOptions = { + temporaryParentDirectory?: string +} + +export class PagedOutputWriter { + private preview = Buffer.alloc(0) + private totalBytes = 0 + private failure?: Error + private closed = false + + constructor( + private readonly store: PagedOutputStore, + readonly handle: string, + readonly ownerId: string, + readonly path: string, + private readonly descriptor: number, + private readonly previewBytes: number + ) {} + + append(chunk: Buffer | string): void { + if (this.closed || this.failure) { + return + } + const value = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk) + try { + let offset = 0 + while (offset < value.byteLength) { + const written = writeSync(this.descriptor, value, offset) + if (written === 0) throw new Error('Unable to write tool output') + offset += written + } + this.totalBytes += value.byteLength + if (this.preview.byteLength < this.previewBytes) { + const remaining = this.previewBytes - this.preview.byteLength + this.preview = Buffer.concat([ + this.preview, + value.subarray(0, remaining) + ]) + } + } catch (error) { + this.failure = + error instanceof Error ? error : new Error('无法保存分页输出') + } + } + + async finish(): Promise<{ + text: string + truncated: boolean + reference?: PagedOutputReference + }> { + if (this.closed) { + throw new Error('分页输出已经关闭') + } + this.closed = true + closeSync(this.descriptor) + if (this.failure) { + await this.store.discard(this) + throw new Error('无法保存完整工具输出', { cause: this.failure }) + } + let previewLength = utf8PrefixLength(this.preview) + let text = this.preview.subarray(0, previewLength).toString('utf8') + // Tool results are JSON: escaped control characters also consume context. + if (Buffer.byteLength(JSON.stringify(text)) - 2 > this.previewBytes) { + let low = 0 + let high = previewLength + while (low < high) { + const middle = Math.ceil((low + high) / 2) + const candidate = utf8Prefix(this.preview.subarray(0, middle)) + if (Buffer.byteLength(JSON.stringify(candidate)) - 2 <= this.previewBytes) { + low = middle + } else { + high = middle - 1 + } + } + previewLength = utf8PrefixLength(this.preview, low) + text = this.preview.subarray(0, previewLength).toString('utf8') + } + if (this.totalBytes <= previewLength) { + await this.store.discard(this) + return { text, truncated: false } + } + this.store.retain(this, this.totalBytes) + return { + text, + truncated: true, + reference: { + handle: this.handle, + nextCursor: previewLength, + totalBytes: this.totalBytes + } + } + } + + async abort(): Promise { + if (!this.closed) { + this.closed = true + closeSync(this.descriptor) + } + await this.store.discard(this) + } +} + +function utf8PrefixLength(value: Buffer, end = value.byteLength): number { + if (end === 0) return 0 + let start = end - 1 + while (start > 0 && (value[start]! & 0xc0) === 0x80) start -= 1 + const lead = value[start]! + const width = lead >= 0xf0 ? 4 : lead >= 0xe0 ? 3 : lead >= 0xc0 ? 2 : 1 + return start + width > end ? start : end +} + +function utf8Prefix(value: Buffer): string { + return value.subarray(0, utf8PrefixLength(value)).toString('utf8') +} + +export class PagedOutputStore { + private readonly entries = new Map() + private readonly writers = new Set() + private directoryPromise?: Promise + private disposed = false + private disposePromise?: Promise + + constructor( + private readonly handlePrefix: string, + private readonly options: PagedOutputStoreOptions = {} + ) {} + + async create( + ownerId: string, + previewBytes: number + ): Promise { + if (this.disposed) { + throw new Error('分页输出存储已关闭') + } + const directory = await this.getDirectory() + if (this.disposed) throw new Error('分页输出存储已关闭') + const id = randomUUID() + const path = join(directory, id) + const descriptor = openSync(path, 'wx') + const writer = new PagedOutputWriter( + this, + `${this.handlePrefix}:${id}`, + ownerId, + path, + descriptor, + previewBytes + ) + this.writers.add(writer) + return writer + } + + async read( + ownerId: string, + handle: string, + cursor = 0, + limitBytes = PAGED_OUTPUT_DEFAULT_PAGE_BYTES + ): Promise { + const entry = this.entries.get(handle) + if (!entry || entry.ownerId !== ownerId) { + throw new Error('分页输出句柄不存在或不属于当前会话') + } + if (!Number.isSafeInteger(cursor) || cursor < 0 || cursor > entry.totalBytes) { + throw new Error('分页输出 cursor 无效') + } + if ( + !Number.isSafeInteger(limitBytes) || + limitBytes < 1 || + limitBytes > PAGED_OUTPUT_MAX_PAGE_BYTES + ) { + throw new Error(`分页输出每次最多读取 ${PAGED_OUTPUT_MAX_PAGE_BYTES} 字节`) + } + + const requestedBytes = Math.min(limitBytes + 3, entry.totalBytes - cursor) + const buffer = Buffer.alloc(requestedBytes) + const descriptor = openSync(entry.path, 'r') + let bytesRead: number + try { + bytesRead = readSync(descriptor, buffer, 0, requestedBytes, cursor) + } finally { + closeSync(descriptor) + } + if (bytesRead && (buffer[0]! & 0xc0) === 0x80) { + throw new Error('分页输出 cursor 必须位于 UTF-8 字符边界') + } + let consumedBytes = utf8PrefixLength(buffer, Math.min(limitBytes, bytesRead)) + // A tiny page still consumes one complete character (at most four bytes). + if (consumedBytes === 0 && bytesRead > 0) { + consumedBytes = utf8PrefixLength(buffer, Math.min(4, bytesRead)) + } + // Preserve forward progress even for an incomplete final UTF-8 sequence. + if (consumedBytes === 0) consumedBytes = bytesRead + const content = buffer.subarray(0, consumedBytes).toString('utf8') + const nextCursor = cursor + consumedBytes + return { + handle, + content, + cursor, + nextCursor, + totalBytes: entry.totalBytes, + eof: nextCursor >= entry.totalBytes + } + } + + async releaseOwner(ownerId: string): Promise { + await Promise.all([...this.writers] + .filter((writer) => writer.ownerId === ownerId) + .map((writer) => writer.abort())) + const owned = [...this.entries].filter( + ([, entry]) => entry.ownerId === ownerId + ) + for (const [handle] of owned) { + this.entries.delete(handle) + } + await Promise.allSettled( + owned.map(([, entry]) => rm(entry.path, { force: true })) + ) + } + + dispose(): Promise { + this.disposePromise ??= this.disposeOnce() + return this.disposePromise + } + + private async disposeOnce(): Promise { + this.disposed = true + await Promise.all([...this.writers].map((writer) => writer.abort())) + this.entries.clear() + if (this.directoryPromise) { + const directory = await this.directoryPromise.catch(() => undefined) + if (directory) { + await rm(directory, { recursive: true, force: true }) + } + } + } + + retain(writer: PagedOutputWriter, totalBytes: number): void { + this.writers.delete(writer) + this.entries.set(writer.handle, { + ownerId: writer.ownerId, + path: writer.path, + totalBytes + }) + } + + async discard(writer: PagedOutputWriter): Promise { + this.writers.delete(writer) + this.entries.delete(writer.handle) + await rm(writer.path, { force: true }) + } + + private getDirectory(): Promise { + this.directoryPromise ??= this.createDirectory() + return this.directoryPromise + } + + private async createDirectory(): Promise { + const parent = this.options.temporaryParentDirectory ?? tmpdir() + await mkdir(parent, { recursive: true }) + return await mkdtemp(join(parent, `goodbuddy-${this.handlePrefix}-`)) + } +} diff --git a/src/main/agent/runtime-selection.test.ts b/src/main/agent/runtime-selection.test.ts index 7c121cba..a4c47131 100644 --- a/src/main/agent/runtime-selection.test.ts +++ b/src/main/agent/runtime-selection.test.ts @@ -229,7 +229,7 @@ describe('runtime selection', () => { }) ).toEqual({ provider: 'opencode', - profileId: secondProfileId + profileId: defaultProfileId }) expect( resolveConfiguredAgentRuntimeSelection(configured, { diff --git a/src/main/agent/runtime-selection.ts b/src/main/agent/runtime-selection.ts index c0a6a89b..5b392435 100644 --- a/src/main/agent/runtime-selection.ts +++ b/src/main/agent/runtime-selection.ts @@ -49,6 +49,7 @@ export function resolveConfiguredAgentRuntimeSelection( settings: ResolvedRuntimeSettings, selection: AgentRuntimeSelection ): AgentRuntimeSelection { + if ('profileId' in selection && selection.profileId) return selection if ( selection.provider !== 'opencode' && selection.provider !== 'continue' && @@ -87,7 +88,7 @@ export function applyRuntimeSelection( } if (selection.provider === 'model') { - const profile = requireProfile(settings, selection.profileId) + const profile = requireProfile(settings, selection.profileId ?? settings.defaultModelProfileId) return { target: 'model', settings: { @@ -108,7 +109,11 @@ export function applyRuntimeSelection( const profile = selection.profileId ? requireProfile(settings, selection.profileId) - : undefined + : selection.provider === 'opencode' + ? settings.opencodeModelProfile + : selection.provider === 'continue' + ? settings.continueModelProfile + : settings.deepseekHarnessModelProfile if (selection.provider === 'opencode') { if (profile && !isAgentRuntimeModelProtocol(profile.protocol)) { throw new Error( diff --git a/src/main/agent/runtime.ts b/src/main/agent/runtime.ts index a78178ae..e852fec1 100644 --- a/src/main/agent/runtime.ts +++ b/src/main/agent/runtime.ts @@ -4,6 +4,7 @@ import type { AgentQuestionAnswer, AgentRequest, AgentRuntimeStatus, + BrowserTabId, RuntimeConversationCompactInput, RuntimeConversationCompactResult, RuntimeNativeSnapshot @@ -137,6 +138,8 @@ export type AgentExecutionRequest = Omit & { trustedInstructions?: string /** Main-process-only request-scoped authorization for built-in data tools. */ knowledgeCapabilityToken?: string + /** Main-process-only browser tab fixed for this request. */ + browserTabId?: BrowserTabId /** * Main-process recovery cursor for an Agent-owned semantic prompt. * It is never sent as prompt content and is ignored by local runtimes. diff --git a/src/main/browser/browser-model-tools.test.ts b/src/main/browser/browser-model-tools.test.ts index c97a355f..8820aa53 100644 --- a/src/main/browser/browser-model-tools.test.ts +++ b/src/main/browser/browser-model-tools.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { browserTabIdSchema } from '../../shared/contracts' import { BrowserModelTools, browserBackInputSchema, @@ -42,12 +43,16 @@ function createService(): BrowserToolService { const signal = new AbortController().signal const ref = 'b_abcdefghijklmnop' +const browserTabId = browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000101' +) describe('BrowserModelTools', () => { it('publishes seven strict, bounded builtin tool definitions', () => { const tools = new BrowserModelTools({ service: createService(), - conversationId: 'conversation' + conversationId: 'conversation', + browserTabId }) const definitions = tools.listTools() expect(definitions.map((definition) => definition.name)).toEqual([ @@ -93,7 +98,8 @@ describe('BrowserModelTools', () => { it('creates dynamic origin-scoped navigation approvals without exposing query values', () => { const tools = new BrowserModelTools({ service: createService(), - conversationId: 'conversation' + conversationId: 'conversation', + browserTabId }) const approval = tools.getApproval('browser_navigate', { url: 'https://example.com/path?token=top-secret' @@ -115,7 +121,8 @@ describe('BrowserModelTools', () => { const service = createService() const tools = new BrowserModelTools({ service, - conversationId: 'conversation' + conversationId: 'conversation', + browserTabId }) const first = tools.getApproval('browser_type', { ref, @@ -139,7 +146,8 @@ describe('BrowserModelTools', () => { 'conversation', ref, 'top-secret', - signal + signal, + browserTabId ) expect(JSON.stringify(result)).not.toContain('top-secret') expect(result.parts[0]).toMatchObject({ @@ -158,7 +166,8 @@ describe('BrowserModelTools', () => { const service = createService() const tools = new BrowserModelTools({ service, - conversationId: 'conversation' + conversationId: 'conversation', + browserTabId }) const navigate = await tools.callTool( 'browser_navigate', @@ -189,12 +198,40 @@ describe('BrowserModelTools', () => { }) await tools.release() expect(service.releaseConversation).toHaveBeenCalledWith('conversation') + expect(service.navigate).toHaveBeenCalledWith( + 'conversation', + 'https://example.com/', + signal, + browserTabId + ) + expect(service.snapshot).toHaveBeenCalledWith( + 'conversation', + signal, + browserTabId + ) + expect(service.click).toHaveBeenCalledWith( + 'conversation', + ref, + signal, + browserTabId + ) + expect(service.back).toHaveBeenCalledWith( + 'conversation', + signal, + browserTabId + ) + expect(service.screenshot).toHaveBeenCalledWith( + 'conversation', + signal, + browserTabId + ) }) it('rejects unknown tools, extra fields, malformed refs, and cancellation', async () => { const tools = new BrowserModelTools({ service: createService(), - conversationId: 'conversation' + conversationId: 'conversation', + browserTabId }) expect(() => tools.getApproval('browser_click', { ref, extra: true }) diff --git a/src/main/browser/browser-model-tools.ts b/src/main/browser/browser-model-tools.ts index cc84ed08..fd40022b 100644 --- a/src/main/browser/browser-model-tools.ts +++ b/src/main/browser/browser-model-tools.ts @@ -12,6 +12,7 @@ import { MAX_BROWSER_INPUT_LENGTH as MAX_INPUT_LENGTH, MAX_BROWSER_SELECT_LENGTH as MAX_SELECT_LENGTH } from './browser-limits' +import type { BrowserTabId } from '../../shared/contracts' const MAX_REF_LENGTH = 64 @@ -195,6 +196,7 @@ export type BrowserToolService = Pick< export type BrowserModelToolsOptions = { service: BrowserToolService conversationId: string + browserTabId: BrowserTabId } function createTextResult(value: unknown): ModelToolResult { @@ -218,10 +220,12 @@ function navigationLabel(url: URL): string { export class BrowserModelTools { private readonly service: BrowserToolService private readonly conversationId: string + private readonly browserTabId: BrowserTabId constructor(options: BrowserModelToolsOptions) { this.service = options.service this.conversationId = options.conversationId + this.browserTabId = options.browserTabId if (!this.conversationId || this.conversationId.length > 500) { throw new Error('浏览器对话标识无效') } @@ -249,7 +253,7 @@ export class BrowserModelTools { throw new Error(`未知浏览器工具:${name}`) } const currentOrigin = safeOrigin( - this.service.getOrigin(this.conversationId) + this.service.getOrigin(this.conversationId, this.browserTabId) ) let description: string let argumentSummary: string @@ -320,18 +324,32 @@ export class BrowserModelTools { if (name === 'browser_navigate') { const input = browserNavigateInputSchema.parse(argumentsValue) return createTextResult( - await this.service.navigate(this.conversationId, input.url, signal) + await this.service.navigate( + this.conversationId, + input.url, + signal, + this.browserTabId + ) ) } if (name === 'browser_snapshot') { browserSnapshotInputSchema.parse(argumentsValue) return createTextResult( - await this.service.snapshot(this.conversationId, signal) + await this.service.snapshot( + this.conversationId, + signal, + this.browserTabId + ) ) } if (name === 'browser_click') { const input = browserClickInputSchema.parse(argumentsValue) - await this.service.click(this.conversationId, input.ref, signal) + await this.service.click( + this.conversationId, + input.ref, + signal, + this.browserTabId + ) return createTextResult({ clicked: input.ref }) } if (name === 'browser_type') { @@ -340,7 +358,8 @@ export class BrowserModelTools { this.conversationId, input.ref, input.text, - signal + signal, + this.browserTabId ) return createTextResult({ typed: input.ref, @@ -354,20 +373,26 @@ export class BrowserModelTools { this.conversationId, input.ref, input.value, - signal + signal, + this.browserTabId ) return createTextResult({ selected: input.ref, value: '[已隐藏]' }) } if (name === 'browser_back') { browserBackInputSchema.parse(argumentsValue) return createTextResult( - await this.service.back(this.conversationId, signal) + await this.service.back( + this.conversationId, + signal, + this.browserTabId + ) ) } browserScreenshotInputSchema.parse(argumentsValue) const screenshot = await this.service.screenshot( this.conversationId, - signal + signal, + this.browserTabId ) return { parts: [ diff --git a/src/main/browser/browser-service.test.ts b/src/main/browser/browser-service.test.ts index ee9938ac..c7a9fca1 100644 --- a/src/main/browser/browser-service.test.ts +++ b/src/main/browser/browser-service.test.ts @@ -31,6 +31,8 @@ function deferred() { function createHarness(options: { maximumSessions?: number + maximumTabsPerConversation?: number + maximumTabsPerWindow?: number idleTimeoutMs?: number cleanupTimeoutMs?: number dispose?: () => Promise @@ -145,6 +147,8 @@ function createHarness(options: { const service = new BrowserService({ policy: new BrowserUrlPolicy(dnsResolver), maximumSessions: options.maximumSessions, + maximumTabsPerConversation: options.maximumTabsPerConversation, + maximumTabsPerWindow: options.maximumTabsPerWindow, idleTimeoutMs: options.idleTimeoutMs, cleanupTimeoutMs: options.cleanupTimeoutMs, liveFrameDelayMs: 0, @@ -165,12 +169,21 @@ afterEach(() => { }) describe('BrowserService', () => { + const firstLeaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2' + const secondLeaseToken = 'db8a2c28-43a6-4aab-93e0-1c01b9374dca' + const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251' + it('presents one session in the shared viewport and tracks user navigation', async () => { const harness = createHarness() const bounds = { x: 900, y: 120, width: 420, height: 640 } const states: BrowserLiveState[] = [] harness.service.onState((state) => states.push(state)) - harness.service.setViewport('conversation', bounds) + harness.service.setViewport( + 'conversation', + bounds, + undefined, + firstLeaseToken + ) await harness.service.navigate( 'conversation', @@ -196,7 +209,12 @@ describe('BrowserService', () => { 'https://example.com' ) - harness.service.setViewport() + harness.service.setViewport( + undefined, + undefined, + undefined, + firstLeaseToken + ) expect(slot?.session.setViewport).toHaveBeenLastCalledWith(undefined) await harness.service.dispose() }) @@ -222,6 +240,7 @@ describe('BrowserService', () => { expect(states.map((state) => state.status)).toEqual([ 'creating', + 'ready', 'loading', 'ready', 'acting', @@ -242,7 +261,7 @@ describe('BrowserService', () => { isLoading: false, canGoBack: false }) - expect(states.find((state) => state.status === 'ready')?.frameDataUrl).toBe( + expect(states.filter((state) => state.status === 'ready').at(-1)?.frameDataUrl).toBe( 'data:image/jpeg;base64,/9j/2Q==' ) expect(states.at(-1)?.frameDataUrl).toBeUndefined() @@ -435,6 +454,253 @@ describe('BrowserService', () => { await harness.service.dispose() }) + it('owns independent tabs inside one conversation and closes only the requested tab', async () => { + const harness = createHarness() + const signal = new AbortController().signal + await harness.service.navigate('conversation', 'https://first.example/', signal) + const [primary] = harness.service.listTabs('conversation', 11) + const sibling = await harness.service.createTab('conversation', 11, signal) + + await harness.service.navigate( + 'conversation', + 'https://second.example/', + signal, + sibling.tabId, + 11 + ) + await harness.service.snapshot( + 'conversation', + signal, + sibling.tabId, + 11 + ) + + expect(harness.service.getSessionCount()).toBe(1) + expect(harness.service.getTabCount('conversation')).toBe(2) + expect(harness.slots[0]?.driver.snapshot).not.toHaveBeenCalled() + expect(harness.slots[1]?.driver.snapshot).toHaveBeenCalledOnce() + await harness.service.closeTab('conversation', sibling.tabId, 11) + expect(harness.service.getTabCount('conversation')).toBe(1) + expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce() + expect(harness.slots[0]?.session.dispose).not.toHaveBeenCalled() + expect(harness.service.listTabs('conversation', 11)[0]?.tabId).toBe( + primary?.tabId + ) + await harness.service.dispose() + }) + + it('blocks closing a leased tab until every request releases it', async () => { + const harness = createHarness() + const signal = new AbortController().signal + const primary = await harness.service.createTab('conversation', 11, signal) + const sibling = await harness.service.createTab('conversation', 11, signal) + const first = harness.service.acquireTabUsage( + 'conversation', + primary.tabId, + 'request-one', + 11 + ) + const second = harness.service.acquireTabUsage( + 'conversation', + primary.tabId, + 'request-two', + 11 + ) + + await expect( + harness.service.closeTab('conversation', primary.tabId, 11) + ).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭') + await expect( + harness.service.closeTab('conversation', sibling.tabId, 11) + ).resolves.toBeUndefined() + first.release() + first.release() + await expect( + harness.service.closeTab('conversation', primary.tabId, 11) + ).rejects.toThrow('浏览器标签页正在被活动请求使用,无法关闭') + second.release() + await expect( + harness.service.closeTab('conversation', primary.tabId, 11) + ).resolves.toBeUndefined() + await harness.service.dispose() + }) + + it('reports only the visible tab owned by the requesting window', async () => { + const harness = createHarness() + const signal = new AbortController().signal + const primary = await harness.service.createTab('conversation', 21, signal) + const sibling = await harness.service.createTab('conversation', 21, signal) + + expect(harness.service.getVisibleTabId('conversation', 21)).toBeUndefined() + harness.service.setViewport( + 'conversation', + { x: 0, y: 0, width: 320, height: 480 }, + sibling.tabId, + firstLeaseToken, + 21 + ) + expect(harness.service.getVisibleTabId('conversation', 21)).toBe( + sibling.tabId + ) + expect(primary.tabId).not.toBe(sibling.tabId) + expect(() => harness.service.getVisibleTabId('conversation', 22)).toThrow( + '不属于当前窗口' + ) + await harness.service.dispose() + }) + + it('aborts active tab leases when a conversation is force-released', async () => { + const harness = createHarness() + const tab = await harness.service.createTab('conversation', 21) + const lease = harness.service.acquireTabUsage( + 'conversation', + tab.tabId, + 'request', + 21 + ) + + await harness.service.releaseConversation('conversation', 21) + + expect(lease.signal.aborted).toBe(true) + expect(harness.service.getTabCount('conversation')).toBe(0) + lease.release() + await harness.service.dispose() + }) + + it('enforces conversation/window ownership, tab limits, and opaque viewport leases', async () => { + const harness = createHarness() + const signal = new AbortController().signal + const primary = await harness.service.createTab('conversation', 21, signal) + const sibling = await harness.service.createTab('conversation', 21, signal) + const bounds = { x: 10, y: 20, width: 300, height: 400 } + + expect( + harness.service.setViewport( + 'conversation', + bounds, + sibling.tabId, + firstLeaseToken, + 21 + ) + ).toBe(true) + expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith( + bounds + ) + expect( + harness.service.setViewport( + undefined, + undefined, + undefined, + secondLeaseToken, + 21 + ) + ).toBe(false) + expect(harness.slots[1]?.session.setViewport).toHaveBeenLastCalledWith( + bounds + ) + await expect( + harness.service.navigate( + 'conversation', + 'https://example.com/', + signal, + primary.tabId, + 22 + ) + ).rejects.toThrow('不属于当前窗口') + await harness.service.dispose() + + const conversationLimited = createHarness({ + maximumTabsPerConversation: 2 + }) + await conversationLimited.service.createTab('limited', 31, signal) + await conversationLimited.service.createTab('limited', 31, signal) + await expect( + conversationLimited.service.createTab('limited', 31, signal) + ).rejects.toThrow('2 个上限') + await conversationLimited.service.dispose() + + const windowLimited = createHarness({ maximumTabsPerWindow: 2 }) + await windowLimited.service.createTab('first', 41, signal) + await windowLimited.service.createTab('second', 41, signal) + await expect( + windowLimited.service.createTab('third', 41, signal) + ).rejects.toThrow('2 个上限') + await windowLimited.service.dispose() + }) + + it('returns one live tab for concurrent requests from the same workbar instance', async () => { + const gate = deferred() + const harness = createHarness({ sessionGate: gate.promise }) + const signal = new AbortController().signal + + const first = harness.service.createTab( + 'conversation', + 21, + signal, + workbarInstanceId + ) + const duplicate = harness.service.createTab( + 'conversation', + 21, + signal, + workbarInstanceId + ) + await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce()) + gate.resolve() + + const [firstTab, duplicateTab] = await Promise.all([first, duplicate]) + expect(duplicateTab.tabId).toBe(firstTab.tabId) + expect(harness.service.getTabCount('conversation')).toBe(1) + expect(harness.service.getOwnerWindowId('conversation')).toBe(21) + await harness.service.dispose() + }) + + it('disposes a tab creation that resolves after its context is closed', async () => { + const harness = createHarness() + const signal = new AbortController().signal + const primary = await harness.service.createTab('conversation', 21, signal) + const gate = deferred() + const implementation = harness.createSession.getMockImplementation()! + harness.createSession.mockImplementationOnce(async (...args) => { + await gate.promise + return implementation(...args) + }) + + const pending = harness.service.createTab( + 'conversation', + 21, + signal, + 'c22e845b-4bc8-4246-9543-dd18dbf20782' + ) + await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledTimes(2)) + await harness.service.closeTab('conversation', primary.tabId, 21) + gate.resolve() + + await expect(pending).rejects.toThrow() + expect(harness.service.getSessionCount()).toBe(0) + expect(harness.slots[1]?.session.dispose).toHaveBeenCalledOnce() + await harness.service.dispose() + }) + + it('reserves window capacity while tab contexts are still being created', async () => { + const gate = deferred() + const harness = createHarness({ + maximumTabsPerWindow: 1, + sessionGate: gate.promise + }) + const signal = new AbortController().signal + const first = harness.service.createTab('first', 51, signal) + await vi.waitFor(() => expect(harness.createSession).toHaveBeenCalledOnce()) + + await expect( + harness.service.createTab('second', 51, signal) + ).rejects.toThrow('1 个上限') + expect(harness.createSession).toHaveBeenCalledOnce() + gate.resolve() + await first + await harness.service.dispose() + }) + it('enforces a hard maximum of three sessions', async () => { const harness = createHarness({ maximumSessions: 3 }) const signal = new AbortController().signal @@ -625,6 +891,42 @@ describe('BrowserService', () => { await harness.service.dispose() }) + it('does not expire a session while a serialized operation is active', async () => { + vi.useFakeTimers() + const harness = createHarness({ idleTimeoutMs: 100 }) + const signal = new AbortController().signal + await harness.service.navigate('conversation', 'https://a.example/', signal) + const gate = deferred() + vi.mocked(harness.slots[0]!.driver.click).mockImplementationOnce(async () => gate.promise) + + const click = harness.service.click('conversation', 'button_ref', signal) + await vi.advanceTimersByTimeAsync(500) + expect(harness.service.getSessionCount()).toBe(1) + gate.resolve() + await click + await vi.advanceTimersByTimeAsync(101) + expect(harness.service.getSessionCount()).toBe(0) + await harness.service.dispose() + }) + + it('ignores stale viewport cleanup tokens', async () => { + const harness = createHarness() + const tab = await harness.service.createTab('conversation', 21) + const bounds = { x: 1, y: 2, width: 300, height: 400 } + harness.service.setViewport('conversation', bounds, tab.tabId, firstLeaseToken, 21) + harness.service.setViewport('conversation', bounds, tab.tabId, secondLeaseToken, 21) + + expect( + harness.service.setViewport(undefined, undefined, undefined, firstLeaseToken, 21) + ).toBe(false) + expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(bounds) + expect( + harness.service.setViewport(undefined, undefined, undefined, secondLeaseToken, 21) + ).toBe(true) + expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined) + await harness.service.dispose() + }) + it('tracks an approved origin across validated back navigation', async () => { const harness = createHarness() const signal = new AbortController().signal @@ -905,10 +1207,19 @@ describe('BrowserService', () => { 'https://example.com/', new AbortController().signal ) + const [tab] = harness.service.listTabs('conversation') + harness.service.setViewport( + 'conversation', + { x: 1, y: 2, width: 300, height: 400 }, + tab!.tabId, + firstLeaseToken + ) await expect( harness.service.releaseConversation('conversation') ).rejects.toThrow('清理超时') expect(harness.service.getSessionCount()).toBe(0) + expect(harness.service.listTabs('conversation')).toEqual([]) + expect(harness.slots[0]?.session.setViewport).toHaveBeenLastCalledWith(undefined) await harness.service.releaseConversation('conversation') await harness.service.dispose() await harness.service.dispose() diff --git a/src/main/browser/browser-service.ts b/src/main/browser/browser-service.ts index 5ac5cc3c..63253156 100644 --- a/src/main/browser/browser-service.ts +++ b/src/main/browser/browser-service.ts @@ -1,3 +1,4 @@ +import { randomUUID } from 'node:crypto' import { BrowserUrlPolicy, canonicalizeBrowserUrl } from './browser-url-policy' import { CdpBrowserDriver, @@ -12,10 +13,16 @@ import { type BrowserWindowBounds, type BrowserWebContents } from './electron-browser-session' -import type { BrowserLiveState } from '../../shared/contracts' +import type { + BrowserLiveState, + BrowserTabId, + BrowserTabSummary +} from '../../shared/contracts' const DEFAULT_IDLE_TIMEOUT_MS = 10 * 60_000 const DEFAULT_CLEANUP_TIMEOUT_MS = 5_000 +const DEFAULT_MAXIMUM_TABS_PER_CONVERSATION = 8 +const DEFAULT_MAXIMUM_TABS_PER_WINDOW = 16 export class BrowserNavigationStoppedError extends Error { constructor() { @@ -24,11 +31,23 @@ export class BrowserNavigationStoppedError extends Error { } } +export const BROWSER_TAB_IN_USE_ERROR = + '浏览器标签页正在被活动请求使用,无法关闭' + +export type BrowserTabUsageLease = { + readonly conversationId: string + readonly tabId: BrowserTabId + readonly owner: string + readonly signal: AbortSignal + release(): void +} + export type BrowserSessionLike = { readonly webContents: BrowserWebContents approveNavigation( target: Awaited> ): void + createTab?(signal: AbortSignal): Promise getCurrentOrigin(): string | undefined isLoading(): boolean onLoadingChange(listener: (isLoading: boolean) => void): () => void @@ -61,6 +80,8 @@ export type BrowserDriverLike = { export type BrowserServiceOptions = { policy?: BrowserUrlPolicy maximumSessions?: number + maximumTabsPerConversation?: number + maximumTabsPerWindow?: number idleTimeoutMs?: number cleanupTimeoutMs?: number liveFrameDelayMs?: number @@ -72,29 +93,65 @@ export type BrowserServiceOptions = { createDriver?: (webContents: BrowserWebContents) => BrowserDriverLike } -type BrowserSlot = { +type ActiveOperation = { + controller: AbortController + stopLoadingAllowed: boolean + status: 'loading' | 'acting' | 'interactive' +} + +type BrowserTabSlot = { conversationId: string + tabId: BrowserTabId session: BrowserSessionLike driver: BrowserDriverLike + createdAt: number origin?: string tail: Promise - active?: { - controller: AbortController - stopLoadingAllowed: boolean - status: 'loading' | 'acting' | 'interactive' - } + active?: ActiveOperation isLoading: boolean stopLoadingRequested: boolean removeLoadingListener: () => void removeNavigationListener: () => void + released: boolean + workbarInstanceId?: string + usageLeases: Map +} + +type BrowserConversationSlot = { + conversationId: string + ownerWindowId?: number + tabs: Map + primaryTabId: BrowserTabId + tabCreationTail: Promise + lifecycle: AbortController + ownedTabs: Map idleTimer?: ReturnType lastUsedAt: number released: boolean } -type SlotCreation = { +type ConversationCreation = { controller: AbortController - promise: Promise + promise: Promise + waiters: Set + ownerWindowId?: number +} + +type ViewportLease = { + leaseToken: string + ownerWindowId?: number + conversationId?: string + tabId?: BrowserTabId + bounds?: BrowserWindowBounds +} + +type OwnedTabCreation = { + conversationId: string + controller: AbortController + promise: Promise waiters: Set } @@ -132,9 +189,7 @@ async function boundedCleanup( }) ]) } finally { - if (timer) { - clearTimeout(timer) - } + if (timer) clearTimeout(timer) } } @@ -144,10 +199,7 @@ async function defaultCreateSession( parentWindow?: BrowserParentWindowHandle ): Promise { return ElectronBrowserSession.create( - { - policy, - ...(parentWindow ? { parentWindow } : {}) - }, + { policy, ...(parentWindow ? { parentWindow } : {}) }, signal ) } @@ -156,27 +208,37 @@ function defaultCreateDriver(webContents: BrowserWebContents): BrowserDriverLike return new CdpBrowserDriver(webContents) } +function createTabId(): BrowserTabId { + return randomUUID() as BrowserTabId +} + +function stateKey(conversationId: string, tabId: BrowserTabId): string { + return `${conversationId}\u0000${tabId}` +} + +function ownershipKey(conversationId: string, workbarInstanceId: string): string { + return `${conversationId}\u0000${workbarInstanceId}` +} + export class BrowserService { private readonly policy: BrowserUrlPolicy private readonly maximumSessions: number + private readonly maximumTabsPerConversation: number + private readonly maximumTabsPerWindow: number private readonly idleTimeoutMs: number private readonly cleanupTimeoutMs: number private readonly liveFrameDelayMs: number - private readonly createSession: NonNullable< - BrowserServiceOptions['createSession'] - > - private readonly createDriver: NonNullable< - BrowserServiceOptions['createDriver'] - > - private readonly slots = new Map() - private readonly creations = new Map() + private readonly createSession: NonNullable + private readonly createDriver: NonNullable + private readonly conversations = new Map() + private readonly creations = new Map() private readonly releaseRequests = new Set() - private readonly stateListeners = new Set< - (state: BrowserLiveState) => void - >() + private readonly pendingConversationTabs = new Map() + private readonly pendingWindowTabs = new Map() + private readonly stateListeners = new Set<(state: BrowserLiveState) => void>() private readonly liveStates = new Map() - private visibleConversationId?: string - private viewportBounds?: BrowserWindowBounds + private readonly ownedTabCreations = new Map() + private viewport?: ViewportLease private lifecycle = new AbortController() private clearOperation?: Promise private clearing = false @@ -185,547 +247,694 @@ export class BrowserService { constructor(options: BrowserServiceOptions = {}) { this.policy = options.policy ?? new BrowserUrlPolicy() this.maximumSessions = options.maximumSessions ?? 3 + this.maximumTabsPerConversation = + options.maximumTabsPerConversation ?? DEFAULT_MAXIMUM_TABS_PER_CONVERSATION + this.maximumTabsPerWindow = + options.maximumTabsPerWindow ?? DEFAULT_MAXIMUM_TABS_PER_WINDOW this.idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS - this.cleanupTimeoutMs = - options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS + this.cleanupTimeoutMs = options.cleanupTimeoutMs ?? DEFAULT_CLEANUP_TIMEOUT_MS this.liveFrameDelayMs = options.liveFrameDelayMs ?? 100 this.createSession = options.createSession ?? - ((policy, signal) => - defaultCreateSession(policy, signal, options.parentWindow)) + ((policy, signal) => defaultCreateSession(policy, signal, options.parentWindow)) this.createDriver = options.createDriver ?? defaultCreateDriver - if ( - !Number.isSafeInteger(this.maximumSessions) || - this.maximumSessions < 1 || - !Number.isSafeInteger(this.idleTimeoutMs) || - this.idleTimeoutMs < 1 || - !Number.isSafeInteger(this.cleanupTimeoutMs) || - this.cleanupTimeoutMs < 1 || - !Number.isSafeInteger(this.liveFrameDelayMs) || - this.liveFrameDelayMs < 0 - ) { + for (const value of [ + this.maximumSessions, + this.maximumTabsPerConversation, + this.maximumTabsPerWindow, + this.idleTimeoutMs, + this.cleanupTimeoutMs + ]) { + if (!Number.isSafeInteger(value) || value < 1) { + throw new Error('浏览器服务限制配置无效') + } + } + if (!Number.isSafeInteger(this.liveFrameDelayMs) || this.liveFrameDelayMs < 0) { throw new Error('浏览器服务限制配置无效') } } - getOrigin(conversationId: string): string | undefined { - return this.slots.get(conversationId)?.origin + getOrigin(conversationId: string, tabId?: BrowserTabId): string | undefined { + const conversation = this.conversations.get(conversationId) + return conversation?.tabs.get(tabId ?? conversation.primaryTabId)?.origin } getSessionCount(): number { - return this.slots.size + return this.conversations.size + } + + getTabCount(conversationId?: string): number { + if (conversationId) return this.conversations.get(conversationId)?.tabs.size ?? 0 + let count = 0 + for (const conversation of this.conversations.values()) count += conversation.tabs.size + return count + } + + getOwnerWindowId(conversationId: string): number | undefined { + return this.conversations.get(conversationId)?.ownerWindowId ?? + this.creations.get(conversationId)?.ownerWindowId } onState(listener: (state: BrowserLiveState) => void): () => void { this.stateListeners.add(listener) - for (const state of this.liveStates.values()) { - listener(state) - } - return () => { - this.stateListeners.delete(listener) - } + for (const state of this.liveStates.values()) listener(state) + return () => this.stateListeners.delete(listener) } setViewport( conversationId?: string, - bounds?: BrowserWindowBounds - ): void { - this.visibleConversationId = conversationId - this.viewportBounds = bounds - for (const [id, slot] of this.slots) { - slot.session.setViewport?.( - id === conversationId ? bounds : undefined - ) + bounds?: BrowserWindowBounds, + tabId?: BrowserTabId, + leaseToken?: string, + ownerWindowId?: number + ): boolean { + if (!leaseToken) { + throw new Error('浏览器视口租约无效') + } + if (!conversationId && this.viewport?.leaseToken !== leaseToken) return false + if (!conversationId && ownerWindowId !== undefined && this.viewport?.ownerWindowId !== ownerWindowId) return false + let effectiveTabId = tabId + if (conversationId && bounds) { + const conversation = this.conversations.get(conversationId) + if (conversation) { + this.assertWindowOwner(conversation, ownerWindowId) + effectiveTabId ??= conversation.primaryTabId + if (!conversation.tabs.has(effectiveTabId)) { + throw new Error('浏览器标签页不存在或不属于当前对话') + } + } + } else if (conversationId || bounds || tabId) { + throw new Error('浏览器视口参数不完整') + } + this.viewport = { + leaseToken, + ...(ownerWindowId !== undefined ? { ownerWindowId } : {}), + ...(conversationId ? { conversationId } : {}), + ...(effectiveTabId ? { tabId: effectiveTabId } : {}), + ...(bounds ? { bounds } : {}) + } + this.applyViewport() + return true + } + + private applyViewport(): void { + for (const conversation of this.conversations.values()) { + for (const tab of conversation.tabs.values()) { + const visible = + this.viewport?.conversationId === conversation.conversationId && + (this.viewport.tabId ?? conversation.primaryTabId) === tab.tabId + tab.session.setViewport?.(visible ? this.viewport?.bounds : undefined) + } } } private emitState( - conversationId: string, + tab: Pick, status: BrowserLiveState['status'], - update: Partial< - Pick< - BrowserLiveState, - 'url' | 'frameDataUrl' | 'error' | 'canGoBack' | 'isLoading' - > - > = {} + update: Partial> = {} ): void { - const previous = this.liveStates.get(conversationId) - const slot = this.slots.get(conversationId) + const key = stateKey(tab.conversationId, tab.tabId) + const previous = this.liveStates.get(key) + const current = this.conversations.get(tab.conversationId)?.tabs.get(tab.tabId) const state: BrowserLiveState = { - conversationId, + conversationId: tab.conversationId, + tabId: tab.tabId, + ...(current + ? this.conversations.get(tab.conversationId)?.ownerWindowId !== undefined + ? { ownerWindowId: this.conversations.get(tab.conversationId)!.ownerWindowId } + : {} + : previous?.ownerWindowId !== undefined + ? { ownerWindowId: previous.ownerWindowId } + : this.creations.get(tab.conversationId)?.ownerWindowId !== undefined + ? { ownerWindowId: this.creations.get(tab.conversationId)!.ownerWindowId } + : {}), status, - sessionActive: - status !== 'creating' && - status !== 'stopped' && - Boolean(slot && !slot.released), + sessionActive: status !== 'creating' && status !== 'stopped' && Boolean(current && !current.released), isLoading: update.isLoading ?? - (status === 'creating' - ? true - : status === 'stopped' - ? false - : slot?.isLoading ?? false), + (status === 'creating' ? true : status === 'stopped' ? false : current?.isLoading ?? false), canGoBack: update.canGoBack ?? previous?.canGoBack ?? false, ...(previous?.url ? { url: previous.url } : {}), - ...(status !== 'stopped' && previous?.frameDataUrl - ? { frameDataUrl: previous.frameDataUrl } - : {}), + ...(status !== 'stopped' && previous?.frameDataUrl ? { frameDataUrl: previous.frameDataUrl } : {}), ...update, updatedAt: Date.now() } - if (status !== 'failed') { - delete state.error - } - if (status === 'stopped') { - this.liveStates.delete(conversationId) - } else { - this.liveStates.set(conversationId, state) - } + if (status !== 'failed') delete state.error + if (status === 'stopped') this.liveStates.delete(key) + else this.liveStates.set(key, state) for (const listener of this.stateListeners) { try { listener(state) } catch { - // A UI observer must not interrupt browser control. + // UI observers cannot interrupt browser control. } } } - private async captureFrame( - conversationId: string, - slot: BrowserSlot, - signal: AbortSignal, - url?: string, - screenshot?: BrowserScreenshot - ): Promise { - let committedUrl = url - let canGoBack = this.liveStates.get(conversationId)?.canGoBack ?? false - try { - const metadata = await slot.driver.getNavigationMetadata(signal) - const currentTarget = canonicalizeBrowserUrl(metadata.url) - if (slot.session.getCurrentOrigin() !== currentTarget.origin) { - throw new Error('浏览器当前页面来源不一致') - } - slot.origin = currentTarget.origin - committedUrl = currentTarget.href - canGoBack = metadata.canGoBack - } catch { - signal.throwIfAborted() - } - let frame = screenshot - if (!frame) { - if (this.liveFrameDelayMs > 0) { - await waitFor( - new Promise((resolve) => - setTimeout(resolve, this.liveFrameDelayMs) - ), - signal - ) - } - const captureDeadline = AbortSignal.any([ - signal, - AbortSignal.timeout(6_000) - ]) - for (let attempt = 0; attempt < 3 && !frame; attempt += 1) { - if (attempt > 0) { - try { - await waitFor( - new Promise((resolve) => - setTimeout(resolve, attempt * 150) - ), - captureDeadline - ) - } catch { - signal.throwIfAborted() - break - } - } - if (slot.session.captureScreenshot) { - try { - frame = await slot.session.captureScreenshot( - AbortSignal.any([ - captureDeadline, - AbortSignal.timeout(1_500) - ]) - ) - } catch { - signal.throwIfAborted() - } - } - if (!frame && !captureDeadline.aborted) { - try { - frame = await slot.driver.screenshot( - AbortSignal.any([ - captureDeadline, - AbortSignal.timeout(1_500) - ]) - ) - } catch { - signal.throwIfAborted() - } - } - } - } - signal.throwIfAborted() - if (slot.released || this.slots.get(conversationId) !== slot) { - return - } - if (!frame) { - const previousFrame = - this.liveStates.get(conversationId)?.frameDataUrl - if (previousFrame) { - this.emitState(conversationId, 'ready', { - ...(committedUrl ? { url: committedUrl } : {}), - canGoBack, - frameDataUrl: previousFrame - }) - return - } - this.emitState(conversationId, 'failed', { - ...(committedUrl ? { url: committedUrl } : {}), - canGoBack, - error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作' - }) - return - } - this.emitState(conversationId, 'ready', { - ...(committedUrl ? { url: committedUrl } : {}), - canGoBack, - frameDataUrl: `data:${frame.mimeType};base64,${frame.data}` - }) - } - - private emitFailure( - conversationId: string, - stage: string, - error: unknown + private assertWindowOwner( + conversation: BrowserConversationSlot, + ownerWindowId?: number ): void { - const detail = - error instanceof Error && error.message - ? error.message.slice(0, 180) - : '未知错误' - this.emitState(conversationId, 'failed', { - error: `${stage}失败:${detail}`.slice(0, 240) - }) + if (ownerWindowId === undefined) return + if (conversation.ownerWindowId === undefined) { + conversation.ownerWindowId = ownerWindowId + return + } + if (conversation.ownerWindowId !== ownerWindowId) { + throw new Error('浏览器对话不属于当前窗口') + } } - private shouldEmitFailure( + private countWindowTabs(ownerWindowId: number): number { + let count = 0 + for (const conversation of this.conversations.values()) { + if (conversation.ownerWindowId === ownerWindowId) count += conversation.tabs.size + } + return count + } + + private assertTabCapacity( + conversation: BrowserConversationSlot | undefined, + ownerWindowId?: number + ): void { + if ( + conversation && + conversation.tabs.size + + (this.pendingConversationTabs.get(conversation.conversationId) ?? 0) >= + this.maximumTabsPerConversation + ) { + throw new Error(`当前对话的浏览器标签页已达到 ${this.maximumTabsPerConversation} 个上限`) + } + if ( + ownerWindowId !== undefined && + this.countWindowTabs(ownerWindowId) + + (this.pendingWindowTabs.get(ownerWindowId) ?? 0) >= + this.maximumTabsPerWindow + ) { + throw new Error(`当前窗口的浏览器标签页已达到 ${this.maximumTabsPerWindow} 个上限`) + } + } + + private reserveTab(conversationId: string, ownerWindowId?: number): () => void { + const conversation = this.conversations.get(conversationId) + this.assertTabCapacity(conversation, ownerWindowId) + this.pendingConversationTabs.set( + conversationId, + (this.pendingConversationTabs.get(conversationId) ?? 0) + 1 + ) + if (ownerWindowId !== undefined) { + this.pendingWindowTabs.set( + ownerWindowId, + (this.pendingWindowTabs.get(ownerWindowId) ?? 0) + 1 + ) + } + return () => { + const conversationPending = + (this.pendingConversationTabs.get(conversationId) ?? 1) - 1 + if (conversationPending > 0) { + this.pendingConversationTabs.set(conversationId, conversationPending) + } else { + this.pendingConversationTabs.delete(conversationId) + } + if (ownerWindowId !== undefined) { + const windowPending = + (this.pendingWindowTabs.get(ownerWindowId) ?? 1) - 1 + if (windowPending > 0) { + this.pendingWindowTabs.set(ownerWindowId, windowPending) + } else { + this.pendingWindowTabs.delete(ownerWindowId) + } + } + } + } + + private async createTabSlot( + conversation: BrowserConversationSlot | undefined, + conversationId: string, + session: BrowserSessionLike, + tabId: BrowserTabId, + workbarInstanceId?: string + ): Promise { + let driver: BrowserDriverLike + try { + driver = this.createDriver(session.webContents) + } catch (error) { + await boundedCleanup(session.dispose(), this.cleanupTimeoutMs).catch(() => undefined) + throw error + } + const tab: BrowserTabSlot = { + conversationId, + tabId, + session, + driver, + createdAt: Date.now(), + tail: Promise.resolve(), + isLoading: session.isLoading(), + stopLoadingRequested: false, + removeLoadingListener: () => undefined, + removeNavigationListener: () => undefined, + released: false, + usageLeases: new Map(), + ...(workbarInstanceId ? { workbarInstanceId } : {}) + } + tab.removeLoadingListener = session.onLoadingChange((isLoading) => { + if (!isLoading) tab.stopLoadingRequested = false + if (tab.released || tab.isLoading === isLoading || !this.hasTab(tab)) return + tab.isLoading = isLoading + const current = this.liveStates.get(stateKey(conversationId, tabId)) + if (current) { + const status = tab.active?.status ?? (isLoading ? 'loading' : current.status === 'loading' ? 'ready' : current.status) + this.emitState(tab, status, { isLoading }) + } + }) + tab.removeNavigationListener = + session.onNavigationChange?.((url) => { + if (!this.hasTab(tab)) return + try { + const target = canonicalizeBrowserUrl(url) + tab.origin = target.origin + this.touchConversation(conversationId) + this.emitState(tab, tab.active?.status ?? 'ready', { url: target.href }) + void tab.driver + .getNavigationMetadata(AbortSignal.timeout(2_000)) + .then((metadata) => { + if (!this.hasTab(tab) || canonicalizeBrowserUrl(metadata.url).href !== target.href) return + this.emitState(tab, tab.active?.status ?? 'ready', { + url: target.href, + canGoBack: metadata.canGoBack + }) + }) + .catch(() => undefined) + } catch { + // Unsupported top-level URLs are rejected by the session. + } + }) ?? (() => undefined) + if (conversation) conversation.tabs.set(tabId, tab) + return tab + } + + private hasTab(tab: BrowserTabSlot): boolean { + return !tab.released && this.conversations.get(tab.conversationId)?.tabs.get(tab.tabId) === tab + } + + private async getOrCreateConversation( conversationId: string, signal: AbortSignal, - error: unknown - ): boolean { - if (error instanceof BrowserNavigationStoppedError) { - return false - } - if (signal.aborted || this.releaseRequests.has(conversationId)) { - return false - } - const message = error instanceof Error ? error.message : '' - return ![ - '浏览器会话已释放', - '浏览器会话已清除', - '浏览器会话已关闭', - '浏览器服务已关闭' - ].some((reason) => message.includes(reason)) - } - - private async getOrCreateSlot( - conversationId: string, - signal: AbortSignal - ): Promise { + ownerWindowId?: number + ): Promise { if (this.disposed || this.clearing || this.lifecycle.signal.aborted) { throw new Error('浏览器服务已关闭') } - const existing = this.slots.get(conversationId) + const existing = this.conversations.get(conversationId) if (existing && !existing.released) { + this.assertWindowOwner(existing, ownerWindowId) return existing } let creation = this.creations.get(conversationId) - const pendingCreations = [...this.creations.keys()].filter( - (id) => !this.slots.has(id) - ).length - if ( - !creation && - this.slots.size + pendingCreations >= this.maximumSessions - ) { + const pendingCreations = [...this.creations.keys()].filter((id) => !this.conversations.has(id)).length + if (!creation && this.conversations.size + pendingCreations >= this.maximumSessions) { throw new Error(`浏览器会话已达到 ${this.maximumSessions} 个上限`) } + if (creation && ownerWindowId !== undefined && creation.ownerWindowId !== undefined && creation.ownerWindowId !== ownerWindowId) { + throw new Error('浏览器对话不属于当前窗口') + } if (!creation) { + const releaseReservation = this.reserveTab(conversationId, ownerWindowId) const controller = new AbortController() const waiters = new Set() - const promise = this.createSlot( + const tabId = createTabId() + const promise = this.createConversation( conversationId, + tabId, + ownerWindowId, AbortSignal.any([this.lifecycle.signal, controller.signal]), () => waiters.size > 0 ) - const currentCreation = { controller, promise, waiters } - creation = currentCreation + void promise.finally(releaseReservation).catch(() => undefined) + creation = { controller, promise, waiters, ownerWindowId } this.creations.set(conversationId, creation) - const removeCreation = (): void => { - if (this.creations.get(conversationId) === creation) { - this.creations.delete(conversationId) - } + this.emitState({ conversationId, tabId }, 'creating') + const current = creation + const remove = (): void => { + if (this.creations.get(conversationId) === current) this.creations.delete(conversationId) } - void promise.then(removeCreation, removeCreation) + void promise.then(remove, remove) } const waiter = Symbol(conversationId) creation.waiters.add(waiter) try { - return await waitFor(creation.promise, signal) + const result = await waitFor(creation.promise, signal) + this.assertWindowOwner(result, ownerWindowId) + return result } finally { creation.waiters.delete(waiter) - if ( - creation.waiters.size === 0 && - this.creations.get(conversationId) === creation - ) { + if (creation.waiters.size === 0 && this.creations.get(conversationId) === creation) { this.creations.delete(conversationId) creation.controller.abort(new Error('浏览器会话创建已取消')) } } } - private async createSlot( + private async createConversation( conversationId: string, + tabId: BrowserTabId, + ownerWindowId: number | undefined, signal: AbortSignal, hasWaiters: () => boolean - ): Promise { - const session = await this.createSession( - this.policy, - signal - ) - if ( - this.disposed || - signal.aborted || - !hasWaiters() || - this.releaseRequests.has(conversationId) - ) { + ): Promise { + const session = await this.createSession(this.policy, signal) + if (this.disposed || signal.aborted || !hasWaiters() || this.releaseRequests.has(conversationId)) { await boundedCleanup(session.dispose(), this.cleanupTimeoutMs) throw new Error('浏览器服务已关闭') } - let driver: BrowserDriverLike - try { - driver = this.createDriver(session.webContents) - } catch (error) { - await boundedCleanup(session.dispose(), this.cleanupTimeoutMs).catch( - () => undefined - ) - throw error - } - const slot: BrowserSlot = { + const conversation: BrowserConversationSlot = { conversationId, - session, - driver, - tail: Promise.resolve(), - isLoading: session.isLoading(), - stopLoadingRequested: false, - removeLoadingListener: () => undefined, - removeNavigationListener: () => undefined, + ...(ownerWindowId !== undefined ? { ownerWindowId } : {}), + tabs: new Map(), + primaryTabId: tabId, + tabCreationTail: Promise.resolve(), + lifecycle: new AbortController(), + ownedTabs: new Map(), lastUsedAt: Date.now(), released: false } - this.slots.set(conversationId, slot) - slot.session.setViewport?.( - this.visibleConversationId === conversationId - ? this.viewportBounds - : undefined - ) - slot.removeLoadingListener = session.onLoadingChange((isLoading) => { - if (!isLoading) { - slot.stopLoadingRequested = false + const tab = await this.createTabSlot(undefined, conversationId, session, tabId) + conversation.tabs.set(tabId, tab) + this.conversations.set(conversationId, conversation) + this.emitState(tab, 'ready') + this.scheduleIdleExpiry(conversation) + this.applyViewport() + return conversation + } + + async createTab( + conversationId: string, + ownerWindowId?: number, + signal: AbortSignal = new AbortController().signal, + workbarInstanceId?: string + ): Promise { + if (workbarInstanceId) { + const key = ownershipKey(conversationId, workbarInstanceId) + const existingConversation = this.conversations.get(conversationId) + const existingTabId = existingConversation?.ownedTabs.get(workbarInstanceId) + const existingTab = existingTabId ? existingConversation?.tabs.get(existingTabId) : undefined + if (existingTab && !existingTab.released) return this.summarizeTab(existingTab) + let creation = this.ownedTabCreations.get(key) + if (!creation) { + const controller = new AbortController() + const waiters = new Set() + const promise = this.createTabInternal( + conversationId, + ownerWindowId, + AbortSignal.any([this.lifecycle.signal, controller.signal]), + workbarInstanceId + ) + creation = { conversationId, controller, promise, waiters } + this.ownedTabCreations.set(key, creation) + const current = creation + void promise.finally(() => { + if (this.ownedTabCreations.get(key) === current) this.ownedTabCreations.delete(key) + }).catch(() => undefined) + } + const waiter = Symbol(workbarInstanceId) + creation.waiters.add(waiter) + try { + return await waitFor(creation.promise, signal) + } finally { + creation.waiters.delete(waiter) + if (creation.waiters.size === 0 && this.ownedTabCreations.get(key) === creation) { + creation.controller.abort(new Error('浏览器标签页创建已取消')) + } + } + } + return this.createTabInternal(conversationId, ownerWindowId, signal) + } + + private async createTabInternal( + conversationId: string, + ownerWindowId: number | undefined, + signal: AbortSignal, + workbarInstanceId?: string + ): Promise { + const existed = this.conversations.has(conversationId) || this.creations.has(conversationId) + const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId) + if (workbarInstanceId) { + const ownedTabId = conversation.ownedTabs.get(workbarInstanceId) + const ownedTab = ownedTabId ? conversation.tabs.get(ownedTabId) : undefined + if (ownedTab && !ownedTab.released) return this.summarizeTab(ownedTab) + const primary = conversation.tabs.get(conversation.primaryTabId) + if (primary && !primary.workbarInstanceId && !existed) { + primary.workbarInstanceId = workbarInstanceId + conversation.ownedTabs.set(workbarInstanceId, primary.tabId) + return this.summarizeTab(primary) + } + } else if (!existed) { + return this.summarizeTab(conversation.tabs.get(conversation.primaryTabId)!) + } + this.assertWindowOwner(conversation, ownerWindowId) + const releaseReservation = this.reserveTab(conversationId, ownerWindowId) + let releaseGate!: () => void + const gate = new Promise((resolve) => { releaseGate = resolve }) + const predecessor = conversation.tabCreationTail.catch(() => undefined) + conversation.tabCreationTail = predecessor.then(() => gate) + try { + await waitFor(predecessor, signal) + const primary = conversation.tabs.get(conversation.primaryTabId) + if (!primary || conversation.released) throw new Error('浏览器会话已关闭') + const effectiveSignal = AbortSignal.any([signal, this.lifecycle.signal, conversation.lifecycle.signal]) + const session = primary.session.createTab + ? await primary.session.createTab(effectiveSignal) + : await this.createSession(this.policy, effectiveSignal) + if (effectiveSignal.aborted || conversation.released || this.conversations.get(conversationId) !== conversation) { + await boundedCleanup(session.dispose(), this.cleanupTimeoutMs) + throw effectiveSignal.reason ?? new Error('浏览器会话已关闭') + } + const tabId = createTabId() + this.emitState({ conversationId, tabId }, 'creating') + const tab = await this.createTabSlot(conversation, conversationId, session, tabId, workbarInstanceId) + if (workbarInstanceId) conversation.ownedTabs.set(workbarInstanceId, tabId) + this.touchConversation(conversationId) + this.emitState(tab, 'ready') + this.applyViewport() + return this.summarizeTab(tab) + } finally { + releaseGate() + releaseReservation() + } + } + + listTabs(conversationId: string, ownerWindowId?: number): BrowserTabSummary[] { + const conversation = this.conversations.get(conversationId) + if (!conversation || conversation.released) return [] + this.assertWindowOwner(conversation, ownerWindowId) + return [...conversation.tabs.values()].map((tab) => this.summarizeTab(tab)) + } + + getVisibleTabId( + conversationId: string, + ownerWindowId?: number + ): BrowserTabId | undefined { + const conversation = this.conversations.get(conversationId) + if (!conversation || conversation.released) return undefined + this.assertWindowOwner(conversation, ownerWindowId) + if ( + this.viewport?.conversationId !== conversationId || + (ownerWindowId !== undefined && + this.viewport.ownerWindowId !== ownerWindowId) + ) { + return undefined + } + const tabId = this.viewport.tabId ?? conversation.primaryTabId + return conversation.tabs.has(tabId) ? tabId : undefined + } + + acquireTabUsage( + conversationId: string, + tabId: BrowserTabId, + owner: string, + ownerWindowId?: number + ): BrowserTabUsageLease { + if (!owner || owner.length > 500) { + throw new Error('浏览器标签页使用租约所有者无效') + } + const conversation = this.conversations.get(conversationId) + if (!conversation || conversation.released) { + throw new Error('浏览器标签页不存在或不属于当前对话') + } + this.assertWindowOwner(conversation, ownerWindowId) + const tab = conversation.tabs.get(tabId) + if (!tab || tab.released) { + throw new Error('浏览器标签页不存在或不属于当前对话') + } + const token = Symbol(owner) + const controller = new AbortController() + tab.usageLeases.set(token, { owner, controller }) + if (conversation.idleTimer) { + clearTimeout(conversation.idleTimer) + conversation.idleTimer = undefined + } + let released = false + return { + conversationId, + tabId, + owner, + signal: controller.signal, + release: (): void => { + if (released) return + released = true + if (tab.usageLeases.delete(token) && this.hasTab(tab)) { + this.touchConversation(conversationId) + } + } + } + } + + private summarizeTab(tab: BrowserTabSlot): BrowserTabSummary { + const state = this.liveStates.get(stateKey(tab.conversationId, tab.tabId)) + return { + conversationId: tab.conversationId, + tabId: tab.tabId, + primary: this.conversations.get(tab.conversationId)?.primaryTabId === tab.tabId, + status: + state?.status && state.status !== 'stopped' ? state.status : 'ready', + isLoading: tab.isLoading, + canGoBack: state?.canGoBack ?? false, + ...(state?.url ? { url: state.url } : {}), + createdAt: tab.createdAt, + updatedAt: state?.updatedAt ?? tab.createdAt + } + } + + async closeTab(conversationId: string, tabId: BrowserTabId, ownerWindowId?: number): Promise { + const conversation = this.conversations.get(conversationId) + if (!conversation || conversation.released) throw new Error('浏览器标签页不存在或不属于当前对话') + this.assertWindowOwner(conversation, ownerWindowId) + const tab = conversation.tabs.get(tabId) + if (!tab) throw new Error('浏览器标签页不存在或不属于当前对话') + if (tab.usageLeases.size > 0) throw new Error(BROWSER_TAB_IN_USE_ERROR) + await this.releaseTab(conversation, tab) + } + + private requireTab(conversationId: string, tabId?: BrowserTabId, ownerWindowId?: number): BrowserTabSlot { + if (this.disposed) throw new Error('浏览器服务已关闭') + const conversation = this.conversations.get(conversationId) + if (!conversation || conversation.released) throw new Error('当前对话尚未建立浏览器会话,请先导航') + this.assertWindowOwner(conversation, ownerWindowId) + const tab = conversation.tabs.get(tabId ?? conversation.primaryTabId) + if (!tab || tab.released || !tab.origin) throw new Error('当前浏览器标签页尚未导航') + return tab + } + + private scheduleIdleExpiry(conversation: BrowserConversationSlot): void { + if (conversation.idleTimer) clearTimeout(conversation.idleTimer) + if (conversation.released || this.disposed) return + conversation.idleTimer = setTimeout(() => { + if (Date.now() - conversation.lastUsedAt < this.idleTimeoutMs) { + this.scheduleIdleExpiry(conversation) + return } if ( - slot.released || - slot.isLoading === isLoading || - this.slots.get(conversationId) !== slot + [...conversation.tabs.values()].some( + (tab) => tab.usageLeases.size > 0 + ) ) { + this.scheduleIdleExpiry(conversation) return } - slot.isLoading = isLoading - const current = this.liveStates.get(conversationId) - if (current) { - const status = - slot.active?.status ?? - (isLoading - ? 'loading' - : current.status === 'loading' - ? 'ready' - : current.status) - this.emitState(conversationId, status, { isLoading }) - } - }) - slot.removeNavigationListener = - session.onNavigationChange?.((url) => { - if (slot.released || this.slots.get(conversationId) !== slot) { - return - } - try { - const target = canonicalizeBrowserUrl(url) - slot.origin = target.origin - slot.lastUsedAt = Date.now() - this.scheduleIdleExpiry(slot) - const status = slot.active?.status ?? 'ready' - this.emitState(conversationId, status, { url: target.href }) - void slot.driver - .getNavigationMetadata(AbortSignal.timeout(2_000)) - .then((metadata) => { - if ( - slot.released || - this.slots.get(conversationId) !== slot || - canonicalizeBrowserUrl(metadata.url).href !== target.href - ) { - return - } - this.emitState( - conversationId, - slot.active?.status ?? 'ready', - { url: target.href, canGoBack: metadata.canGoBack } - ) - }) - .catch(() => undefined) - } catch { - // The session rejects unsupported top-level URLs. - } - }) ?? (() => undefined) - this.scheduleIdleExpiry(slot) - return slot - } - - private requireSlot(conversationId: string): BrowserSlot { - if (this.disposed) { - throw new Error('浏览器服务已关闭') - } - const slot = this.slots.get(conversationId) - if (!slot || slot.released || !slot.origin) { - throw new Error('当前对话尚未建立浏览器会话,请先导航') - } - return slot - } - - private scheduleIdleExpiry(slot: BrowserSlot): void { - if (slot.idleTimer) { - clearTimeout(slot.idleTimer) - } - if (slot.released || this.disposed) { - return - } - slot.idleTimer = setTimeout(() => { - if (Date.now() - slot.lastUsedAt < this.idleTimeoutMs) { - this.scheduleIdleExpiry(slot) - return - } - void this.releaseSlot(slot).catch(() => undefined) + void this.releaseConversationSlot(conversation).catch(() => undefined) }, this.idleTimeoutMs) } + private touchConversation(conversationId: string): void { + const conversation = this.conversations.get(conversationId) + if (!conversation) return + conversation.lastUsedAt = Date.now() + if ([...conversation.tabs.values()].some((tab) => tab.active)) { + if (conversation.idleTimer) clearTimeout(conversation.idleTimer) + conversation.idleTimer = undefined + return + } + this.scheduleIdleExpiry(conversation) + } + private async serialize( - slot: BrowserSlot, + tab: BrowserTabSlot, signal: AbortSignal, operation: (effectiveSignal: AbortSignal) => Promise, - status: 'loading' | 'acting' | 'interactive', + status: ActiveOperation['status'], stopLoadingAllowed: boolean ): Promise { signal.throwIfAborted() - if (slot.released || this.disposed) { - throw new Error('浏览器会话已关闭') - } + if (!this.hasTab(tab) || this.disposed) throw new Error('浏览器会话已关闭') let releaseGate!: () => void - const gate = new Promise((resolve) => { - releaseGate = resolve - }) - const predecessor = slot.tail.catch(() => undefined) - slot.tail = predecessor.then(() => gate) + const gate = new Promise((resolve) => { releaseGate = resolve }) + const predecessor = tab.tail.catch(() => undefined) + tab.tail = predecessor.then(() => gate) let operationController: AbortController | undefined try { await waitFor(predecessor, signal) - if (slot.released || this.disposed) { - throw new Error('浏览器会话已关闭') + if (!this.hasTab(tab) || this.disposed) throw new Error('浏览器会话已关闭') + const conversation = this.conversations.get(tab.conversationId) + if (conversation?.idleTimer) { + clearTimeout(conversation.idleTimer) + conversation.idleTimer = undefined } - if (status) { - this.emitState(slot.conversationId, status) - } - if (slot.idleTimer) { - clearTimeout(slot.idleTimer) - slot.idleTimer = undefined - } - slot.stopLoadingRequested = false + this.emitState(tab, status) + tab.stopLoadingRequested = false operationController = new AbortController() - slot.active = { - controller: operationController, - stopLoadingAllowed, - status - } - const effectiveSignal = AbortSignal.any([ - signal, - this.lifecycle.signal, - operationController.signal - ]) - return await operation(effectiveSignal) + tab.active = { controller: operationController, stopLoadingAllowed, status } + return await operation(AbortSignal.any([signal, this.lifecycle.signal, operationController.signal])) } finally { - if ( - operationController && - slot.active?.controller === operationController - ) { - slot.active = undefined - } + if (operationController && tab.active?.controller === operationController) tab.active = undefined releaseGate() - if (operationController) { - slot.lastUsedAt = Date.now() - this.scheduleIdleExpiry(slot) - } + if (operationController) this.touchConversation(tab.conversationId) } } - private verifyCurrentOrigin(slot: BrowserSlot): string { - const current = slot.session.getCurrentOrigin() - if (!slot.origin || current !== slot.origin) { - throw new Error('浏览器页面来源已改变,会话已被拒绝') - } + private verifyCurrentOrigin(tab: BrowserTabSlot): string { + const current = tab.session.getCurrentOrigin() + if (!tab.origin || current !== tab.origin) throw new Error('浏览器页面来源已改变,会话已被拒绝') return current } - private async verifyCurrentOriginOrRelease( - slot: BrowserSlot - ): Promise { + private async verifyCurrentOriginOrRelease(tab: BrowserTabSlot): Promise { try { - return this.verifyCurrentOrigin(slot) + return this.verifyCurrentOrigin(tab) } catch (error) { - await this.releaseSlot(slot).catch(() => undefined) + const conversation = this.conversations.get(tab.conversationId) + if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined) throw error } } - private async runInSession( + private shouldEmitFailure(conversationId: string, signal: AbortSignal, error: unknown): boolean { + if (error instanceof BrowserNavigationStoppedError || signal.aborted || this.releaseRequests.has(conversationId)) return false + const message = error instanceof Error ? error.message : '' + return !['浏览器会话已释放', '浏览器会话已清除', '浏览器会话已关闭', '浏览器服务已关闭'].some((reason) => message.includes(reason)) + } + + private emitFailure(tab: Pick, stage: string, error: unknown): void { + const detail = error instanceof Error && error.message ? error.message.slice(0, 180) : '未知错误' + this.emitState(tab, 'failed', { error: `${stage}失败:${detail}`.slice(0, 240) }) + } + + private async runInTab( conversationId: string, signal: AbortSignal, - status: 'loading' | 'acting' | 'interactive', + status: ActiveOperation['status'], stopLoadingAllowed: boolean, failureStage: string, - operation: ( - slot: BrowserSlot, - effectiveSignal: AbortSignal - ) => Promise + operation: (tab: BrowserTabSlot, effectiveSignal: AbortSignal) => Promise, + tabId?: BrowserTabId, + ownerWindowId?: number ): Promise { - let slot: BrowserSlot | undefined + let tab: BrowserTabSlot | undefined try { - const requiredSlot = this.requireSlot(conversationId) - slot = requiredSlot - return await this.serialize( - requiredSlot, - signal, - (effectiveSignal) => operation(requiredSlot, effectiveSignal), - status, - stopLoadingAllowed - ) + tab = this.requireTab(conversationId, tabId, ownerWindowId) + return await this.serialize(tab, signal, (effectiveSignal) => operation(tab!, effectiveSignal), status, stopLoadingAllowed) } catch (error) { - if ( - error instanceof BrowserNavigationStoppedError && - slot && - status !== 'loading' - ) { - this.recoverStoppedNavigation(slot) - } - if (this.shouldEmitFailure(conversationId, signal, error)) { - this.emitFailure(conversationId, failureStage, error) - } + if (error instanceof BrowserNavigationStoppedError && tab && status !== 'loading') this.recoverStoppedNavigation(tab) + if (tab && this.shouldEmitFailure(conversationId, signal, error)) this.emitFailure(tab, failureStage, error) throw error } } @@ -733,411 +942,361 @@ export class BrowserService { async navigate( conversationId: string, url: string, - signal: AbortSignal + signal: AbortSignal, + tabId?: BrowserTabId, + ownerWindowId?: number ): Promise<{ url: string; origin: string }> { - if ( - !this.slots.has(conversationId) && - !this.creations.has(conversationId) - ) { - this.emitState(conversationId, 'creating') - } + let tab: BrowserTabSlot | undefined try { - const slot = await this.getOrCreateSlot(conversationId, signal) - return await this.serialize(slot, signal, async (effectiveSignal) => { + const conversation = await this.getOrCreateConversation(conversationId, signal, ownerWindowId) + tab = conversation.tabs.get(tabId ?? conversation.primaryTabId) + if (!tab || tab.released) throw new Error('浏览器标签页不存在或不属于当前对话') + return await this.serialize(tab, signal, async (effectiveSignal) => { const target = await this.policy.validate(url, effectiveSignal) - slot.session.approveNavigation(target) - return this.completeNavigation( - conversationId, - slot, - effectiveSignal, - () => - slot.driver.navigate( - target.url.href, - effectiveSignal - ), - '浏览器导航结果来源不一致' - ) + tab!.session.approveNavigation(target) + return this.completeNavigation(tab!, effectiveSignal, () => tab!.driver.navigate(target.url.href, effectiveSignal), '浏览器导航结果来源不一致') }, 'loading', true) } catch (error) { - if (this.shouldEmitFailure(conversationId, signal, error)) { - this.emitFailure(conversationId, '浏览器导航', error) - } + if (tab && this.shouldEmitFailure(conversationId, signal, error)) this.emitFailure(tab, '浏览器导航', error) throw error } } - async snapshot( - conversationId: string, - signal: AbortSignal - ): Promise { - return this.runInSession( - conversationId, - signal, - 'acting', - false, - '读取浏览器页面', - async (slot, effectiveSignal) => { - await this.verifyCurrentOriginOrRelease(slot) - const snapshot = await slot.driver.snapshot(effectiveSignal) - const target = canonicalizeBrowserUrl(snapshot.url) - if (target.origin !== slot.origin) { - await this.releaseSlot(slot).catch(() => undefined) - throw new Error('浏览器快照来源与当前会话不一致') + async snapshot(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { + return this.runInTab(conversationId, signal, 'acting', false, '读取浏览器页面', async (tab, effectiveSignal) => { + await this.verifyCurrentOriginOrRelease(tab) + const snapshot = await tab.driver.snapshot(effectiveSignal) + const target = canonicalizeBrowserUrl(snapshot.url) + if (target.origin !== tab.origin) { + const conversation = this.conversations.get(conversationId) + if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined) + throw new Error('浏览器快照来源与当前会话不一致') + } + await this.captureFrame(tab, effectiveSignal, target.href) + return { ...snapshot, url: target.href } + }, tabId, ownerWindowId) + } + + async click(conversationId: string, ref: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { + await this.runInTab(conversationId, signal, 'acting', true, '浏览器点击', async (tab, effectiveSignal) => { + await this.verifyCurrentOriginOrRelease(tab) + await tab.driver.click(ref, effectiveSignal) + await this.captureFrame(tab, effectiveSignal) + }, tabId, ownerWindowId) + } + + async type(conversationId: string, ref: string, text: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { + await this.runInTab(conversationId, signal, 'acting', false, '浏览器输入', async (tab, effectiveSignal) => { + await this.verifyCurrentOriginOrRelease(tab) + await tab.driver.type(ref, text, effectiveSignal) + await this.captureFrame(tab, effectiveSignal) + }, tabId, ownerWindowId) + } + + async select(conversationId: string, ref: string, value: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { + await this.runInTab(conversationId, signal, 'acting', false, '浏览器选择', async (tab, effectiveSignal) => { + await this.verifyCurrentOriginOrRelease(tab) + await tab.driver.select(ref, value, effectiveSignal) + await this.captureFrame(tab, effectiveSignal) + }, tabId, ownerWindowId) + } + + async back(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<{ url: string; origin: string }> { + return this.runInTab(conversationId, signal, 'loading', true, '浏览器返回', async (tab, effectiveSignal) => { + await this.verifyCurrentOriginOrRelease(tab) + const historyTarget = await tab.driver.getBackTarget(effectiveSignal) + const target = await this.policy.validate(historyTarget.url, effectiveSignal) + tab.session.approveNavigation(target) + return this.completeNavigation(tab, effectiveSignal, () => tab.driver.backTo(historyTarget, effectiveSignal), '浏览器返回结果来源不一致') + }, tabId, ownerWindowId) + } + + async reload(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise<{ url: string; origin: string }> { + return this.runInTab(conversationId, signal, 'loading', true, '浏览器刷新', async (tab, effectiveSignal) => { + await this.verifyCurrentOriginOrRelease(tab) + return this.completeNavigation(tab, effectiveSignal, () => tab.driver.reload(effectiveSignal), '浏览器刷新结果来源不一致') + }, tabId, ownerWindowId) + } + + async screenshot(conversationId: string, signal: AbortSignal, tabId?: BrowserTabId, ownerWindowId?: number): Promise { + return this.runInTab(conversationId, signal, 'acting', false, '浏览器截图', async (tab, effectiveSignal) => { + await this.verifyCurrentOriginOrRelease(tab) + let screenshot: BrowserScreenshot | undefined + if (tab.session.captureScreenshot) { + try { + screenshot = await tab.session.captureScreenshot(effectiveSignal) + } catch { + effectiveSignal.throwIfAborted() } - await this.captureFrame( - conversationId, - slot, - effectiveSignal, - target.href - ) - return { ...snapshot, url: target.href } } - ) + screenshot ??= await tab.driver.screenshot(effectiveSignal) + await this.captureFrame(tab, effectiveSignal, undefined, screenshot) + return screenshot + }, tabId, ownerWindowId) } - async click( - conversationId: string, - ref: string, - signal: AbortSignal - ): Promise { - await this.runInSession( - conversationId, - signal, - 'acting', - true, - '浏览器点击', - async (slot, effectiveSignal) => { - await this.verifyCurrentOriginOrRelease(slot) - await slot.driver.click(ref, effectiveSignal) - await this.captureFrame(conversationId, slot, effectiveSignal) - } - ) - } - - async type( - conversationId: string, - ref: string, - text: string, - signal: AbortSignal - ): Promise { - await this.runInSession( - conversationId, - signal, - 'acting', - false, - '浏览器输入', - async (slot, effectiveSignal) => { - await this.verifyCurrentOriginOrRelease(slot) - await slot.driver.type(ref, text, effectiveSignal) - await this.captureFrame(conversationId, slot, effectiveSignal) - } - ) - } - - async select( - conversationId: string, - ref: string, - value: string, - signal: AbortSignal - ): Promise { - await this.runInSession( - conversationId, - signal, - 'acting', - false, - '浏览器选择', - async (slot, effectiveSignal) => { - await this.verifyCurrentOriginOrRelease(slot) - await slot.driver.select(ref, value, effectiveSignal) - await this.captureFrame(conversationId, slot, effectiveSignal) - } - ) - } - - async back( - conversationId: string, - signal: AbortSignal - ): Promise<{ url: string; origin: string }> { - return this.runInSession( - conversationId, - signal, - 'loading', - true, - '浏览器返回', - async (slot, effectiveSignal) => { - await this.verifyCurrentOriginOrRelease(slot) - const historyTarget = - await slot.driver.getBackTarget(effectiveSignal) - const target = await this.policy.validate( - historyTarget.url, - effectiveSignal - ) - slot.session.approveNavigation(target) - return this.completeNavigation( - conversationId, - slot, - effectiveSignal, - () => slot.driver.backTo(historyTarget, effectiveSignal), - '浏览器返回结果来源不一致' - ) - } - ) - } - - async reload( - conversationId: string, - signal: AbortSignal - ): Promise<{ url: string; origin: string }> { - return this.runInSession( - conversationId, - signal, - 'loading', - true, - '浏览器刷新', - async (slot, effectiveSignal) => { - await this.verifyCurrentOriginOrRelease(slot) - return this.completeNavigation( - conversationId, - slot, - effectiveSignal, - () => slot.driver.reload(effectiveSignal), - '浏览器刷新结果来源不一致' - ) - } - ) - } - - async screenshot( - conversationId: string, - signal: AbortSignal - ): Promise { - return this.runInSession( - conversationId, - signal, - 'acting', - false, - '浏览器截图', - async (slot, effectiveSignal) => { - await this.verifyCurrentOriginOrRelease(slot) - let screenshot: BrowserScreenshot | undefined - if (slot.session.captureScreenshot) { - try { - screenshot = await slot.session.captureScreenshot( - effectiveSignal - ) - } catch { - effectiveSignal.throwIfAborted() - } - } - screenshot ??= await slot.driver.screenshot(effectiveSignal) - await this.captureFrame( - conversationId, - slot, - effectiveSignal, - undefined, - screenshot - ) - return screenshot - } - ) - } - - async stopLoading(conversationId: string): Promise { - const slot = this.slots.get(conversationId) - const active = slot?.active - if (!slot || slot.released) { - return false - } - if (slot.stopLoadingRequested) { - return false - } + async stopLoading(conversationId: string, tabId?: BrowserTabId, ownerWindowId?: number): Promise { + const conversation = this.conversations.get(conversationId) + if (!conversation) return false + this.assertWindowOwner(conversation, ownerWindowId) + const tab = conversation.tabs.get(tabId ?? conversation.primaryTabId) + const active = tab?.active + if (!tab || tab.released || tab.stopLoadingRequested) return false if (active) { - if ( - !active.stopLoadingAllowed || - active.controller.signal.aborted || - (active.status !== 'loading' && - !slot.isLoading && - !slot.session.isLoading()) - ) { - return false - } - slot.isLoading = false - slot.stopLoadingRequested = true - slot.session.stopLoading() + if (!active.stopLoadingAllowed || active.controller.signal.aborted || (active.status !== 'loading' && !tab.isLoading && !tab.session.isLoading())) return false + tab.isLoading = false + tab.stopLoadingRequested = true + tab.session.stopLoading() active.controller.abort(new BrowserNavigationStoppedError()) return true } - if (!slot.isLoading && !slot.session.isLoading()) { - return false - } - slot.isLoading = false - slot.stopLoadingRequested = true - slot.session.stopLoading() - this.recoverStoppedNavigation(slot) + if (!tab.isLoading && !tab.session.isLoading()) return false + tab.isLoading = false + tab.stopLoadingRequested = true + tab.session.stopLoading() + this.recoverStoppedNavigation(tab) return true } - private recoverStoppedNavigation(slot: BrowserSlot): void { - if (slot.released || this.slots.get(slot.conversationId) !== slot) { - return - } - slot.isLoading = false - const currentUrl = slot.session.webContents.getURL() + private recoverStoppedNavigation(tab: BrowserTabSlot): void { + if (!this.hasTab(tab)) return + tab.isLoading = false try { - const currentTarget = canonicalizeBrowserUrl(currentUrl) - if (slot.session.getCurrentOrigin() !== currentTarget.origin) { - throw new Error('浏览器停止导航后的页面来源不一致') - } - slot.origin = currentTarget.origin - this.emitState(slot.conversationId, 'ready', { - url: currentTarget.href, - isLoading: false - }) + const target = canonicalizeBrowserUrl(tab.session.webContents.getURL()) + if (tab.session.getCurrentOrigin() !== target.origin) throw new Error('浏览器停止导航后的页面来源不一致') + tab.origin = target.origin + this.emitState(tab, 'ready', { url: target.href, isLoading: false }) } catch { - if (!slot.released && this.slots.get(slot.conversationId) === slot) { - this.emitState(slot.conversationId, 'ready', { isLoading: false }) - } + if (this.hasTab(tab)) this.emitState(tab, 'ready', { isLoading: false }) } } private async completeNavigation( - conversationId: string, - slot: BrowserSlot, + tab: BrowserTabSlot, signal: AbortSignal, operation: () => Promise<{ url: string }>, originMismatchMessage: string ): Promise<{ url: string; origin: string }> { try { const result = await operation() - const finalTarget = await this.policy.validateRedirect( - result.url, - signal - ) - if (slot.session.getCurrentOrigin() !== finalTarget.origin) { - throw new Error(originMismatchMessage) - } - slot.origin = finalTarget.origin - await this.captureFrame( - conversationId, - slot, - signal, - finalTarget.url.href - ) - return { - url: finalTarget.url.href, - origin: finalTarget.origin - } + const finalTarget = await this.policy.validateRedirect(result.url, signal) + if (tab.session.getCurrentOrigin() !== finalTarget.origin) throw new Error(originMismatchMessage) + tab.origin = finalTarget.origin + await this.captureFrame(tab, signal, finalTarget.url.href) + return { url: finalTarget.url.href, origin: finalTarget.origin } } catch (error) { if (error instanceof BrowserNavigationStoppedError) { - this.recoverStoppedNavigation(slot) + this.recoverStoppedNavigation(tab) throw error } - await this.releaseSlot(slot).catch(() => undefined) + const conversation = this.conversations.get(tab.conversationId) + if (conversation) await this.releaseTab(conversation, tab).catch(() => undefined) throw error } } - async releaseConversation(conversationId: string): Promise { - this.releaseRequests.add(conversationId) - let releasedSlot = false + private async captureFrame( + tab: BrowserTabSlot, + signal: AbortSignal, + url?: string, + screenshot?: BrowserScreenshot + ): Promise { + let committedUrl = url + const previous = this.liveStates.get(stateKey(tab.conversationId, tab.tabId)) + let canGoBack = previous?.canGoBack ?? false try { - const creation = this.creations.get(conversationId) - if (creation) { - creation.controller.abort(new Error('浏览器会话已释放')) - const slot = await creation.promise.catch(() => undefined) - if (slot) { - await this.releaseSlot(slot) - releasedSlot = true + const metadata = await tab.driver.getNavigationMetadata(signal) + const target = canonicalizeBrowserUrl(metadata.url) + if (tab.session.getCurrentOrigin() !== target.origin) throw new Error('浏览器当前页面来源不一致') + tab.origin = target.origin + committedUrl = target.href + canGoBack = metadata.canGoBack + } catch { + signal.throwIfAborted() + } + let frame = screenshot + if (!frame) { + if (this.liveFrameDelayMs > 0) { + await waitFor(new Promise((resolve) => setTimeout(resolve, this.liveFrameDelayMs)), signal) + } + const deadline = AbortSignal.any([signal, AbortSignal.timeout(6_000)]) + for (let attempt = 0; attempt < 3 && !frame; attempt += 1) { + if (attempt > 0) { + try { + await waitFor(new Promise((resolve) => setTimeout(resolve, attempt * 150)), deadline) + } catch { + signal.throwIfAborted() + break + } + } + if (tab.session.captureScreenshot) { + try { + frame = await tab.session.captureScreenshot(AbortSignal.any([deadline, AbortSignal.timeout(1_500)])) + } catch { + signal.throwIfAborted() + } + } + if (!frame && !deadline.aborted) { + try { + frame = await tab.driver.screenshot(AbortSignal.any([deadline, AbortSignal.timeout(1_500)])) + } catch { + signal.throwIfAborted() + } } } - const slot = this.slots.get(conversationId) - if (slot) { - await this.releaseSlot(slot) - releasedSlot = true + } + signal.throwIfAborted() + if (!this.hasTab(tab)) return + if (!frame) { + if (previous?.frameDataUrl) { + this.emitState(tab, 'ready', { + ...(committedUrl ? { url: committedUrl } : {}), + canGoBack, + frameDataUrl: previous.frameDataUrl + }) + } else { + this.emitState(tab, 'failed', { + ...(committedUrl ? { url: committedUrl } : {}), + canGoBack, + error: '页面已就绪,但实时画面捕获失败,请重试浏览器操作' + }) } - if (!releasedSlot) { - this.emitState(conversationId, 'stopped') + return + } + this.emitState(tab, 'ready', { + ...(committedUrl ? { url: committedUrl } : {}), + canGoBack, + frameDataUrl: `data:${frame.mimeType};base64,${frame.data}` + }) + } + + async releaseConversation(conversationId: string, ownerWindowId?: number): Promise { + const ownedConversation = this.conversations.get(conversationId) + if (ownedConversation) this.assertWindowOwner(ownedConversation, ownerWindowId) + const creation = this.creations.get(conversationId) + if ( + ownerWindowId !== undefined && + creation?.ownerWindowId !== undefined && + creation.ownerWindowId !== ownerWindowId + ) { + throw new Error('浏览器对话不属于当前窗口') + } + this.releaseRequests.add(conversationId) + let emitted = false + try { + const pendingCreation = this.creations.get(conversationId) + if (pendingCreation) { + pendingCreation.controller.abort(new Error('浏览器会话已释放')) + const created = await pendingCreation.promise.catch(() => undefined) + if (created) { + await this.releaseConversationSlot(created) + emitted = true + } + } + const conversation = this.conversations.get(conversationId) + if (conversation) { + await this.releaseConversationSlot(conversation) + emitted = true + } + const ownedCreations = [...this.ownedTabCreations.values()].filter( + (creation) => creation.conversationId === conversationId + ) + for (const creation of ownedCreations) { + creation.controller.abort(new Error('浏览器会话已释放')) + } + await Promise.allSettled(ownedCreations.map((creation) => creation.promise)) + if (!emitted) { + for (const state of [...this.liveStates.values()].filter((item) => item.conversationId === conversationId)) { + if (state.tabId) this.emitState({ ...state, tabId: state.tabId }, 'stopped') + } } } finally { - if (!this.disposed) { - this.releaseRequests.delete(conversationId) - } + if (!this.disposed) this.releaseRequests.delete(conversationId) } } - private async releaseSlot(slot: BrowserSlot): Promise { - if (slot.released) { - return + private async releaseTab(conversation: BrowserConversationSlot, tab: BrowserTabSlot): Promise { + if (tab.released) return + tab.released = true + conversation.tabs.delete(tab.tabId) + tab.removeLoadingListener() + tab.removeNavigationListener() + tab.active?.controller.abort(new Error('浏览器会话已释放')) + for (const lease of tab.usageLeases.values()) { + lease.controller.abort(new Error('浏览器标签页使用租约已终止')) } - slot.released = true - this.slots.delete(slot.conversationId) - if (slot.idleTimer) { - clearTimeout(slot.idleTimer) - slot.idleTimer = undefined + tab.usageLeases.clear() + if (tab.workbarInstanceId) conversation.ownedTabs.delete(tab.workbarInstanceId) + if (conversation.tabs.size === 0) { + conversation.released = true + conversation.lifecycle.abort(new Error('浏览器会话已释放')) + if (conversation.idleTimer) clearTimeout(conversation.idleTimer) + this.conversations.delete(conversation.conversationId) + } else if (conversation.primaryTabId === tab.tabId) { + conversation.primaryTabId = conversation.tabs.keys().next().value! } - slot.removeLoadingListener() - slot.removeNavigationListener() - slot.active?.controller.abort(new Error('浏览器会话已释放')) - try { - slot.driver.dispose() - } finally { + if (this.viewport?.conversationId === conversation.conversationId && this.viewport.tabId === tab.tabId) { try { - await boundedCleanup(slot.session.dispose(), this.cleanupTimeoutMs) - } finally { - this.emitState(slot.conversationId, 'stopped') + tab.session.setViewport?.(undefined) + } catch { + // The service state is still cleared when the native view is already gone. } + this.viewport = undefined + this.applyViewport() } + let disposalError: unknown + try { + tab.driver.dispose() + } catch (error) { + disposalError = error + } + try { + await boundedCleanup(tab.session.dispose(), this.cleanupTimeoutMs) + } catch (error) { + disposalError ??= error + } finally { + this.emitState(tab, 'stopped') + } + if (disposalError) throw disposalError + } + + private async releaseConversationSlot(conversation: BrowserConversationSlot): Promise { + if (conversation.released) return + conversation.released = true + conversation.lifecycle.abort(new Error('浏览器会话已释放')) + this.conversations.delete(conversation.conversationId) + if (conversation.idleTimer) clearTimeout(conversation.idleTimer) + const tabs = [...conversation.tabs.values()] + await Promise.allSettled(tabs.map((tab) => this.releaseTab(conversation, tab))).then((results) => { + const failure = results.find((result) => result.status === 'rejected') + if (failure?.status === 'rejected') throw failure.reason + }) } clearSessions(): Promise { - if (this.disposed) { - return Promise.resolve() - } - if (this.clearOperation) { - return this.clearOperation - } + if (this.disposed) return Promise.resolve() + if (this.clearOperation) return this.clearOperation const operation = this.performClearSessions() this.clearOperation = operation - void operation.then( - () => { - if (this.clearOperation === operation) { - this.clearOperation = undefined - } - }, - () => { - if (this.clearOperation === operation) { - this.clearOperation = undefined - } - } - ) + void operation.finally(() => { + if (this.clearOperation === operation) this.clearOperation = undefined + }).catch(() => undefined) return operation } private async performClearSessions(): Promise { this.clearing = true - const lifecycle = this.lifecycle - lifecycle.abort(new Error('浏览器会话已清除')) - const requestedReleases = new Set(this.creations.keys()) - for (const conversationId of requestedReleases) { - this.releaseRequests.add(conversationId) - } - const slots = new Set(this.slots.values()) + this.lifecycle.abort(new Error('浏览器会话已清除')) + const requested = new Set(this.creations.keys()) + for (const id of requested) this.releaseRequests.add(id) try { - await Promise.allSettled( - [...this.creations.values()].map((creation) => creation.promise) - ) - for (const slot of this.slots.values()) { - slots.add(slot) - } - await Promise.allSettled( - [...slots].map((slot) => this.releaseSlot(slot)) - ) - this.slots.clear() + await Promise.allSettled([...this.creations.values()].map((creation) => creation.promise)) + await Promise.allSettled([...this.ownedTabCreations.values()].map((creation) => creation.promise)) + await Promise.allSettled([...this.conversations.values()].map((conversation) => this.releaseConversationSlot(conversation))) + this.conversations.clear() } finally { if (!this.disposed) { - for (const conversationId of requestedReleases) { - this.releaseRequests.delete(conversationId) - } + for (const id of requested) this.releaseRequests.delete(id) this.lifecycle = new AbortController() this.clearing = false } @@ -1145,26 +1304,18 @@ export class BrowserService { } async dispose(): Promise { - if (this.disposed) { - return - } + if (this.disposed) return this.disposed = true this.clearing = true this.lifecycle.abort(new Error('浏览器服务已关闭')) - const slots = new Set(this.slots.values()) - for (const [conversationId, creation] of this.creations) { - this.releaseRequests.add(conversationId) + for (const [id, creation] of this.creations) { + this.releaseRequests.add(id) creation.controller.abort(new Error('浏览器服务已关闭')) - const created = await creation.promise.catch(() => undefined) - if (created) { - slots.add(created) - } } - for (const slot of this.slots.values()) { - slots.add(slot) - } - await Promise.allSettled([...slots].map((slot) => this.releaseSlot(slot))) - this.slots.clear() + await Promise.allSettled([...this.creations.values()].map((creation) => creation.promise)) + await Promise.allSettled([...this.ownedTabCreations.values()].map((creation) => creation.promise)) + await Promise.allSettled([...this.conversations.values()].map((conversation) => this.releaseConversationSlot(conversation))) + this.conversations.clear() this.stateListeners.clear() this.liveStates.clear() } diff --git a/src/main/browser/electron-browser-session.test.ts b/src/main/browser/electron-browser-session.test.ts index 56ac8e71..89b12a7f 100644 --- a/src/main/browser/electron-browser-session.test.ts +++ b/src/main/browser/electron-browser-session.test.ts @@ -11,10 +11,12 @@ import { function deferred() { let resolve!: (value: T) => void - const promise = new Promise((resolvePromise) => { + let reject!: (error: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { resolve = resolvePromise + reject = rejectPromise }) - return { promise, resolve } + return { promise, resolve, reject } } function createHarness() { @@ -348,6 +350,65 @@ describe('ElectronBrowserSession', () => { ) }) + it('creates sibling views in the same partition and cleans shared state after the final tab', async () => { + const first = createHarness() + const second = createHarness() + const createPartition = vi.fn(async () => first.partition) + const createView = vi + .fn<(options: Record) => Promise>() + .mockResolvedValueOnce(first.window) + .mockResolvedValueOnce(second.window) + const root = await ElectronBrowserSession.create({ + policy: first.policy, + createPartition, + createView, + createProxy: () => first.proxy + }) + + const sibling = await root.createTab(new AbortController().signal) + + expect(createPartition).toHaveBeenCalledOnce() + expect(sibling.partition).toBe(root.partition) + expect( + (createView.mock.calls[1]?.[0].webPreferences as Record) + .partition + ).toBe(root.partition) + await root.dispose() + expect(first.partition.clearData).not.toHaveBeenCalled() + expect(first.proxy.dispose).not.toHaveBeenCalled() + await sibling.dispose() + expect(first.partition.clearData).toHaveBeenCalledOnce() + expect(first.partition.closeAllConnections).toHaveBeenCalledOnce() + expect(first.proxy.dispose).toHaveBeenCalledOnce() + }) + + it('cleans shared state once when sibling creation fails after root disposal', async () => { + const first = createHarness() + const second = createHarness() + const setupGate = deferred>() + second.sendCommand.mockImplementationOnce(() => setupGate.promise) + const createView = vi + .fn<(options: Record) => Promise>() + .mockResolvedValueOnce(first.window) + .mockResolvedValueOnce(second.window) + const root = await ElectronBrowserSession.create({ + policy: first.policy, + createPartition: async () => first.partition, + createView, + createProxy: () => first.proxy + }) + + const sibling = root.createTab(new AbortController().signal) + await vi.waitFor(() => expect(second.sendCommand).toHaveBeenCalled()) + await root.dispose() + setupGate.reject(new Error('debugger failed')) + await expect(sibling).rejects.toThrow('无法创建浏览器标签页') + + expect(first.partition.clearData).toHaveBeenCalledOnce() + expect(first.partition.closeAllConnections).toHaveBeenCalledOnce() + expect(first.proxy.dispose).toHaveBeenCalledOnce() + }) + it('detaches listeners and clears isolated data on idempotent disposal', async () => { const harness = createHarness() const session = await ElectronBrowserSession.create({ diff --git a/src/main/browser/electron-browser-session.ts b/src/main/browser/electron-browser-session.ts index fe1297bc..8e292675 100644 --- a/src/main/browser/electron-browser-session.ts +++ b/src/main/browser/electron-browser-session.ts @@ -126,6 +126,30 @@ type Listener = { listener: BrowserEventListener } +type SharedBrowserResources = { + partition: string + partitionSession: BrowserPartitionSession + proxy: FilteringProxyLike + createView: (options: Record) => Promise + setupTimeoutMs: number + cleanupTimeoutMs: number + references: number + cleanup?: Promise +} + +async function releaseSharedResources( + resources: SharedBrowserResources +): Promise { + resources.references -= 1 + if (resources.references !== 0) return + resources.cleanup ??= cleanupIsolatedState( + resources.partitionSession, + resources.proxy, + resources.cleanupTimeoutMs + ) + await resources.cleanup +} + function managedBrowserUserAgent(): string { const platform = process.platform === 'win32' @@ -243,14 +267,12 @@ export class ElectronBrowserSession { private constructor( private readonly policy: BrowserUrlPolicy, - private readonly partitionSession: BrowserPartitionSession, private readonly view: BrowserViewHandle, - private readonly proxy: FilteringProxyLike, - partition: string, - private readonly cleanupTimeoutMs: number, - private readonly parentWindow?: BrowserParentWindowHandle + private readonly resources: SharedBrowserResources, + private readonly parentWindow?: BrowserParentWindowHandle, + private readonly listenForPartitionEvents = true ) { - this.partition = partition + this.partition = resources.partition this.webContents = view.webContents } @@ -369,11 +391,16 @@ export class ElectronBrowserSession { ) result = new ElectronBrowserSession( options.policy, - partitionSession, view, - managedProxy, - partition, - cleanupTimeoutMs, + { + partition, + partitionSession, + proxy: managedProxy, + createView, + setupTimeoutMs, + cleanupTimeoutMs, + references: 1 + }, options.parentWindow ) setupStage = '初始化浏览器协议' @@ -480,14 +507,16 @@ export class ElectronBrowserSession { this.emitNavigation(url) } }) - this.listen( - this.partitionSession, - 'will-download', - (event: { preventDefault(): void }, item: { cancel?(): void }) => { - event.preventDefault() - item.cancel?.() - } - ) + if (this.listenForPartitionEvents) { + this.listen( + this.resources.partitionSession, + 'will-download', + (event: { preventDefault(): void }, item: { cancel?(): void }) => { + event.preventDefault() + item.cancel?.() + } + ) + } contents.debugger.attach('1.3') await contents.debugger.sendCommand('Page.enable') this.assertOpen() @@ -625,6 +654,83 @@ export class ElectronBrowserSession { this.approvedOrigin = target.origin } + async createTab(signal: AbortSignal): Promise { + this.assertOpen() + const resources = this.resources + resources.references += 1 + let view: BrowserViewHandle | undefined + let viewAttached = false + let tab: ElectronBrowserSession | undefined + try { + view = await boundedSetup( + resources.createView({ + webPreferences: { + partition: resources.partition, + sandbox: true, + contextIsolation: true, + nodeIntegration: false, + nodeIntegrationInSubFrames: false, + nodeIntegrationInWorker: false, + backgroundThrottling: false, + webSecurity: true, + allowRunningInsecureContent: false, + plugins: false, + devTools: false, + safeDialogs: true + } + }), + signal, + resources.setupTimeoutMs, + (lateView) => { + if (!lateView.webContents.isDestroyed()) { + lateView.webContents.close?.({ waitForBeforeUnload: false }) + if (!lateView.webContents.isDestroyed()) { + lateView.webContents.destroy() + } + } + } + ) + if (this.parentWindow?.contentView && view.nativeView) { + this.parentWindow.contentView.addChildView(view.nativeView) + viewAttached = true + } + if (!view.webContents.loadURL) { + throw new Error('浏览器视图导航不可用') + } + await boundedSetup( + view.webContents.loadURL('about:blank'), + signal, + resources.setupTimeoutMs + ) + tab = new ElectronBrowserSession( + this.policy, + view, + resources, + this.parentWindow + ) + await boundedSetup(tab.initialize(), signal, resources.setupTimeoutMs) + return tab + } catch (error) { + if (tab) { + await tab.dispose().catch(() => undefined) + } else { + if (view) { + if (viewAttached && view.nativeView) { + this.parentWindow?.contentView?.removeChildView(view.nativeView) + } + if (!view.webContents.isDestroyed()) { + view.webContents.close?.({ waitForBeforeUnload: false }) + if (!view.webContents.isDestroyed()) { + view.webContents.destroy() + } + } + } + await releaseSharedResources(resources).catch(() => undefined) + } + throw new Error('无法创建浏览器标签页', { cause: error }) + } + } + async dispose(): Promise { if (this.disposed) { return @@ -650,10 +756,6 @@ export class ElectronBrowserSession { this.webContents.destroy() } } - await cleanupIsolatedState( - this.partitionSession, - this.proxy, - this.cleanupTimeoutMs - ) + await releaseSharedResources(this.resources) } } diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts index d96adb92..9162b834 100644 --- a/src/main/ipc.test.ts +++ b/src/main/ipc.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { EventEmitter } from 'node:events' import { mkdtemp, readFile, @@ -13,7 +14,12 @@ import type { AssistantProject, ConversationQueueItem } from '../shared/assistant-contracts' -import type { AgentEvent, BrowserLiveState } from '../shared/contracts' +import { + browserTabIdSchema, + type AgentEvent, + type BrowserLiveState, + type BrowserTabId +} from '../shared/contracts' import type { SshHostAgentConnectionStatus } from '../shared/ssh-host-contracts' @@ -391,12 +397,13 @@ describe('registerIpcHandlers computer capabilities', () => { }) it('validates computer capability requests and restricts them to the trusted renderer', async () => { - const webContents = { + const webContents = Object.assign(new EventEmitter(), { + id: 7, mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), isDestroyed: vi.fn(() => false), send: vi.fn() - } + }) const window = { webContents, isDestroyed: vi.fn(() => false), @@ -444,6 +451,36 @@ describe('registerIpcHandlers computer capabilities', () => { const stopLoading = vi.fn(async () => true) const setViewport = vi.fn() const releaseConversation = vi.fn(async () => {}) + const createTab = vi.fn(async (conversationId: string) => ({ + conversationId, + tabId: '4d7886c3-4c9c-4e84-bf65-1a4a841f5296' + })) + const listTabs = vi.fn(() => []) + const getVisibleTabId = vi.fn(() => undefined) + const acquireTabUsage = vi.fn( + (conversationId: string, tabId: BrowserTabId, owner: string) => ({ + conversationId, + tabId, + owner, + signal: new AbortController().signal, + release: vi.fn() + }) + ) + const closeTab = vi.fn(async () => {}) + const snapshotBrowser = vi.fn(async () => ({ + url: 'https://example.com/', + title: 'Example', + nodes: [], + truncated: false + })) + const clickBrowser = vi.fn(async () => {}) + const typeBrowser = vi.fn(async () => {}) + const selectBrowser = vi.fn(async () => {}) + const screenshotBrowser = vi.fn(async () => ({ + type: 'image' as const, + mimeType: 'image/jpeg' as const, + data: '/9j/2Q==' + })) const selectFiles = vi.fn( async ( _window: unknown, @@ -505,9 +542,19 @@ describe('registerIpcHandlers computer capabilities', () => { onRuntimeSettingsChanged, undefined, { + createTab, + listTabs, + getVisibleTabId, + acquireTabUsage, + closeTab, navigate, back, reload, + snapshot: snapshotBrowser, + click: clickBrowser, + type: typeBrowser, + select: selectBrowser, + screenshot: screenshotBrowser, stopLoading, setViewport, releaseConversation, @@ -649,8 +696,16 @@ describe('registerIpcHandlers computer capabilities', () => { )?.(event, 'unsupported') ).rejects.toThrow() + const browserTabId = browserTabIdSchema.parse( + '4d7886c3-4c9c-4e84-bf65-1a4a841f5296' + ) + const siblingBrowserTabId = browserTabIdSchema.parse( + '0387bd61-3a12-40ce-98d7-ef5d14cc8251' + ) browserStateListener?.({ conversationId: 'browser-conversation', + tabId: browserTabId, + ownerWindowId: 7, status: 'ready', sessionActive: true, isLoading: false, @@ -665,8 +720,22 @@ describe('registerIpcHandlers computer capabilities', () => { status: 'ready' }) ) + const ownedStateSendCount = webContents.send.mock.calls.length + browserStateListener?.({ + conversationId: 'foreign-conversation', + tabId: browserTabId, + ownerWindowId: 8, + status: 'ready', + sessionActive: true, + isLoading: false, + canGoBack: false, + updatedAt: 1 + }) + expect(webContents.send).toHaveBeenCalledTimes(ownedStateSendCount) browserStateListener?.({ conversationId: 'browser-conversation', + tabId: browserTabId, + ownerWindowId: 7, status: 'acting', sessionActive: true, isLoading: false, @@ -678,6 +747,22 @@ describe('registerIpcHandlers computer capabilities', () => { expect(repeatedFramePayload).not.toHaveProperty('frameDataUrl') browserStateListener?.({ conversationId: 'browser-conversation', + tabId: siblingBrowserTabId, + ownerWindowId: 7, + status: 'ready', + sessionActive: true, + isLoading: false, + canGoBack: false, + frameDataUrl: 'data:image/jpeg;base64,frame', + updatedAt: 2 + }) + expect(webContents.send.mock.calls.at(-1)?.[1]).toHaveProperty( + 'frameDataUrl' + ) + browserStateListener?.({ + conversationId: 'browser-conversation', + tabId: browserTabId, + ownerWindowId: 7, status: 'stopped', sessionActive: false, isLoading: false, @@ -686,6 +771,8 @@ describe('registerIpcHandlers computer capabilities', () => { }) browserStateListener?.({ conversationId: 'browser-conversation', + tabId: browserTabId, + ownerWindowId: 7, status: 'ready', sessionActive: true, isLoading: false, @@ -703,7 +790,37 @@ describe('registerIpcHandlers computer capabilities', () => { }) ).resolves.toBeUndefined() expect(releaseConversation).toHaveBeenCalledWith( - 'browser-conversation' + 'browser-conversation', + 7 + ) + await expect( + electronMocks.handlers.get(ipcChannels.browserCreateTab)?.(event, { + conversationId: 'browser-conversation', + workbarInstanceId: siblingBrowserTabId + }) + ).resolves.toMatchObject({ tabId: browserTabId }) + expect(createTab).toHaveBeenCalledWith( + 'browser-conversation', + 7, + expect.any(AbortSignal), + siblingBrowserTabId + ) + await expect( + electronMocks.handlers.get(ipcChannels.browserListTabs)?.(event, { + conversationId: 'browser-conversation' + }) + ).resolves.toEqual([]) + expect(listTabs).toHaveBeenCalledWith('browser-conversation', 7) + await expect( + electronMocks.handlers.get(ipcChannels.browserCloseTab)?.(event, { + conversationId: 'browser-conversation', + tabId: browserTabId + }) + ).resolves.toBeUndefined() + expect(closeTab).toHaveBeenCalledWith( + 'browser-conversation', + browserTabId, + 7 ) await expect( electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, { @@ -714,7 +831,9 @@ describe('registerIpcHandlers computer capabilities', () => { expect(navigate).toHaveBeenCalledWith( 'browser-conversation', 'https://example.com/', - expect.any(AbortSignal) + expect.any(AbortSignal), + undefined, + 7 ) await expect( electronMocks.handlers.get(ipcChannels.browserBack)?.(event, { @@ -723,7 +842,9 @@ describe('registerIpcHandlers computer capabilities', () => { ).resolves.toBeUndefined() expect(back).toHaveBeenCalledWith( 'browser-conversation', - expect.any(AbortSignal) + expect.any(AbortSignal), + undefined, + 7 ) await expect( electronMocks.handlers.get(ipcChannels.browserReload)?.(event, { @@ -732,7 +853,9 @@ describe('registerIpcHandlers computer capabilities', () => { ).resolves.toBeUndefined() expect(reload).toHaveBeenCalledWith( 'browser-conversation', - expect.any(AbortSignal) + expect.any(AbortSignal), + undefined, + 7 ) await expect( electronMocks.handlers.get(ipcChannels.browserStopLoading)?.( @@ -740,23 +863,55 @@ describe('registerIpcHandlers computer capabilities', () => { { conversationId: 'browser-conversation' } ) ).resolves.toBeUndefined() - expect(stopLoading).toHaveBeenCalledWith('browser-conversation') + expect(stopLoading).toHaveBeenCalledWith( + 'browser-conversation', + undefined, + 7 + ) expect( electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, { conversationId: 'browser-conversation', + leaseToken: siblingBrowserTabId, bounds: { x: 900, y: 120, width: 320, height: 600 } }) ).toBeUndefined() - expect(setViewport).toHaveBeenCalledWith('browser-conversation', { - x: 900, - y: 120, - width: 320, - height: 600 - }) + expect(setViewport).toHaveBeenCalledWith( + 'browser-conversation', + { x: 900, y: 120, width: 320, height: 600 }, + undefined, + siblingBrowserTabId, + 7 + ) expect( - electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, {}) + electronMocks.handlers.get(ipcChannels.browserSetViewport)?.(event, { + leaseToken: siblingBrowserTabId + }) ).toBeUndefined() - expect(setViewport).toHaveBeenLastCalledWith() + expect(setViewport).toHaveBeenLastCalledWith( + undefined, + undefined, + undefined, + siblingBrowserTabId, + 7 + ) + navigate.mockImplementationOnce( + async (...args: unknown[]) => + new Promise((_resolve, reject) => { + const signal = args[2] as AbortSignal + signal.addEventListener('abort', () => reject(signal.reason), { + once: true + }) + }) + ) + const abandonedNavigation = electronMocks.handlers.get( + ipcChannels.browserNavigate + )?.(event, { + conversationId: 'browser-conversation', + url: 'https://example.com/abandoned' + }) as Promise + await vi.waitFor(() => expect(webContents.listenerCount('destroyed')).toBe(1)) + webContents.emit('destroyed') + await expect(abandonedNavigation).rejects.toThrow('所属窗口已关闭') navigate.mockRejectedValueOnce(new BrowserNavigationStoppedError()) await expect( electronMocks.handlers.get(ipcChannels.browserNavigate)?.(event, { @@ -4878,7 +5033,8 @@ describe('registerIpcHandlers agent terminal state', () => { knowledgeGateway?: Record, magicNotesEnabled = false, goodbuddyConfigService?: Record, - capabilityServiceOverride?: Record + capabilityServiceOverride?: Record, + browserControl?: Record ) { const assistantDatabase = { createTask: vi.fn(), @@ -4990,6 +5146,7 @@ describe('registerIpcHandlers agent terminal state', () => { })) } const webContents = { + id: 9, mainFrame: { url: 'file:///goodbuddy/index.html' }, getURL: vi.fn(() => 'file:///goodbuddy/index.html'), send: vi.fn() @@ -5068,7 +5225,7 @@ describe('registerIpcHandlers agent terminal state', () => { {} as never, onRuntimeSettingsChanged, onBeforeClearLocalData, - undefined, + browserControl as never, subagentService as never, undefined, { @@ -7230,6 +7387,7 @@ describe('registerIpcHandlers agent terminal state', () => { requestId: string knowledgeCapabilityToken?: string trustedInstructions?: string + browserTabId?: BrowserTabId }> = [] const runtime = { runtimeId: 'opencode', @@ -7240,6 +7398,7 @@ describe('registerIpcHandlers agent terminal state', () => { requestId: string knowledgeCapabilityToken?: string trustedInstructions?: string + browserTabId?: BrowserTabId }) { receivedRequests.push(request) yield { requestId: request.requestId, type: 'done' } @@ -7257,6 +7416,30 @@ describe('registerIpcHandlers agent terminal state', () => { const getEnabledBuiltinMcpServerIds = vi.fn(async () => [ 'builtin-browser' ]) + const primaryTabId = browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000401' + ) + const siblingTabId = browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000402' + ) + const browserControl = { + listTabs: vi.fn(() => [ + { tabId: siblingTabId, primary: false }, + { tabId: primaryTabId, primary: true } + ]), + getVisibleTabId: vi.fn(() => undefined), + acquireTabUsage: vi.fn( + (conversationId: string, tabId: BrowserTabId, owner: string) => ({ + conversationId, + tabId, + owner, + signal: new AbortController().signal, + release: vi.fn() + }) + ), + createTab: vi.fn(), + onState: vi.fn(() => () => undefined) + } const harness = createHarness( runtime, undefined, @@ -7268,7 +7451,8 @@ describe('registerIpcHandlers agent terminal state', () => { knowledgeGateway, false, undefined, - { getEnabledBuiltinMcpServerIds } + { getEnabledBuiltinMcpServerIds }, + browserControl ) const event = trustedEvent(harness.webContents) const askRequestId = '00000000-0000-4000-8000-000000000026' @@ -7308,16 +7492,229 @@ describe('registerIpcHandlers agent terminal state', () => { expect.any(AbortSignal), 'none', undefined, - 'browser-execute' + 'browser-execute', + primaryTabId, + expect.objectContaining({ + conversationId: 'browser-execute', + tabId: primaryTabId, + owner: executeRequestId + }) ) expect(receivedRequests[0]?.knowledgeCapabilityToken).toBeUndefined() expect(receivedRequests[1]).toMatchObject({ knowledgeCapabilityToken: 'browser-capability', + browserTabId: primaryTabId, trustedInstructions: expect.stringContaining('browser_navigate') }) + expect(browserControl.listTabs).toHaveBeenCalledWith( + 'browser-execute', + harness.webContents.id + ) + expect(browserControl.createTab).not.toHaveBeenCalled() + expect(browserControl.acquireTabUsage).toHaveBeenCalledWith( + 'browser-execute', + primaryTabId, + executeRequestId, + harness.webContents.id + ) await harness.dispose() }) + it('prefers the owner window visible browser tab over the primary tab', async () => { + const requestId = '00000000-0000-4000-8000-000000000028' + const primaryTabId = browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000411' + ) + const visibleTabId = browserTabIdSchema.parse( + '00000000-0000-4000-8000-000000000412' + ) + const received: Array<{ browserTabId?: BrowserTabId }> = [] + const runtime = { + runtimeId: 'model', + capability: 'chat', + supportsToolExecution: true, + async *run(request: { requestId: string; browserTabId?: BrowserTabId }) { + received.push(request) + yield { requestId: request.requestId, type: 'done' } + } + } + const release = vi.fn() + const knowledgeGateway = { + grant: vi.fn(() => 'browser-capability'), + getAvailableToolNames: vi.fn(() => ['browser_navigate']), + drainReferences: vi.fn(() => []), + revoke: vi.fn() + } + const browserControl = { + listTabs: vi.fn(() => [ + { tabId: primaryTabId, primary: true }, + { tabId: visibleTabId, primary: false } + ]), + getVisibleTabId: vi.fn(() => visibleTabId), + acquireTabUsage: vi.fn( + (conversationId: string, tabId: BrowserTabId, owner: string) => ({ + conversationId, + tabId, + owner, + signal: new AbortController().signal, + release + }) + ), + createTab: vi.fn(), + onState: vi.fn(() => () => undefined) + } + const harness = createHarness( + runtime, + undefined, + 'always', + undefined, + false, + undefined, + undefined, + knowledgeGateway, + false, + undefined, + { getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) }, + browserControl + ) + + await harness.handler?.(trustedEvent(harness.webContents), { + requestId, + conversationId: 'visible-browser', + prompt: '使用当前网页', + workMode: 'execute', + knowledgeLibraryIds: [] + }) + await vi.waitFor(() => + expect(harness.assistantDatabase.updateTaskStatus).toHaveBeenCalledWith( + requestId, + 'completed' + ) + ) + + expect(received[0]?.browserTabId).toBe(visibleTabId) + expect(browserControl.acquireTabUsage).toHaveBeenCalledWith( + 'visible-browser', + visibleTabId, + requestId, + harness.webContents.id + ) + expect(browserControl.createTab).not.toHaveBeenCalled() + await harness.dispose() + }) + + it.each(['error', 'cancel'] as const)( + 'releases a request-created browser tab lease on runtime %s', + async (outcome) => { + const requestId = outcome === 'error' + ? '00000000-0000-4000-8000-000000000029' + : '00000000-0000-4000-8000-000000000030' + const tabId = browserTabIdSchema.parse( + outcome === 'error' + ? '00000000-0000-4000-8000-000000000421' + : '00000000-0000-4000-8000-000000000422' + ) + let markStarted!: () => void + const started = new Promise((resolve) => { + markStarted = resolve + }) + const runtime = { + runtimeId: 'model', + capability: 'chat', + supportsToolExecution: true, + async *run(request: { requestId: string }, signal: AbortSignal) { + markStarted() + if (outcome === 'error') throw new Error('runtime failed') + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(signal.reason), { + once: true + }) + }) + yield { requestId: request.requestId, type: 'done' } + } + } + const release = vi.fn() + let grantedLease: + | { release(): void } + | undefined + const knowledgeGateway = { + grant: vi.fn((...args: unknown[]) => { + grantedLease = args[7] as { release(): void } + return 'browser-capability' + }), + getAvailableToolNames: vi.fn(() => ['browser_navigate']), + drainReferences: vi.fn(() => []), + revoke: vi.fn(() => grantedLease?.release()) + } + const createTab = vi.fn< + ( + conversationId: string, + ownerWindowId?: number, + signal?: AbortSignal, + workbarInstanceId?: string + ) => Promise<{ tabId: BrowserTabId }> + >(async () => ({ tabId })) + const browserControl = { + listTabs: vi.fn(() => []), + getVisibleTabId: vi.fn(() => undefined), + createTab, + acquireTabUsage: vi.fn( + (conversationId: string, acquiredTabId: BrowserTabId, owner: string) => ({ + conversationId, + tabId: acquiredTabId, + owner, + signal: new AbortController().signal, + release + }) + ), + onState: vi.fn(() => () => undefined) + } + const harness = createHarness( + runtime, + undefined, + 'always', + undefined, + false, + undefined, + undefined, + knowledgeGateway, + false, + undefined, + { getEnabledBuiltinMcpServerIds: vi.fn(async () => ['builtin-browser']) }, + browserControl + ) + const event = trustedEvent(harness.webContents) + + await harness.handler?.(event, { + requestId, + conversationId: `browser-${outcome}`, + prompt: '使用浏览器', + workMode: 'execute', + knowledgeLibraryIds: [] + }) + await started + if (outcome === 'cancel') { + harness.cancelHandler?.(event, requestId) + } + await vi.waitFor(() => expect(release).toHaveBeenCalledOnce()) + + const workbarInstanceId = createTab.mock.calls[0]?.[3] + expect(workbarInstanceId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u + ) + expect(browserControl.acquireTabUsage).toHaveBeenCalledWith( + `browser-${outcome}`, + tabId, + requestId, + harness.webContents.id + ) + expect(knowledgeGateway.revoke).toHaveBeenCalledWith( + 'browser-capability' + ) + await harness.dispose() + } + ) + it.each(['ask', 'execute'] as const)( 'does not grant or advertise scoped data tools to external OpenCode in %s mode', async (workMode) => { @@ -9400,7 +9797,7 @@ describe('registerIpcHandlers agent terminal state', () => { ) => Promise ) { authorization = await authorize({ - scopeKey: 'model:builtin:workspace_write_text', + scopeKey: 'model:builtin:workspace_apply_patch', title: '写入文件', description: '写入 README.md' }) diff --git a/src/main/ipc.ts b/src/main/ipc.ts index b0877312..663276ce 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -4,7 +4,8 @@ import { clipboard, dialog, ipcMain, - shell + shell, + type IpcMainInvokeEvent } from 'electron' import { lstat, @@ -13,7 +14,7 @@ import { realpath, stat } from 'node:fs/promises' -import { randomUUID } from 'node:crypto' +import { createHash, randomUUID } from 'node:crypto' import { homedir } from 'node:os' import { basename, extname, isAbsolute, join } from 'node:path' import { z } from 'zod' @@ -27,11 +28,19 @@ import { agentQuestionResponseSchema, agentRequestSchema, browserBackRequestSchema, + browserClickRequestSchema, + browserCloseTabRequestSchema, + browserCreateTabRequestSchema, + browserListTabsRequestSchema, browserNavigateRequestSchema, browserReloadRequestSchema, + browserScreenshotRequestSchema, + browserSelectRequestSchema, browserSetViewportRequestSchema, + browserSnapshotRequestSchema, browserStopLoadingRequestSchema, browserStopRequestSchema, + browserTypeRequestSchema, clipboardTextSchema, conversationQueueUserInputSchema, defaultRuntimeSettings, @@ -62,6 +71,7 @@ import { type AgentRequest, type AppInfo, type BrowserLiveState, + type BrowserTabId, type ConversationQueueDispatch, type ConversationQueueUserInput, type KnowledgeSearchReference, @@ -442,10 +452,102 @@ function runtimeTargetFor( type ScopedDataCapability = { token?: string toolNames: readonly string[] + browserTabId?: BrowserTabId } -function grantScopedDataCapability(input: { +type BrowserRequestTabUsageLease = { + readonly conversationId: string + readonly tabId: BrowserTabId + readonly owner: string + readonly signal: AbortSignal + release(): void +} + +type BrowserCapabilityControl = { + createTab( + conversationId: string, + ownerWindowId?: number, + signal?: AbortSignal, + workbarInstanceId?: string + ): Promise<{ tabId: BrowserTabId }> + listTabs( + conversationId: string, + ownerWindowId?: number + ): Array<{ tabId: BrowserTabId; primary: boolean }> + getVisibleTabId( + conversationId: string, + ownerWindowId?: number + ): BrowserTabId | undefined + acquireTabUsage( + conversationId: string, + tabId: BrowserTabId, + owner: string, + ownerWindowId?: number + ): BrowserRequestTabUsageLease +} + +function requestWorkbarInstanceId(owner: string): string { + const bytes = createHash('sha256') + .update('goodbuddy:browser-request-workbar:') + .update(owner) + .digest() + .subarray(0, 16) + bytes[6] = (bytes[6]! & 0x0f) | 0x50 + bytes[8] = (bytes[8]! & 0x3f) | 0x80 + const hex = bytes.toString('hex') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}` +} + +async function bindBrowserTab(input: { + control?: BrowserCapabilityControl + conversationId: string + browserTabId?: BrowserTabId + owner: string + ownerWindowId?: number + signal: AbortSignal +}): Promise { + if (!input.control) { + throw new Error('GoodBuddy 内置浏览器服务不可用') + } + const tabs = input.control.listTabs( + input.conversationId, + input.ownerWindowId + ) + let tabId: BrowserTabId + if (input.browserTabId) { + if (!tabs.some((tab) => tab.tabId === input.browserTabId)) { + throw new Error('浏览器标签页不存在或不属于当前对话') + } + tabId = input.browserTabId + } else { + const visibleTabId = input.control.getVisibleTabId( + input.conversationId, + input.ownerWindowId + ) + const primary = tabs.find((tab) => tab.primary) + tabId = visibleTabId && tabs.some((tab) => tab.tabId === visibleTabId) + ? visibleTabId + : primary?.tabId ?? + ( + await input.control.createTab( + input.conversationId, + input.ownerWindowId, + input.signal, + requestWorkbarInstanceId(input.owner) + ) + ).tabId + } + return input.control.acquireTabUsage( + input.conversationId, + tabId, + input.owner, + input.ownerWindowId + ) +} + +async function grantScopedDataCapability(input: { gateway?: KnowledgeMcpGateway + browserControl?: BrowserCapabilityControl runtime: AgentRuntime enabledServers: readonly BuiltinMcpServerId[] requestId: string @@ -454,12 +556,15 @@ function grantScopedDataCapability(input: { configAccess?: MagicNotesCapabilityAccess workspacePath?: string browserConversationId?: string + browserTabId?: BrowserTabId + ownerWindowId?: number authorizeConfigApply?: ( event: GoodBuddyConfigApplyEvent, signal: AbortSignal ) => Promise signal: AbortSignal -}): ScopedDataCapability { + abort?: (reason: unknown) => void +}): Promise { const enabledServers = new Set(input.enabledServers) const libraryIds = enabledServers.has('knowledge-base') ? input.libraryIds @@ -485,6 +590,22 @@ function grantScopedDataCapability(input: { if (!input.gateway) { throw new Error('GoodBuddy 内置工具服务不可用') } + const browserUsageLease = browserConversationId + ? await bindBrowserTab({ + control: input.browserControl, + conversationId: browserConversationId, + browserTabId: input.browserTabId, + owner: input.requestId, + ownerWindowId: input.ownerWindowId, + signal: input.signal + }) + : undefined + const browserTabId = browserUsageLease?.tabId + browserUsageLease?.signal.addEventListener( + 'abort', + () => input.abort?.(browserUsageLease.signal.reason), + { once: true } + ) const config = configAccess !== 'none' && input.workspacePath ? { @@ -500,7 +621,9 @@ function grantScopedDataCapability(input: { input.signal, magicNotesAccess, config, - browserConversationId + browserConversationId, + browserTabId, + browserUsageLease ) : config ? input.gateway.grant( @@ -518,6 +641,7 @@ function grantScopedDataCapability(input: { ) return { token, + browserTabId, toolNames: token ? input.gateway.getAvailableToolNames(token) : [] @@ -1086,25 +1210,98 @@ export function registerIpcHandlers( onRuntimeSettingsChanged: () => Promise, onBeforeClearLocalData?: () => Promise, browserControl?: { + createTab( + conversationId: string, + ownerWindowId?: number, + signal?: AbortSignal, + workbarInstanceId?: string + ): Promise + listTabs( + conversationId: string, + ownerWindowId?: number + ): unknown[] + getVisibleTabId( + conversationId: string, + ownerWindowId?: number + ): BrowserTabId | undefined + acquireTabUsage( + conversationId: string, + tabId: BrowserTabId, + owner: string, + ownerWindowId?: number + ): BrowserRequestTabUsageLease + closeTab( + conversationId: string, + tabId: BrowserTabId, + ownerWindowId?: number + ): Promise navigate( conversationId: string, url: string, - signal: AbortSignal + signal: AbortSignal, + tabId?: BrowserTabId, + ownerWindowId?: number ): Promise<{ url: string; origin: string }> back( conversationId: string, - signal: AbortSignal + signal: AbortSignal, + tabId?: BrowserTabId, + ownerWindowId?: number ): Promise<{ url: string; origin: string }> reload( conversationId: string, - signal: AbortSignal + signal: AbortSignal, + tabId?: BrowserTabId, + ownerWindowId?: number ): Promise<{ url: string; origin: string }> - stopLoading(conversationId: string): boolean | Promise + snapshot( + conversationId: string, + signal: AbortSignal, + tabId?: BrowserTabId, + ownerWindowId?: number + ): Promise + click( + conversationId: string, + ref: string, + signal: AbortSignal, + tabId?: BrowserTabId, + ownerWindowId?: number + ): Promise + type( + conversationId: string, + ref: string, + text: string, + signal: AbortSignal, + tabId?: BrowserTabId, + ownerWindowId?: number + ): Promise + select( + conversationId: string, + ref: string, + value: string, + signal: AbortSignal, + tabId?: BrowserTabId, + ownerWindowId?: number + ): Promise + screenshot( + conversationId: string, + signal: AbortSignal, + tabId?: BrowserTabId, + ownerWindowId?: number + ): Promise + stopLoading( + conversationId: string, + tabId?: BrowserTabId, + ownerWindowId?: number + ): boolean | Promise setViewport( conversationId?: string, - bounds?: { x: number; y: number; width: number; height: number } - ): void - releaseConversation(conversationId: string): Promise + bounds?: { x: number; y: number; width: number; height: number }, + tabId?: BrowserTabId, + leaseToken?: string, + ownerWindowId?: number + ): boolean | void + releaseConversation(conversationId: string, ownerWindowId?: number): Promise onState(listener: (state: BrowserLiveState) => void): () => void }, subagentService?: SubagentService, @@ -1378,21 +1575,22 @@ export function registerIpcHandlers( window.on('unmaximize', notifyMaximizedChanged) const lastSentBrowserFrames = new Map() const removeBrowserStateListener = browserControl?.onState((state) => { - if (!window.isDestroyed()) { + if (!window.isDestroyed() && state.ownerWindowId === window.webContents.id) { + const tabStateKey = `${state.conversationId}\u0000${state.tabId}` const frame = state.frameDataUrl let payload: BrowserLiveState = state if ( frame && - lastSentBrowserFrames.get(state.conversationId) === frame + lastSentBrowserFrames.get(tabStateKey) === frame ) { payload = { ...state } delete payload.frameDataUrl } if (frame) { - lastSentBrowserFrames.set(state.conversationId, frame) + lastSentBrowserFrames.set(tabStateKey, frame) } if (state.status === 'stopped') { - lastSentBrowserFrames.delete(state.conversationId) + lastSentBrowserFrames.delete(tabStateKey) } window.webContents.send(ipcChannels.browserState, payload) } @@ -2343,8 +2541,9 @@ export function registerIpcHandlers( (id) => id !== 'builtin-browser' ) : [] - const notesCapability = grantScopedDataCapability({ + const notesCapability = await grantScopedDataCapability({ gateway: knowledgeGateway, + browserControl: browserControl as BrowserCapabilityControl | undefined, runtime: requestRuntime, enabledServers: enabledBuiltinMcpServers, requestId, @@ -2358,7 +2557,9 @@ export function registerIpcHandlers( schedule.workMode === 'execute' ? runtimeConversationId : undefined, - signal: controller.signal + ownerWindowId: window.webContents.id, + signal: controller.signal, + abort: (reason) => controller.abort(reason) }) knowledgeCapabilityToken = notesCapability.token const noteTools = notesCapability.toolNames @@ -2456,6 +2657,9 @@ export function registerIpcHandlers( trustedInstructions, ...(knowledgeCapabilityToken ? { knowledgeCapabilityToken } + : {}), + ...(notesCapability.browserTabId + ? { browserTabId: notesCapability.browserTabId } : {}) } for await (const agentEvent of requestRuntime.run( @@ -3467,7 +3671,7 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const request = browserStopRequestSchema.parse(input) await Promise.allSettled([ - browserControl?.releaseConversation(request.conversationId), + browserControl?.releaseConversation(request.conversationId, event.sender.id), selectedRuntimes ? selectedRuntimes.releaseConversation(request.conversationId) : runtime.releaseConversation?.(request.conversationId) @@ -3490,7 +3694,45 @@ export function registerIpcHandlers( return browserControl } + const runBrowserOperation = async ( + event: IpcMainInvokeEvent, + operation: (signal: AbortSignal) => Promise, + timeoutMs = 30_000 + ): Promise => { + const controller = new AbortController() + const sender = event.sender + const senderDestroyed = (): void => { + controller.abort(new Error('浏览器请求所属窗口已关闭')) + } + if (sender.isDestroyed()) senderDestroyed() + else sender.once?.('destroyed', senderDestroyed) + try { + const signal = AbortSignal.any([ + controller.signal, + AbortSignal.timeout(timeoutMs) + ]) + signal.throwIfAborted() + return await new Promise((resolve, reject) => { + const aborted = (): void => reject(signal.reason) + signal.addEventListener('abort', aborted, { once: true }) + void operation(signal).then( + (value) => { + signal.removeEventListener('abort', aborted) + resolve(value) + }, + (error: unknown) => { + signal.removeEventListener('abort', aborted) + reject(error) + } + ) + }) + } finally { + sender.removeListener?.('destroyed', senderDestroyed) + } + } + const runUiBrowserNavigation = async ( + event: IpcMainInvokeEvent, operation: ( control: NonNullable, signal: AbortSignal @@ -3498,7 +3740,7 @@ export function registerIpcHandlers( ): Promise => { try { const control = await requireBrowserControl() - await operation(control, new AbortController().signal) + await runBrowserOperation(event, (signal) => operation(control, signal)) } catch (error) { if (!(error instanceof BrowserNavigationStoppedError)) { throw error @@ -3506,16 +3748,56 @@ export function registerIpcHandlers( } } + registerHandler( + ipcChannels.browserCreateTab, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserCreateTabRequestSchema.parse(input) + const control = await requireBrowserControl() + return runBrowserOperation(event, (signal) => + control.createTab( + request.conversationId, + event.sender.id, + signal, + request.workbarInstanceId + ) + ) + } + ) + + registerHandler(ipcChannels.browserListTabs, async (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserListTabsRequestSchema.parse(input) + const control = await requireBrowserControl() + return control.listTabs(request.conversationId, event.sender.id) + }) + + registerHandler( + ipcChannels.browserCloseTab, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserCloseTabRequestSchema.parse(input) + const control = await requireBrowserControl() + await control.closeTab( + request.conversationId, + request.tabId, + event.sender.id + ) + } + ) + registerHandler( ipcChannels.browserNavigate, async (event, input: unknown) => { assertTrustedSender(event, window) const request = browserNavigateRequestSchema.parse(input) - await runUiBrowserNavigation((control, signal) => + await runUiBrowserNavigation(event, (control, signal) => control.navigate( request.conversationId, request.url, - signal + signal, + request.tabId, + event.sender.id ) ) } @@ -3526,10 +3808,12 @@ export function registerIpcHandlers( async (event, input: unknown) => { assertTrustedSender(event, window) const request = browserBackRequestSchema.parse(input) - await runUiBrowserNavigation((control, signal) => + await runUiBrowserNavigation(event, (control, signal) => control.back( request.conversationId, - signal + signal, + request.tabId, + event.sender.id ) ) } @@ -3540,10 +3824,12 @@ export function registerIpcHandlers( async (event, input: unknown) => { assertTrustedSender(event, window) const request = browserReloadRequestSchema.parse(input) - await runUiBrowserNavigation((control, signal) => + await runUiBrowserNavigation(event, (control, signal) => control.reload( request.conversationId, - signal + signal, + request.tabId, + event.sender.id ) ) } @@ -3554,7 +3840,79 @@ export function registerIpcHandlers( async (event, input: unknown) => { assertTrustedSender(event, window) const request = browserStopLoadingRequestSchema.parse(input) - await browserControl?.stopLoading(request.conversationId) + await browserControl?.stopLoading( + request.conversationId, + request.tabId, + event.sender.id + ) + } + ) + + registerHandler(ipcChannels.browserSnapshot, async (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserSnapshotRequestSchema.parse(input) + const control = await requireBrowserControl() + return runBrowserOperation(event, (signal) => control.snapshot( + request.conversationId, + signal, + request.tabId, + event.sender.id + )) + }) + + registerHandler(ipcChannels.browserClick, async (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserClickRequestSchema.parse(input) + const control = await requireBrowserControl() + await runBrowserOperation(event, (signal) => control.click( + request.conversationId, + request.ref, + signal, + request.tabId, + event.sender.id + )) + }) + + registerHandler(ipcChannels.browserType, async (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserTypeRequestSchema.parse(input) + const control = await requireBrowserControl() + await runBrowserOperation(event, (signal) => control.type( + request.conversationId, + request.ref, + request.text, + signal, + request.tabId, + event.sender.id + )) + }) + + registerHandler(ipcChannels.browserSelect, async (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserSelectRequestSchema.parse(input) + const control = await requireBrowserControl() + await runBrowserOperation(event, (signal) => control.select( + request.conversationId, + request.ref, + request.value, + signal, + request.tabId, + event.sender.id + )) + }) + + registerHandler( + ipcChannels.browserScreenshot, + async (event, input: unknown) => { + assertTrustedSender(event, window) + const request = browserScreenshotRequestSchema.parse(input) + const control = await requireBrowserControl() + return runBrowserOperation(event, (signal) => control.screenshot( + request.conversationId, + signal, + request.tabId, + event.sender.id + )) } ) @@ -3789,8 +4147,9 @@ export function registerIpcHandlers( : undefined : resolvedRuntimeSettings?.workspacePath const controller = new AbortController() - const scopedCapability = grantScopedDataCapability({ + const scopedCapability = await grantScopedDataCapability({ gateway: knowledgeGateway, + browserControl: browserControl as BrowserCapabilityControl | undefined, runtime: selectedRuntime, enabledServers: enabledBuiltinMcpServers, requestId: enrichedRequest.requestId, @@ -3807,7 +4166,9 @@ export function registerIpcHandlers( enrichedRequest.workMode === 'execute' ? enrichedRequest.conversationId : undefined, - signal: controller.signal + ownerWindowId: event.sender.id, + signal: controller.signal, + abort: (reason) => controller.abort(reason) }) const knowledgeCapabilityToken = scopedCapability.token const availableTools = [ @@ -3846,6 +4207,9 @@ export function registerIpcHandlers( const request: AgentExecutionRequest = knowledgeCapabilityToken ? { ...baseRequest, knowledgeCapabilityToken } : baseRequest + if (scopedCapability.browserTabId) { + request.browserTabId = scopedCapability.browserTabId + } const managedSshExecution = agentRuntimeSelected && configExecutionSpace?.kind === 'ssh' @@ -4649,7 +5013,6 @@ export function registerIpcHandlers( (message) => message.state === 'complete' && message.content.trim() ) - .slice(-500) if ( persistedHistory.length !== request.history.length || persistedHistory.some( @@ -6639,7 +7002,13 @@ export function registerIpcHandlers( assertTrustedSender(event, window) const request = browserSetViewportRequestSchema.parse(input) if (!request.conversationId || !request.bounds) { - browserControl?.setViewport() + browserControl?.setViewport( + undefined, + undefined, + undefined, + request.leaseToken, + event.sender.id + ) return } browserControl?.setViewport(request.conversationId, { @@ -6647,7 +7016,7 @@ export function registerIpcHandlers( y: request.bounds.y, width: request.bounds.width, height: request.bounds.height - }) + }, request.tabId, request.leaseToken, event.sender.id) } ) registerHandler( diff --git a/src/main/runtime-settings-store.ts b/src/main/runtime-settings-store.ts index 91ff4ac1..5cda1e9d 100644 --- a/src/main/runtime-settings-store.ts +++ b/src/main/runtime-settings-store.ts @@ -515,12 +515,10 @@ const defaultSettings: StoredSettings = { ], defaultModelProfileId, opencodeModelSource: { - kind: 'profile', - profileId: defaultModelProfileId + kind: 'default' }, continueModelSource: { - kind: 'profile', - profileId: defaultModelProfileId + kind: 'default' }, opencodeBaseUrl: defaultRuntimeSettings.opencodeBaseUrl, opencodeEmbedded: defaultRuntimeSettings.opencodeEmbedded, @@ -820,7 +818,7 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings { const normalizeSource = ( source: RuntimeSettings['opencodeModelSource'] ): RuntimeSettings['opencodeModelSource'] => { - if (source.kind === 'platform') { + if (source.kind !== 'profile') { return source } const profile = modelProfiles.find( @@ -839,6 +837,7 @@ function normalizeStoredSettings(settings: StoredSettings): StoredSettings { if (!source || source.kind === 'platform') { return { kind: 'platform' } } + if (source.kind === 'default') return source const profile = modelProfiles.find( (candidate) => candidate.id === source.profileId ) @@ -1431,6 +1430,9 @@ export class RuntimeSettingsStore { modelProfiles: readonly ResolvedModelProfile[] ): ResolvedModelProfile | undefined { const source = settings.deepseekHarnessModelSource + if (source.kind === 'default') { + return modelProfiles.find((profile) => profile.id === settings.defaultModelProfileId) + } if (source.kind === 'profile') { return modelProfiles.find( (profile) => profile.id === source.profileId @@ -1936,12 +1938,14 @@ export class RuntimeSettingsStore { ) const opencodeModelProfile = !agent.opencodeBaseUrl && - settings.opencodeModelSource.kind === 'profile' - ? profilesById.get(settings.opencodeModelSource.profileId) + settings.opencodeModelSource.kind !== 'platform' + ? profilesById.get(settings.opencodeModelSource.kind === 'default' + ? settings.defaultModelProfileId : settings.opencodeModelSource.profileId) : undefined const continueModelProfile = - settings.continueModelSource.kind === 'profile' - ? profilesById.get(settings.continueModelSource.profileId) + settings.continueModelSource.kind !== 'platform' + ? profilesById.get(settings.continueModelSource.kind === 'default' + ? settings.defaultModelProfileId : settings.continueModelSource.profileId) : undefined const deepseekHarnessModelProfile = this.resolveDeepSeekHarnessModelProfile(settings, modelProfiles) @@ -2362,7 +2366,7 @@ export class RuntimeSettingsStore { source: RuntimeSettings['opencodeModelSource'], runtimeLabel: 'OpenCode' | 'Continue' ): void => { - if (source.kind === 'platform') { + if (source.kind !== 'profile') { return } const profile = modelProfiles.find( @@ -2386,7 +2390,7 @@ export class RuntimeSettingsStore { const repairRuntimeSource = ( source: RuntimeSettings['opencodeModelSource'] ): RuntimeSettings['opencodeModelSource'] => { - if (source.kind === 'platform') { + if (source.kind !== 'profile') { return source } const profile = modelProfiles.find( diff --git a/src/preload/index.ts b/src/preload/index.ts index d90e44f6..c3054480 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -7,7 +7,23 @@ import { type AgentRuntimeDetection, type AgentRuntimeStatus, type AppInfo, + type BrowserBackRequest, + type BrowserClickRequest, + type BrowserCloseTabRequest, + type BrowserCreateTabRequest, type BrowserLiveState, + type BrowserListTabsRequest, + type BrowserNavigateRequest, + type BrowserReloadRequest, + type BrowserScreenshotRequest, + type BrowserScreenshotResult, + type BrowserSelectRequest, + type BrowserSetViewportRequest, + type BrowserSnapshotRequest, + type BrowserSnapshotResult, + type BrowserStopLoadingRequest, + type BrowserTabSummary, + type BrowserTypeRequest, type ContextAttachment, type ContextFileSelectionProgress, type DesktopApi, @@ -187,6 +203,7 @@ import type { TerminalWriteRequest } from '../shared/terminal-contracts' + const desktopApi: DesktopApi = { app: { getInfo: () => ipcRenderer.invoke(ipcChannels.appInfo) as Promise, @@ -307,32 +324,77 @@ const desktopApi: DesktopApi = { } }, browser: { - navigate: async (conversationId: string, url: string) => { - await ipcRenderer.invoke(ipcChannels.browserNavigate, { - conversationId, - url - }) + createTab: (request: BrowserCreateTabRequest) => + ipcRenderer.invoke( + ipcChannels.browserCreateTab, + request + ) as Promise, + listTabs: (request: BrowserListTabsRequest) => + ipcRenderer.invoke( + ipcChannels.browserListTabs, + request + ) as Promise, + closeTab: async (request: BrowserCloseTabRequest) => { + await ipcRenderer.invoke(ipcChannels.browserCloseTab, request) }, - back: async (conversationId: string) => { - await ipcRenderer.invoke(ipcChannels.browserBack, { - conversationId - }) + navigate: async ( + requestOrConversation: BrowserNavigateRequest | string, + url?: string + ) => { + await ipcRenderer.invoke( + ipcChannels.browserNavigate, + typeof requestOrConversation === 'string' + ? { conversationId: requestOrConversation, url } + : requestOrConversation + ) }, - reload: async (conversationId: string) => { - await ipcRenderer.invoke(ipcChannels.browserReload, { - conversationId - }) + back: async (requestOrConversation: BrowserBackRequest | string) => { + await ipcRenderer.invoke( + ipcChannels.browserBack, + typeof requestOrConversation === 'string' + ? { conversationId: requestOrConversation } + : requestOrConversation + ) }, - stopLoading: async (conversationId: string) => { - await ipcRenderer.invoke(ipcChannels.browserStopLoading, { - conversationId - }) + reload: async (requestOrConversation: BrowserReloadRequest | string) => { + await ipcRenderer.invoke( + ipcChannels.browserReload, + typeof requestOrConversation === 'string' + ? { conversationId: requestOrConversation } + : requestOrConversation + ) }, - setViewport: async (conversationId, bounds) => { - await ipcRenderer.invoke(ipcChannels.browserSetViewport, { - ...(conversationId ? { conversationId } : {}), - ...(bounds ? { bounds } : {}) - }) + stopLoading: async ( + requestOrConversation: BrowserStopLoadingRequest | string + ) => { + await ipcRenderer.invoke( + ipcChannels.browserStopLoading, + typeof requestOrConversation === 'string' + ? { conversationId: requestOrConversation } + : requestOrConversation + ) + }, + snapshot: (request: BrowserSnapshotRequest) => + ipcRenderer.invoke( + ipcChannels.browserSnapshot, + request + ) as Promise, + click: async (request: BrowserClickRequest) => { + await ipcRenderer.invoke(ipcChannels.browserClick, request) + }, + type: async (request: BrowserTypeRequest) => { + await ipcRenderer.invoke(ipcChannels.browserType, request) + }, + select: async (request: BrowserSelectRequest) => { + await ipcRenderer.invoke(ipcChannels.browserSelect, request) + }, + screenshot: (request: BrowserScreenshotRequest) => + ipcRenderer.invoke( + ipcChannels.browserScreenshot, + request + ) as Promise, + setViewport: async (request: BrowserSetViewportRequest) => { + await ipcRenderer.invoke(ipcChannels.browserSetViewport, request) }, stop: async (conversationId: string) => { await ipcRenderer.invoke( diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts index 60e7ec12..0a4f1bb6 100644 --- a/src/preload/preload-sandbox.test.ts +++ b/src/preload/preload-sandbox.test.ts @@ -47,16 +47,40 @@ describe('sandboxed preload', () => { /browser: \{(?[\s\S]*?)\r?\n {2}\},\r?\n {2}terminal:/u )?.groups?.body ?? '' for (const method of [ + 'createTab:', + 'listTabs:', + 'closeTab:', 'navigate:', 'back:', 'reload:', 'stopLoading:', - 'interact:', + 'snapshot:', + 'click:', + 'type:', + 'select:', + 'screenshot:', + 'setViewport:', 'stop:', 'onState:' ]) { expect(browser).toContain(method) } + for (const channel of [ + 'browserCreateTab', + 'browserListTabs', + 'browserCloseTab', + 'browserSnapshot', + 'browserClick', + 'browserType', + 'browserSelect', + 'browserScreenshot', + 'browserSetViewport' + ]) { + expect(browser).toContain(`ipcChannels.${channel}`) + } + expect(browser).toContain('requestOrConversation: BrowserNavigateRequest') + expect(browser).toContain('setViewport: async (request: BrowserSetViewportRequest)') + expect(browser).not.toContain('leaseId') expect(browser).not.toMatch( /\b(?:webContents|debugger|session|partition|cookie)\b/iu ) diff --git a/src/renderer/src/RightAssistantSidebar.resize.test.tsx b/src/renderer/src/RightAssistantSidebar.resize.test.tsx index 7cbf6888..426f417a 100644 --- a/src/renderer/src/RightAssistantSidebar.resize.test.tsx +++ b/src/renderer/src/RightAssistantSidebar.resize.test.tsx @@ -7,7 +7,18 @@ import { waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { WorkspaceFilePreview } from '../../shared/assistant-contracts' +import type { + AssistantProject, + AssistantTask, + WorkspaceFilePreview +} from '../../shared/assistant-contracts' +import type { + BrowserCreateTabRequest, + BrowserLiveState, + BrowserSetViewportRequest, + BrowserTabId, + BrowserTabSummary +} from '../../shared/contracts' import { RightAssistantSidebar, type AssistantSidebarTab, @@ -17,15 +28,74 @@ import { afterEach(cleanup) +const firstBrowserTabId = + '00000000-0000-4000-8000-000000000401' as BrowserTabId +const secondBrowserTabId = + '00000000-0000-4000-8000-000000000402' as BrowserTabId +const browserApi = { + createTab: vi.fn<(request: BrowserCreateTabRequest) => Promise>(), + closeTab: vi.fn(async () => undefined), + setViewport: vi.fn< + (request: BrowserSetViewportRequest) => Promise + >(async () => undefined) +} + +function browserSummary( + conversationId: string, + tabId: BrowserTabId, + primary: boolean +): BrowserTabSummary { + return { + conversationId, + tabId, + primary, + status: 'ready', + isLoading: false, + canGoBack: false, + createdAt: 1, + updatedAt: 1 + } +} + beforeEach(() => { localStorage.clear() Object.defineProperty(window, 'innerWidth', { configurable: true, value: 1400 }) + browserApi.createTab.mockReset() + browserApi.closeTab.mockReset().mockResolvedValue(undefined) + browserApi.setViewport.mockReset().mockResolvedValue(undefined) + const tabsByOwner = new Map() + browserApi.createTab.mockImplementation(async ({ conversationId, workbarInstanceId }) => { + const ownershipKey = `${conversationId}:${workbarInstanceId}` + let tabId = tabsByOwner.get(ownershipKey) + if (!tabId) { + tabId = tabsByOwner.size === 0 ? firstBrowserTabId : secondBrowserTabId + tabsByOwner.set(ownershipKey, tabId) + } + return browserSummary(conversationId, tabId, tabsByOwner.size === 1) + }) + Object.defineProperty(window, 'goodbuddy', { + configurable: true, + value: { browser: browserApi } + }) }) -function renderSidebar({ +const currentProject: AssistantProject = { + id: '00000000-0000-4000-8000-000000000301', + name: '当前项目', + description: '', + rootPath: 'D:\\project', + defaultWorkMode: 'ask', + kind: 'user', + executionSpace: { kind: 'local', rootPath: 'D:\\project' }, + status: 'active', + createdAt: '2026-09-09T00:00:00.000Z', + updatedAt: '2026-09-09T00:00:00.000Z' +} + +function sidebarElement({ tab = 'tasks', approvals = [], artifacts = [], @@ -36,8 +106,16 @@ function renderSidebar({ })), onLoadArtifact = vi.fn(async () => undefined), onLoadWorkspaceFile = vi.fn(), + tasks = [], + activeProject = currentProject, workspaceProjectId, - restoreFocusRef + restoreFocusRef, + activeConversationId, + browserStates, + onBackBrowser, + onNavigateBrowser, + onReloadBrowser, + onStopLoadingBrowser }: { tab?: AssistantSidebarTab approvals?: PendingSidebarApproval[] @@ -58,31 +136,46 @@ function renderSidebar({ path: string, offsetBytes?: number ) => Promise + tasks?: AssistantTask[] + activeProject?: AssistantProject | null workspaceProjectId?: string restoreFocusRef?: { current: HTMLElement | null } -} = {}): HTMLElement { - render( + activeConversationId?: string + browserStates?: Readonly>>> + onBackBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise + onNavigateBrowser?: (conversationId: string, tabId: BrowserTabId, url: string) => Promise + onReloadBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise + onStopLoadingBrowser?: (conversationId: string, tabId: BrowserTabId) => Promise +} = {}): React.JSX.Element { + return (
undefined)} onListWorkspaceDirectory={onListWorkspaceDirectory} onLoadArtifact={onLoadArtifact} onLoadWorkspaceFile={onLoadWorkspaceFile} + onNavigateBrowser={onNavigateBrowser} onLoadWorkspaceDiff={vi.fn()} onOpenWorkspaceEntry={vi.fn(async () => undefined)} onRefreshChanges={vi.fn(async () => undefined)} + onReloadBrowser={onReloadBrowser} onRemoveSchedule={vi.fn(async () => undefined)} onRespondApproval={vi.fn()} onRunSchedule={vi.fn(async () => undefined)} onSetScheduleEnabled={vi.fn(async () => undefined)} + onStopLoadingBrowser={onStopLoadingBrowser} onOpenTask={vi.fn()} onTabChange={vi.fn()} open @@ -92,7 +185,10 @@ function renderSidebar({ />
) +} +function renderSidebar(options: Parameters[0] = {}): HTMLElement { + render(sidebarElement(options)) return screen.getByRole('complementary', { name: '助手工作栏' }) @@ -257,7 +353,7 @@ describe('RightAssistantSidebar resizing', () => { expect( screen.getAllByRole('tab').map((tab) => tab.textContent) - ).toEqual(['任务中心', '工作区', '浏览器', '成果']) + ).toEqual(['任务中心', '工作区', '浏览器 · 未绑定会话', '成果']) expect( screen.queryByRole('tab', { name: '预览' }) ).not.toBeInTheDocument() @@ -289,6 +385,16 @@ describe('RightAssistantSidebar resizing', () => { screen.getByRole('separator', { name: '调整助手工作栏宽度' }) ).toHaveAttribute('tabindex', '-1') + fireEvent.click(screen.getByRole('tab', { name: '工作区' })) + expect(sidebar).not.toHaveClass('assistant-sidebar--browser-fullscreen') + fireEvent.click(screen.getByRole('tab', { name: /浏览器/u })) + expect( + screen.getByRole('button', { name: '全屏显示浏览器' }) + ).toHaveAttribute('aria-pressed', 'false') + fireEvent.click( + screen.getByRole('button', { name: '全屏显示浏览器' }) + ) + fireEvent.keyDown(window, { key: 'Escape' }) expect(sidebar).not.toHaveClass( 'assistant-sidebar--browser-fullscreen' @@ -332,6 +438,7 @@ describe('RightAssistantSidebar resizing', () => { conversationId: 'conversation-1', messageId: 'message-1', approvalId: 'approval-1', + projectId: currentProject.id, title: '写入工作区', description: '更新 release.md', toolName: 'write_file' @@ -452,6 +559,102 @@ describe('RightAssistantSidebar resizing', () => { expect(onListWorkspaceDirectory).toHaveBeenCalledTimes(2) }) + it('scopes approvals by their owning conversation project', () => { + renderSidebar({ + approvals: [ + { + conversationId: 'conversation-current', + projectId: currentProject.id, + messageId: 'message-current', + approvalId: 'approval-current', + title: '当前项目审批', + description: 'current' + }, + { + conversationId: 'conversation-global', + messageId: 'message-global', + approvalId: 'approval-global', + title: '全局审批', + description: 'global' + } + ] + }) + + expect(screen.getByText('当前项目审批')).toBeInTheDocument() + expect(screen.queryByText('全局审批')).not.toBeInTheDocument() + fireEvent.click( + within(screen.getByRole('group', { name: '任务范围' })).getByRole( + 'button', + { name: '全局' } + ) + ) + expect(screen.getByText('全局审批')).toBeInTheDocument() + expect(screen.queryByText('当前项目审批')).not.toBeInTheDocument() + expect(screen.getByLabelText('等待审批: 1')).toBeInTheDocument() + }) + + it('scopes tasks to the current project, global tasks, or all projects and persists the choice', () => { + const createTask = ( + id: string, + title: string, + projectId?: string + ): AssistantTask => ({ + id, + projectId, + title, + instructions: title, + origin: 'schedule', + status: 'running', + createdAt: '2026-09-09T00:00:00.000Z' + }) + renderSidebar({ + tasks: [ + createTask('task-current', '当前项目任务', currentProject.id), + createTask('task-global', '全局任务'), + createTask( + 'task-other', + '其他项目任务', + '00000000-0000-4000-8000-000000000302' + ) + ] + }) + + expect(screen.getByText('当前项目任务')).toBeInTheDocument() + expect(screen.queryByText('全局任务')).not.toBeInTheDocument() + + fireEvent.click( + within(screen.getByRole('group', { name: '任务范围' })).getByRole( + 'button', + { name: '全局' } + ) + ) + expect(screen.getByText('全局任务')).toBeInTheDocument() + expect(screen.queryByText('当前项目任务')).not.toBeInTheDocument() + + fireEvent.click( + within(screen.getByRole('group', { name: '任务范围' })).getByRole( + 'button', + { name: '所有项目' } + ) + ) + expect(screen.getByText('当前项目任务')).toBeInTheDocument() + expect(screen.getByText('全局任务')).toBeInTheDocument() + expect(screen.getByText('其他项目任务')).toBeInTheDocument() + expect(JSON.parse(localStorage.getItem('goodbuddy.workbar-layout.v1')!)) + .toEqual(expect.objectContaining({ taskScope: 'all-projects' })) + }) + + it('shows a clear current-project empty state when no project is active', () => { + renderSidebar({ activeProject: null }) + + expect( + screen.getByText( + '没有活动项目。请选择其他任务范围或先打开一个项目。' + ) + ).toBeInTheDocument() + expect(screen.queryByText('等待审批')).not.toBeInTheDocument() + }) + it('keeps preview content and reading position when refreshing fails', async () => { const onLoadWorkspaceFile = vi.fn().mockResolvedValueOnce({ path: 'README.md', name: 'README.md', content: '# Original heading', @@ -590,4 +793,342 @@ describe('RightAssistantSidebar resizing', () => { screen.queryByRole('tab', { name: '预览' }) ).not.toBeInTheDocument() }) + + it('keeps browser A selected and usable after switching to B, and binds a new browser to B', async () => { + const onNavigateBrowser = vi.fn(async () => undefined) + const onBackBrowser = vi.fn(async () => undefined) + const onReloadBrowser = vi.fn(async () => undefined) + const onStopLoadingBrowser = vi.fn(async () => undefined) + const browserStates = { + 'conversation-a': { + [firstBrowserTabId]: { + ...browserSummary('conversation-a', firstBrowserTabId, true), + sessionActive: true, + canGoBack: true, + url: 'https://a.example/' + } + }, + 'conversation-b': { + [secondBrowserTabId]: { + ...browserSummary('conversation-b', secondBrowserTabId, true), + sessionActive: true, + url: 'https://b.example/' + } + } + } + const props = { + tab: 'browser' as const, + activeConversationId: 'conversation-a', + browserStates, + onNavigateBrowser, + onBackBrowser, + onReloadBrowser, + onStopLoadingBrowser + } + const view = render(sidebarElement(props)) + await waitFor(() => + expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/') + ) + const browserA = screen.getByRole('tab', { name: '浏览器 · conversation-a' }) + const ownerA = browserApi.createTab.mock.calls[0]![0].workbarInstanceId + + view.rerender(sidebarElement({ ...props, activeConversationId: 'conversation-b' })) + + expect(browserA).toBeVisible() + expect(browserA).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/') + expect(browserApi.createTab).toHaveBeenCalledOnce() + fireEvent.change(screen.getByRole('textbox', { name: '浏览器地址' }), { + target: { value: 'https://a.example/next' } + }) + fireEvent.click(screen.getByRole('button', { name: '前往' })) + await waitFor(() => expect(onNavigateBrowser).toHaveBeenCalledWith( + 'conversation-a', firstBrowserTabId, 'https://a.example/next' + )) + fireEvent.click(screen.getByRole('button', { name: '返回' })) + await waitFor(() => { + expect(onBackBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId) + expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled() + }) + fireEvent.click(screen.getByRole('button', { name: '刷新' })) + await waitFor(() => { + expect(onReloadBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId) + expect(screen.getByRole('button', { name: '刷新' })).toBeEnabled() + }) + + view.rerender(sidebarElement({ + ...props, + activeConversationId: 'conversation-b', + browserStates: { + ...browserStates, + 'conversation-a': { + [firstBrowserTabId]: { ...browserStates['conversation-a'][firstBrowserTabId]!, isLoading: true } + } + } + })) + fireEvent.click(screen.getByRole('button', { name: '停止加载' })) + await waitFor(() => expect(onStopLoadingBrowser).toHaveBeenCalledWith('conversation-a', firstBrowserTabId)) + + fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' })) + fireEvent.click(screen.getByText('浏览器', { selector: 'strong' }).closest('button')!) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2)) + expect(browserApi.createTab).toHaveBeenLastCalledWith({ + conversationId: 'conversation-b', + workbarInstanceId: expect.any(String) + }) + expect(browserApi.createTab.mock.calls[1]![0].workbarInstanceId).not.toBe(ownerA) + expect(screen.getByRole('tab', { name: '浏览器 · conversation-b' })).toHaveAttribute('aria-selected', 'true') + expect(browserA).toBeVisible() + await waitFor(() => expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://b.example/')) + fireEvent.click(screen.getByRole('button', { name: '前往' })) + await waitFor(() => expect(onNavigateBrowser).toHaveBeenLastCalledWith( + 'conversation-b', secondBrowserTabId, 'https://b.example/' + )) + + fireEvent.click(browserA) + expect(browserA).toHaveAttribute('aria-selected', 'true') + expect(screen.getByRole('textbox', { name: '浏览器地址' })).toHaveValue('https://a.example/') + expect(browserApi.createTab).toHaveBeenCalledTimes(2) + }) + + it('keeps two browser instances independent and closes only the selected Main tab', async () => { + const conversationId = 'conversation-a' + const onNavigateBrowser = vi.fn(async () => undefined) + const states: Record> = { + [conversationId]: { + [firstBrowserTabId]: { + conversationId, + tabId: firstBrowserTabId, + status: 'ready', + sessionActive: true, + isLoading: false, + canGoBack: false, + url: 'https://first.example/', + updatedAt: 1 + }, + [secondBrowserTabId]: { + conversationId, + tabId: secondBrowserTabId, + status: 'ready', + sessionActive: true, + isLoading: false, + canGoBack: false, + url: 'https://second.example/', + updatedAt: 2 + } + } + } + renderSidebar({ + tab: 'browser', + activeConversationId: conversationId, + browserStates: states, + onNavigateBrowser + }) + + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce()) + const firstAddress = screen.getByRole('textbox', { + name: '浏览器地址' + }) + await waitFor(() => + expect(firstAddress).toHaveValue('https://first.example/') + ) + fireEvent.change(firstAddress, { + target: { value: 'https://first.example/next' } + }) + fireEvent.click(screen.getByRole('button', { name: '前往' })) + await waitFor(() => + expect(onNavigateBrowser).toHaveBeenCalledWith( + conversationId, + firstBrowserTabId, + 'https://first.example/next' + ) + ) + + fireEvent.click(screen.getByRole('button', { name: '打开工作栏应用' })) + fireEvent.click( + screen.getByText('浏览器', { selector: 'strong' }).closest('button')! + ) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2)) + const ownershipRequests = browserApi.createTab.mock.calls.map( + ([request]) => request + ) + expect(ownershipRequests[0]?.conversationId).toBe(conversationId) + expect(ownershipRequests[1]?.conversationId).toBe(conversationId) + expect(ownershipRequests[0]?.workbarInstanceId).not.toBe( + ownershipRequests[1]?.workbarInstanceId + ) + const secondAddress = screen.getByRole('textbox', { + name: '浏览器地址' + }) + await waitFor(() => + expect(secondAddress).toHaveValue('https://second.example/') + ) + fireEvent.change(secondAddress, { + target: { value: 'https://second.example/next' } + }) + fireEvent.click(screen.getByRole('button', { name: '前往' })) + await waitFor(() => + expect(onNavigateBrowser).toHaveBeenLastCalledWith( + conversationId, + secondBrowserTabId, + 'https://second.example/next' + ) + ) + + fireEvent.click( + screen.getByRole('button', { name: /关闭浏览器 · conversation-a · 2/u }) + ) + await waitFor(() => + expect(browserApi.closeTab).toHaveBeenCalledWith({ + conversationId, + tabId: secondBrowserTabId + }) + ) + expect( + screen.getByRole('tab', { name: '浏览器 · conversation-a' }) + ).toBeInTheDocument() + expect(screen.queryByRole('tab', { name: /· 2$/u })).not.toBeInTheDocument() + }) + + it('restores a browser by stable ownership and releases only its viewport lease', async () => { + const conversationId = 'conversation-restored' + const workbarInstanceId = '10000000-0000-4000-8000-000000000003' + localStorage.setItem( + 'goodbuddy.workbar-layout.v1', + JSON.stringify({ + instances: [ + { + id: '10000000-0000-4000-8000-000000000001', + appId: 'tasks', + title: '任务中心' + }, + { + id: '10000000-0000-4000-8000-000000000002', + appId: 'workspace', + title: '工作区' + }, + { + id: '10000000-0000-4000-8000-000000000003', + appId: 'browser', + title: '浏览器 · 已恢复', + targetRef: { type: 'conversation', conversationId } + } + ], + activeInstanceId: '10000000-0000-4000-8000-000000000001', + expanded: true, + dock: 'right', + widthRatio: 0.3, + taskScope: 'current-project' + }) + ) + const bounds = vi + .spyOn(HTMLElement.prototype, 'getBoundingClientRect') + .mockReturnValue({ + bottom: 500, + height: 400, + left: 900, + right: 1300, + top: 100, + width: 400, + x: 900, + y: 100, + toJSON: () => ({}) + }) + try { + renderSidebar({ activeConversationId: 'different-conversation' }) + expect(browserApi.createTab).not.toHaveBeenCalled() + + fireEvent.click( + screen.getByRole('tab', { name: `浏览器 · ${conversationId}` }) + ) + await waitFor(() => + expect(browserApi.createTab).toHaveBeenCalledWith({ + conversationId, + workbarInstanceId + }) + ) + await waitFor(() => + expect(browserApi.setViewport).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId, + tabId: firstBrowserTabId, + bounds: expect.any(Object), + leaseToken: expect.any(String) + }) + ) + ) + const acquired = browserApi.setViewport.mock.calls.find( + ([request]) => 'conversationId' in request + )?.[0] + if (!acquired || !('conversationId' in acquired)) { + throw new Error('Expected an acquired viewport lease') + } + expect(acquired.leaseToken).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu + ) + + fireEvent.click(screen.getByRole('tab', { name: '工作区' })) + await waitFor(() => + expect(browserApi.setViewport).toHaveBeenCalledWith({ + leaseToken: acquired.leaseToken + }) + ) + expect( + browserApi.setViewport.mock.calls.some( + ([request]) => + Object.keys(request).length === 1 && + request.leaseToken !== acquired.leaseToken + ) + ).toBe(false) + + fireEvent.click( + screen.getByRole('tab', { name: `浏览器 · ${conversationId}` }) + ) + await waitFor(() => { + const acquisitions = browserApi.setViewport.mock.calls + .map(([request]) => request) + .filter((request) => 'conversationId' in request) + expect(acquisitions).toHaveLength(2) + expect(acquisitions[1]!.leaseToken).not.toBe( + acquisitions[0]!.leaseToken + ) + }) + + cleanup() + renderSidebar({ activeConversationId: 'different-conversation' }) + fireEvent.click( + screen.getByRole('tab', { name: `浏览器 · ${conversationId}` }) + ) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledTimes(2)) + expect(browserApi.createTab).toHaveBeenLastCalledWith({ + conversationId, + workbarInstanceId + }) + await waitFor(() => + expect(browserApi.setViewport).toHaveBeenLastCalledWith( + expect.objectContaining({ + conversationId, + tabId: firstBrowserTabId + }) + ) + ) + } finally { + bounds.mockRestore() + } + }) + + it('keeps a browser workbar tab when closing its Main tab fails', async () => { + browserApi.closeTab.mockRejectedValueOnce(new Error('Close denied')) + renderSidebar({ tab: 'browser', activeConversationId: 'conversation-a' }) + await waitFor(() => expect(browserApi.createTab).toHaveBeenCalledOnce()) + + fireEvent.click( + screen.getByRole('button', { name: /关闭浏览器 · conversation-a/u }) + ) + + expect(await screen.findByRole('alert')).toHaveTextContent('Close denied') + expect( + screen.getByRole('tab', { name: '浏览器 · conversation-a' }) + ).toBeInTheDocument() + }) }) diff --git a/src/renderer/src/WorkbarShell.test.tsx b/src/renderer/src/WorkbarShell.test.tsx index 9764c420..edf5d76e 100644 --- a/src/renderer/src/WorkbarShell.test.tsx +++ b/src/renderer/src/WorkbarShell.test.tsx @@ -39,12 +39,14 @@ const stylesheet = readFileSync( function ControlledShell({ initialInstances = DEFAULT_WORKBAR_INSTANCES, onCreate = vi.fn(), + onClose, onTerminalTarget = vi.fn((): WorkbarTargetRef => ({ type: 'local' })) }: { initialInstances?: readonly WorkbarTabInstance[] onCreate?: (request: WorkbarInstanceCreateRequest) => void + onClose?: (instance: WorkbarTabInstance) => boolean | void | Promise onTerminalTarget?: () => | WorkbarTargetRef | Promise @@ -55,19 +57,27 @@ function ControlledShell({ ) return ( - - setInstances((current) => - current.filter((candidate) => candidate.id !== instance.id) - ) - } - onCreateInstance={onCreate} - onResolveTerminalTarget={onTerminalTarget} - renderPanel={(instance) =>

{instance.title}内容

} - /> + <> + + {activeId ?? 'none'} + + { + const accepted = await onClose?.(instance) + if (accepted === false) return false + setInstances((current) => + current.filter((candidate) => candidate.id !== instance.id) + ) + return true + }} + onCreateInstance={onCreate} + onResolveTerminalTarget={onTerminalTarget} + renderPanel={(instance) =>

{instance.title}内容

} + /> + ) } @@ -87,7 +97,7 @@ describe('WorkbarShell', () => { ) }) - it('provides four default single-instance tabs and keeps add outside the tablist', () => { + it('provides fixed defaults plus a browser tab and keeps add outside the tablist', () => { render() const tablist = screen.getByRole('tablist', { @@ -113,6 +123,20 @@ describe('WorkbarShell', () => { ).not.toHaveClass('workbar-shell__tab-item--active') }) + it('does not expose close controls for fixed tabs', () => { + render() + + expect( + screen.queryByRole('button', { name: '关闭任务中心' }) + ).not.toBeInTheDocument() + expect( + screen.queryByRole('button', { name: '关闭工作区' }) + ).not.toBeInTheDocument() + expect( + screen.getByRole('button', { name: '关闭浏览器' }) + ).toBeInTheDocument() + }) + it('opens the catalog, focuses its first choice, and restores add-button focus on Escape', async () => { render() const addButton = screen.getByRole('button', { @@ -196,6 +220,22 @@ describe('WorkbarShell', () => { ) }) + it('allows another browser instance to be requested', () => { + const onCreate = vi.fn() + render() + + fireEvent.click( + screen.getByRole('button', { name: '打开工作栏应用' }) + ) + fireEvent.click( + screen.getByText('浏览器', { selector: 'strong' }).closest('button')! + ) + + expect(onCreate).toHaveBeenCalledWith( + expect.objectContaining({ appId: 'browser' }) + ) + }) + it('supports roving Arrow, Home, and End navigation', () => { render() const tasks = screen.getByRole('tab', { name: '任务中心' }) @@ -214,19 +254,23 @@ describe('WorkbarShell', () => { expect(tasks).toHaveFocus() }) - it('closes the active tab, activates its right neighbor, and restores focus', async () => { - render() - const workspace = screen.getByRole('tab', { name: '工作区' }) - fireEvent.click(workspace) + it('closes an active closable tab, activates its neighbor, and restores focus', async () => { + render( + + ) + const browser = screen.getByRole('tab', { name: '浏览器' }) + fireEvent.click(browser) fireEvent.click( - screen.getByRole('button', { name: '关闭工作区' }) + screen.getByRole('button', { name: '关闭浏览器' }) ) - const browser = screen.getByRole('tab', { name: '浏览器' }) - await waitFor(() => expect(browser).toHaveFocus()) - expect(browser).toHaveAttribute('aria-selected', 'true') + const results = screen.getByRole('tab', { name: '成果' }) + await waitFor(() => expect(results).toHaveFocus()) + expect(results).toHaveAttribute('aria-selected', 'true') expect( - screen.queryByRole('tab', { name: '工作区' }) + screen.queryByRole('tab', { name: '浏览器' }) ).not.toBeInTheDocument() }) @@ -234,19 +278,39 @@ describe('WorkbarShell', () => { render() const tasks = screen.getByRole('tab', { name: '任务中心' }) fireEvent.click( - screen.getByRole('button', { name: '关闭成果' }) + screen.getByRole('button', { name: '关闭浏览器' }) ) await waitFor(() => expect(tasks).toHaveFocus()) expect(tasks).toHaveAttribute('aria-selected', 'true') }) + it('retains an instance when its close operation rejects', async () => { + const onClose = vi.fn(async () => { + throw new Error('Close denied') + }) + render( + + ) + + fireEvent.click( + screen.getByRole('button', { name: `关闭${terminalOne.title}` }) + ) + + await waitFor(() => expect(onClose).toHaveBeenCalledOnce()) + expect(screen.getByRole('tab', { name: terminalOne.title })) + .toBeInTheDocument() + }) + it('shows the empty state while preserving the add command after the last close', async () => { render( - + ) fireEvent.click( - screen.getByRole('button', { name: '关闭任务中心' }) + screen.getByRole('button', { name: `关闭${terminalOne.title}` }) ) const addButton = screen.getByRole('button', { @@ -256,6 +320,9 @@ describe('WorkbarShell', () => { expect( screen.getByText('还没有打开的工作栏应用') ).toBeInTheDocument() + expect(screen.getByTestId('active-workbar-instance')).toHaveTextContent( + 'none' + ) expect(addButton).toBeVisible() }) }) diff --git a/src/renderer/src/WorkbarShell.tsx b/src/renderer/src/WorkbarShell.tsx index 375a3c4b..afd456eb 100644 --- a/src/renderer/src/WorkbarShell.tsx +++ b/src/renderer/src/WorkbarShell.tsx @@ -61,7 +61,7 @@ export type WorkbarShellProps = { activeInstanceId: string | null renderPanel: (instance: WorkbarTabInstance) => ReactNode renderTabAdornment?: (instance: WorkbarTabInstance) => ReactNode - onActiveInstanceChange: (instanceId: string) => void + onActiveInstanceChange: (instanceId: string | null) => void onCreateInstance: (request: WorkbarInstanceCreateRequest) => void onCloseInstance: ( instance: WorkbarTabInstance @@ -289,6 +289,12 @@ export function WorkbarShell({ const handleClose = async ( instance: WorkbarTabInstance ): Promise => { + const definition = appDefinitions.find( + (candidate) => candidate.id === instance.appId + ) + if (definition?.closable !== true) { + return + } const closingIndex = instances.findIndex( (candidate) => candidate.id === instance.id ) @@ -299,19 +305,18 @@ export function WorkbarShell({ null) : activeInstance - const closeResult = onCloseInstance(instance) - const closeAccepted = - closeResult && - typeof (closeResult as PromiseLike).then === - 'function' - ? await closeResult - : closeResult + let closeAccepted: boolean | void + try { + closeAccepted = await onCloseInstance(instance) + } catch { + return + } if (closeAccepted === false) { return } pendingTabFocusRef.current = nextActive?.id ?? null - if (nextActive && nextActive.id !== activeInstanceId) { - onActiveInstanceChange(nextActive.id) + if (isActive && nextActive?.id !== activeInstanceId) { + onActiveInstanceChange(nextActive?.id ?? null) } } @@ -331,6 +336,10 @@ export function WorkbarShell({ const selected = !catalogOpen && instance.id === activeInstanceId const AppIcon = WORKBAR_APP_ICONS[instance.appId] + const closable = + appDefinitions.find( + (definition) => definition.id === instance.appId + )?.closable === true return (
{renderTabAdornment?.(instance)} - + {closable ? ( + + ) : null}
) })} diff --git a/src/renderer/src/i18n/locales/en-US/app.ts b/src/renderer/src/i18n/locales/en-US/app.ts index 13cb72b0..5fc511c4 100644 --- a/src/renderer/src/i18n/locales/en-US/app.ts +++ b/src/renderer/src/i18n/locales/en-US/app.ts @@ -208,6 +208,9 @@ export const app = { switching: 'Switching…', picker: 'Runtime and model', directModels: 'Direct models', + defaultDirect: 'Direct · Follow the global default model', + fixedRuntimeModel: 'Fixed Runtime model', + fixedModel: 'Fixed', deepseekHarnessGroup: 'DeepSeek Harness (Developer preview · OpenAI-compatible)', manage: 'Manage Runtime and model connections', diff --git a/src/renderer/src/i18n/locales/en-US/workspace.ts b/src/renderer/src/i18n/locales/en-US/workspace.ts index e84bf801..1765666f 100644 --- a/src/renderer/src/i18n/locales/en-US/workspace.ts +++ b/src/renderer/src/i18n/locales/en-US/workspace.ts @@ -310,6 +310,14 @@ export const workspace = { startedAt: 'Started {{time}}', nextRunAt: 'Next run: {{time}}', notStarted: 'Not run yet', + scope: { + ariaLabel: 'Task scope', + currentProject: 'Current project', + global: 'Global', + allProjects: 'All projects', + noCurrentProject: + 'No project is active. Choose another task scope or open a project.' + }, filters: { ariaLabel: 'Filter tasks', attention: 'Attention', @@ -365,6 +373,7 @@ export const workspace = { }, browser: { title: 'Live browser', + unbound: 'No conversation', interact: 'Interact', interacting: 'Interacting', close: 'Close browser', @@ -407,6 +416,8 @@ export const workspace = { backBrowser: 'Could not go back', reloadBrowser: 'Could not refresh the web page', stopLoadingBrowser: 'Could not stop loading the page', + closeBrowser: 'Could not close this browser tab', + browserControlUnavailable: 'Browser tab controls are unavailable', interactBrowser: 'Could not open the browser interaction window', stopBrowser: 'Could not close the browser' } diff --git a/src/renderer/src/i18n/locales/zh-CN/app.ts b/src/renderer/src/i18n/locales/zh-CN/app.ts index 6f55f81e..f60d3d27 100644 --- a/src/renderer/src/i18n/locales/zh-CN/app.ts +++ b/src/renderer/src/i18n/locales/zh-CN/app.ts @@ -195,6 +195,9 @@ export const app = { switching: '切换中…', picker: 'Runtime 和模型', directModels: '直连模型', + defaultDirect: '直连 · 跟随全局默认模型', + fixedRuntimeModel: '固定 Runtime 模型', + fixedModel: '固定', deepseekHarnessGroup: 'DeepSeek Harness(开发者预览 · OpenAI 兼容)', manage: '管理 Runtime 和模型连接', diff --git a/src/renderer/src/i18n/locales/zh-CN/workspace.ts b/src/renderer/src/i18n/locales/zh-CN/workspace.ts index 3af64e3f..bdc45278 100644 --- a/src/renderer/src/i18n/locales/zh-CN/workspace.ts +++ b/src/renderer/src/i18n/locales/zh-CN/workspace.ts @@ -302,6 +302,13 @@ export const workspace = { startedAt: '{{time}} 开始', nextRunAt: '下次运行:{{time}}', notStarted: '尚未运行', + scope: { + ariaLabel: '任务范围', + currentProject: '当前项目', + global: '全局', + allProjects: '所有项目', + noCurrentProject: '没有活动项目。请选择其他任务范围或先打开一个项目。' + }, filters: { ariaLabel: '筛选任务', attention: '待关注', @@ -355,6 +362,7 @@ export const workspace = { }, browser: { title: '实时浏览器', + unbound: '未绑定会话', interact: '交互', interacting: '交互中', close: '关闭浏览器', @@ -397,6 +405,8 @@ export const workspace = { backBrowser: '返回上一页失败', reloadBrowser: '刷新网页失败', stopLoadingBrowser: '停止加载失败', + closeBrowser: '关闭此浏览器 Tab 失败', + browserControlUnavailable: '浏览器 Tab 控制不可用', interactBrowser: '打开浏览器交互窗口失败', stopBrowser: '关闭浏览器失败' } diff --git a/src/renderer/src/runtime-selection.test.ts b/src/renderer/src/runtime-selection.test.ts index a3ea7fb3..1e4a6be1 100644 --- a/src/renderer/src/runtime-selection.test.ts +++ b/src/renderer/src/runtime-selection.test.ts @@ -17,7 +17,7 @@ function harnessSettings( } describe('DeepSeek Harness runtime selection', () => { - it('uses the configured Chat Completions profile', () => { + it('references the Runtime configuration without copying its profile', () => { const selection = getRuntimeSelectionForProvider( 'deepseek-harness', harnessSettings({ @@ -27,8 +27,7 @@ describe('DeepSeek Harness runtime selection', () => { ) expect(selection).toEqual({ - provider: 'deepseek-harness', - profileId: harnessProfileId + provider: 'deepseek-harness' } satisfies Record) }) diff --git a/src/shared/contracts.test.ts b/src/shared/contracts.test.ts index cfba6924..c727ec9f 100644 --- a/src/shared/contracts.test.ts +++ b/src/shared/contracts.test.ts @@ -1,8 +1,11 @@ import { describe, expect, it } from 'vitest' import { agentRequestSchema, + browserCloseTabRequestSchema, + browserCreateTabRequestSchema, browserLiveStateSchema, browserNavigateRequestSchema, + browserSetViewportRequestSchema, browserStopLoadingRequestSchema, builtinEmbeddingConnectionId, clipboardTextSchema, @@ -23,9 +26,14 @@ describe('clipboard text contract', () => { }) describe('browser control contracts', () => { + const tabId = '4d7886c3-4c9c-4e84-bf65-1a4a841f5296' + const workbarInstanceId = '0387bd61-3a12-40ce-98d7-ef5d14cc8251' + const leaseToken = '7d201980-0ad4-4670-81d4-dc2bf79f03b2' + it('requires authoritative toolbar metadata and accepts committed URLs up to 8192 characters', () => { const base = { conversationId: 'conversation', + tabId, status: 'ready' as const, sessionActive: true, isLoading: false, @@ -51,10 +59,12 @@ describe('browser control contracts', () => { expect( browserNavigateRequestSchema.parse({ conversationId: 'conversation', + tabId, url: 'https://example.com/' }) ).toEqual({ conversationId: 'conversation', + tabId, url: 'https://example.com/' }) expect( @@ -70,6 +80,48 @@ describe('browser control contracts', () => { }).success ).toBe(true) }) + + it('uses opaque tab IDs and validates complete viewport ownership leases', () => { + expect( + browserCreateTabRequestSchema.parse({ + conversationId: 'conversation', + workbarInstanceId + }) + ).toEqual({ conversationId: 'conversation', workbarInstanceId }) + expect( + browserCloseTabRequestSchema.safeParse({ + conversationId: 'conversation', + tabId + }).success + ).toBe(true) + expect( + browserCloseTabRequestSchema.safeParse({ + conversationId: 'conversation', + tabId: 'predictable-tab-name' + }).success + ).toBe(false) + expect( + browserSetViewportRequestSchema.safeParse({ + conversationId: 'conversation', + tabId, + leaseToken, + bounds: { x: 1, y: 2, width: 300, height: 400 } + }).success + ).toBe(true) + expect( + browserSetViewportRequestSchema.safeParse({ tabId, leaseToken }).success + ).toBe(false) + expect( + browserLiveStateSchema.safeParse({ + conversationId: 'conversation', + status: 'ready', + sessionActive: true, + isLoading: false, + canGoBack: false, + updatedAt: 1 + }).success + ).toBe(false) + }) }) const baseInput = { diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index ce6a52d9..32435f9a 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -30,8 +30,6 @@ import { actorConversationSubagentActivitySchema, legacyConversationSubagentActivitySchema, legacyWorkModeSchema, - maximumConversationHistoryCharacters, - maximumConversationHistoryMessages, type AssistantProject, type AssistantArtifact, type AssistantMemory, @@ -346,11 +344,9 @@ export const agentRequestSchema = z contextIds: z.array(z.string().uuid()).max(8).optional(), history: z .array(conversationHistoryMessageSchema) - .max(maximumConversationHistoryMessages) .optional(), historyMessageIds: z .array(z.string().uuid()) - .max(maximumConversationHistoryMessages) .optional(), currentUserMessageId: z.string().uuid().optional(), currentAssistantMessageId: z.string().uuid().optional(), @@ -359,18 +355,6 @@ export const agentRequestSchema = z }) .strict() .superRefine((request, context) => { - const historyLength = - request.history?.reduce( - (total, message) => total + message.content.length, - 0 - ) ?? 0 - if (historyLength > maximumConversationHistoryCharacters) { - context.addIssue({ - code: 'custom', - path: ['history'], - message: `会话历史总长度不能超过 ${maximumConversationHistoryCharacters.toLocaleString()} 个字符` - }) - } if ( request.historyMessageIds && request.historyMessageIds.length !== @@ -683,6 +667,7 @@ const embeddingConnectionInputSchema = z.discriminatedUnion('kind', [ ]) export const runtimeModelSourceSchema = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('default') }).strict(), z.object({ kind: z.literal('platform') }).strict(), z .object({ @@ -1373,18 +1358,28 @@ export type AppInfo = { shortcutStatus?: GlobalShortcutRegistrationStatus } +export const browserTabIdSchema = z.string().uuid().brand<'BrowserTabId'>() +export type BrowserTabId = z.infer + +export const browserWorkbarInstanceIdSchema = z.string().uuid() +export const browserViewportLeaseTokenSchema = z.string().uuid() + +const browserStatusSchema = z.enum([ + 'creating', + 'loading', + 'ready', + 'acting', + 'interactive', + 'failed', + 'stopped' +]) + export const browserLiveStateSchema = z .object({ conversationId: conversationIdSchema, - status: z.enum([ - 'creating', - 'loading', - 'ready', - 'acting', - 'interactive', - 'failed', - 'stopped' - ]), + tabId: browserTabIdSchema, + ownerWindowId: z.number().int().nonnegative().optional(), + status: browserStatusSchema, sessionActive: z.boolean(), isLoading: z.boolean(), canGoBack: z.boolean(), @@ -1404,19 +1399,73 @@ export const browserLiveStateSchema = z export type BrowserLiveState = z.infer +export const browserTabSummarySchema = z + .object({ + conversationId: conversationIdSchema, + tabId: browserTabIdSchema, + primary: z.boolean(), + status: browserStatusSchema.exclude(['stopped']), + isLoading: z.boolean(), + canGoBack: z.boolean(), + url: z.string().max(8_192).optional(), + createdAt: z.number().int().nonnegative(), + updatedAt: z.number().int().nonnegative() + }) + .strict() + +export type BrowserTabSummary = z.infer + +export const browserCreateTabRequestSchema = z + .object({ + conversationId: conversationIdSchema, + workbarInstanceId: browserWorkbarInstanceIdSchema + }) + .strict() +export type BrowserCreateTabRequest = z.infer + +export const browserListTabsRequestSchema = z + .object({ conversationId: conversationIdSchema }) + .strict() +export type BrowserListTabsRequest = z.infer + +export const browserTabRequestSchema = z + .object({ + conversationId: conversationIdSchema, + tabId: browserTabIdSchema + }) + .strict() +export type BrowserTabRequest = z.infer + +export const browserCloseTabRequestSchema = browserTabRequestSchema +export type BrowserCloseTabRequest = BrowserTabRequest + export const browserStopRequestSchema = z .object({ conversationId: conversationIdSchema }) .strict() -export const browserBackRequestSchema = browserStopRequestSchema -export const browserReloadRequestSchema = browserStopRequestSchema -export const browserStopLoadingRequestSchema = browserStopRequestSchema +const browserCompatibleTabRequestSchema = z + .object({ + conversationId: conversationIdSchema, + tabId: browserTabIdSchema.optional() + }) + .strict() + +export const browserBackTabRequestSchema = browserCompatibleTabRequestSchema +export const browserReloadTabRequestSchema = browserCompatibleTabRequestSchema +export const browserStopLoadingTabRequestSchema = browserCompatibleTabRequestSchema +export const browserBackRequestSchema = browserBackTabRequestSchema +export const browserReloadRequestSchema = browserReloadTabRequestSchema +export const browserStopLoadingRequestSchema = browserStopLoadingTabRequestSchema +export const browserSnapshotRequestSchema = browserCompatibleTabRequestSchema +export const browserScreenshotRequestSchema = browserCompatibleTabRequestSchema export const browserSetViewportRequestSchema = z .object({ conversationId: conversationIdSchema.optional(), + tabId: browserTabIdSchema.optional(), + leaseToken: browserViewportLeaseTokenSchema, bounds: z .object({ x: z.number().int().nonnegative(), @@ -1429,7 +1478,9 @@ export const browserSetViewportRequestSchema = z }) .strict() .refine( - (value) => Boolean(value.conversationId) === Boolean(value.bounds), + (value) => + (!value.conversationId && !value.tabId && !value.bounds) || + Boolean(value.conversationId && value.bounds), '浏览器视口参数不完整' ) @@ -1440,10 +1491,90 @@ export type BrowserViewportBounds = NonNullable< export const browserNavigateRequestSchema = z .object({ conversationId: conversationIdSchema, + tabId: browserTabIdSchema.optional(), url: z.string().min(1).max(8_192) }) .strict() +export type BrowserNavigateRequest = Omit< + z.infer, + 'tabId' +> & { tabId: BrowserTabId } + +export const browserClickRequestSchema = browserCompatibleTabRequestSchema + .extend({ ref: z.string().min(1).max(256) }) + .strict() +export const browserTypeRequestSchema = browserCompatibleTabRequestSchema + .extend({ + ref: z.string().min(1).max(256), + text: z.string().max(16_384) + }) + .strict() +export const browserSelectRequestSchema = browserCompatibleTabRequestSchema + .extend({ + ref: z.string().min(1).max(256), + value: z.string().max(1_024) + }) + .strict() + +type BrowserOwnedRequest = Omit & { + tabId: BrowserTabId +} + +export type BrowserBackRequest = BrowserOwnedRequest< + z.infer +> +export type BrowserReloadRequest = BrowserOwnedRequest< + z.infer +> +export type BrowserStopLoadingRequest = BrowserOwnedRequest< + z.infer +> +export type BrowserSnapshotRequest = BrowserOwnedRequest< + z.infer +> +export type BrowserScreenshotRequest = BrowserOwnedRequest< + z.infer +> +export type BrowserClickRequest = BrowserOwnedRequest< + z.infer +> +export type BrowserTypeRequest = BrowserOwnedRequest< + z.infer +> +export type BrowserSelectRequest = BrowserOwnedRequest< + z.infer +> +export type BrowserSetViewportRequest = + | { + conversationId: string + tabId: BrowserTabId + bounds: BrowserViewportBounds + leaseToken: string + } + | { leaseToken: string } + +export type BrowserSnapshotResult = { + url: string + title: string + nodes: Array<{ + ref: string + role: string + name: string + value?: string + disabled?: boolean + focused?: boolean + editable?: boolean + }> + truncated: boolean +} + +export type BrowserScreenshotResult = { + type: 'image' + mimeType: 'image/jpeg' + data: string +} + export const knowledgeIdSchema = z.string().uuid() export const knowledgeCreateSchema = z .object({ @@ -1637,14 +1768,31 @@ export type DesktopApi = { onEvent: (listener: (event: AgentEvent) => void) => () => void } browser: { - navigate: (conversationId: string, url: string) => Promise - back: (conversationId: string) => Promise - reload: (conversationId: string) => Promise - stopLoading: (conversationId: string) => Promise - setViewport: ( - conversationId?: string, - bounds?: BrowserViewportBounds - ) => Promise + createTab?: (request: BrowserCreateTabRequest) => Promise + listTabs?: (request: BrowserListTabsRequest) => Promise + closeTab?: (request: BrowserCloseTabRequest) => Promise + navigate: { + (request: BrowserNavigateRequest): Promise + (conversationId: string, url: string): Promise + } + back: { + (request: BrowserBackRequest): Promise + (conversationId: string): Promise + } + reload: { + (request: BrowserReloadRequest): Promise + (conversationId: string): Promise + } + stopLoading: { + (request: BrowserStopLoadingRequest): Promise + (conversationId: string): Promise + } + snapshot?: (request: BrowserSnapshotRequest) => Promise + click?: (request: BrowserClickRequest) => Promise + type?: (request: BrowserTypeRequest) => Promise + select?: (request: BrowserSelectRequest) => Promise + screenshot?: (request: BrowserScreenshotRequest) => Promise + setViewport: (request: BrowserSetViewportRequest) => Promise stop: (conversationId: string) => Promise onState: (listener: (state: BrowserLiveState) => void) => () => void } diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index b7a88370..d95e3331 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -22,10 +22,18 @@ export const ipcChannels = { agentQuestionRespond: 'agent:question:respond', agentCompactConversation: 'agent:context:compact', agentEvent: 'agent:event', + browserCreateTab: 'browser:tab:create', + browserListTabs: 'browser:tab:list', + browserCloseTab: 'browser:tab:close', browserNavigate: 'browser:navigate', browserBack: 'browser:back', browserReload: 'browser:reload', browserStopLoading: 'browser:stop-loading', + browserSnapshot: 'browser:snapshot', + browserClick: 'browser:click', + browserType: 'browser:type', + browserSelect: 'browser:select', + browserScreenshot: 'browser:screenshot', browserSetViewport: 'browser:set-viewport', browserStop: 'browser:stop', browserState: 'browser:state', diff --git a/src/shared/runtime-selection-contracts.ts b/src/shared/runtime-selection-contracts.ts index 984f0761..7dc67ea2 100644 --- a/src/shared/runtime-selection-contracts.ts +++ b/src/shared/runtime-selection-contracts.ts @@ -10,7 +10,7 @@ export const agentRuntimeSelectionSchema = z.discriminatedUnion( z .object({ provider: z.literal('model'), - profileId: runtimeSelectionProfileIdSchema + profileId: runtimeSelectionProfileIdSchema.optional() }) .strict(), z @@ -48,17 +48,21 @@ export type RuntimeSelectionRepairSettings = { }> defaultModelProfileId: string opencodeModelSource: + | { kind: 'default' } | { kind: 'platform' } | { kind: 'profile'; profileId: string } continueModelSource: + | { kind: 'default' } | { kind: 'platform' } | { kind: 'profile'; profileId: string } deepseekHarnessModelSource?: + | { kind: 'default' } | { kind: 'platform' } | { kind: 'profile'; profileId: string } } type RuntimeModelSource = + | { kind: 'default' } | { kind: 'platform' } | { kind: 'profile'; profileId: string } @@ -79,34 +83,9 @@ type ChannelModelProfile = RuntimeSelectionRepairSettings['modelProfiles'][numbe export function getRuntimeSelectionForProvider( provider: Exclude, - settings: RuntimeSelectionDefaultSettings + _settings: RuntimeSelectionDefaultSettings ): AgentRuntimeSelection { - if (provider === 'model') { - return { - provider, - profileId: settings.defaultModelProfileId - } - } - const source = - provider === 'opencode' - ? settings.opencodeModelSource - : provider === 'continue' - ? settings.continueModelSource - : settings.deepseekHarnessModelSource - const resolvedProfile = - provider === 'opencode' - ? settings.opencodeModelProfile - : provider === 'continue' - ? settings.continueModelProfile - : settings.deepseekHarnessModelProfile - return { - provider, - ...(source?.kind === 'profile' - ? { profileId: source.profileId } - : !source && resolvedProfile - ? { profileId: resolvedProfile.id } - : {}) - } + return { provider } } export function getDefaultRuntimeSelection( @@ -121,6 +100,20 @@ export function getDefaultRuntimeSelection( : getRuntimeSelectionForProvider('model', settings) } +// Resolve only for presentation or request diagnostics, never for persisted defaults. +export function getRuntimeSelectionProfileId( + selection: AgentRuntimeSelection, + settings: RuntimeSelectionDefaultSettings +): string | undefined { + if (selection.provider === 'auto') return undefined + if (selection.profileId) return selection.profileId + if (selection.provider === 'model') return settings.defaultModelProfileId + const source = selection.provider === 'opencode' ? settings.opencodeModelSource + : selection.provider === 'continue' ? settings.continueModelSource : settings.deepseekHarnessModelSource + return source?.kind === 'default' ? settings.defaultModelProfileId + : source?.kind === 'profile' ? source.profileId : undefined +} + export function isChannelModelProfileUsable( profile: ChannelModelProfile ): boolean { @@ -162,11 +155,7 @@ export function repairChannelRuntimeSelection( isChannelModelProfileUsable(profile) ) ?? settings.modelProfiles.find(isChannelModelProfileUsable) - const defaultDirectSelection: AgentRuntimeSelection = { - provider: 'model', - profileId: - defaultDirectProfile?.id ?? settings.defaultModelProfileId - } + const defaultDirectSelection: AgentRuntimeSelection = { provider: 'model' } if (selection.provider === 'auto') { return defaultDirectSelection } @@ -196,7 +185,7 @@ export function repairChannelRuntimeSelection( return repaired } const profile = settings.modelProfiles.find( - (candidate) => candidate.id === repaired.profileId + (candidate) => candidate.id === (repaired.profileId ?? defaultDirectProfile?.id) ) return profile && isChannelModelProfileUsable(profile) ? repaired @@ -217,29 +206,15 @@ export function repairAgentRuntimeSelection( return selection } if (selection.provider === 'model') { - return { - provider: 'model', - profileId: settings.defaultModelProfileId - } - } - const source = - selection.provider === 'opencode' - ? settings.opencodeModelSource - : selection.provider === 'continue' - ? settings.continueModelSource - : settings.deepseekHarnessModelSource ?? { kind: 'platform' } - return { - provider: selection.provider, - ...(source.kind === 'profile' - ? { profileId: source.profileId } - : {}) + return { provider: 'model' } } + return { provider: selection.provider } } export function agentRuntimeSelectionKey( selection: AgentRuntimeSelection ): string { return `${selection.provider}:${'profileId' in selection - ? selection.profileId ?? 'platform' + ? selection.profileId ?? 'default' : 'default'}` } diff --git a/src/shared/terminal-contracts.ts b/src/shared/terminal-contracts.ts index 80babedf..2dadca95 100644 --- a/src/shared/terminal-contracts.ts +++ b/src/shared/terminal-contracts.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { workbarTargetRefSchema } from './workbar-contracts' +import { workbarExecutionTargetRefSchema } from './workbar-contracts' export const TERMINAL_LIMITS = { maximumSessionsPerWindow: 12, @@ -35,7 +35,7 @@ export type TerminalSessionId = z.infer< typeof terminalSessionIdSchema > -export const terminalTargetSchema = workbarTargetRefSchema +export const terminalTargetSchema = workbarExecutionTargetRefSchema export type TerminalTarget = z.infer export const terminalSessionStateSchema = z.enum([ diff --git a/src/shared/workbar-contracts.test.ts b/src/shared/workbar-contracts.test.ts index c77359b2..0e0d148c 100644 --- a/src/shared/workbar-contracts.test.ts +++ b/src/shared/workbar-contracts.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { WORKBAR_APP_DEFINITIONS, WORKBAR_LIMITS, + normalizeWorkbarLayoutPreferences, workbarAppDefinitionSchema, workbarLayoutPreferencesSchema, workbarTabInstanceSchema @@ -14,20 +15,28 @@ const projectId = '00000000-0000-4000-8000-000000000201' describe('workbar contracts', () => { it('freezes the first application catalog and instance policies', () => { expect( - WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultOpen }) => ({ + WORKBAR_APP_DEFINITIONS.map(({ id, instancePolicy, defaultContext, defaultOpen, required, closable, reorderable }) => ({ id, instancePolicy, - defaultOpen + defaultContext, + defaultOpen, + required, + closable, + reorderable })) ).toEqual([ - { id: 'tasks', instancePolicy: 'single', defaultOpen: true }, - { id: 'workspace', instancePolicy: 'single', defaultOpen: true }, - { id: 'browser', instancePolicy: 'single', defaultOpen: true }, - { id: 'results', instancePolicy: 'single', defaultOpen: true }, + { id: 'tasks', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true }, + { id: 'workspace', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: true, closable: false, reorderable: true }, + { id: 'browser', instancePolicy: 'multiple', defaultContext: 'current-conversation', defaultOpen: true, required: false, closable: true, reorderable: true }, + { id: 'results', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, required: false, closable: true, reorderable: true }, { id: 'terminal', instancePolicy: 'multiple', - defaultOpen: false + defaultContext: 'current-project', + defaultOpen: false, + required: false, + closable: true, + reorderable: true } ]) for (const definition of WORKBAR_APP_DEFINITIONS) { @@ -51,12 +60,52 @@ describe('workbar contracts', () => { activeInstanceId: terminalId, expanded: true, dock: 'right', - widthRatio: 0.35 + widthRatio: 0.35, + taskScope: 'current-project' } as const expect(workbarLayoutPreferencesSchema.parse(layout)).toEqual(layout) }) + it('persists a browser conversation binding without a runtime tab identifier', () => { + expect( + workbarTabInstanceSchema.parse({ + id: '00000000-0000-4000-8000-000000000103', + appId: 'browser', + title: '浏览器 · 会话 A', + targetRef: { type: 'conversation', conversationId: 'conversation-a' } + }) + ).toEqual({ + id: '00000000-0000-4000-8000-000000000103', + appId: 'browser', + title: '浏览器 · 会话 A', + targetRef: { type: 'conversation', conversationId: 'conversation-a' } + }) + expect( + workbarTabInstanceSchema.safeParse({ + id: '00000000-0000-4000-8000-000000000103', + appId: 'browser', + title: '浏览器', + targetRef: { type: 'conversation', conversationId: 'conversation-a' }, + tabId: '00000000-0000-4000-8000-000000000999' + }).success + ).toBe(false) + }) + + it('explicitly keeps every existing app visible across context switches', () => { + for (const definition of WORKBAR_APP_DEFINITIONS) { + expect(definition.visibleAcrossContextSwitches).toBe(true) + const { visibleAcrossContextSwitches, ...withoutVisibility } = definition + expect(workbarAppDefinitionSchema.safeParse(withoutVisibility).success).toBe(false) + expect( + workbarAppDefinitionSchema.parse({ + ...withoutVisibility, + visibleAcrossContextSwitches + }).visibleAcrossContextSwitches + ).toBe(true) + } + }) + it('rejects unknown and sensitive target parameters', () => { for (const targetRef of [ { type: 'local', cwd: 'C:\\secret' }, @@ -102,7 +151,8 @@ describe('workbar contracts', () => { activeInstanceId: taskId, expanded: true, dock: 'right', - widthRatio: 0.4 + widthRatio: 0.4, + taskScope: 'current-project' } expect( workbarLayoutPreferencesSchema.safeParse({ @@ -131,4 +181,61 @@ describe('workbar contracts', () => { }).success ).toBe(false) }) + + it('normalizes duplicate policies, fixed defaults, and stale active state', () => { + const browserId = '00000000-0000-4000-8000-000000000103' + const secondBrowserId = '00000000-0000-4000-8000-000000000104' + const workspaceId = '00000000-0000-4000-8000-000000000105' + const normalized = normalizeWorkbarLayoutPreferences( + { + instances: [ + { id: browserId, appId: 'browser', title: '浏览器' }, + { id: secondBrowserId, appId: 'browser', title: '浏览器 2' }, + { id: workspaceId, appId: 'workspace', title: '工作区' }, + { id: terminalId, appId: 'workspace', title: '重复工作区' } + ], + activeInstanceId: terminalId, + expanded: true, + dock: 'right', + widthRatio: 0.4, + taskScope: 'all-projects' + }, + [ + { id: taskId, appId: 'tasks', title: '任务中心' }, + { id: workspaceId, appId: 'workspace', title: '工作区' } + ] + ) + + expect(normalized?.instances.map(({ id, appId }) => ({ id, appId }))) + .toEqual([ + { id: taskId, appId: 'tasks' }, + { id: browserId, appId: 'browser' }, + { id: secondBrowserId, appId: 'browser' }, + { id: workspaceId, appId: 'workspace' } + ]) + expect(normalized?.activeInstanceId).toBe(taskId) + expect(normalized?.taskScope).toBe('all-projects') + }) + + it('preserves a valid empty layout with a null active instance', () => { + expect( + normalizeWorkbarLayoutPreferences( + { + instances: [], + activeInstanceId: null, + expanded: false, + dock: 'right', + widthRatio: 0.4 + }, + [] + ) + ).toEqual({ + instances: [], + activeInstanceId: null, + expanded: false, + dock: 'right', + widthRatio: 0.4, + taskScope: 'current-project' + }) + }) }) diff --git a/src/shared/workbar-contracts.ts b/src/shared/workbar-contracts.ts index f0a31ff8..88cf6afb 100644 --- a/src/shared/workbar-contracts.ts +++ b/src/shared/workbar-contracts.ts @@ -38,7 +38,8 @@ export type WorkbarInstancePolicy = z.infer< export const workbarDefaultContextSchema = z.enum([ 'application', - 'current-project' + 'current-project', + 'current-conversation' ]) export type WorkbarDefaultContext = z.infer< typeof workbarDefaultContextSchema @@ -78,7 +79,11 @@ export const workbarAppDefinitionSchema = z ), instancePolicy: workbarInstancePolicySchema, defaultContext: workbarDefaultContextSchema, + visibleAcrossContextSwitches: z.boolean(), defaultOpen: z.boolean(), + required: z.boolean(), + closable: z.boolean(), + reorderable: z.boolean(), availability: workbarAvailabilitySchema }) .strict() @@ -93,57 +98,77 @@ export type WorkbarAppDefinition = z.infer< export const WORKBAR_APP_DEFINITIONS = [ { id: 'tasks', + visibleAcrossContextSwitches: true, label: '任务中心', icon: 'tasks', description: '查看和管理当前任务。', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, + required: true, + closable: false, + reorderable: true, availability: { state: 'available' } }, { id: 'workspace', + visibleAcrossContextSwitches: true, label: '工作区', icon: 'workspace', description: '查看当前项目的工作区。', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, + required: true, + closable: false, + reorderable: true, availability: { state: 'available' } }, { id: 'browser', + visibleAcrossContextSwitches: true, label: '浏览器', icon: 'browser', description: '浏览任务相关内容。', - instancePolicy: 'single', - defaultContext: 'application', + instancePolicy: 'multiple', + defaultContext: 'current-conversation', defaultOpen: true, + required: false, + closable: true, + reorderable: true, availability: { state: 'available' } }, { id: 'results', + visibleAcrossContextSwitches: true, label: '成果', icon: 'results', description: '查看任务生成的成果。', instancePolicy: 'single', defaultContext: 'current-project', defaultOpen: true, + required: false, + closable: true, + reorderable: true, availability: { state: 'available' } }, { id: 'terminal', + visibleAcrossContextSwitches: true, label: '终端', icon: 'terminal', description: '打开当前项目的用户终端。', instancePolicy: 'multiple', defaultContext: 'current-project', defaultOpen: false, + required: false, + closable: true, + reorderable: true, availability: { state: 'available' } } ] as const satisfies readonly WorkbarAppDefinition[] -export const workbarTargetRefSchema = z.discriminatedUnion('type', [ +export const workbarExecutionTargetRefSchema = z.discriminatedUnion('type', [ z.object({ type: z.literal('local') }).strict(), z .object({ @@ -152,6 +177,19 @@ export const workbarTargetRefSchema = z.discriminatedUnion('type', [ }) .strict() ]) +export type WorkbarExecutionTargetRef = z.infer< + typeof workbarExecutionTargetRefSchema +> + +export const workbarTargetRefSchema = z.union([ + workbarExecutionTargetRefSchema, + z + .object({ + type: z.literal('conversation'), + conversationId: z.string().min(1).max(128) + }) + .strict() +]) export type WorkbarTargetRef = z.infer< typeof workbarTargetRefSchema > @@ -165,13 +203,37 @@ export const workbarTabInstanceSchema = z }) .strict() .superRefine((instance, context) => { - if (instance.appId === 'terminal' && !instance.targetRef) { + if ( + instance.appId === 'terminal' && + (!instance.targetRef || instance.targetRef.type === 'conversation') + ) { context.addIssue({ code: 'custom', path: ['targetRef'], message: 'Terminal instances require a public target reference' }) } + if ( + instance.appId === 'browser' && + instance.targetRef && + instance.targetRef.type !== 'conversation' + ) { + context.addIssue({ + code: 'custom', + path: ['targetRef'], + message: 'Browser instances only accept conversation bindings' + }) + } + if ( + instance.appId !== 'browser' && + instance.targetRef?.type === 'conversation' + ) { + context.addIssue({ + code: 'custom', + path: ['targetRef'], + message: 'Conversation bindings are reserved for browser instances' + }) + } }) export type WorkbarTabInstance = z.infer< typeof workbarTabInstanceSchema @@ -183,7 +245,14 @@ const singleInstanceAppIds = new Set( ).map((definition) => definition.id) ) -export const workbarLayoutPreferencesSchema = z +export const workbarTaskScopeSchema = z.enum([ + 'current-project', + 'global', + 'all-projects' +]) +export type WorkbarTaskScope = z.infer + +const workbarLayoutShapeSchema = z .object({ instances: z .array(workbarTabInstanceSchema) @@ -191,9 +260,12 @@ export const workbarLayoutPreferencesSchema = z activeInstanceId: z.string().uuid().nullable(), expanded: z.boolean(), dock: z.literal('right'), - widthRatio: z.number().finite().gt(0).lt(1) + widthRatio: z.number().finite().gt(0).lt(1), + taskScope: workbarTaskScopeSchema.default('current-project') }) .strict() + +export const workbarLayoutPreferencesSchema = workbarLayoutShapeSchema .superRefine((layout, context) => { const instanceIds = new Set() const seenSingleApps = new Set() @@ -244,3 +316,77 @@ export const workbarLayoutPreferencesSchema = z export type WorkbarLayoutPreferences = z.infer< typeof workbarLayoutPreferencesSchema > + +export function normalizeWorkbarLayoutPreferences( + value: unknown, + defaultInstances: readonly WorkbarTabInstance[] +): WorkbarLayoutPreferences | undefined { + const parsed = workbarLayoutShapeSchema.safeParse(value) + if (!parsed.success) { + return undefined + } + + const instanceIds = new Set() + const seenSingleApps = new Set() + const instances: WorkbarTabInstance[] = [] + for (const instance of parsed.data.instances) { + if ( + instanceIds.has(instance.id) || + (singleInstanceAppIds.has(instance.appId) && + seenSingleApps.has(instance.appId)) + ) { + continue + } + instanceIds.add(instance.id) + if (singleInstanceAppIds.has(instance.appId)) { + seenSingleApps.add(instance.appId) + } + instances.push(instance) + } + + for (const defaultInstance of defaultInstances) { + const definition = WORKBAR_APP_DEFINITIONS.find( + (candidate) => candidate.id === defaultInstance.appId + ) + if ( + definition?.required !== true || + instances.some( + (instance) => instance.appId === defaultInstance.appId + ) + ) { + continue + } + const collidingIdIndex = instances.findIndex( + (instance) => instance.id === defaultInstance.id + ) + if (collidingIdIndex >= 0) { + const [removed] = instances.splice(collidingIdIndex, 1) + if (removed && singleInstanceAppIds.has(removed.appId)) { + seenSingleApps.delete(removed.appId) + } + } + instances.splice( + WORKBAR_APP_DEFINITIONS.findIndex( + (candidate) => candidate.id === defaultInstance.appId + ), + 0, + defaultInstance + ) + } + + const boundedInstances = instances.slice( + 0, + WORKBAR_LIMITS.maximumOpenInstances + ) + const activeInstanceId = boundedInstances.some( + (instance) => instance.id === parsed.data.activeInstanceId + ) + ? parsed.data.activeInstanceId + : boundedInstances[0]?.id ?? null + + return workbarLayoutPreferencesSchema.parse({ + ...parsed.data, + instances: boundedInstances, + activeInstanceId + }) +} diff --git a/tests/browser-tabs-electron-e2e.ts b/tests/browser-tabs-electron-e2e.ts new file mode 100644 index 00000000..001819b6 --- /dev/null +++ b/tests/browser-tabs-electron-e2e.ts @@ -0,0 +1,145 @@ +import assert from 'node:assert/strict' +import { createServer } from 'node:http' +import { app, BrowserWindow } from 'electron' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { KnowledgeService } from '../src/main/knowledge/knowledge-service' +import { KnowledgeMcpGateway } from '../src/main/agent/knowledge-mcp-gateway' +import { BrowserService } from '../src/main/browser/browser-service' + +const conversationId = 'browser-tabs-electron-e2e' +const otherConversationId = 'browser-tabs-electron-e2e-other' +const firstWorkbarId = '00000000-0000-4000-8000-000000000901' +const secondWorkbarId = '00000000-0000-4000-8000-000000000902' + +function page(title: string, body = ''): string { + return `

${title}

${body}` +} + +async function main(): Promise { + await app.whenReady() + const target = createServer((request, response) => { + response.setHeader('content-type', 'text/html; charset=utf-8') + if (request.url === '/set-cookie') { + response.setHeader('set-cookie', 'shared-browser-tab=visible; Path=/; SameSite=Lax') + response.end(page('Cookie set')) + return + } + if (request.url === '/cookie') { + response.end(page('Cookie page', '')) + return + } + response.end(page(request.url === '/mcp' ? 'MCP target' : request.url === '/second' ? 'Second tab' : 'First tab')) + }) + await new Promise((resolve, reject) => { + target.once('error', reject) + target.listen(0, '127.0.0.1', resolve) + }) + const address = target.address() + assert(address && typeof address !== 'string') + const origin = `http://127.0.0.1:${address.port}` + const window = new BrowserWindow({ show: false, width: 900, height: 700 }) + const browser = new BrowserService({ parentWindow: window }) + const gateway = new KnowledgeMcpGateway( + { + database: { listKnowledgeBases: () => [] } + } as unknown as KnowledgeService, + { browserService: browser } + ) + const controller = new AbortController() + let client: Client | undefined + let token: string | undefined + try { + const first = await browser.createTab( + conversationId, + window.webContents.id, + controller.signal, + firstWorkbarId + ) + const second = await browser.createTab( + conversationId, + window.webContents.id, + controller.signal, + secondWorkbarId + ) + assert.notEqual(first.tabId, second.tabId) + + await browser.navigate(conversationId, `${origin}/first`, controller.signal, first.tabId, window.webContents.id) + await browser.navigate(conversationId, `${origin}/second`, controller.signal, second.tabId, window.webContents.id) + assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, first.tabId, window.webContents.id)), /First tab/u) + assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /Second tab/u) + + await browser.navigate(conversationId, `${origin}/set-cookie`, controller.signal, first.tabId, window.webContents.id) + await browser.navigate(conversationId, `${origin}/cookie`, controller.signal, second.tabId, window.webContents.id) + assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /shared-browser-tab=visible/u) + + await gateway.start() + const usage = browser.acquireTabUsage(conversationId, first.tabId, 'electron-e2e', window.webContents.id) + token = gateway.grant('electron-e2e', [], controller.signal, 'none', undefined, conversationId, first.tabId, usage) + assert(token) + client = new Client({ name: 'browser-tabs-electron-e2e', version: '1.0.0' }) + await client.connect( + new StreamableHTTPClientTransport(new URL(gateway.getEndpoint()!), { + requestInit: { headers: { Authorization: `Bearer ${token}` } } + }) + ) + const other = await browser.createTab(otherConversationId, window.webContents.id, controller.signal) + assert.notEqual(other.tabId, first.tabId) + await browser.navigate(otherConversationId, `${origin}/second`, controller.signal, other.tabId, window.webContents.id) + assert.match(JSON.stringify(await browser.snapshot(otherConversationId, controller.signal, other.tabId, window.webContents.id)), /Second tab/u) + assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, first.tabId, window.webContents.id)), /Cookie set/u) + + await assert.rejects( + browser.navigate(otherConversationId, `${origin}/mcp`, controller.signal, first.tabId, window.webContents.id), + /不属于当前对话/u + ) + await assert.rejects( + browser.navigate(conversationId, `${origin}/mcp`, controller.signal, other.tabId, window.webContents.id), + /不属于当前对话/u + ) + + const bounds = { x: 0, y: 0, width: 800, height: 600 } + assert.equal(browser.setViewport(conversationId, bounds, first.tabId, 'first-viewport', window.webContents.id), true) + assert.equal(browser.getVisibleTabId(conversationId, window.webContents.id), first.tabId) + assert.equal(browser.setViewport(otherConversationId, bounds, other.tabId, 'other-viewport', window.webContents.id), true) + assert.equal(browser.getVisibleTabId(otherConversationId, window.webContents.id), other.tabId) + assert.equal(browser.getVisibleTabId(conversationId, window.webContents.id), undefined) + + await client.callTool({ name: 'browser_navigate', arguments: { url: `${origin}/mcp` } }) + const mcpSnapshot = await client.callTool({ name: 'browser_snapshot', arguments: {} }) + assert.match(JSON.stringify(mcpSnapshot), /MCP target/u) + assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, first.tabId, window.webContents.id)), /MCP target/u) + assert.match(JSON.stringify(await browser.snapshot(otherConversationId, controller.signal, other.tabId, window.webContents.id)), /Second tab/u) + assert.equal(browser.getVisibleTabId(otherConversationId, window.webContents.id), other.tabId) + assert.match(JSON.stringify(await browser.snapshot(conversationId, controller.signal, second.tabId, window.webContents.id)), /shared-browser-tab=visible/u) + + await assert.rejects( + browser.closeTab(conversationId, first.tabId, window.webContents.id), + /正在被.*请求使用/u + ) + await browser.closeTab(otherConversationId, other.tabId, window.webContents.id) + assert.equal(browser.getTabCount(otherConversationId), 0) + assert.equal(browser.getTabCount(conversationId), 2) + gateway.revoke(token) + token = undefined + await browser.closeTab(conversationId, first.tabId, window.webContents.id) + assert.equal(browser.getTabCount(conversationId), 1) + await browser.closeTab(conversationId, second.tabId, window.webContents.id) + assert.equal(browser.getTabCount(conversationId), 0) + console.log('Real Electron browser-tab and MCP E2E passed') + } finally { + if (token) gateway.revoke(token) + await client?.close().catch(() => undefined) + controller.abort() + await gateway.dispose().catch(() => undefined) + await browser.dispose().catch(() => undefined) + if (!window.isDestroyed()) window.destroy() + await new Promise((resolve) => target.close(() => resolve())) + app.quit() + } +} + +void main().catch((error: unknown) => { + console.error(error) + app.exit(1) +})