Skip to content

Commit f294d8e

Browse files
authored
refactor: yobrowser tools (#1357)
* refactor(browser): unify yobrowser sessions * fix(agent): sanitize offload filenames * fix(floating-button): stabilize drag sizing * fix(agent-runtime): avoid offload id collisions
1 parent 79b08f5 commit f294d8e

File tree

47 files changed

+1478
-2088
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

47 files changed

+1478
-2088
lines changed

docs/architecture/tool-system.md

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -624,16 +624,15 @@ class AgentFileSystemHandler {
624624

625625
### YoBrowser CDP 工具
626626

627-
YoBrowser 提供基于 Chrome DevTools Protocol (CDP) 的最小工具集,在 agent 模式下直接可用
627+
YoBrowser 在 agent 模式下直接提供 session 级单实例 browser 能力,每个 session 最多绑定一个 sidepanel browser
628628

629629
**可用工具**
630-
- `yo_browser_tab_list` - 列出所有浏览器 tabs
631-
- `yo_browser_tab_new` - 创建新 tab
632-
- `yo_browser_tab_activate` - 激活指定 tab
633-
- `yo_browser_tab_close` - 关闭 tab
634-
- `yo_browser_cdp_send` - 发送 CDP 命令
630+
- `load_url` - 懒创建当前 session 的 browser 并导航到目标 URL
631+
- `get_browser_status` - 返回当前 session browser 的页面、导航和可见性状态
632+
- `cdp_send` - 向当前 session browser 发送 CDP 命令
635633

636-
**安全约束**
634+
**约束**
635+
- `cdp_send` 不会自动创建 browser;必须先调用 `load_url`
637636
- `local://` URL 禁止 CDP attach(在 `BrowserTab.ensureSession()` 中检查)
638637
- 所有 CDP 命令通过 `webContents.debugger.sendCommand()` 执行
639638

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# YoBrowser Session 单实例实施计划
2+
3+
## 1. 主进程模型
4+
5+
1. `YoBrowserPresenter``Map<sessionId, SessionBrowserState>` 替代全局单状态。
6+
2. 每个 `SessionBrowserState` 仅包含一个 `WebContentsView`、一个 `BrowserTab`、attach 信息、可见性和最后一次 bounds。
7+
3. `load_url` 负责首次懒加载:创建 browser、发起 sidepanel open、等待 host ready、再导航。
8+
9+
## 2. 工具路由
10+
11+
1. `YoBrowserToolDefinitions` 仅注册 `load_url``get_browser_status``cdp_send`
12+
2. `YoBrowserToolHandler.callTool` 必须接收 `conversationId` 并据此路由 session。
13+
3. `AgentToolManager``ToolPresenter` 把这 3 个名字视为内建 YoBrowser 工具。
14+
4. MCP 同名工具在定义收集阶段直接过滤。
15+
16+
## 3. Renderer 行为
17+
18+
1. `BrowserPanel` 接收 `sessionId`,所有 presenter 调用都显式带 sessionId。
19+
2. 切换 session 时先 detach 旧 session browser。
20+
3. 若旧 session 状态不是 `working`,立即 destroy。
21+
4. 若旧 session 状态是 `working`,加入 `pendingBrowserDestroySessionIds`,等状态变更后再 destroy。
22+
5. YoBrowser 事件 payload 带 `sessionId`,只更新当前 panel 对应的会话。
23+
24+
## 4. 独立 browser 下线
25+
26+
1. 删除 `src/renderer/browser` 旧壳入口。
27+
2. `windowPresenter` 中旧 `browser` window 类型不再创建独立窗口,统一回退到 chat window。
28+
3. 清理 `tabPresenter` 中依赖 `browserTabId` 的 YoBrowser 分支。
29+
30+
## 5. 测试策略
31+
32+
1. main:
33+
- tool definitions 只剩 3 个新工具。
34+
- 旧工具名报 unknown tool。
35+
- `load_url` 懒加载与 host-ready 流程成立。
36+
- session 间 browser state 隔离。
37+
2. renderer:
38+
- `BrowserPanel` 仅响应当前 session 事件。
39+
- session 切换时的 detach / destroy / pending destroy 成立。
40+
3. 回归:
41+
- `cdp_send` 仍走 offload。
42+
- disabled tools 存储和展示使用新工具名。

docs/specs/yobrowser-optimization/spec.md

Lines changed: 42 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,64 +1,66 @@
1-
# YoBrowser Optimization(UI + CDP 工具)
1+
# YoBrowser Session 单实例收敛
22

33
## 背景
44

5-
当前 YoBrowser 在 Workspace 侧边栏存在 UI 问题:
6-
- `src/renderer/src/components/workspace/WorkspaceView.vue``agent` 模式下总会渲染 `WorkspaceBrowserTabs` 分区,即便没有任何 tab,也会出现一块空区域。
5+
当前 YoBrowser 还保留了多 window / 多 tab 的旧抽象,但实际运行时已经收敛为单个 sidepanel browser host。继续暴露 `open / close / focus / list`、windowId、tabId 等接口,只会增加状态分叉和错误恢复成本。
76

8-
## 目标(Goals)
7+
同时,session 切换后 browser 的回收策略需要和会话状态对齐:如果旧 session 仍在 `working`,切走时只能先 detach,不能立刻销毁,否则会打断本轮工具调用。
98

10-
1. **UI**:只有存在 YoBrowser tabs 时,Workspace 侧边栏才显示 Browser Tabs 分区。
11-
2. **Agent 工具直接注入**:YoBrowser 工具(`yo_browser_*`)在 agent 模式下直接可用,无需激活任何 skill。
9+
## 目标
1210

13-
## 非目标(Non-Goals)
11+
1. 每个 session 最多持有一个 YoBrowser `webContents`
12+
2. agent 仅暴露 3 个裸工具名:`load_url``get_browser_status``cdp_send`
13+
3. `load_url` 首次调用时懒创建 browser,并自动完成 sidepanel attach 流程。
14+
4. session 切换时按会话状态销毁:
15+
-`working`:立即 detach 并销毁。
16+
- `working`:先 detach,待状态结束后再销毁。
17+
5. 下线旧独立 browser shell,只保留聊天右侧 sidepanel 的 YoBrowser。
1418

15-
- 不调整 YoBrowser window 的 UI、尺寸、布局、位置策略。
16-
- 不修改 `BrowserContextBuilder.buildSystemPrompt` 的注入策略(不做减少/压缩/裁剪)。
17-
- 不改造其他 agent 工具(filesystem/bash/mcp 等)。
18-
- 不使用 skills 系统来控制 YoBrowser 工具的可见性。
19+
## 非目标
1920

20-
## 用户故事(User Stories)
21+
- 不扩成通用多窗口 browser 系统。
22+
- 不保留旧 `yo_browser_*` 别名兼容。
23+
- 不让 `cdp_send` 自动创建 browser;必须先 `load_url`
24+
- 不额外重构通用 window presenter 架构。
2125

22-
- 作为用户,我不希望在没有任何浏览器 tab 的情况下,Workspace 侧边栏仍出现空的 Browser Tabs 分区。
23-
- 作为 agent 用户,我希望 YoBrowser 自动化能力以 CDP 为核心,工具在 agent 模式下直接可用。
26+
## 用户故事
2427

25-
## 约束与假设(Constraints & Assumptions)
28+
- 作为 agent 用户,我希望 browser 工具是直接、稳定、少状态的,不需要理解 window/tab 多实体模型。
29+
- 作为使用多会话的用户,我希望切换 session 时前一个 session 的 browser 不串到当前会话。
30+
- 作为正在执行 browser 工具的用户,我希望切走会话不会打断仍在运行中的 browser 操作。
2631

27-
- YoBrowser 现有实现已经基于 Electron Debugger/CDP(`CDPManager`, `BrowserTab.ensureSession()`)。
28-
- 安全边界:`local://` URL 禁止绑定 CDP(`BrowserTab` 现有逻辑已做限制)。
32+
## 约束与假设
2933

30-
## 验收标准(Acceptance Criteria)
34+
- “正在 loading” 统一按当前 session 状态 `working` 处理。
35+
- 一个 session 同时只允许一个 sidepanel browser 实例。
36+
- `cdp_send` 永远绑定当前 tool call 的 `conversationId`
37+
- `load_url``get_browser_status``cdp_send` 视为内建保留工具名,MCP 不得覆盖。
3138

32-
### A. UI:Workspace Browser Tabs 展示逻辑
39+
## 验收标准
3340

34-
- [ ] `src/renderer/src/components/workspace/WorkspaceView.vue` 仅在 `chatMode === 'agent' && yoBrowserStore.tabCount > 0` 时渲染 `WorkspaceBrowserTabs`
35-
- [ ]`tabCount === 0` 时,不显示 Browser Tabs 分区(不保留空白区域)。
41+
### A. 工具面
3642

37-
### B. 工具:YoBrowser CDP 工具直接注入(agent 模式)
43+
- [ ] agent tool definitions 仅包含 `load_url``get_browser_status``cdp_send`
44+
- [ ]`yo_browser_*` 名称调用时返回 unknown tool。
45+
- [ ] `cdp_send` 若 session browser 尚未初始化,返回明确错误,要求先 `load_url`
3846

39-
- [ ] agent tool definitions 中包含 `yo_browser_*` 工具(agent 模式下直接可用)。
40-
- [ ] agent 的 tool call 路由正确处理 `yo_browser_*` 工具(`toolName.startsWith('yo_browser_')`)。
41-
- [ ] 不依赖 skills 系统(不检查 `activeSkills`)。
47+
### B. Session 生命周期
4248

43-
### C. 工具实现:CDP 方式 + 合适的参数定义
49+
- [ ] `load_url` 首次调用时才创建对应 session 的 browser。
50+
- [ ] 不同 session 持有各自独立 browser state,不共享 page / visibility / attach 状态。
51+
- [ ] session 切换时,旧 session 若非 `working`,立即 destroy。
52+
- [ ] session 切换时,旧 session 若为 `working`,仅 detach;该 session 结束后再 destroy。
4453

45-
- [ ] 工具集合:
46-
- `yo_browser_tab_list`:列出 tabs 与 active tab。
47-
- `yo_browser_tab_new`:创建新 tab(可选 url)。
48-
- `yo_browser_tab_activate`:激活 tab。
49-
- `yo_browser_tab_close`:关闭 tab。
50-
- `yo_browser_cdp_send`:向指定/当前 tab 的 CDP session 发送 `{ method, params }`
51-
- [ ] 参数 schema 符合 CDP 使用方式(method、params 等)。
52-
- [ ] 保留安全边界:`local://` 禁止 CDP attach。
54+
### C. UI 与事件
5355

54-
### D. Prompt/Context
56+
- [ ] Renderer 仅响应当前 `sessionId` 的 YoBrowser 事件。
57+
- [ ] 切换 session 后,browser panel 不显示前一个 session 的状态。
58+
- [ ] 旧独立 browser shell 入口不再可用。
5559

56-
- [ ] `BrowserContextBuilder.buildSystemPrompt` 的注入保持现状(不做减少/压缩/裁剪)。
60+
### D. 文档与接口
5761

58-
### E. 兼容性
59-
60-
- [ ] 不涉及数据迁移。
61-
- [ ] 现有 YoBrowser UI/窗口/Tab 生命周期保持可用。
62+
- [ ] `IYoBrowserPresenter` 与共享类型收敛到 session-aware 单实例接口。
63+
- [ ] 架构文档与本 spec 使用新工具名与新生命周期语义。
6264

6365
## Open Questions
6466

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# YoBrowser Session 单实例任务拆分
2+
3+
1. 收敛共享类型与 presenter 接口到 session-aware 单实例模型。
4+
2. 重写 `YoBrowserPresenter` 的 session 状态管理、attach、detach、destroy 流程。
5+
3. 将 tool definitions / handler / agent routing 切到 `load_url``get_browser_status``cdp_send`
6+
4. 在 renderer sidepanel 中按 `sessionId` 驱动 browser panel,并实现 `working` 态延迟销毁。
7+
5. 删除旧独立 browser shell 与 `browserTabId` 相关残留。
8+
6. 更新 main / renderer / agent presenter 测试到新工具名和新生命周期。
9+
7. 更新规格与架构文档,并跑格式化、i18n、lint、关键测试。

electron.vite.config.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,6 @@ export default defineConfig({
6464
resolve: {
6565
alias: {
6666
'@': resolve('src/renderer/src'),
67-
'@browser': resolve('src/renderer/browser'),
6867
'@shared': resolve('src/shared'),
6968
"@shadcn": resolve('src/shadcn'),
7069
vue: 'vue/dist/vue.esm-bundler.js'
@@ -107,7 +106,6 @@ export default defineConfig({
107106
cssCodeSplit: false,
108107
rollupOptions: {
109108
input: {
110-
browser: resolve('src/renderer/browser/index.html'),
111109
index: resolve('src/renderer/index.html'),
112110
floating: resolve('src/renderer/floating/index.html'),
113111
splash: resolve('src/renderer/splash/index.html'),

src/main/lib/agentRuntime/sessionPaths.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import { createHash } from 'crypto'
12
import { app } from 'electron'
23
import path from 'path'
34

5+
const INVALID_WINDOWS_SEGMENT_CHARS = new Set(['<', '>', ':', '"', '/', '\\', '|', '?', '*'])
6+
const TRAILING_WINDOWS_SEGMENT_CHARS = /[. ]+$/g
7+
48
export function getSessionsRoot(): string {
59
return path.resolve(app.getPath('home'), '.deepchat', 'sessions')
610
}
@@ -29,7 +33,7 @@ export function resolveToolOffloadPath(conversationId: string, toolCallId: strin
2933
return null
3034
}
3135

32-
const safeToolCallId = toolCallId.replace(/[\\/]/g, '_')
36+
const safeToolCallId = sanitizeToolCallIdForOffload(toolCallId)
3337
return path.join(sessionDir, `tool_${safeToolCallId}.offload`)
3438
}
3539

@@ -41,3 +45,21 @@ export function resolveToolOffloadTemplatePath(conversationId: string): string |
4145

4246
return path.join(sessionDir, 'tool_<toolCallId>.offload')
4347
}
48+
49+
function sanitizeToolCallIdForOffload(toolCallId: string): string {
50+
const sanitizedBase = Array.from(toolCallId.trim(), (char) => {
51+
const charCode = char.charCodeAt(0)
52+
if (charCode <= 0x1f || INVALID_WINDOWS_SEGMENT_CHARS.has(char)) {
53+
return '_'
54+
}
55+
56+
return char
57+
})
58+
.join('')
59+
.replace(TRAILING_WINDOWS_SEGMENT_CHARS, '')
60+
61+
const fingerprint = createHash('sha1').update(toolCallId).digest('hex').slice(0, 8)
62+
const sanitized = [sanitizedBase || 'tool_call', fingerprint].filter(Boolean).join('_')
63+
64+
return sanitized || 'tool_call'
65+
}

src/main/presenter/agentPresenter/acp/agentToolManager.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
CHAT_SETTINGS_TOOL_NAMES
2020
} from './chatSettingsTools'
2121
import type { AgentToolRuntimePort } from '../runtimePorts'
22+
import { YO_BROWSER_TOOL_NAMES } from '../../browser/YoBrowserToolDefinitions'
2223

2324
// Consider moving to a shared handlers location in future refactoring
2425
import {
@@ -66,6 +67,7 @@ interface AgentToolManagerOptions {
6667
}
6768

6869
export class AgentToolManager {
70+
private static readonly YO_BROWSER_TOOL_NAME_SET = new Set<string>(YO_BROWSER_TOOL_NAMES)
6971
private agentWorkspacePath: string | null
7072
private fileSystemHandler: AgentFileSystemHandler | null = null
7173
private bashHandler: AgentBashHandler | null = null
@@ -393,8 +395,8 @@ export class AgentToolManager {
393395
}
394396

395397
// Route to YoBrowser CDP tools
396-
if (toolName.startsWith('yo_browser_')) {
397-
const response = await this.getYoBrowserToolHandler().callTool(toolName, args)
398+
if (AgentToolManager.YO_BROWSER_TOOL_NAME_SET.has(toolName)) {
399+
const response = await this.getYoBrowserToolHandler().callTool(toolName, args, conversationId)
398400
return {
399401
content: response
400402
}

src/main/presenter/agentPresenter/loop/toolCallProcessor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ const QUESTION_ERROR_KEY = 'common.error.invalidQuestionRequest'
9393

9494
// Tools that require offload when output exceeds threshold
9595
// Tools not in this list will never trigger offload (e.g., read has its own pagination)
96-
const TOOLS_REQUIRING_OFFLOAD = new Set(['exec', 'ls', 'find', 'grep', 'yo_browser_cdp_send'])
96+
const TOOLS_REQUIRING_OFFLOAD = new Set(['exec', 'ls', 'find', 'grep', 'cdp_send'])
9797

9898
export class ToolCallProcessor {
9999
constructor(private readonly options: ToolCallProcessorOptions) {}

src/main/presenter/browser/BrowserContextBuilder.ts

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,13 @@
1-
import type { BrowserToolDefinition, BrowserWindowInfo } from '@shared/types/browser'
1+
import type { BrowserToolDefinition, YoBrowserStatus } from '@shared/types/browser'
22

33
export class BrowserContextBuilder {
4-
static buildSystemPrompt(windows: BrowserWindowInfo[], activeWindowId: number | null): string {
5-
const activeWindow = windows.find((browserWindow) => browserWindow.id === activeWindowId)
6-
const windowLines =
7-
windows.length === 0
8-
? ['- No browser windows open.']
9-
: windows.map((browserWindow) => {
10-
const marker = browserWindow.id === activeWindowId ? '*' : ' '
11-
const title = browserWindow.page.title || browserWindow.page.url || 'Untitled'
12-
return `${marker} ${title} (${browserWindow.page.url || 'about:blank'})`
13-
})
4+
static buildSystemPrompt(status: YoBrowserStatus): string {
5+
const page = status.page
6+
const pageLine = page ? `${page.title || page.url || 'Untitled'} (${page.url})` : 'none'
7+
148
return [
159
'Yo Browser is available for web exploration.',
16-
`Active window: ${activeWindow ? `${activeWindow.page.title || activeWindow.page.url} (${activeWindow.id})` : 'none'}`,
17-
'Open browser windows:',
18-
...windowLines,
10+
`Current page: ${pageLine}`,
1911
'Use Yo Browser to browse, extract DOM, run scripts, capture screenshots, and download files.'
2012
].join('\n')
2113
}

0 commit comments

Comments
 (0)