From d2eea272effea3467e991770cecbce13cadcc4fa Mon Sep 17 00:00:00 2001 From: zerob13 Date: Sat, 20 Dec 2025 15:40:54 +0800 Subject: [PATCH 01/29] refactor: extract agent mode and workspace for every llm --- ...\351\207\215\346\236\204_47fd60ee.plan.md" | 620 ++++++++++++++++++ .../configPresenter/mcpConfHelper.ts | 85 +-- src/main/presenter/index.ts | 18 +- .../agent/agentFileSystemHandler.ts | 269 ++++++++ .../agent/agentToolManager.ts | 289 ++++++++ .../managers/agentLoopHandler.ts | 129 ++-- .../managers/toolCallProcessor.ts | 1 + .../mcpPresenter/inMemoryServers/builder.ts | 5 +- src/main/presenter/toolPresenter/index.ts | 117 ++++ .../presenter/toolPresenter/toolMapper.ts | 81 +++ .../presenter/workspacePresenter/index.ts | 165 +++++ src/renderer/src/components/ChatView.vue | 20 +- src/renderer/src/components/ModelChooser.vue | 8 + .../src/components/chat-input/ChatInput.vue | 78 ++- .../composables/useAgentWorkspace.ts | 225 +++++++ .../chat-input/composables/useChatMode.ts | 93 +++ .../workspace/WorkspaceFileNode.vue | 180 +++++ .../components/workspace/WorkspaceFiles.vue | 102 +++ .../components/workspace/WorkspacePlan.vue | 121 ++++ .../workspace/WorkspaceTerminal.vue | 87 +++ .../components/workspace/WorkspaceView.vue | 50 ++ src/renderer/src/i18n/en-US/chat.json | 40 +- src/renderer/src/i18n/zh-CN/chat.json | 40 +- src/renderer/src/stores/chat.ts | 16 +- src/renderer/src/stores/workspace.ts | 285 ++++++++ src/shared/types/index.d.ts | 2 + src/shared/types/presenters/index.d.ts | 15 +- .../types/presenters/legacy.presenters.d.ts | 6 + .../types/presenters/thread.presenter.d.ts | 2 + .../types/presenters/tool.presenter.d.ts | 29 + src/shared/types/presenters/workspace.d.ts | 140 ++++ 31 files changed, 3195 insertions(+), 123 deletions(-) create mode 100644 ".cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" create mode 100644 src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts create mode 100644 src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts create mode 100644 src/main/presenter/toolPresenter/index.ts create mode 100644 src/main/presenter/toolPresenter/toolMapper.ts create mode 100644 src/main/presenter/workspacePresenter/index.ts create mode 100644 src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts create mode 100644 src/renderer/src/components/chat-input/composables/useChatMode.ts create mode 100644 src/renderer/src/components/workspace/WorkspaceFileNode.vue create mode 100644 src/renderer/src/components/workspace/WorkspaceFiles.vue create mode 100644 src/renderer/src/components/workspace/WorkspacePlan.vue create mode 100644 src/renderer/src/components/workspace/WorkspaceTerminal.vue create mode 100644 src/renderer/src/components/workspace/WorkspaceView.vue create mode 100644 src/renderer/src/stores/workspace.ts create mode 100644 src/shared/types/presenters/tool.presenter.d.ts create mode 100644 src/shared/types/presenters/workspace.d.ts diff --git "a/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" "b/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" new file mode 100644 index 000000000..e43fea821 --- /dev/null +++ "b/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" @@ -0,0 +1,620 @@ +--- +name: 通用 Workspace 和 Agent 能力重构 +overview: 将 Workspace 和 Agent 能力从 ACP 专用扩展到所有模型,重构 filesystem MCP 为 Agent 工具,统一管理 MCP 和 Agent 工具调用注入逻辑,并重构 AcpWorkspaceView 为通用 Workspace 组件。 +todos: + - id: create-unified-tool-presenter + content: 创建统一的 ToolPresenter,管理所有工具(MCP、Agent 等),提供统一的工具定义接口和路由映射机制 + status: completed + - id: create-agent-tool-manager + content: 创建 AgentToolManager 类,管理所有 Agent 工具(Yo Browser、FileSystem 等),类似 Yo Browser 的实现方式,只要 Agent 模式启用就全部注入 + status: completed + - id: create-agent-filesystem-handler + content: 创建 AgentFileSystemHandler 类,封装文件系统操作能力,从 FileSystemServer 中提取核心逻辑,工具名称不加前缀(如 read_file, write_file) + status: completed + - id: integrate-tool-routing + content: 在 ToolPresenter 中实现工具路由映射机制,根据工具名称映射到对应的工具源(MCP 或 Agent),统一使用 MCP 规范的工具定义格式 + status: completed + dependencies: + - create-unified-tool-presenter + - create-agent-tool-manager + - id: update-agent-loop-handler + content: 更新 AgentLoopHandler 使用统一的 ToolPresenter,简化工具注入逻辑,Agent 工具只要启用就全部注入 + status: completed + dependencies: + - create-unified-tool-presenter + - id: remove-filesystem-mcp + content: 从 MCP 系统中移除 filesystem server:删除 buildInFileSystem 配置,移除 builder 中的创建逻辑,添加数据迁移 + status: completed + dependencies: + - create-agent-filesystem-handler + - id: add-chat-mode-database + content: 创建 useChatMode composable,使用 configPresenter.setSetting/getSetting 存储和读取 chatMode(参考 useInputSettings 的实现),确保数据库持久化 + status: completed + - id: create-workspace-presenter + content: 创建通用 WorkspacePresenter,从 AcpWorkspacePresenter 重构,移除 ACP 特定依赖 + status: completed + - id: create-workspace-store + content: 创建通用 workspace store,从 acpWorkspace store 重构,支持所有模型的 Agent 模式 + status: completed + dependencies: + - create-workspace-presenter + - id: refactor-workspace-components + content: 重构 Workspace 组件:重命名 AcpWorkspaceView 为 WorkspaceView,更新所有子组件,移除 ACP 依赖 + status: completed + dependencies: + - create-workspace-store + - id: add-chat-mode-switch + content: 在 chatConfig 中添加 chatMode 字段,创建 useChatMode composable,在 ChatInput.vue 中添加 Mode Switch 选择器(支持 chat、agent、acp agent 三种模式) + status: completed + dependencies: + - add-chat-mode-database + - id: add-workspace-path-selection + content: 在 chatConfig 中添加 agentWorkspacePath 字段,创建 useAgentWorkspace composable 统一管理工作目录:acp agent 模式使用 ACP workdir 逻辑,agent 模式使用 filesystem 工具的工作目录。在 ChatInput.vue 的 Tools 区域添加统一的目录选择按钮(仅在 agent 或 acp agent 模式时显示) + status: completed + dependencies: + - add-chat-mode-switch + - id: update-model-selection-logic + content: 更新模型选择逻辑:只有在 acp agent 模式下才能选择 ACP 模型,其他模式隐藏 ACP 模型 + status: completed + dependencies: + - add-chat-mode-switch + - id: update-chat-view-integration + content: 更新 ChatView 使用通用 WorkspaceView,更新事件监听和状态同步逻辑 + status: completed + dependencies: + - refactor-workspace-components + - id: add-i18n-translations + content: 添加所有新增 UI 元素的 i18n 翻译(中文、英文等),更新相关翻译键 + status: completed + dependencies: + - add-agent-mode-config + - add-workspace-path-selection + - id: update-types-definitions + content: 更新 shared/presenter.d.ts 中的类型定义,添加 Agent 模式相关类型,更新 Workspace 相关接口 + status: completed + dependencies: + - create-workspace-presenter + - add-agent-mode-config +--- + +# 通用 Workspace 和 Agent 能力重构计划 + +## 架构概览 + +```mermaid +graph TB + subgraph "Agent Loop" + AL[AgentLoopHandler] + TCP[ToolCallProcessor] + end + + subgraph "统一工具路由" + TP[ToolPresenter] + TM[ToolMapper] + end + + subgraph "工具源" + MCP[MCP Tools] + AGENT[Agent Tools] + end + + subgraph "Agent 工具" + YO[Yo Browser] + FS[FileSystem] + TERM[Terminal 未来] + end + + subgraph "Workspace" + WS[WorkspaceView] + FILES[Files Section] + PLAN[Plan Section] + TERM_UI[Terminal Section] + end + + AL --> TCP + TCP --> TP + TP --> TM + TM --> MCP + TM --> AGENT + AGENT --> YO + AGENT --> FS + AGENT --> TERM + WS --> FILES + WS --> PLAN + WS --> TERM_UI +``` + + + +## 核心变更 + +### 1. 统一工具路由架构 + +**目标**:抽象统一的工具路由层,统一管理所有工具源,使用 MCP 规范的工具定义格式。**实现**: + +- 创建 `ToolPresenter` 类,统一管理所有工具(MCP、Agent 等) +- 创建 `ToolMapper` 类,实现工具名称到工具源的映射机制 +- 所有工具定义统一使用 MCP 规范格式(`MCPToolDefinition`) +- 工具调用时根据映射路由到对应的工具源处理器 +- 未来支持工具去重和映射(如果 MCP 和 Agent 有同名工具,可以映射到 MCP 工具) + +### 2. Agent 工具管理简化 + +**目标**:简化 Agent 工具管理,根据 chatMode 决定工具注入。**实现**: + +- 创建 `AgentToolManager` 类,管理所有 Agent 工具 +- Agent 工具包括: +- **Yo Browser**:保持现有实现,工具名称使用 `browser_` 前缀(如 `browser_navigate`) +- **FileSystem**:新增,工具名称**不加前缀**(如 `read_file`, `write_file`) +- **Terminal**:未来扩展 +- 工具注入逻辑: +- **chat 模式**:不注入 Agent 工具(只有 MCP 工具) +- **agent 模式**:注入所有 Agent 工具(yo browser、filesystem 等) +- **acp agent 模式**:根据 ACP 逻辑决定(后续补充具体实现) +- 保留扩展能力,未来可以扩展成按需注入 + +### 3. 文件系统能力抽象 + +**目标**:将 filesystem MCP 重构为 Agent 工具,从 MCP 系统中移除。**实现**: + +- 创建 `AgentFileSystemHandler` 类,封装文件操作能力 +- 工具名称不加前缀,例如:`read_file`, `write_file`, `list_directory` 等 +- 从 `mcpConfHelper.ts` 中移除 `buildInFileSystem` 配置 +- 从 `inMemoryServers/builder.ts` 中移除 filesystem server 的创建逻辑 + +### 4. 通用 Mode Switch 配置 + +**目标**:添加通用的 Mode Switch,支持三种模式(chat、agent、acp agent),支持数据库持久化。**实现**: + +- 在配置存储(ElectronStore)中添加 `chatMode: 'chat' | 'agent' | 'acp agent'` 字段 +- 在 `chatConfig` 中添加 `chatMode` 字段(从配置存储读取) +- 创建 `useChatMode` composable,管理模式状态 +- 在 `ChatInput.vue` 中添加 Mode Switch 选择器(下拉选择或按钮组) +- 三种模式的区别: +- **chat**:基础聊天模式,只有 MCP 工具,不支持 yo browser、文件读写等功能 +- **agent**:内置 agent 模式,包含 workdir 设置、各种工具(yo browser、文件读写等)、agent loop 定制内容 +- **acp agent**:ACP 模式,只有这个模式才能选择 ACP 模型,loop 和逻辑会有不同(后续补充具体实现) +- 确保配置的持久化和统一化管理 +- **注意**:这个 mode switch 和 ACP agent 里面的 mode(session mode)不是一回事 + +### 5. Workspace 组件通用化 + +**目标**:将 `AcpWorkspaceView` 重构为通用的 `WorkspaceView`,支持所有模型。**实现**: + +- 重命名 `AcpWorkspaceView.vue` → `WorkspaceView.vue` +- 重命名 `acpWorkspace` store → `workspace` store +- 重命名 `AcpWorkspacePresenter` → `WorkspacePresenter` +- 移除 ACP 特定的依赖,改为基于 Agent 模式判断 + +### 6. Workspace 路径选择(统一化) + +**目标**:统一化目录选择按钮,不同模式使用不同的工作目录。**实现**: + +- 在 `chatConfig` 中添加 `agentWorkspacePath: string | null` 字段 +- 创建 `useAgentWorkspace` composable,统一管理工作目录选择 +- 在 `ChatInput.vue` 的 Tools 区域添加目录选择按钮(在 agent 或 acp agent 模式下显示) +- 目录选择按钮逻辑统一化: +- **acp agent 模式**:使用 ACP workdir(现有的 ACP workdir 逻辑) +- **agent 模式**:使用 filesystem 工具的工作目录,以及未来各种工具的工作目录 +- 按钮样式和行为:参考现有的 ACP workdir 按钮 + +## 文件变更清单 + +### 新增文件 + +1. `src/main/presenter/toolPresenter/index.ts` - 统一工具路由 Presenter,管理所有工具源 +2. `src/main/presenter/toolPresenter/toolMapper.ts` - 工具映射器,实现工具名称到工具源的映射 +3. `src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts` - Agent 工具管理器,管理所有 Agent 工具 +4. `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts` - 文件系统能力处理器,工具名称不加前缀 +5. `src/renderer/src/stores/workspace.ts` - 通用 Workspace Store(从 acpWorkspace 重构) +6. `src/main/presenter/workspacePresenter/index.ts` - 通用 Workspace Presenter(从 acpWorkspacePresenter 重构) +7. `src/renderer/src/components/workspace/WorkspaceView.vue` - 通用 Workspace 组件(从 acp-workspace 重构) +8. `src/renderer/src/components/workspace/WorkspaceFiles.vue` - 文件列表组件 +9. `src/renderer/src/components/workspace/WorkspacePlan.vue` - 计划组件 +10. `src/renderer/src/components/workspace/WorkspaceTerminal.vue` - 终端组件 +11. `src/renderer/src/components/chat-input/composables/useChatMode.ts` - Chat Mode Switch composable(参考 useInputSettings 实现,使用 configPresenter.setSetting/getSetting 持久化) +12. `src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts` - Workspace 路径选择 composable(统一化,根据 chatMode 使用不同的逻辑:acp agent 模式使用 ACP workdir,agent 模式使用 filesystem 工作目录) + +### 修改文件 + +1. `src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts` - 使用统一的 ToolPresenter,简化工具注入逻辑 +2. `src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts` - 使用 ToolPresenter 进行工具调用路由 +3. `src/main/presenter/configPresenter/index.ts` - 无需修改,使用现有的 setSetting/getSetting 方法即可(chatMode 通过 'input_chatMode' key 存储) +4. `src/main/presenter/configPresenter/mcpConfHelper.ts` - 移除 buildInFileSystem 配置 +5. `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts` - 移除 filesystem server +6. `src/renderer/src/stores/chat.ts` - 添加 chatMode 和 agentWorkspacePath 配置 +7. `src/shared/presenter.d.ts` - 更新类型定义,添加 ToolPresenter 接口和 ChatMode 类型 +8. `src/renderer/src/components/chat-input/ChatInput.vue` - 添加 Mode Switch 选择器和路径选择器 + +- 添加 Mode Switch 选择器:使用 icon + 下拉选择(类似模型选择器,但更简约) +- Icon 映射:chat 用 `message-circle-more`,agent 用 `bot`,acp agent 用 `bot-message-square` +- 在 agent 或 acp agent 模式时显示统一的目录选择按钮 +- 集成 `useChatMode` 和 `useAgentWorkspace` composables + +9. `src/renderer/src/components/ModelChooser.vue` - 更新模型选择逻辑,只在 acp agent 模式下显示 ACP 模型 +10. `src/renderer/src/components/chat/ChatView.vue` - 使用通用 WorkspaceView + +### 删除/废弃文件 + +1. `src/renderer/src/components/acp-workspace/` - 整个目录(重构为 workspace) +2. `src/renderer/src/stores/acpWorkspace.ts` - 重构为 workspace.ts +3. `src/main/presenter/acpWorkspacePresenter/` - 重构为 workspacePresenter + +## 实施步骤 + +### Phase 1: 统一工具路由架构 + +1. 创建 `ToolPresenter` 类: + +- 统一管理所有工具源(MCP、Agent) +- 提供 `getAllToolDefinitions()` 方法,返回统一的 MCP 规范格式工具定义 +- 提供 `callTool()` 方法,根据工具映射路由到对应的处理器 + +2. 创建 `ToolMapper` 类: + +- 实现工具名称到工具源的映射机制 +- 支持未来扩展工具去重和映射功能 + +3. 创建 `AgentToolManager` 类: + +- 管理所有 Agent 工具(Yo Browser、FileSystem 等) +- 只要 Agent 模式启用,所有工具都注入 +- 工具定义统一使用 MCP 规范格式 + +4. 创建 `AgentFileSystemHandler` 类: + +- 封装文件系统操作能力 +- 工具名称不加前缀(如 `read_file`, `write_file`) +- 工具定义使用 MCP 规范格式 + +### Phase 2: 集成工具路由 + +1. 更新 `AgentLoopHandler`: + +- 使用 `ToolPresenter` 获取所有工具定义 +- 简化工具注入逻辑,不再区分工具源 + +2. 更新 `ToolCallProcessor`: + +- 使用 `ToolPresenter.callTool()` 进行工具调用 +- 根据工具映射自动路由到对应的处理器 + +### Phase 3: 通用 Mode Switch 配置 + +1. 创建 `useChatMode` composable(参考 `useInputSettings` 的实现方式): + +- 使用 `configPresenter.setSetting('input_chatMode', value)` 保存模式 +- 使用 `configPresenter.getSetting('input_chatMode')` 读取模式 +- 管理模式状态(chat、agent、acp agent) +- 默认值:`'chat'` +- 在 `onMounted` 时自动加载保存的模式 +- 提供 `setMode()` 方法切换模式并持久化 + +2. 在 `chatConfig` 中添加 `chatMode` 字段: + +- 从 `useChatMode` composable 读取当前模式 +- 支持会话级别的覆盖(如果需要) + +3. 在 `ChatInput.vue` 中添加 Mode Switch 选择器: + +- 使用下拉选择(类似模型选择器),但样式更简约 +- 在 ChatInput 中只显示一个 icon,hover 时显示当前模式 +- Icon 映射: +- chat: `lucide:message-circle-more` +- agent: `lucide:bot` +- acp agent: `lucide:bot-message-square` +- 点击 icon 或 hover 时显示下拉选择器 +- 集成 `useChatMode` composable +- 根据当前模式显示不同的 UI 和功能 + +4. 更新模型选择逻辑: + +- 只有在 `acp agent` 模式下才显示 ACP 模型 +- 其他模式隐藏 ACP 模型选项 + +### Phase 4: MCP Filesystem 移除 + +1. 从 `mcpConfHelper.ts` 移除 `buildInFileSystem` 配置 +2. 从 `inMemoryServers/builder.ts` 移除 filesystem server +3. 添加数据迁移逻辑,将现有 buildInFileSystem 配置迁移 + +### Phase 5: Workspace 组件通用化 + +1. 创建通用 `WorkspacePresenter` +2. 创建通用 `workspace` store +3. 重构 Workspace 组件,移除 ACP 依赖 +4. 更新事件系统,支持通用 Workspace + +### Phase 6: Workspace 路径选择(统一化) + +1. 在 `chatConfig` 中添加 `agentWorkspacePath` 字段 +2. 创建 `useAgentWorkspace` composable,统一管理工作目录: + +- 根据当前 `chatMode` 决定工作目录的用途 +- **acp agent 模式**:使用现有的 ACP workdir 逻辑(`useAcpWorkdir`) +- **agent 模式**:使用 filesystem 工具的工作目录 +- 支持未来扩展其他工具的工作目录 +- 提供统一的接口和状态管理 + +3. 在 `ChatInput.vue` 中添加统一的目录选择按钮: + +- 在 `agent` 或 `acp agent` 模式下显示 +- 参考现有的 ACP workdir 按钮实现 +- 根据模式显示不同的 tooltip 和逻辑 +- 按钮样式和行为统一 + +4. 实现临时目录创建和管理逻辑 + +### Phase 7: 集成和测试 + +1. 更新所有引用 ACP Workspace 的地方 +2. 添加 i18n 翻译 +3. 测试各种场景(切换不同模式、路径选择、模型选择等) +4. 更新文档 + +## 关键技术点 + +### 工具命名规范 + +- **MCP 工具**:保持原样(如 `read_files`, `write_file`) +- **Agent 工具**:**不加前缀**(如 `read_file`, `write_file`, `browser_navigate`) +- Yo Browser:保持 `browser_` 前缀(已存在) +- FileSystem:不加前缀(如 `read_file`, `write_file`) +- Terminal:未来不加前缀(如 `execute_command`) + +### 工具路由机制 + +- 所有工具定义统一使用 MCP 规范格式(`MCPToolDefinition`) +- `ToolMapper` 维护工具名称到工具源的映射 +- 工具调用时根据映射自动路由: +- 如果工具名称映射到 MCP → 调用 `mcpPresenter.callTool()` +- 如果工具名称映射到 Agent → 调用 `agentToolManager.callTool()` +- 未来支持工具去重:如果 MCP 和 Agent 有同名工具,可以配置映射到 MCP 工具 + +### Agent 工具注入机制(基于 Mode) + +- 根据 `chatMode` 决定工具注入: +- **chat 模式**:不注入 Agent 工具,只有 MCP 工具 +- **agent 模式**:注入所有 Agent 工具(yo browser、filesystem 等) +- **acp agent 模式**:根据 ACP 逻辑决定(后续补充具体实现) +- 工具注入逻辑: +- Yo Browser:在 agent 或 acp agent 模式下,当浏览器窗口打开时注入 +- FileSystem:在 agent 或 acp agent 模式下注入 +- Terminal:未来按需扩展 +- 保留扩展能力,未来可以扩展成按需注入机制 + +### 配置持久化 + +- `chatMode` 通过 `configPresenter.setSetting('input_chatMode', value)` 存储 +- 通过 `configPresenter.getSetting('input_chatMode')` 读取 +- 类型:`'chat' | 'agent' | 'acp agent'` +- 默认值:`'chat'` +- 存储方式:与 `input_webSearch`、`input_deepThinking` 相同,存储在 ElectronStore 中 +- 在 `useChatMode` composable 的 `onMounted` 时自动加载保存的模式 +- 配置统一化管理,通过 `ConfigPresenter` 访问 +- 会话级别的配置可以覆盖全局配置(如果需要) + +### Mode Switch 与 ACP Session Mode 的区别 + +- **Chat Mode Switch**:全局模式选择,决定整个会话的行为和可用功能 +- chat:基础聊天模式 +- agent:内置 agent 模式 +- acp agent:ACP 专用模式 +- **ACP Session Mode**:ACP agent 模式下的会话模式(如 plan、code 等),由 ACP agent 内部定义 +- 两者是不同层级的概念,互不干扰 + +### 路径安全 + +- Agent 模式下的文件操作必须限制在用户选择的 workspace 路径内 +- 临时目录在会话结束后自动清理 +- 所有路径操作都需要验证权限 + +### 向后兼容 + +- 保留 ACP Provider 的现有功能 +- 迁移现有 ACP Workspace 数据到通用 Workspace +- 确保现有 MCP filesystem 配置能平滑迁移 + +## ChatInput.vue 重构细节 + +### Mode Switch 选择器实现 + +**位置**:在 Tools 区域,搜索开关之后,MCP Tools 之前**实现方式**:Icon + 下拉选择(类似模型选择器,但更简约)**Icon 映射**: + +- chat: `lucide:message-circle-more` +- agent: `lucide:bot` +- acp agent: `lucide:bot-message-square` + +**实现示例**: + +```vue + + + + + + + + {{ t('chat.mode.current', { mode: chatMode.currentLabel.value }) }} + + + + +
+
+ + {{ mode.label }} + +
+
+
+
+``` + +**useChatMode composable 需要添加**: + +```typescript +const modeIcons = { + chat: 'lucide:message-circle-more', + agent: 'lucide:bot', + 'acp agent': 'lucide:bot-message-square' +} + +const currentIcon = computed(() => modeIcons[currentMode.value]) +``` + +**useChatMode composable 实现示例**(参考 useInputSettings): + +```typescript +export function useChatMode() { + const configPresenter = usePresenter('configPresenter') + const currentMode = ref<'chat' | 'agent' | 'acp agent'>('chat') + + const setMode = async (mode: 'chat' | 'agent' | 'acp agent') => { + const previousValue = currentMode.value + currentMode.value = mode + + try { + await configPresenter.setSetting('input_chatMode', mode) + } catch (error) { + currentMode.value = previousValue + console.error('Failed to save chat mode:', error) + } + } + + const loadMode = async () => { + try { + const saved = await configPresenter.getSetting('input_chatMode') + currentMode.value = (saved as 'chat' | 'agent' | 'acp agent') || 'chat' + } catch (error) { + currentMode.value = 'chat' + console.error('Failed to load chat mode, using default:', error) + } + } + + onMounted(async () => { + await loadMode() + }) + + return { + currentMode, + setMode, + loadMode + } +} +``` + + + +### 目录选择按钮实现(统一化) + +**位置**:在 Tools 区域,Mode Switch 之后,仅在 `agent` 或 `acp agent` 模式下显示**代码位置**:在 Tools 区域添加**显示条件**:`chatMode.currentMode.value === 'agent' || chatMode.currentMode.value === 'acp agent'`**统一化逻辑**: + +- **acp agent 模式**:使用现有的 ACP workdir 逻辑(`useAcpWorkdir`) +- **agent 模式**:使用 filesystem 工具的工作目录(`useAgentWorkspace`) +- 按钮样式和行为统一,但根据模式显示不同的 tooltip 和逻辑 + +**实现示例**: + +```vue + + + + + +

+ {{ workspace.tooltipTitle }} +

+

+ {{ workspace.tooltipCurrent }} +

+

+ {{ workspace.tooltipSelect }} +

+
+
+``` + +**useAgentWorkspace composable 需要根据模式统一化**: + +```typescript +export function useAgentWorkspace(options: { + chatMode: Ref<'chat' | 'agent' | 'acp agent'> + // ... other options +}) { + const acpWorkdir = useAcpWorkdir(...) // 用于 acp agent 模式 + const agentWorkspace = ref(null) // 用于 agent 模式 + + const hasWorkspace = computed(() => { + if (options.chatMode.value === 'acp agent') { + return acpWorkdir.hasWorkdir.value + } + return agentWorkspace.value !== null + }) + + const workspacePath = computed(() => { + if (options.chatMode.value === 'acp agent') { + return acpWorkdir.workdir.value + } + return agentWorkspace.value + }) + + const tooltipTitle = computed(() => { + if (options.chatMode.value === 'acp agent') { + return t('chat.input.acpWorkdirTooltip') + } + return t('chat.input.agentWorkspaceTooltip') + }) + + // ... 其他逻辑 +} +``` + + + +## 注意事项 + +1. **文件大小限制**:确保每个文件不超过 200 行(TypeScript) +2. **文件夹文件数限制**:每个文件夹不超过 8 个文件 +3. **UI 一致性**:Mode Switch 和目录选择按钮的样式和行为应该与现有的 UI 元素保持一致 +4. **工具定义统一**:所有工具定义必须使用 MCP 规范格式,确保映射和转换函数可以共用 \ No newline at end of file diff --git a/src/main/presenter/configPresenter/mcpConfHelper.ts b/src/main/presenter/configPresenter/mcpConfHelper.ts index f0e1a6dfa..c4914ed1d 100644 --- a/src/main/presenter/configPresenter/mcpConfHelper.ts +++ b/src/main/presenter/configPresenter/mcpConfHelper.ts @@ -2,7 +2,8 @@ import { eventBus, SendTarget } from '@/eventbus' import { MCPServerConfig } from '@shared/presenter' import { MCP_EVENTS } from '@/events' import ElectronStore from 'electron-store' -import { app } from 'electron' +// app is used in DEFAULT_INMEMORY_SERVERS but removed buildInFileSystem +// import { app } from 'electron' import { compare } from 'compare-versions' import { presenter } from '..' @@ -110,16 +111,7 @@ const PLATFORM_SPECIFIC_SERVERS: Record = { // Extract inmemory type services as constants const DEFAULT_INMEMORY_SERVERS: Record = { - buildInFileSystem: { - args: [app.getPath('home')], - descriptions: 'DeepChat内置文件系统mcp服务', - icons: '📁', - autoApprove: ['read'], - type: 'inmemory' as MCPServerType, - command: 'filesystem', - env: {}, - disable: true - }, + // buildInFileSystem has been removed - filesystem capabilities are now provided via Agent tools Artifacts: { args: [], descriptions: 'DeepChat内置 artifacts mcp服务', @@ -404,6 +396,7 @@ export class McpConfHelper { } // 遍历所有默认的inmemory服务,确保它们都存在 + // Note: buildInFileSystem is excluded as it's now provided via Agent tools for (const [serverName, serverConfig] of Object.entries(DEFAULT_INMEMORY_SERVERS)) { ensureBuiltInServerExists(serverName, serverConfig) } @@ -874,55 +867,43 @@ export class McpConfHelper { this.mcpStore.delete('defaultServer') } - // 迁移 filesystem 服务器到 buildInFileSystem + // Migrate filesystem/buildInFileSystem servers - these are now provided via Agent tools try { const mcpServers = this.mcpStore.get('mcpServers') || {} - // console.log('mcpServers', mcpServers) + const defaultServers = this.mcpStore.get('defaultServers') || [] + let hasChanges = false + + // Remove old filesystem server if (mcpServers.filesystem) { - console.log( - 'Detected old version filesystem MCP server, starting migration to buildInFileSystem' - ) + console.log('Removing old filesystem MCP server (now provided via Agent tools)') + delete mcpServers.filesystem + hasChanges = true + } - // 检查 buildInFileSystem 是否已存在 - if (!mcpServers.buildInFileSystem) { - // 创建 buildInFileSystem 配置 - mcpServers.buildInFileSystem = { - args: [app.getPath('home')], // 默认值 - descriptions: '内置文件系统mcp服务', - icons: '💾', - autoApprove: ['read'], - type: 'inmemory' as MCPServerType, - command: 'filesystem', - env: {}, - disable: false - } - } + // Remove buildInFileSystem server + if (mcpServers.buildInFileSystem) { + console.log('Removing buildInFileSystem MCP server (now provided via Agent tools)') + delete mcpServers.buildInFileSystem + hasChanges = true + } - // 如果 filesystem 的 args 长度大于 2,将第三个参数及以后的参数迁移 - if (mcpServers.filesystem.args && mcpServers.filesystem.args.length > 2) { - mcpServers.buildInFileSystem.args = mcpServers.filesystem.args.slice(2) - } + // Remove from default servers list + const updatedDefaultServers = defaultServers.filter( + (name) => name !== 'filesystem' && name !== 'buildInFileSystem' + ) + if (updatedDefaultServers.length !== defaultServers.length) { + this.mcpStore.set('defaultServers', updatedDefaultServers) + hasChanges = true + } - // 迁移 autoApprove 设置 - if (mcpServers.filesystem.autoApprove) { - mcpServers.buildInFileSystem.autoApprove = [...mcpServers.filesystem.autoApprove] - } + // Mark as removed for tracking + if (mcpServers.filesystem || mcpServers.buildInFileSystem) { + this.markBuiltInServerRemoved('buildInFileSystem') + } - delete mcpServers.filesystem - // 更新 mcpServers + if (hasChanges) { this.mcpStore.set('mcpServers', mcpServers) - - // 如果 filesystem 是默认服务器,将 buildInFileSystem 添加到默认服务器列表 - const defaultServers = this.mcpStore.get('defaultServers') || [] - if ( - defaultServers.includes('filesystem') && - !defaultServers.includes('buildInFileSystem') - ) { - defaultServers.push('buildInFileSystem') - this.mcpStore.set('defaultServers', defaultServers) - } - - console.log('Migration from filesystem to buildInFileSystem completed') + console.log('Migration: filesystem MCP servers removed (now available via Agent tools)') } } catch (error) { console.error('Error occurred while migrating filesystem server:', error) diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index c638ee719..3cab1d5a2 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -24,6 +24,8 @@ import { IUpgradePresenter, IWindowPresenter, IAcpWorkspacePresenter, + IWorkspacePresenter, + IToolPresenter, IYoBrowserPresenter } from '@shared/presenter' import { eventBus } from '@/eventbus' @@ -44,6 +46,8 @@ import { YoBrowserPresenter } from './browser/YoBrowserPresenter' import { CONFIG_EVENTS, WINDOW_EVENTS } from '@/events' import { KnowledgePresenter } from './knowledgePresenter' import { AcpWorkspacePresenter } from './acpWorkspacePresenter' +import { WorkspacePresenter } from './workspacePresenter' +import { ToolPresenter } from './toolPresenter' // IPC调用上下文接口 interface IPCCallContext { @@ -82,6 +86,8 @@ export class Presenter implements IPresenter { floatingButtonPresenter: FloatingButtonPresenter knowledgePresenter: IKnowledgePresenter acpWorkspacePresenter: IAcpWorkspacePresenter + workspacePresenter: IWorkspacePresenter + toolPresenter: IToolPresenter yoBrowserPresenter: IYoBrowserPresenter // llamaCppPresenter: LlamaCppPresenter // 保留原始注释 dialogPresenter: IDialogPresenter @@ -126,9 +132,19 @@ export class Presenter implements IPresenter { this.filePresenter ) - // Initialize ACP Workspace presenter + // Initialize ACP Workspace presenter (legacy, kept for backward compatibility) this.acpWorkspacePresenter = new AcpWorkspacePresenter() + // Initialize generic Workspace presenter (for all Agent modes) + this.workspacePresenter = new WorkspacePresenter() + + // Initialize unified Tool presenter (for routing MCP and Agent tools) + this.toolPresenter = new ToolPresenter({ + mcpPresenter: this.mcpPresenter, + yoBrowserPresenter: this.yoBrowserPresenter, + configPresenter: this.configPresenter + }) + // this.llamaCppPresenter = new LlamaCppPresenter() // 保留原始注释 this.setupEventBus() // 设置事件总线监听 } diff --git a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts new file mode 100644 index 000000000..1e170cd75 --- /dev/null +++ b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts @@ -0,0 +1,269 @@ +import fs from 'fs/promises' +import path from 'path' +import os from 'os' +import { z } from 'zod' +import { minimatch } from 'minimatch' +import { createTwoFilesPatch } from 'diff' + +const ReadFileArgsSchema = z.object({ + paths: z.array(z.string()).min(1).describe('Array of file paths to read') +}) + +const WriteFileArgsSchema = z.object({ + path: z.string(), + content: z.string() +}) + +const ListDirectoryArgsSchema = z.object({ + path: z.string(), + showDetails: z.boolean().default(false), + sortBy: z.enum(['name', 'size', 'modified']).default('name') +}) + +const CreateDirectoryArgsSchema = z.object({ + path: z.string() +}) + +const MoveFilesArgsSchema = z.object({ + sources: z.array(z.string()).min(1), + destination: z.string() +}) + +const EditTextArgsSchema = z.object({ + path: z.string(), + operation: z.enum(['replace_pattern', 'edit_lines']), + pattern: z.string().optional(), + replacement: z.string().optional(), + global: z.boolean().default(true), + caseSensitive: z.boolean().default(false), + edits: z + .array( + z.object({ + oldText: z.string(), + newText: z.string() + }) + ) + .optional(), + dryRun: z.boolean().default(false) +}) + +const FileSearchArgsSchema = z.object({ + path: z.string().optional(), + pattern: z.string(), + searchType: z.enum(['glob', 'name']).default('glob'), + excludePatterns: z.array(z.string()).optional().default([]), + caseSensitive: z.boolean().default(false), + maxResults: z.number().default(1000) +}) + +export class AgentFileSystemHandler { + private allowedDirectories: string[] + + constructor(allowedDirectories: string[]) { + if (allowedDirectories.length === 0) { + throw new Error('At least one allowed directory must be provided') + } + this.allowedDirectories = allowedDirectories.map((dir) => + this.normalizePath(path.resolve(this.expandHome(dir))) + ) + } + + private normalizePath(p: string): string { + return path.normalize(p) + } + + private expandHome(filepath: string): string { + if (filepath.startsWith('~/') || filepath === '~') { + return path.join(os.homedir(), filepath.slice(1)) + } + return filepath + } + + private async validatePath(requestedPath: string): Promise { + const expandedPath = this.expandHome(requestedPath) + const absolute = path.isAbsolute(expandedPath) + ? path.resolve(expandedPath) + : path.resolve(process.cwd(), expandedPath) + const normalizedRequested = this.normalizePath(absolute) + const isAllowed = this.allowedDirectories.some((dir) => normalizedRequested.startsWith(dir)) + if (!isAllowed) { + throw new Error( + `Access denied - path outside allowed directories: ${absolute} not in ${this.allowedDirectories.join(', ')}` + ) + } + try { + const realPath = await fs.realpath(absolute) + const normalizedReal = this.normalizePath(realPath) + const isRealPathAllowed = this.allowedDirectories.some((dir) => + normalizedReal.startsWith(dir) + ) + if (!isRealPathAllowed) { + throw new Error('Access denied - symlink target outside allowed directories') + } + return realPath + } catch { + const parentDir = path.dirname(absolute) + try { + const realParentPath = await fs.realpath(parentDir) + const normalizedParent = this.normalizePath(realParentPath) + const isParentAllowed = this.allowedDirectories.some((dir) => + normalizedParent.startsWith(dir) + ) + if (!isParentAllowed) { + throw new Error('Access denied - parent directory outside allowed directories') + } + return absolute + } catch { + throw new Error(`Parent directory does not exist: ${parentDir}`) + } + } + } + + async readFile(args: unknown): Promise { + const parsed = ReadFileArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + const results = await Promise.all( + parsed.data.paths.map(async (filePath: string) => { + try { + const validPath = await this.validatePath(filePath) + const content = await fs.readFile(validPath, 'utf-8') + return `${filePath}:\n${content}\n` + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + return `${filePath}: Error - ${errorMessage}` + } + }) + ) + return results.join('\n---\n') + } + + async writeFile(args: unknown): Promise { + const parsed = WriteFileArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + const validPath = await this.validatePath(parsed.data.path) + await fs.writeFile(validPath, parsed.data.content, 'utf-8') + return `Successfully wrote to ${parsed.data.path}` + } + + async listDirectory(args: unknown): Promise { + const parsed = ListDirectoryArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + const validPath = await this.validatePath(parsed.data.path) + const entries = await fs.readdir(validPath, { withFileTypes: true }) + const formatted = entries + .map((entry) => { + const prefix = entry.isDirectory() ? '[DIR]' : '[FILE]' + return `${prefix} ${entry.name}` + }) + .join('\n') + return `Directory listing for ${parsed.data.path}:\n\n${formatted}` + } + + async createDirectory(args: unknown): Promise { + const parsed = CreateDirectoryArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + const validPath = await this.validatePath(parsed.data.path) + await fs.mkdir(validPath, { recursive: true }) + return `Successfully created directory ${parsed.data.path}` + } + + async moveFiles(args: unknown): Promise { + const parsed = MoveFilesArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + const results = await Promise.all( + parsed.data.sources.map(async (source) => { + const validSourcePath = await this.validatePath(source) + const validDestPath = await this.validatePath( + path.join(parsed.data.destination, path.basename(source)) + ) + try { + await fs.rename(validSourcePath, validDestPath) + return `Successfully moved ${source} to ${parsed.data.destination}` + } catch (e) { + return `Move ${source} failed: ${JSON.stringify(e)}` + } + }) + ) + return results.join('\n') + } + + async editText(args: unknown): Promise { + const parsed = EditTextArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + const validPath = await this.validatePath(parsed.data.path) + const content = await fs.readFile(validPath, 'utf-8') + let modifiedContent = content + + if (parsed.data.operation === 'edit_lines' && parsed.data.edits) { + for (const edit of parsed.data.edits) { + if (!modifiedContent.includes(edit.oldText)) { + throw new Error(`Cannot find exact matching content: ${edit.oldText}`) + } + modifiedContent = modifiedContent.replace(edit.oldText, edit.newText) + } + } else if (parsed.data.operation === 'replace_pattern' && parsed.data.pattern) { + const flags = parsed.data.caseSensitive ? 'g' : 'gi' + const regex = new RegExp(parsed.data.pattern, flags) + modifiedContent = modifiedContent.replace(regex, parsed.data.replacement || '') + } + + const diff = createTwoFilesPatch(validPath, validPath, content, modifiedContent) + if (!parsed.data.dryRun) { + await fs.writeFile(validPath, modifiedContent, 'utf-8') + } + return diff + } + + async searchFiles(args: unknown): Promise { + const parsed = FileSearchArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + const rootPath = parsed.data.path + ? await this.validatePath(parsed.data.path) + : this.allowedDirectories[0] + const results: string[] = [] + + const search = async (currentPath: string) => { + const entries = await fs.readdir(currentPath, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(currentPath, entry.name) + try { + await this.validatePath(fullPath) + const isMatch = + parsed.data.searchType === 'glob' + ? minimatch(entry.name, parsed.data.pattern, { + dot: true, + nocase: !parsed.data.caseSensitive + }) + : parsed.data.caseSensitive + ? entry.name.includes(parsed.data.pattern) + : entry.name.toLowerCase().includes(parsed.data.pattern.toLowerCase()) + if (isMatch) { + results.push(fullPath) + } + if (entry.isDirectory()) { + await search(fullPath) + } + } catch { + continue + } + } + } + + await search(rootPath) + return results.slice(0, parsed.data.maxResults).join('\n') + } +} diff --git a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts new file mode 100644 index 000000000..cfd3545fe --- /dev/null +++ b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts @@ -0,0 +1,289 @@ +import type { MCPToolDefinition } from '@shared/presenter' +import type { IYoBrowserPresenter } from '@shared/presenter' +import { zodToJsonSchema } from 'zod-to-json-schema' +import { z } from 'zod' +import { AgentFileSystemHandler } from './agentFileSystemHandler' + +interface AgentToolManagerOptions { + yoBrowserPresenter: IYoBrowserPresenter + agentWorkspacePath: string | null +} + +export class AgentToolManager { + private readonly yoBrowserPresenter: IYoBrowserPresenter + private readonly agentWorkspacePath: string | null + private fileSystemHandler: AgentFileSystemHandler | null = null + + constructor(options: AgentToolManagerOptions) { + this.yoBrowserPresenter = options.yoBrowserPresenter + this.agentWorkspacePath = options.agentWorkspacePath + if (this.agentWorkspacePath) { + this.fileSystemHandler = new AgentFileSystemHandler([this.agentWorkspacePath]) + } + } + + /** + * Get all Agent tool definitions in MCP format + */ + async getAllToolDefinitions(context: { + chatMode: 'chat' | 'agent' | 'acp agent' + supportsVision: boolean + agentWorkspacePath: string | null + }): Promise { + const defs: MCPToolDefinition[] = [] + + // Update filesystem handler if workspace path changed + if (context.agentWorkspacePath !== this.agentWorkspacePath) { + if (context.agentWorkspacePath) { + this.fileSystemHandler = new AgentFileSystemHandler([context.agentWorkspacePath]) + } else { + this.fileSystemHandler = null + } + } + + // 1. Yo Browser tools (only when browser window is open) + if (context.chatMode !== 'chat') { + const hasBrowserWindow = await this.yoBrowserPresenter.hasWindow() + if (hasBrowserWindow) { + try { + const yoDefs = await this.yoBrowserPresenter.getToolDefinitions(context.supportsVision) + defs.push(...yoDefs) + } catch (error) { + console.warn('[AgentToolManager] Failed to load Yo Browser tool definitions', error) + } + } + } + + // 2. FileSystem tools (only when workspace path is set) + if (context.chatMode !== 'chat' && this.fileSystemHandler) { + const fsDefs = this.getFileSystemToolDefinitions() + defs.push(...fsDefs) + } + + return defs + } + + /** + * Call an Agent tool + */ + async callTool(toolName: string, args: Record): Promise { + // Route to Yo Browser tools + if (toolName.startsWith('browser_')) { + const response = await this.yoBrowserPresenter.callTool( + toolName, + args as Record + ) + return typeof response === 'string' ? response : JSON.stringify(response) + } + + // Route to FileSystem tools + if (this.fileSystemHandler) { + return await this.callFileSystemTool(toolName, args) + } + + throw new Error(`Unknown Agent tool: ${toolName}`) + } + + private getFileSystemToolDefinitions(): MCPToolDefinition[] { + const ReadFileSchema = z.object({ + paths: z.array(z.string()).min(1) + }) + + const WriteFileSchema = z.object({ + path: z.string(), + content: z.string() + }) + + const ListDirectorySchema = z.object({ + path: z.string(), + showDetails: z.boolean().default(false), + sortBy: z.enum(['name', 'size', 'modified']).default('name') + }) + + const CreateDirectorySchema = z.object({ + path: z.string() + }) + + const MoveFilesSchema = z.object({ + sources: z.array(z.string()).min(1), + destination: z.string() + }) + + const EditTextSchema = z.object({ + path: z.string(), + operation: z.enum(['replace_pattern', 'edit_lines']), + pattern: z.string().optional(), + replacement: z.string().optional(), + global: z.boolean().default(true), + caseSensitive: z.boolean().default(false), + edits: z + .array( + z.object({ + oldText: z.string(), + newText: z.string() + }) + ) + .optional(), + dryRun: z.boolean().default(false) + }) + + const FileSearchSchema = z.object({ + path: z.string().optional(), + pattern: z.string(), + searchType: z.enum(['glob', 'name']).default('glob'), + excludePatterns: z.array(z.string()).optional().default([]), + caseSensitive: z.boolean().default(false), + maxResults: z.number().default(1000) + }) + + return [ + { + type: 'function', + function: { + name: 'read_file', + description: 'Read the contents of one or more files', + parameters: zodToJsonSchema(ReadFileSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'write_file', + description: 'Write content to a file', + parameters: zodToJsonSchema(WriteFileSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'list_directory', + description: 'List files and directories in a path', + parameters: zodToJsonSchema(ListDirectorySchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'create_directory', + description: 'Create a directory', + parameters: zodToJsonSchema(CreateDirectorySchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'move_files', + description: 'Move or rename files and directories', + parameters: zodToJsonSchema(MoveFilesSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'edit_text', + description: 'Edit text files using pattern replacement or line-based editing', + parameters: zodToJsonSchema(EditTextSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'search_files', + description: 'Search for files matching a pattern', + parameters: zodToJsonSchema(FileSearchSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + } + ] + } + + private async callFileSystemTool( + toolName: string, + args: Record + ): Promise { + if (!this.fileSystemHandler) { + throw new Error('FileSystem handler not initialized') + } + + switch (toolName) { + case 'read_file': + return await this.fileSystemHandler.readFile(args) + case 'write_file': + return await this.fileSystemHandler.writeFile(args) + case 'list_directory': + return await this.fileSystemHandler.listDirectory(args) + case 'create_directory': + return await this.fileSystemHandler.createDirectory(args) + case 'move_files': + return await this.fileSystemHandler.moveFiles(args) + case 'edit_text': + return await this.fileSystemHandler.editText(args) + case 'search_files': + return await this.fileSystemHandler.searchFiles(args) + default: + throw new Error(`Unknown FileSystem tool: ${toolName}`) + } + } +} diff --git a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts index 3443baf42..5234e7f32 100644 --- a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts +++ b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts @@ -1,10 +1,4 @@ -import { - ChatMessage, - IConfigPresenter, - LLMAgentEvent, - MCPToolCall, - MCPToolDefinition -} from '@shared/presenter' +import { ChatMessage, IConfigPresenter, LLMAgentEvent, MCPToolCall } from '@shared/presenter' import { presenter } from '@/presenter' import { eventBus, SendTarget } from '@/eventbus' import { ACP_WORKSPACE_EVENTS } from '@/events' @@ -12,6 +6,7 @@ import { BaseLLMProvider } from '../baseProvider' import { StreamState } from '../types' import { RateLimitManager } from './rateLimitManager' import { ToolCallProcessor } from './toolCallProcessor' +import { ToolPresenter } from '../../toolPresenter' interface AgentLoopHandlerOptions { configPresenter: IConfigPresenter @@ -23,49 +18,76 @@ interface AgentLoopHandlerOptions { export class AgentLoopHandler { private readonly toolCallProcessor: ToolCallProcessor + private toolPresenter: ToolPresenter | null = null private currentSupportsVision = false constructor(private readonly options: AgentLoopHandlerOptions) { this.toolCallProcessor = new ToolCallProcessor({ getAllToolDefinitions: async (context) => { - const defs: MCPToolDefinition[] = [] - const mcpDefs = await presenter.mcpPresenter.getAllToolDefinitions(context.enabledMcpTools) - defs.push(...mcpDefs) - - // Check if browser window is open - independent of MCP - const hasBrowserWindow = await presenter.yoBrowserPresenter.hasWindow() - if (hasBrowserWindow) { + // Get chatMode from global config (default to 'chat') + const chatMode = + ((await this.options.configPresenter.getSetting('input_chatMode')) as + | 'chat' + | 'agent' + | 'acp agent') || 'chat' + + // Get agentWorkspacePath from conversation settings (if available) + let agentWorkspacePath: string | null = null + if (context.conversationId) { try { - const yoDefs = await presenter.yoBrowserPresenter.getToolDefinitions( - this.currentSupportsVision + const conversation = await presenter.threadPresenter.getConversation( + context.conversationId ) - defs.push(...yoDefs) + if (conversation) { + // For acp agent mode, use acpWorkdirMap + if (chatMode === 'acp agent' && conversation.settings.acpWorkdirMap) { + const modelId = conversation.settings.modelId + agentWorkspacePath = conversation.settings.acpWorkdirMap[modelId] ?? null + } else { + // For agent mode, use agentWorkspacePath + agentWorkspacePath = conversation.settings.agentWorkspacePath ?? null + } + } } catch (error) { - console.warn('[AgentLoop] Failed to load Yo Browser tool definitions', error) + console.warn('[AgentLoopHandler] Failed to get conversation settings:', error) } } - return defs + return await this.getToolPresenter().getAllToolDefinitions({ + enabledMcpTools: context.enabledMcpTools, + chatMode, + supportsVision: this.currentSupportsVision, + agentWorkspacePath + }) }, callTool: async (request: MCPToolCall) => { - if (request.function.name.startsWith('browser_')) { - const response = await presenter.yoBrowserPresenter.callTool( - request.function.name, - JSON.parse(request.function.arguments || '{}') as Record - ) - return { - content: typeof response === 'string' ? response : JSON.stringify(response), - rawData: { - toolCallId: request.id, - content: response - } - } - } - return await presenter.mcpPresenter.callTool(request) + return await this.getToolPresenter().callTool(request) } }) } + /** + * Lazy initialization of ToolPresenter + * This is needed because ToolPresenter depends on mcpPresenter and yoBrowserPresenter + * which are created after LLMProviderPresenter in the Presenter initialization order + */ + private getToolPresenter(): ToolPresenter { + if (!this.toolPresenter) { + // Check if presenter is fully initialized + if (!presenter.mcpPresenter || !presenter.yoBrowserPresenter) { + throw new Error( + 'ToolPresenter dependencies not initialized. mcpPresenter and yoBrowserPresenter must be initialized first.' + ) + } + this.toolPresenter = new ToolPresenter({ + mcpPresenter: presenter.mcpPresenter, + yoBrowserPresenter: presenter.yoBrowserPresenter, + configPresenter: this.options.configPresenter + }) + } + return this.toolPresenter + } + private requiresReasoningField(modelId: string): boolean { const lower = modelId.toLowerCase() return lower.includes('deepseek-reasoner') || lower.includes('kimi-k2-thinking') @@ -183,20 +205,40 @@ export class AgentLoopHandler { try { console.log(`[Agent Loop] Iteration ${toolCallCount + 1} for event: ${eventId}`) - // Check if browser window is open - independent of MCP - const hasBrowserWindow = await presenter.yoBrowserPresenter.hasWindow() - let toolDefs = await presenter.mcpPresenter.getAllToolDefinitions(enabledMcpTools) - if (hasBrowserWindow) { + // Get chatMode from global config + const chatMode = + ((await this.options.configPresenter.getSetting('input_chatMode')) as + | 'chat' + | 'agent' + | 'acp agent') || 'chat' + + // Get agentWorkspacePath from conversation settings + let agentWorkspacePath: string | null = null + if (conversationId) { try { - const yoDefs = await presenter.yoBrowserPresenter.getToolDefinitions( - this.currentSupportsVision - ) - toolDefs = [...toolDefs, ...yoDefs] + const conversation = await presenter.threadPresenter.getConversation(conversationId) + if (conversation) { + // For acp agent mode, use acpWorkdirMap + if (chatMode === 'acp agent' && conversation.settings.acpWorkdirMap) { + agentWorkspacePath = conversation.settings.acpWorkdirMap[modelId] ?? null + } else { + // For agent mode, use agentWorkspacePath + agentWorkspacePath = conversation.settings.agentWorkspacePath ?? null + } + } } catch (error) { - console.warn('[AgentLoop] Failed to load Yo Browser tool definitions', error) + console.warn('[AgentLoopHandler] Failed to get conversation settings:', error) } } + // Get all tool definitions using ToolPresenter + const toolDefs = await this.getToolPresenter().getAllToolDefinitions({ + enabledMcpTools, + chatMode, + supportsVision: this.currentSupportsVision, + agentWorkspacePath + }) + const canExecute = this.options.rateLimitManager.canExecuteImmediately(providerId) if (!canExecute) { const config = this.options.rateLimitManager.getProviderRateLimitConfig(providerId) @@ -509,7 +551,8 @@ export class AgentLoopHandler { modelConfig, abortSignal: abortController.signal, currentToolCallCount: toolCallCount, - maxToolCalls: MAX_TOOL_CALLS + maxToolCalls: MAX_TOOL_CALLS, + conversationId }) while (true) { diff --git a/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts b/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts index 362c64ce5..a0bb66062 100644 --- a/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts +++ b/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts @@ -21,6 +21,7 @@ interface ToolCallExecutionContext { abortSignal: AbortSignal currentToolCallCount: number maxToolCalls: number + conversationId?: string } interface ToolCallProcessResult { diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts index 5fba3baaa..acffaf8cf 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/builder.ts @@ -1,5 +1,5 @@ import { ArtifactsServer } from './artifactsServer' -import { FileSystemServer } from './filesystem' +// FileSystemServer has been removed - filesystem capabilities are now provided via Agent tools import { BochaSearchServer } from './bochaSearchServer' import { BraveSearchServer } from './braveSearchServer' import { ImageServer } from './imageServer' @@ -21,8 +21,7 @@ export function getInMemoryServer( env?: Record ) { switch (serverName) { - case 'buildInFileSystem': - return new FileSystemServer(args) + // buildInFileSystem has been removed - filesystem capabilities are now provided via Agent tools case 'Artifacts': return new ArtifactsServer() case 'bochaSearch': diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts new file mode 100644 index 000000000..ee64001ed --- /dev/null +++ b/src/main/presenter/toolPresenter/index.ts @@ -0,0 +1,117 @@ +import type { + IConfigPresenter, + IMCPPresenter, + IYoBrowserPresenter, + MCPToolDefinition, + MCPToolCall, + MCPToolResponse +} from '@shared/presenter' +import { ToolMapper } from './toolMapper' +import { AgentToolManager } from '../llmProviderPresenter/agent/agentToolManager' + +export interface IToolPresenter { + getAllToolDefinitions(context: { + enabledMcpTools?: string[] + chatMode?: 'chat' | 'agent' | 'acp agent' + supportsVision?: boolean + agentWorkspacePath?: string | null + }): Promise + callTool(request: MCPToolCall): Promise<{ content: unknown; rawData: MCPToolResponse }> +} + +interface ToolPresenterOptions { + mcpPresenter: IMCPPresenter + yoBrowserPresenter: IYoBrowserPresenter + configPresenter: IConfigPresenter +} + +/** + * ToolPresenter - Unified tool routing presenter + * Manages all tool sources (MCP, Agent) and provides unified interface + */ +export class ToolPresenter implements IToolPresenter { + private readonly mapper: ToolMapper + private readonly options: ToolPresenterOptions + private agentToolManager: AgentToolManager | null = null + + constructor(options: ToolPresenterOptions) { + this.options = options + this.mapper = new ToolMapper() + } + + /** + * Get all tool definitions from all sources + * Returns unified MCP-format tool definitions + */ + async getAllToolDefinitions(context: { + enabledMcpTools?: string[] + chatMode?: 'chat' | 'agent' | 'acp agent' + supportsVision?: boolean + agentWorkspacePath?: string | null + }): Promise { + const defs: MCPToolDefinition[] = [] + this.mapper.clear() + + const chatMode = context.chatMode || 'chat' + const supportsVision = context.supportsVision || false + const agentWorkspacePath = context.agentWorkspacePath || null + + // 1. Get MCP tools + const mcpDefs = await this.options.mcpPresenter.getAllToolDefinitions(context.enabledMcpTools) + defs.push(...mcpDefs) + this.mapper.registerTools(mcpDefs, 'mcp') + + // 2. Get Agent tools (only in agent or acp agent mode) + if (chatMode !== 'chat') { + // Initialize or update AgentToolManager if workspace path changed + if (!this.agentToolManager) { + this.agentToolManager = new AgentToolManager({ + yoBrowserPresenter: this.options.yoBrowserPresenter, + agentWorkspacePath + }) + } + + try { + const agentDefs = await this.agentToolManager.getAllToolDefinitions({ + chatMode, + supportsVision, + agentWorkspacePath + }) + defs.push(...agentDefs) + this.mapper.registerTools(agentDefs, 'agent') + } catch (error) { + console.warn('[ToolPresenter] Failed to load Agent tool definitions', error) + } + } + + return defs + } + + /** + * Call a tool, routing to the appropriate source based on mapping + */ + async callTool(request: MCPToolCall): Promise<{ content: unknown; rawData: MCPToolResponse }> { + const toolName = request.function.name + const source = this.mapper.getToolSource(toolName) + + if (!source) { + throw new Error(`Tool ${toolName} not found in any source`) + } + + if (source === 'agent' && this.agentToolManager) { + // Route to Agent tool manager + const args = JSON.parse(request.function.arguments || '{}') as Record + const response = await this.agentToolManager.callTool(toolName, args) + return { + content: typeof response === 'string' ? response : JSON.stringify(response), + rawData: { + toolCallId: request.id, + content: response + } + } + } + + // Route to MCP (default) + return await this.options.mcpPresenter.callTool(request) + } +} diff --git a/src/main/presenter/toolPresenter/toolMapper.ts b/src/main/presenter/toolPresenter/toolMapper.ts new file mode 100644 index 000000000..ba7247258 --- /dev/null +++ b/src/main/presenter/toolPresenter/toolMapper.ts @@ -0,0 +1,81 @@ +import type { MCPToolDefinition } from '@shared/presenter' + +export type ToolSource = 'mcp' | 'agent' + +export interface ToolMapping { + toolName: string + source: ToolSource + originalName?: string +} + +/** + * ToolMapper - Maps tool names to their sources (MCP or Agent) + * Supports future tool deduplication and mapping features + */ +export class ToolMapper { + private toolNameToSource = new Map() + private toolMappings: ToolMapping[] = [] + + /** + * Register a tool mapping + */ + registerTool(toolName: string, source: ToolSource, originalName?: string): void { + this.toolNameToSource.set(toolName, source) + this.toolMappings.push({ + toolName, + source, + originalName: originalName || toolName + }) + } + + /** + * Register multiple tools from tool definitions + */ + registerTools(tools: MCPToolDefinition[], source: ToolSource): void { + for (const tool of tools) { + this.registerTool(tool.function.name, source) + } + } + + /** + * Get the source for a tool name + */ + getToolSource(toolName: string): ToolSource | undefined { + return this.toolNameToSource.get(toolName) + } + + /** + * Check if a tool is mapped + */ + hasTool(toolName: string): boolean { + return this.toolNameToSource.has(toolName) + } + + /** + * Clear all mappings + */ + clear(): void { + this.toolNameToSource.clear() + this.toolMappings = [] + } + + /** + * Get all mappings + */ + getAllMappings(): ToolMapping[] { + return [...this.toolMappings] + } + + /** + * Future: Support tool deduplication + * If MCP and Agent have the same tool name, map to MCP by default + */ + resolveDuplicate(toolName: string, preferredSource?: ToolSource): ToolSource { + const existing = this.toolNameToSource.get(toolName) + if (existing && preferredSource && existing !== preferredSource) { + // Future: Allow configuration to prefer one source over another + return preferredSource + } + return existing || 'mcp' + } +} diff --git a/src/main/presenter/workspacePresenter/index.ts b/src/main/presenter/workspacePresenter/index.ts new file mode 100644 index 000000000..e17e31a0b --- /dev/null +++ b/src/main/presenter/workspacePresenter/index.ts @@ -0,0 +1,165 @@ +import path from 'path' +import { shell } from 'electron' +import { eventBus, SendTarget } from '@/eventbus' +import { ACP_WORKSPACE_EVENTS } from '@/events' +import { readDirectoryShallow } from '../acpWorkspacePresenter/directoryReader' +import { PlanStateManager } from '../acpWorkspacePresenter/planStateManager' +import type { + IWorkspacePresenter, + WorkspaceFileNode, + WorkspacePlanEntry, + WorkspaceTerminalSnippet, + WorkspaceRawPlanEntry +} from '@shared/presenter' + +export class WorkspacePresenter implements IWorkspacePresenter { + private readonly planManager = new PlanStateManager() + // Allowed workspace paths (registered by Agent sessions) + private readonly allowedWorkspaces = new Set() + + /** + * Register a workspace path as allowed for reading + * Returns Promise to ensure IPC call completion + */ + async registerWorkspace(workspacePath: string): Promise { + const normalized = path.resolve(workspacePath) + this.allowedWorkspaces.add(normalized) + } + + /** + * Unregister a workspace path + */ + async unregisterWorkspace(workspacePath: string): Promise { + const normalized = path.resolve(workspacePath) + this.allowedWorkspaces.delete(normalized) + } + + /** + * Check if a path is within allowed workspaces + */ + private isPathAllowed(targetPath: string): boolean { + const normalized = path.resolve(targetPath) + for (const workspace of this.allowedWorkspaces) { + // Check if targetPath is equal to or under the workspace + if (normalized === workspace || normalized.startsWith(workspace + path.sep)) { + return true + } + } + return false + } + + /** + * Read directory (shallow, only first level) + * Use expandDirectory to load subdirectory contents + */ + async readDirectory(dirPath: string): Promise { + // Security check: only allow reading within registered workspaces + if (!this.isPathAllowed(dirPath)) { + console.warn(`[Workspace] Blocked read attempt for unauthorized path: ${dirPath}`) + return [] + } + // AcpFileNode and WorkspaceFileNode have the same structure + const nodes = await readDirectoryShallow(dirPath) + return nodes as unknown as WorkspaceFileNode[] + } + + /** + * Expand a directory to load its children (lazy loading) + * @param dirPath Directory path to expand + */ + async expandDirectory(dirPath: string): Promise { + // Security check: only allow reading within registered workspaces + if (!this.isPathAllowed(dirPath)) { + console.warn(`[Workspace] Blocked expand attempt for unauthorized path: ${dirPath}`) + return [] + } + // AcpFileNode and WorkspaceFileNode have the same structure + const nodes = await readDirectoryShallow(dirPath) + return nodes as unknown as WorkspaceFileNode[] + } + + /** + * Reveal a file or directory in the system file manager + */ + async revealFileInFolder(filePath: string): Promise { + // Security check: only allow revealing within registered workspaces + if (!this.isPathAllowed(filePath)) { + console.warn(`[Workspace] Blocked reveal attempt for unauthorized path: ${filePath}`) + return + } + + const normalizedPath = path.resolve(filePath) + + try { + shell.showItemInFolder(normalizedPath) + } catch (error) { + console.error(`[Workspace] Failed to reveal path: ${normalizedPath}`, error) + } + } + + /** + * Open a file or directory with the system default application + */ + async openFile(filePath: string): Promise { + if (!this.isPathAllowed(filePath)) { + console.warn(`[Workspace] Blocked open attempt for unauthorized path: ${filePath}`) + return + } + + const normalizedPath = path.resolve(filePath) + + try { + const errorMessage = await shell.openPath(normalizedPath) + if (errorMessage) { + console.error(`[Workspace] Failed to open path: ${normalizedPath}`, errorMessage) + } + } catch (error) { + console.error(`[Workspace] Failed to open path: ${normalizedPath}`, error) + } + } + + /** + * Get plan entries + */ + async getPlanEntries(conversationId: string): Promise { + // WorkspacePlanEntry and AcpPlanEntry have the same structure + return this.planManager.getEntries(conversationId) as unknown as WorkspacePlanEntry[] + } + + /** + * Update plan entries (called by agent content mapper) + */ + async updatePlanEntries(conversationId: string, entries: WorkspaceRawPlanEntry[]): Promise { + // WorkspaceRawPlanEntry and AcpRawPlanEntry have the same structure + const updated = this.planManager.updateEntries( + conversationId, + entries as unknown as import('@shared/presenter').AcpRawPlanEntry[] + ) as unknown as WorkspacePlanEntry[] + + // Send event to renderer + eventBus.sendToRenderer(ACP_WORKSPACE_EVENTS.PLAN_UPDATED, SendTarget.ALL_WINDOWS, { + conversationId, + entries: updated + }) + } + + /** + * Emit terminal output snippet (called by agent content mapper) + */ + async emitTerminalSnippet( + conversationId: string, + snippet: WorkspaceTerminalSnippet + ): Promise { + eventBus.sendToRenderer(ACP_WORKSPACE_EVENTS.TERMINAL_OUTPUT, SendTarget.ALL_WINDOWS, { + conversationId, + snippet + }) + } + + /** + * Clear workspace data for a conversation + */ + async clearWorkspaceData(conversationId: string): Promise { + this.planManager.clear(conversationId) + } +} diff --git a/src/renderer/src/components/ChatView.vue b/src/renderer/src/components/ChatView.vue index a508cb3b1..007c78ed5 100644 --- a/src/renderer/src/components/ChatView.vue +++ b/src/renderer/src/components/ChatView.vue @@ -10,10 +10,10 @@ /> - + - @@ -55,13 +55,13 @@ import { ref, onMounted, onUnmounted, watch, computed, nextTick } from 'vue' import MessageList from './message/MessageList.vue' import ChatInput from './chat-input/ChatInput.vue' -import AcpWorkspaceView from './acp-workspace/AcpWorkspaceView.vue' +import WorkspaceView from './workspace/WorkspaceView.vue' import { useRoute } from 'vue-router' import { UserMessageContent } from '@shared/chat' import { STREAM_EVENTS, SHORTCUT_EVENTS } from '@/events' import { useUiSettingsStore } from '@/stores/uiSettingsStore' import { useChatStore } from '@/stores/chat' -import { useAcpWorkspaceStore } from '@/stores/acpWorkspace' +import { useWorkspaceStore } from '@/stores/workspace' import { useCleanDialog } from '@/composables/message/useCleanDialog' import { useI18n } from 'vue-i18n' import { @@ -78,11 +78,11 @@ const { t } = useI18n() const route = useRoute() const uiSettingsStore = useUiSettingsStore() const chatStore = useChatStore() -const acpWorkspaceStore = useAcpWorkspaceStore() +const workspaceStore = useWorkspaceStore() const cleanDialog = useCleanDialog() -// Show workspace only in ACP mode and when open -const showAcpWorkspace = computed(() => acpWorkspaceStore.isAcpMode && acpWorkspaceStore.isOpen) +// Show workspace only in agent mode and when open +const showWorkspace = computed(() => workspaceStore.isAgentMode && workspaceStore.isOpen) const messageList = ref() const chatInput = ref() @@ -110,8 +110,8 @@ const formatFilePathForEditor = (filePath: string) => window.api?.formatPathForInput?.(filePath) ?? (/\s/.test(filePath) ? `"${filePath}"` : filePath) const toRelativePath = (filePath: string) => { - const workdir = acpWorkspaceStore.currentWorkdir ?? undefined - return window.api?.toRelativePath?.(filePath, workdir) ?? filePath + const workspacePath = workspaceStore.currentWorkspacePath ?? undefined + return window.api?.toRelativePath?.(filePath, workspacePath) ?? filePath } const handleAppendFilePath = (filePath: string) => { diff --git a/src/renderer/src/components/ModelChooser.vue b/src/renderer/src/components/ModelChooser.vue index c48fb2ef3..e40ee6871 100644 --- a/src/renderer/src/components/ModelChooser.vue +++ b/src/renderer/src/components/ModelChooser.vue @@ -78,6 +78,7 @@ import ModelIcon from '@/components/icons/ModelIcon.vue' import { ModelType } from '@shared/model' import type { RENDERER_MODEL_META } from '@shared/presenter' import { Icon } from '@iconify/vue' +import { useChatMode } from '@/components/chat-input/composables/useChatMode' const { t } = useI18n() const keyword = ref('') @@ -86,6 +87,7 @@ const providerStore = useProviderStore() const modelStore = useModelStore() const themeStore = useThemeStore() const langStore = useLanguageStore() +const chatMode = useChatMode() const emit = defineEmits<{ (e: 'update:model', model: RENDERER_MODEL_META, providerId: string): void @@ -105,10 +107,16 @@ const props = defineProps({ const providers = computed(() => { const sortedProviders = providerStore.sortedProviders const enabledModels = modelStore.enabledModels + const currentMode = chatMode.currentMode.value const orderedProviders = sortedProviders .filter((provider) => provider.enable) .map((provider) => { + // Hide ACP provider unless in acp agent mode + if (provider.id === 'acp' && currentMode !== 'acp agent') { + return null + } + const enabledProvider = enabledModels.find((entry) => entry.providerId === provider.id) if (!enabledProvider || enabledProvider.models.length === 0) { return null diff --git a/src/renderer/src/components/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue index 072c9e13f..1186d5324 100644 --- a/src/renderer/src/components/chat-input/ChatInput.vue +++ b/src/renderer/src/components/chat-input/ChatInput.vue @@ -114,36 +114,77 @@ {{ t('chat.features.webSearch') }} - + + + + + + +
+
+ + {{ mode.label }} + +
+
+
+
+ + +

- {{ t('chat.input.acpWorkdirTooltip') }} + {{ workspace.tooltipTitle }}

-

- {{ t('chat.input.acpWorkdirCurrent', { path: acpWorkdir.workdir.value }) }} +

+ {{ workspace.tooltipCurrent }}

- {{ t('chat.input.acpWorkdirSelect') }} + {{ workspace.tooltipSelect }}

@@ -404,6 +445,8 @@ import { useContextLength } from './composables/useContextLength' import { useSendButtonState } from './composables/useSendButtonState' import { useAcpWorkdir } from './composables/useAcpWorkdir' import { useAcpMode } from './composables/useAcpMode' +import { useChatMode, type ChatMode } from './composables/useChatMode' +import { useAgentWorkspace } from './composables/useAgentWorkspace' // === Stores === import { useChatStore } from '@/stores/chat' @@ -504,6 +547,10 @@ const showFakeCaret = computed(() => caretVisible.value && !props.disabled) // Initialize settings management const { settings, toggleWebSearch } = useInputSettings() +// Initialize chat mode management +const chatMode = useChatMode() +const modeSelectOpen = ref(false) + // Initialize history composable first (needed for editor placeholder) const history = useInputHistory(null as any, t) @@ -674,6 +721,12 @@ const acpWorkdir = useAcpWorkdir({ conversationId }) +// Unified workspace management (for agent and acp agent modes) +const workspace = useAgentWorkspace({ + conversationId, + activeModel: activeModelSource +}) + // Extract isStreaming first so we can pass it to useAcpMode const { disabledSend, isStreaming } = sendButtonState @@ -738,6 +791,11 @@ const onWebSearchClick = async () => { await toggleWebSearch() } +const handleModeSelect = async (mode: ChatMode) => { + await chatMode.setMode(mode) + modeSelectOpen.value = false +} + const onKeydown = (e: KeyboardEvent) => { if (e.code === 'Enter' && !e.shiftKey) { editorComposable.handleEditorEnter(e, disabledSend.value, emitSend) diff --git a/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts b/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts new file mode 100644 index 000000000..9554df2b9 --- /dev/null +++ b/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts @@ -0,0 +1,225 @@ +// === Vue Core === +import { ref, computed, watch } from 'vue' +import { useI18n } from 'vue-i18n' + +// === Composables === +import { usePresenter } from '@/composables/usePresenter' +import { useChatMode } from './useChatMode' +import { useAcpWorkdir } from './useAcpWorkdir' +import { useChatStore } from '@/stores/chat' + +// === Types === +import type { Ref } from 'vue' + +export interface UseAgentWorkspaceOptions { + conversationId: Ref + activeModel: Ref<{ id: string; providerId: string } | null> +} + +/** + * Unified workspace path management composable + * Handles workspace path selection for both agent and acp agent modes + */ +export function useAgentWorkspace(options: UseAgentWorkspaceOptions) { + const { t } = useI18n() + const threadPresenter = usePresenter('threadPresenter') + const chatMode = useChatMode() + const chatStore = useChatStore() + + // Use ACP workdir for acp agent mode + const acpWorkdir = useAcpWorkdir({ + conversationId: options.conversationId, + activeModel: options.activeModel + }) + + // Agent workspace path (for agent mode) + const agentWorkspacePath = ref(null) + const pendingWorkspacePath = ref(null) + const loading = ref(false) + const syncPreference = (workspacePath: string | null) => { + const setPreference = ( + chatStore as { + setAgentWorkspacePreference?: (path: string | null) => void + updateChatConfig?: (config: { agentWorkspacePath?: string | null }) => Promise + } + ).setAgentWorkspacePreference + + if (typeof setPreference === 'function') { + setPreference(workspacePath) + return + } + + if (typeof chatStore.updateChatConfig === 'function') { + void chatStore.updateChatConfig({ agentWorkspacePath: workspacePath }) + } + } + + // === Computed === + const hasWorkspace = computed(() => { + if (chatMode.currentMode.value === 'acp agent') { + return acpWorkdir.hasWorkdir.value + } + return Boolean(pendingWorkspacePath.value ?? agentWorkspacePath.value) + }) + + const workspacePath = computed(() => { + if (chatMode.currentMode.value === 'acp agent') { + return acpWorkdir.workdir.value + } + return pendingWorkspacePath.value ?? agentWorkspacePath.value + }) + + const tooltipTitle = computed(() => { + if (chatMode.currentMode.value === 'acp agent') { + return t('chat.input.acpWorkdirTooltip') + } + return t('chat.input.agentWorkspaceTooltip') + }) + + const tooltipCurrent = computed(() => { + if (!hasWorkspace.value) return '' + if (chatMode.currentMode.value === 'acp agent') { + return t('chat.input.acpWorkdirCurrent', { path: workspacePath.value || '' }) + } + return t('chat.input.agentWorkspaceCurrent', { path: workspacePath.value || '' }) + }) + + const tooltipSelect = computed(() => { + if (chatMode.currentMode.value === 'acp agent') { + return t('chat.input.acpWorkdirSelect') + } + return t('chat.input.agentWorkspaceSelect') + }) + + // === Methods === + const selectWorkspace = async () => { + if (chatMode.currentMode.value === 'acp agent') { + // Use ACP workdir selection + await acpWorkdir.selectWorkdir() + return + } + + // For agent mode, select workspace path + loading.value = true + try { + const devicePresenter = usePresenter('devicePresenter') + const result = await devicePresenter.selectDirectory() + + if (!result.canceled && result.filePaths && result.filePaths.length > 0) { + const selectedPath = result.filePaths[0] + agentWorkspacePath.value = selectedPath + syncPreference(selectedPath) + + // Save to conversation settings when available + if (options.conversationId.value) { + await threadPresenter.updateConversationSettings(options.conversationId.value, { + agentWorkspacePath: selectedPath + }) + pendingWorkspacePath.value = null + } else { + pendingWorkspacePath.value = selectedPath + } + + // Register workspace with presenter + const workspacePresenter = usePresenter('workspacePresenter') + await workspacePresenter.registerWorkspace(selectedPath) + } + } catch (error) { + console.error('[useAgentWorkspace] Failed to select workspace:', error) + } finally { + loading.value = false + } + } + + // Load workspace path from conversation settings + const loadWorkspacePath = async () => { + if (chatMode.currentMode.value === 'acp agent') { + // ACP workdir is loaded by useAcpWorkdir + return + } + + if (!options.conversationId.value) { + if (!pendingWorkspacePath.value) { + agentWorkspacePath.value = null + } + return + } + + try { + // Load agent workspace path from conversation settings + const conversation = await threadPresenter.getConversation(options.conversationId.value) + const savedPath = conversation?.settings?.agentWorkspacePath ?? null + if (savedPath) { + agentWorkspacePath.value = savedPath + pendingWorkspacePath.value = null + syncPreference(savedPath) + // Register workspace with presenter + const workspacePresenter = usePresenter('workspacePresenter') + await workspacePresenter.registerWorkspace(savedPath) + } else if (!pendingWorkspacePath.value) { + agentWorkspacePath.value = null + } + } catch (error) { + console.error('[useAgentWorkspace] Failed to load workspace path:', error) + } + } + + const syncPendingWorkspaceWhenReady = async () => { + if (chatMode.currentMode.value !== 'agent') return + const selectedPath = pendingWorkspacePath.value + if (!selectedPath || !options.conversationId.value) return + + loading.value = true + try { + await threadPresenter.updateConversationSettings(options.conversationId.value, { + agentWorkspacePath: selectedPath + }) + agentWorkspacePath.value = selectedPath + pendingWorkspacePath.value = null + + const workspacePresenter = usePresenter('workspacePresenter') + await workspacePresenter.registerWorkspace(selectedPath) + } catch (error) { + console.error('[useAgentWorkspace] Failed to sync pending workspace:', error) + } finally { + loading.value = false + } + } + + watch( + () => options.conversationId.value, + () => { + if (pendingWorkspacePath.value) { + void syncPendingWorkspaceWhenReady() + } + } + ) + + // Watch for chatMode and conversationId changes + watch( + [() => chatMode.currentMode.value, () => options.conversationId.value], + async ([newMode, conversationId]) => { + if (newMode === 'agent' && conversationId) { + await loadWorkspacePath() + } else if (newMode === 'acp agent') { + // ACP workdir is handled by useAcpWorkdir + } else { + // Clear workspace path when switching to chat mode + agentWorkspacePath.value = null + pendingWorkspacePath.value = null + } + }, + { immediate: true } + ) + + return { + hasWorkspace, + workspacePath, + loading, + tooltipTitle, + tooltipCurrent, + tooltipSelect, + selectWorkspace, + loadWorkspacePath + } +} diff --git a/src/renderer/src/components/chat-input/composables/useChatMode.ts b/src/renderer/src/components/chat-input/composables/useChatMode.ts new file mode 100644 index 000000000..882f311de --- /dev/null +++ b/src/renderer/src/components/chat-input/composables/useChatMode.ts @@ -0,0 +1,93 @@ +// === Vue Core === +import { ref, onMounted, computed } from 'vue' + +// === Composables === +import { usePresenter } from '@/composables/usePresenter' + +export type ChatMode = 'chat' | 'agent' | 'acp agent' + +const MODE_ICONS = { + chat: 'lucide:message-circle-more', + agent: 'lucide:bot', + 'acp agent': 'lucide:bot-message-square' +} as const + +const MODE_LABELS = { + chat: 'Chat', + agent: 'Agent', + 'acp agent': 'ACP Agent' +} as const + +/** + * Manages chat mode selection (chat, agent, acp agent) + * Similar to useInputSettings, stores mode in database via configPresenter + */ +export function useChatMode() { + // === Presenters === + const configPresenter = usePresenter('configPresenter') + + // === Local State === + const currentMode = ref('chat') + + // === Computed === + const currentIcon = computed(() => MODE_ICONS[currentMode.value]) + const currentLabel = computed(() => MODE_LABELS[currentMode.value]) + const isAgentMode = computed( + () => currentMode.value === 'agent' || currentMode.value === 'acp agent' + ) + + const modes = computed(() => [ + { value: 'chat' as ChatMode, label: MODE_LABELS.chat, icon: MODE_ICONS.chat }, + { value: 'agent' as ChatMode, label: MODE_LABELS.agent, icon: MODE_ICONS.agent }, + { + value: 'acp agent' as ChatMode, + label: MODE_LABELS['acp agent'], + icon: MODE_ICONS['acp agent'] + } + ]) + + // === Public Methods === + const setMode = async (mode: ChatMode) => { + const previousValue = currentMode.value + currentMode.value = mode + + try { + await configPresenter.setSetting('input_chatMode', mode) + } catch (error) { + // Revert to previous value on error + currentMode.value = previousValue + console.error('Failed to save chat mode:', error) + // TODO: Show user-facing notification when toast system is available + } + } + + const loadMode = async () => { + try { + const saved = await configPresenter.getSetting('input_chatMode') + currentMode.value = (saved as ChatMode) || 'chat' + } catch (error) { + // Fall back to safe defaults on error + currentMode.value = 'chat' + console.error('Failed to load chat mode, using default:', error) + } + } + + // === Lifecycle Hooks === + onMounted(async () => { + try { + await loadMode() + } catch (error) { + console.error('Failed to initialize chat mode:', error) + } + }) + + return { + currentMode, + currentIcon, + currentLabel, + isAgentMode, + modes, + setMode, + loadMode + } +} diff --git a/src/renderer/src/components/workspace/WorkspaceFileNode.vue b/src/renderer/src/components/workspace/WorkspaceFileNode.vue new file mode 100644 index 000000000..3f32d365f --- /dev/null +++ b/src/renderer/src/components/workspace/WorkspaceFileNode.vue @@ -0,0 +1,180 @@ + + + + + diff --git a/src/renderer/src/components/workspace/WorkspaceFiles.vue b/src/renderer/src/components/workspace/WorkspaceFiles.vue new file mode 100644 index 000000000..633d01d0f --- /dev/null +++ b/src/renderer/src/components/workspace/WorkspaceFiles.vue @@ -0,0 +1,102 @@ + + + + + diff --git a/src/renderer/src/components/workspace/WorkspacePlan.vue b/src/renderer/src/components/workspace/WorkspacePlan.vue new file mode 100644 index 000000000..ecf237d8e --- /dev/null +++ b/src/renderer/src/components/workspace/WorkspacePlan.vue @@ -0,0 +1,121 @@ + + + + + diff --git a/src/renderer/src/components/workspace/WorkspaceTerminal.vue b/src/renderer/src/components/workspace/WorkspaceTerminal.vue new file mode 100644 index 000000000..f36a107a2 --- /dev/null +++ b/src/renderer/src/components/workspace/WorkspaceTerminal.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/renderer/src/components/workspace/WorkspaceView.vue b/src/renderer/src/components/workspace/WorkspaceView.vue new file mode 100644 index 000000000..32e599b15 --- /dev/null +++ b/src/renderer/src/components/workspace/WorkspaceView.vue @@ -0,0 +1,50 @@ + + + diff --git a/src/renderer/src/i18n/en-US/chat.json b/src/renderer/src/i18n/en-US/chat.json index 236e9dc99..7db876238 100644 --- a/src/renderer/src/i18n/en-US/chat.json +++ b/src/renderer/src/i18n/en-US/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "Select a folder to use as the ACP workdir", "acpWorkdirCurrent": "Current workdir: {path}", "acpMode": "Mode", - "acpModeTooltip": "Current mode: {mode}" + "acpModeTooltip": "Current mode: {mode}", + "agentWorkspaceTooltip": "Set Agent workspace directory", + "agentWorkspaceSelect": "Select a folder to use as the workspace", + "agentWorkspaceCurrent": "Current workspace: {path}" }, "features": { "webSearch": "Web Search", @@ -89,6 +92,41 @@ "params": "Parameters", "responseData": "Response data" }, + "mode": { + "current": "Current mode: {mode}", + "chat": "Chat", + "agent": "Agent", + "acpAgent": "ACP Agent" + }, + "workspace": { + "title": "Workspace", + "collapse": "Collapse", + "plan": { + "section": "Plan", + "empty": "No tasks yet", + "status": { + "pending": "Pending", + "in_progress": "In Progress", + "completed": "Completed", + "failed": "Failed", + "skipped": "Skipped" + } + }, + "files": { + "section": "Files", + "empty": "No files", + "loading": "Loading files...", + "contextMenu": { + "openFile": "Open file", + "revealInFolder": "Show in file manager", + "insertPath": "Insert into input" + } + }, + "terminal": { + "section": "Terminal", + "empty": "No output yet" + } + }, "acp": { "workspace": { "title": "Workspace", diff --git a/src/renderer/src/i18n/zh-CN/chat.json b/src/renderer/src/i18n/zh-CN/chat.json index 8092230cd..3b33539f7 100644 --- a/src/renderer/src/i18n/zh-CN/chat.json +++ b/src/renderer/src/i18n/zh-CN/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "选择工作目录", "acpWorkdirCurrent": "当前工作目录:{path}", "acpMode": "模式", - "acpModeTooltip": "当前模式:{mode}" + "acpModeTooltip": "当前模式:{mode}", + "agentWorkspaceTooltip": "设置 Agent 工作目录", + "agentWorkspaceSelect": "选择工作目录", + "agentWorkspaceCurrent": "当前工作目录:{path}" }, "features": { "webSearch": "联网搜索", @@ -89,6 +92,41 @@ "params": "参数", "responseData": "响应数据" }, + "mode": { + "current": "当前模式:{mode}", + "chat": "聊天", + "agent": "Agent", + "acpAgent": "ACP Agent" + }, + "workspace": { + "title": "工作区", + "collapse": "收起", + "plan": { + "section": "计划", + "empty": "暂无任务", + "status": { + "pending": "待处理", + "in_progress": "进行中", + "completed": "已完成", + "failed": "失败", + "skipped": "已跳过" + } + }, + "files": { + "section": "文件", + "empty": "暂无文件", + "loading": "加载文件中...", + "contextMenu": { + "openFile": "打开文件", + "revealInFolder": "在文件管理器中打开", + "insertPath": "插入到输入框" + } + }, + "terminal": { + "section": "终端", + "empty": "暂无输出" + } + }, "acp": { "workspace": { "title": "工作区", diff --git a/src/renderer/src/stores/chat.ts b/src/renderer/src/stores/chat.ts index 8ab4df7c7..61abbc86e 100644 --- a/src/renderer/src/stores/chat.ts +++ b/src/renderer/src/stores/chat.ts @@ -68,7 +68,8 @@ export const useChatStore = defineStore('chat', () => { reasoningEffort: undefined, verbosity: undefined, selectedVariantsMap: {}, - acpWorkdirMap: {} + acpWorkdirMap: {}, + agentWorkspacePath: null }) // Deeplink 消息缓存 @@ -181,6 +182,13 @@ export const useChatStore = defineStore('chat', () => { } } + if (normalizedSettings.agentWorkspacePath === undefined) { + const pendingWorkspacePath = chatConfig.value.agentWorkspacePath ?? null + if (pendingWorkspacePath) { + normalizedSettings.agentWorkspacePath = pendingWorkspacePath + } + } + const threadId = await threadP.createConversation(title, normalizedSettings, getTabId()) // 因为 createConversation 内部已经调用了 setActiveConversation // 并且可以确定是为当前tab激活,所以在这里可以直接、安全地更新本地状态 @@ -221,6 +229,11 @@ export const useChatStore = defineStore('chat', () => { chatConfig.value = { ...chatConfig.value, acpWorkdirMap: nextMap } } + const setAgentWorkspacePreference = (workspacePath: string | null) => { + const nextPath = workspacePath?.trim() ? workspacePath : null + chatConfig.value = { ...chatConfig.value, agentWorkspacePath: nextPath } + } + // 处理消息的 extra 信息 const enrichMessageWithExtra = async (message: Message): Promise => { if ( @@ -1495,6 +1508,7 @@ export const useChatStore = defineStore('chat', () => { chatConfig, updateChatConfig, setAcpWorkdirPreference, + setAgentWorkspacePreference, retryMessage, deleteMessage, clearActiveThread, diff --git a/src/renderer/src/stores/workspace.ts b/src/renderer/src/stores/workspace.ts new file mode 100644 index 000000000..c7d65b7f6 --- /dev/null +++ b/src/renderer/src/stores/workspace.ts @@ -0,0 +1,285 @@ +import { defineStore } from 'pinia' +import { ref, computed, watch } from 'vue' +import { usePresenter } from '@/composables/usePresenter' +import { useChatStore } from './chat' +import { ACP_WORKSPACE_EVENTS } from '@/events' +import type { + WorkspacePlanEntry, + WorkspaceFileNode, + WorkspaceTerminalSnippet +} from '@shared/presenter' +import { useChatMode } from '@/components/chat-input/composables/useChatMode' + +// Debounce delay for file tree refresh (ms) +const FILE_REFRESH_DEBOUNCE_MS = 500 + +export const useWorkspaceStore = defineStore('workspace', () => { + const chatStore = useChatStore() + const workspacePresenter = usePresenter('workspacePresenter') + const chatMode = useChatMode() + + // === State === + const isOpen = ref(false) + const isLoading = ref(false) + const planEntries = ref([]) + const fileTree = ref([]) + const terminalSnippets = ref([]) + const lastSyncedConversationId = ref(null) + const lastSuccessfulWorkspace = ref(null) + + // Debounce timer for file refresh + let fileRefreshDebounceTimer: ReturnType | null = null + + // === Computed Properties === + const isAgentMode = computed( + () => chatMode.currentMode.value === 'agent' || chatMode.currentMode.value === 'acp agent' + ) + + const currentWorkspacePath = computed(() => { + // For acp agent mode, use ACP workdir + if (chatMode.currentMode.value === 'acp agent') { + const modelId = chatStore.chatConfig.modelId + if (!modelId) return null + return chatStore.chatConfig.acpWorkdirMap?.[modelId] ?? null + } + // For agent mode, use agentWorkspacePath + return chatStore.chatConfig.agentWorkspacePath ?? null + }) + + const completedPlanCount = computed( + () => planEntries.value.filter((e) => e.status === 'completed').length + ) + + const totalPlanCount = computed(() => planEntries.value.length) + + const planProgress = computed(() => { + if (totalPlanCount.value === 0) return 0 + return Math.round((completedPlanCount.value / totalPlanCount.value) * 100) + }) + + // === Methods === + const toggle = () => { + isOpen.value = !isOpen.value + } + + const setOpen = (open: boolean) => { + isOpen.value = open + } + + const refreshFileTree = async () => { + const workspacePath = currentWorkspacePath.value + const conversationIdBefore = chatStore.getActiveThreadId() + + if (!workspacePath) { + fileTree.value = [] + return + } + + // Register workspace before reading (security boundary) - await to ensure completion + await workspacePresenter.registerWorkspace(workspacePath) + + isLoading.value = true + try { + // Only read first level (lazy loading) + const result = (await workspacePresenter.readDirectory(workspacePath)) ?? [] + // Guard against race condition: only update if still on the same conversation + if (chatStore.getActiveThreadId() === conversationIdBefore) { + fileTree.value = result + lastSuccessfulWorkspace.value = workspacePath + } + } catch (error) { + console.error('[Workspace] Failed to load file tree:', error) + if (chatStore.getActiveThreadId() === conversationIdBefore) { + if (lastSuccessfulWorkspace.value !== workspacePath) { + fileTree.value = [] + } + } + } finally { + if (chatStore.getActiveThreadId() === conversationIdBefore) { + isLoading.value = false + } + } + } + + /** + * Debounced file tree refresh - merges multiple refresh requests within a short time window + */ + const debouncedRefreshFileTree = () => { + if (fileRefreshDebounceTimer) { + clearTimeout(fileRefreshDebounceTimer) + } + fileRefreshDebounceTimer = setTimeout(() => { + fileRefreshDebounceTimer = null + refreshFileTree() + }, FILE_REFRESH_DEBOUNCE_MS) + } + + /** + * Load children for a directory node (lazy loading) + */ + const loadDirectoryChildren = async (node: WorkspaceFileNode): Promise => { + if (!node.isDirectory) return + + try { + const children = (await workspacePresenter.expandDirectory(node.path)) ?? [] + node.children = children + node.expanded = true + } catch (error) { + console.error('[Workspace] Failed to load directory children:', error) + node.children = [] + node.expanded = true + } + } + + const refreshPlanEntries = async () => { + const conversationId = chatStore.getActiveThreadId() + if (!conversationId) { + planEntries.value = [] + return + } + + try { + const result = (await workspacePresenter.getPlanEntries(conversationId)) ?? [] + // Guard against race condition: only update if still on the same conversation + if (chatStore.getActiveThreadId() === conversationId) { + planEntries.value = result + } + } catch (error) { + console.error('[Workspace] Failed to load plan entries:', error) + } + } + + /** + * Toggle file node expansion (with lazy loading support) + */ + const toggleFileNode = async (node: WorkspaceFileNode): Promise => { + if (!node.isDirectory) return + + if (node.expanded) { + // Collapse: just toggle expanded state + node.expanded = false + } else { + // Expand: load children if not yet loaded + if (node.children === undefined) { + await loadDirectoryChildren(node) + } else { + node.expanded = true + } + } + } + + const clearData = () => { + planEntries.value = [] + fileTree.value = [] + terminalSnippets.value = [] + lastSyncedConversationId.value = null + lastSuccessfulWorkspace.value = null + } + + // === Event Listeners === + const setupEventListeners = () => { + // Plan update event + window.electron.ipcRenderer.on( + ACP_WORKSPACE_EVENTS.PLAN_UPDATED, + (_, payload: { conversationId: string; entries: WorkspacePlanEntry[] }) => { + if (payload.conversationId === chatStore.getActiveThreadId()) { + planEntries.value = payload.entries + } + } + ) + + // Terminal output event + window.electron.ipcRenderer.on( + ACP_WORKSPACE_EVENTS.TERMINAL_OUTPUT, + (_, payload: { conversationId: string; snippet: WorkspaceTerminalSnippet }) => { + if (payload.conversationId === chatStore.getActiveThreadId()) { + // Keep latest 10 items + terminalSnippets.value = [payload.snippet, ...terminalSnippets.value.slice(0, 9)] + } + } + ) + + // File change event - refresh file tree (debounced to merge rapid updates) + window.electron.ipcRenderer.on( + ACP_WORKSPACE_EVENTS.FILES_CHANGED, + (_, payload: { conversationId: string }) => { + if (payload.conversationId === chatStore.getActiveThreadId() && isAgentMode.value) { + debouncedRefreshFileTree() + } + } + ) + } + + // === Watchers === + // Watch for conversation changes + watch( + () => chatStore.getActiveThreadId(), + async (newId) => { + if (newId !== lastSyncedConversationId.value) { + lastSyncedConversationId.value = newId ?? null + if (newId && isAgentMode.value) { + await Promise.all([refreshPlanEntries(), refreshFileTree()]) + } else { + clearData() + } + } + } + ) + + // Watch for workspace path changes + watch( + currentWorkspacePath, + (workspacePath, previousWorkspacePath) => { + if (workspacePath !== previousWorkspacePath) { + lastSuccessfulWorkspace.value = null + } + + if (isAgentMode.value && workspacePath) { + refreshFileTree() + } + }, + { immediate: true } + ) + + // Watch for Agent mode changes + watch( + isAgentMode, + (isAgent) => { + if (isAgent) { + setOpen(true) + refreshFileTree() + refreshPlanEntries() + } else { + setOpen(false) + clearData() + } + }, + { immediate: true } + ) + + // Initialize event listeners + setupEventListeners() + + return { + // State + isOpen, + isLoading, + planEntries, + fileTree, + terminalSnippets, + // Computed + isAgentMode, + currentWorkspacePath, + completedPlanCount, + totalPlanCount, + planProgress, + // Methods + toggle, + setOpen, + refreshFileTree, + refreshPlanEntries, + toggleFileNode, + loadDirectoryChildren, + clearData + } +}) diff --git a/src/shared/types/index.d.ts b/src/shared/types/index.d.ts index c86805f21..3889c34eb 100644 --- a/src/shared/types/index.d.ts +++ b/src/shared/types/index.d.ts @@ -2,4 +2,6 @@ export type * from './presenters/legacy.presenters' export type * from './presenters/agent-provider' export type * from './presenters/acp-workspace' +export type * from './presenters/workspace' +export type * from './presenters/tool.presenter' export * from './browser' diff --git a/src/shared/types/presenters/index.d.ts b/src/shared/types/presenters/index.d.ts index 38bc11c6e..786d07e2b 100644 --- a/src/shared/types/presenters/index.d.ts +++ b/src/shared/types/presenters/index.d.ts @@ -35,7 +35,7 @@ export type { export type * from './agent-provider' -// ACP Workspace types +// ACP Workspace types (legacy, kept for backward compatibility) export type { AcpPlanStatus, AcpPlanEntry, @@ -45,5 +45,18 @@ export type { IAcpWorkspacePresenter } from './acp-workspace' +// Generic Workspace types (for all Agent modes) +export type { + WorkspacePlanStatus, + WorkspacePlanEntry, + WorkspaceFileNode, + WorkspaceTerminalSnippet, + WorkspaceRawPlanEntry, + IWorkspacePresenter +} from './workspace' + +// Tool Presenter types +export type { IToolPresenter } from './tool.presenter' + // Re-export legacy types temporarily for compatibility export * from './legacy.presenters' diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index ee17cb9d0..b3669ca9c 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -8,6 +8,8 @@ import type { NowledgeMemThread, NowledgeMemExportSummary } from '../nowledgeMem import { ProviderChange, ProviderBatchUpdate } from './provider-operations' import type { AgentSessionLifecycleStatus } from './agent-provider' import type { IAcpWorkspacePresenter } from './acp-workspace' +import type { IWorkspacePresenter } from './workspace' +import type { IToolPresenter } from './tool.presenter' import type { BrowserTabInfo, BrowserContextSnapshot, @@ -442,6 +444,8 @@ export interface IPresenter { dialogPresenter: IDialogPresenter knowledgePresenter: IKnowledgePresenter acpWorkspacePresenter: IAcpWorkspacePresenter + workspacePresenter: IWorkspacePresenter + toolPresenter: IToolPresenter init(): void destroy(): void } @@ -1012,6 +1016,8 @@ export type CONVERSATION_SETTINGS = { verbosity?: 'low' | 'medium' | 'high' selectedVariantsMap?: Record acpWorkdirMap?: Record + chatMode?: 'chat' | 'agent' | 'acp agent' + agentWorkspacePath?: string | null } export type CONVERSATION = { diff --git a/src/shared/types/presenters/thread.presenter.d.ts b/src/shared/types/presenters/thread.presenter.d.ts index 0edf4ed9d..029833a20 100644 --- a/src/shared/types/presenters/thread.presenter.d.ts +++ b/src/shared/types/presenters/thread.presenter.d.ts @@ -28,6 +28,8 @@ export type CONVERSATION_SETTINGS = { reasoningEffort?: 'minimal' | 'low' | 'medium' | 'high' verbosity?: 'low' | 'medium' | 'high' acpWorkdirMap?: Record + chatMode?: 'chat' | 'agent' | 'acp agent' + agentWorkspacePath?: string | null } export type CONVERSATION = { diff --git a/src/shared/types/presenters/tool.presenter.d.ts b/src/shared/types/presenters/tool.presenter.d.ts new file mode 100644 index 000000000..a51024d32 --- /dev/null +++ b/src/shared/types/presenters/tool.presenter.d.ts @@ -0,0 +1,29 @@ +/** + * Tool Presenter Types + * Types for the unified tool routing presenter + */ + +import type { MCPToolDefinition, MCPToolCall, MCPToolResponse } from './legacy.presenters' + +/** + * Tool Presenter interface + * Unified interface for managing all tool sources (MCP, Agent) + */ +export interface IToolPresenter { + /** + * Get all tool definitions from all sources + * @param context Context for tool definition retrieval + */ + getAllToolDefinitions(context: { + enabledMcpTools?: string[] + chatMode?: 'chat' | 'agent' | 'acp agent' + supportsVision?: boolean + agentWorkspacePath?: string | null + }): Promise + + /** + * Call a tool, routing to the appropriate source + * @param request Tool call request + */ + callTool(request: MCPToolCall): Promise<{ content: unknown; rawData: MCPToolResponse }> +} diff --git a/src/shared/types/presenters/workspace.d.ts b/src/shared/types/presenters/workspace.d.ts new file mode 100644 index 000000000..5a40ed750 --- /dev/null +++ b/src/shared/types/presenters/workspace.d.ts @@ -0,0 +1,140 @@ +/** + * Workspace Types + * Types for the generic workspace panel functionality (supports all Agent modes) + */ + +/** + * Plan entry status + */ +export type WorkspacePlanStatus = 'pending' | 'in_progress' | 'completed' | 'failed' | 'skipped' + +/** + * Plan entry - task from Agent + */ +export type WorkspacePlanEntry = { + /** Unique identifier (system generated) */ + id: string + /** Task content description */ + content: string + /** Task status */ + status: WorkspacePlanStatus + /** Priority (optional, from agent) */ + priority?: string | null + /** Update timestamp */ + updatedAt: number +} + +/** + * File tree node + */ +export type WorkspaceFileNode = { + /** File/directory name */ + name: string + /** Full path */ + path: string + /** Whether it's a directory */ + isDirectory: boolean + /** Child nodes (directories only) */ + children?: WorkspaceFileNode[] + /** Whether expanded (frontend state) */ + expanded?: boolean +} + +/** + * Terminal output snippet - from Agent tool_call terminal output + */ +export type WorkspaceTerminalSnippet = { + /** Unique identifier */ + id: string + /** Executed command */ + command: string + /** Working directory */ + cwd?: string + /** Output content (truncated) */ + output: string + /** Whether truncated */ + truncated: boolean + /** Exit code (after command completion) */ + exitCode?: number | null + /** Timestamp */ + timestamp: number +} + +/** + * Raw plan entry from agent content mapper + */ +export type WorkspaceRawPlanEntry = { + content: string + status?: string | null + priority?: string | null +} + +/** + * Workspace Presenter interface + */ +export interface IWorkspacePresenter { + /** + * Register a workspace path as allowed for reading (security boundary) + * @param workspacePath Workspace directory path + */ + registerWorkspace(workspacePath: string): Promise + + /** + * Unregister a workspace path + * @param workspacePath Workspace directory path + */ + unregisterWorkspace(workspacePath: string): Promise + + /** + * Read directory (shallow, only first level) + * Use expandDirectory to load subdirectory contents + * @param dirPath Directory path + * @returns Array of file tree nodes (directories have children = undefined) + */ + readDirectory(dirPath: string): Promise + + /** + * Expand a directory to load its children (lazy loading) + * @param dirPath Directory path to expand + * @returns Array of child file tree nodes + */ + expandDirectory(dirPath: string): Promise + + /** + * Reveal a file or directory in the system file manager + * @param filePath Path to reveal + */ + revealFileInFolder(filePath: string): Promise + + /** + * Open a file or directory using the system default application + * @param filePath Path to open + */ + openFile(filePath: string): Promise + + /** + * Get plan entries for a conversation + * @param conversationId Conversation ID + */ + getPlanEntries(conversationId: string): Promise + + /** + * Update plan entries for a conversation (called internally by Agent events) + * @param conversationId Conversation ID + * @param entries Raw plan entries from agent + */ + updatePlanEntries(conversationId: string, entries: WorkspaceRawPlanEntry[]): Promise + + /** + * Emit terminal snippet (called internally by Agent events) + * @param conversationId Conversation ID + * @param snippet Terminal snippet + */ + emitTerminalSnippet(conversationId: string, snippet: WorkspaceTerminalSnippet): Promise + + /** + * Clear workspace data for a conversation + * @param conversationId Conversation ID + */ + clearWorkspaceData(conversationId: string): Promise +} From 6de0e6ef330a95225511fbb4b90d9f37a4dd123f Mon Sep 17 00:00:00 2001 From: zerob13 Date: Sat, 20 Dec 2025 23:39:38 +0800 Subject: [PATCH 02/29] feat: support workdir on agent mode --- ...\351\207\215\346\236\204_47fd60ee.plan.md" | 3 +- .../agent/agentToolManager.ts | 3 +- .../sqlitePresenter/tables/conversations.ts | 148 ++++++++++++++---- src/main/presenter/threadPresenter/index.ts | 18 +++ .../managers/conversationManager.ts | 54 ++++++- src/main/presenter/toolPresenter/index.ts | 36 ++++- src/renderer/src/components/NewThread.vue | 40 +++++ .../src/components/chat-input/ChatInput.vue | 32 +++- .../composables/useAgentWorkspace.ts | 40 +++-- .../chat-input/composables/useChatMode.ts | 20 +-- src/renderer/src/stores/chat.ts | 34 +++- 11 files changed, 364 insertions(+), 64 deletions(-) diff --git "a/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" "b/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" index e43fea821..695b433a2 100644 --- "a/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" +++ "b/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" @@ -616,5 +616,4 @@ export function useAgentWorkspace(options: { 1. **文件大小限制**:确保每个文件不超过 200 行(TypeScript) 2. **文件夹文件数限制**:每个文件夹不超过 8 个文件 -3. **UI 一致性**:Mode Switch 和目录选择按钮的样式和行为应该与现有的 UI 元素保持一致 -4. **工具定义统一**:所有工具定义必须使用 MCP 规范格式,确保映射和转换函数可以共用 \ No newline at end of file +3. **UI 一致性**:Mode Switch 和目录选择按钮的样式和行为应该与现有的 UI 元素保持一致 \ No newline at end of file diff --git a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts index cfd3545fe..1f19a79f2 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts @@ -11,7 +11,7 @@ interface AgentToolManagerOptions { export class AgentToolManager { private readonly yoBrowserPresenter: IYoBrowserPresenter - private readonly agentWorkspacePath: string | null + private agentWorkspacePath: string | null private fileSystemHandler: AgentFileSystemHandler | null = null constructor(options: AgentToolManagerOptions) { @@ -39,6 +39,7 @@ export class AgentToolManager { } else { this.fileSystemHandler = null } + this.agentWorkspacePath = context.agentWorkspacePath } // 1. Yo Browser tools (only when browser window is open) diff --git a/src/main/presenter/sqlitePresenter/tables/conversations.ts b/src/main/presenter/sqlitePresenter/tables/conversations.ts index ee6e2c4ee..dc8a758b0 100644 --- a/src/main/presenter/sqlitePresenter/tables/conversations.ts +++ b/src/main/presenter/sqlitePresenter/tables/conversations.ts @@ -2,6 +2,7 @@ import { BaseTable } from './baseTable' import type Database from 'better-sqlite3-multiple-ciphers' import { CONVERSATION, CONVERSATION_SETTINGS } from '@shared/presenter' import { nanoid } from 'nanoid' +import * as fs from 'fs' type ConversationRow = { id: string @@ -118,12 +119,20 @@ export class ConversationsTable extends BaseTable { ALTER TABLE conversations ADD COLUMN search_strategy TEXT DEFAULT NULL; ` } + if (version === 8) { + return ` + -- 添加 agent_workspace_path 字段 + ALTER TABLE conversations ADD COLUMN agent_workspace_path TEXT DEFAULT NULL; + -- 添加 acp_workdir_map 字段 + ALTER TABLE conversations ADD COLUMN acp_workdir_map TEXT DEFAULT NULL; + ` + } return null } getLatestVersion(): number { - return 7 + return 8 } async create(title: string, settings: Partial = {}): Promise { @@ -149,9 +158,11 @@ export class ConversationsTable extends BaseTable { enable_search, forced_search, search_strategy, - context_chain + context_chain, + agent_workspace_path, + acp_workdir_map ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `) const conv_id = nanoid() const now = Date.now() @@ -176,8 +187,30 @@ export class ConversationsTable extends BaseTable { settings.enableSearch !== undefined ? (settings.enableSearch ? 1 : 0) : null, settings.forcedSearch !== undefined ? (settings.forcedSearch ? 1 : 0) : null, settings.searchStrategy !== undefined ? settings.searchStrategy : null, - settings.selectedVariantsMap ? JSON.stringify(settings.selectedVariantsMap) : '{}' + settings.selectedVariantsMap ? JSON.stringify(settings.selectedVariantsMap) : '{}', + settings.agentWorkspacePath !== undefined && settings.agentWorkspacePath !== null + ? settings.agentWorkspacePath + : null, + settings.acpWorkdirMap ? JSON.stringify(settings.acpWorkdirMap) : null ) + // #region agent log + const logPath = '/Users/zerob13/Documents/deepchat/.cursor/debug.log' + const logEntry = + JSON.stringify({ + location: 'conversations.ts:create', + message: 'create - saved to database', + data: { + agentWorkspacePath: settings.agentWorkspacePath, + acpWorkdirMap: settings.acpWorkdirMap, + convId: conv_id + }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'post-fix', + hypothesisId: 'FIXED' + }) + '\n' + fs.appendFileSync(logPath, logEntry) + // #endregion return conv_id } @@ -206,17 +239,68 @@ export class ConversationsTable extends BaseTable { enable_search, forced_search, search_strategy, - context_chain + context_chain, + agent_workspace_path, + acp_workdir_map FROM conversations WHERE conv_id = ? ` ) - .get(conversationId) as ConversationRow & { is_pinned: number } + .get(conversationId) as ConversationRow & { + is_pinned: number + agent_workspace_path: string | null + acp_workdir_map: string | null + } if (!result) { throw new Error(`Conversation ${conversationId} not found`) } + const settings = { + systemPrompt: result.systemPrompt, + temperature: result.temperature, + contextLength: result.contextLength, + maxTokens: result.maxTokens, + providerId: result.providerId, + modelId: result.modelId, + artifacts: result.artifacts as 0 | 1, + enabledMcpTools: getJsonField(result.enabled_mcp_tools, undefined), + thinkingBudget: result.thinking_budget !== null ? result.thinking_budget : undefined, + reasoningEffort: result.reasoning_effort + ? (result.reasoning_effort as 'minimal' | 'low' | 'medium' | 'high') + : undefined, + verbosity: result.verbosity ? (result.verbosity as 'low' | 'medium' | 'high') : undefined, + enableSearch: result.enable_search !== null ? Boolean(result.enable_search) : undefined, + forcedSearch: result.forced_search !== null ? Boolean(result.forced_search) : undefined, + searchStrategy: result.search_strategy + ? (result.search_strategy as 'turbo' | 'max') + : undefined, + selectedVariantsMap: getJsonField(result.context_chain, undefined), + agentWorkspacePath: + result.agent_workspace_path !== null && result.agent_workspace_path !== undefined + ? result.agent_workspace_path + : undefined, + acpWorkdirMap: getJsonField(result.acp_workdir_map, undefined) + } + // #region agent log + const logPath = '/Users/zerob13/Documents/deepchat/.cursor/debug.log' + const logEntry = + JSON.stringify({ + location: 'conversations.ts:get', + message: 'get - loaded from database', + data: { + agentWorkspacePath: settings.agentWorkspacePath, + acpWorkdirMap: settings.acpWorkdirMap, + rawAgentWorkspacePath: result.agent_workspace_path, + rawAcpWorkdirMap: result.acp_workdir_map + }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'post-fix', + hypothesisId: 'FIXED' + }) + '\n' + fs.appendFileSync(logPath, logEntry) + // #endregion return { id: result.id, title: result.title, @@ -224,33 +308,13 @@ export class ConversationsTable extends BaseTable { updatedAt: result.updatedAt, is_new: result.is_new, is_pinned: result.is_pinned, - settings: { - systemPrompt: result.systemPrompt, - temperature: result.temperature, - contextLength: result.contextLength, - maxTokens: result.maxTokens, - providerId: result.providerId, - modelId: result.modelId, - artifacts: result.artifacts as 0 | 1, - enabledMcpTools: getJsonField(result.enabled_mcp_tools, undefined), - thinkingBudget: result.thinking_budget !== null ? result.thinking_budget : undefined, - reasoningEffort: result.reasoning_effort - ? (result.reasoning_effort as 'minimal' | 'low' | 'medium' | 'high') - : undefined, - verbosity: result.verbosity ? (result.verbosity as 'low' | 'medium' | 'high') : undefined, - enableSearch: result.enable_search !== null ? Boolean(result.enable_search) : undefined, - forcedSearch: result.forced_search !== null ? Boolean(result.forced_search) : undefined, - searchStrategy: result.search_strategy - ? (result.search_strategy as 'turbo' | 'max') - : undefined, - selectedVariantsMap: getJsonField(result.context_chain, undefined) - } + settings } } async update(conversationId: string, data: Partial): Promise { const updates: string[] = [] - const params: (string | number)[] = [] + const params: (string | number | null)[] = [] if (data.title !== undefined) { updates.push('title = ?') @@ -328,6 +392,18 @@ export class ConversationsTable extends BaseTable { updates.push('context_chain = ?') params.push(JSON.stringify(data.settings.selectedVariantsMap)) } + if (data.settings.agentWorkspacePath !== undefined) { + updates.push('agent_workspace_path = ?') + params.push( + data.settings.agentWorkspacePath !== null ? data.settings.agentWorkspacePath : null + ) + } + if (data.settings.acpWorkdirMap !== undefined) { + updates.push('acp_workdir_map = ?') + params.push( + data.settings.acpWorkdirMap ? JSON.stringify(data.settings.acpWorkdirMap) : null + ) + } } if (updates.length > 0 || data.updatedAt) { updates.push('updated_at = ?') @@ -379,13 +455,18 @@ export class ConversationsTable extends BaseTable { enable_search, forced_search, search_strategy, - context_chain + context_chain, + agent_workspace_path, + acp_workdir_map FROM conversations ORDER BY updated_at DESC LIMIT ? OFFSET ? ` ) - .all(pageSize, offset) as ConversationRow[] + .all(pageSize, offset) as (ConversationRow & { + agent_workspace_path: string | null + acp_workdir_map: string | null + })[] return { total: totalResult.count, @@ -415,7 +496,12 @@ export class ConversationsTable extends BaseTable { searchStrategy: row.search_strategy ? (row.search_strategy as 'turbo' | 'max') : undefined, - selectedVariantsMap: getJsonField(row.context_chain, undefined) + selectedVariantsMap: getJsonField(row.context_chain, undefined), + agentWorkspacePath: + row.agent_workspace_path !== null && row.agent_workspace_path !== undefined + ? row.agent_workspace_path + : undefined, + acpWorkdirMap: getJsonField(row.acp_workdir_map, undefined) } })) } diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts index c33759a4a..5e7593db5 100644 --- a/src/main/presenter/threadPresenter/index.ts +++ b/src/main/presenter/threadPresenter/index.ts @@ -368,6 +368,24 @@ export class ThreadPresenter implements IThreadPresenter { ): Promise { const conversation = await this.getConversation(conversationId) const { providerId, modelId } = conversation.settings + // #region agent log + const fs = await import('fs') + const logPath = '/Users/zerob13/Documents/deepchat/.cursor/debug.log' + const logEntry = + JSON.stringify({ + location: 'threadPresenter/index.ts:369', + message: 'sendMessage - retrieved conversation', + data: { + agentWorkspacePath: conversation.settings.agentWorkspacePath, + allSettingsKeys: Object.keys(conversation.settings) + }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'E' + }) + '\n' + fs.appendFileSync(logPath, logEntry) + // #endregion console.log('sendMessage', conversation) const message = await this.messageManager.sendMessage( conversationId, diff --git a/src/main/presenter/threadPresenter/managers/conversationManager.ts b/src/main/presenter/threadPresenter/managers/conversationManager.ts index f5b7d32ee..c4da8b051 100644 --- a/src/main/presenter/threadPresenter/managers/conversationManager.ts +++ b/src/main/presenter/threadPresenter/managers/conversationManager.ts @@ -185,6 +185,24 @@ export class ConversationManager { defaultSettings.selectedVariantsMap = {} } + // #region agent log + const fs = await import('fs') + const logPath = '/Users/zerob13/Documents/deepchat/.cursor/debug.log' + const logEntry1 = + JSON.stringify({ + location: 'conversationManager.ts:188', + message: 'createConversation - input settings', + data: { + agentWorkspacePath: settings.agentWorkspacePath, + allSettingsKeys: Object.keys(settings) + }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'A,D' + }) + '\n' + fs.appendFileSync(logPath, logEntry1) + // #endregion const sanitizedSettings: Partial = { ...settings } Object.keys(sanitizedSettings).forEach((key) => { const typedKey = key as keyof CONVERSATION_SETTINGS @@ -193,7 +211,24 @@ export class ConversationManager { delete sanitizedSettings[typedKey] } }) - + // #region agent log + const logEntry2 = + JSON.stringify({ + location: 'conversationManager.ts:195', + message: 'createConversation - after sanitization', + data: { + agentWorkspacePath: sanitizedSettings.agentWorkspacePath, + wasRemoved: + !('agentWorkspacePath' in sanitizedSettings) && 'agentWorkspacePath' in settings, + allSettingsKeys: Object.keys(sanitizedSettings) + }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'A,D' + }) + '\n' + fs.appendFileSync(logPath, logEntry2) + // #endregion const mergedSettings = { ...defaultSettings } const previewSettings = { ...mergedSettings, ...sanitizedSettings } @@ -223,7 +258,22 @@ export class ConversationManager { if (mergedSettings.temperature === undefined || mergedSettings.temperature === null) { mergedSettings.temperature = defaultModelsSettings?.temperature ?? 0.7 } - + // #region agent log + const logEntry3 = + JSON.stringify({ + location: 'conversationManager.ts:221', + message: 'createConversation - before save', + data: { + agentWorkspacePath: mergedSettings.agentWorkspacePath, + allSettingsKeys: Object.keys(mergedSettings) + }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'A,D' + }) + '\n' + fs.appendFileSync(logPath, logEntry3) + // #endregion const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) if (options.forceNewAndActivate) { diff --git a/src/main/presenter/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts index ee64001ed..41ec59fb1 100644 --- a/src/main/presenter/toolPresenter/index.ts +++ b/src/main/presenter/toolPresenter/index.ts @@ -8,6 +8,7 @@ import type { } from '@shared/presenter' import { ToolMapper } from './toolMapper' import { AgentToolManager } from '../llmProviderPresenter/agent/agentToolManager' +import { jsonrepair } from 'jsonrepair' export interface IToolPresenter { getAllToolDefinitions(context: { @@ -77,8 +78,15 @@ export class ToolPresenter implements IToolPresenter { supportsVision, agentWorkspacePath }) - defs.push(...agentDefs) - this.mapper.registerTools(agentDefs, 'agent') + const filteredAgentDefs = agentDefs.filter((tool) => { + if (!this.mapper.hasTool(tool.function.name)) return true + console.warn( + `[ToolPresenter] Tool name conflict for '${tool.function.name}', preferring MCP tool.` + ) + return false + }) + defs.push(...filteredAgentDefs) + this.mapper.registerTools(filteredAgentDefs, 'agent') } catch (error) { console.warn('[ToolPresenter] Failed to load Agent tool definitions', error) } @@ -98,9 +106,29 @@ export class ToolPresenter implements IToolPresenter { throw new Error(`Tool ${toolName} not found in any source`) } - if (source === 'agent' && this.agentToolManager) { + if (source === 'agent') { + if (!this.agentToolManager) { + throw new Error(`Agent tool manager not initialized for tool ${toolName}`) + } // Route to Agent tool manager - const args = JSON.parse(request.function.arguments || '{}') as Record + let args: Record = {} + const argsString = request.function.arguments || '' + if (argsString.trim().length > 0) { + try { + args = JSON.parse(argsString) as Record + } catch (error) { + console.warn('[ToolPresenter] Failed to parse tool arguments, trying jsonrepair:', error) + try { + args = JSON.parse(jsonrepair(argsString)) as Record + } catch (error) { + console.warn( + '[ToolPresenter] Failed to repair tool arguments, using empty args.', + error + ) + args = {} + } + } + } const response = await this.agentToolManager.callTool(toolName, args) return { content: typeof response === 'string' ? response : JSON.stringify(response), diff --git a/src/renderer/src/components/NewThread.vue b/src/renderer/src/components/NewThread.vue index 70ea69fb5..d5fb0d7f8 100644 --- a/src/renderer/src/components/NewThread.vue +++ b/src/renderer/src/components/NewThread.vue @@ -436,6 +436,30 @@ onBeforeUnmount(() => { }) const handleSend = async (content: UserMessageContent) => { + // #region agent log + const chatInput = chatInputRef.value + const pathFromInput = chatInput?.getAgentWorkspacePath?.() + const pathFromStore = chatStore.chatConfig.agentWorkspacePath + const agentWorkspacePath = pathFromInput ?? pathFromStore ?? undefined + fetch('http://127.0.0.1:7242/ingest/96aae794-ae5b-4c8b-839c-d427e7ad0242', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + location: 'NewThread.vue:438', + message: 'handleSend - before createThread', + data: { + pathFromInput, + pathFromStore, + agentWorkspacePath, + chatMode: chatInput?.getChatMode?.() + }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'A,B,C' + }) + }).catch(() => {}) + // #endregion const threadId = await chatStore.createThread(content.text, { providerId: activeModel.value.providerId, modelId: activeModel.value.id, @@ -451,11 +475,27 @@ const handleSend = async (content: UserMessageContent) => { reasoningEffort: reasoningEffort.value, verbosity: verbosity.value, enabledMcpTools: chatStore.chatConfig.enabledMcpTools, + agentWorkspacePath, acpWorkdirMap: pendingAcpWorkdir.value && activeModel.value.providerId === 'acp' ? { [activeModel.value.id]: pendingAcpWorkdir.value } : undefined } as any) + // #region agent log + fetch('http://127.0.0.1:7242/ingest/96aae794-ae5b-4c8b-839c-d427e7ad0242', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + location: 'NewThread.vue:462', + message: 'handleSend - after createThread', + data: { threadId, agentWorkspacePath, settingsPassed: { agentWorkspacePath } }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'A,D' + }) + }).catch(() => {}) + // #endregion console.log('threadId', threadId, activeModel.value) chatStore.sendMessage(content) } diff --git a/src/renderer/src/components/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue index 1186d5324..d3a96e588 100644 --- a/src/renderer/src/components/chat-input/ChatInput.vue +++ b/src/renderer/src/components/chat-input/ChatInput.vue @@ -550,6 +550,11 @@ const { settings, toggleWebSearch } = useInputSettings() // Initialize chat mode management const chatMode = useChatMode() const modeSelectOpen = ref(false) +console.log( + '%c🤪 ~ file: /Users/zerob13/Documents/deepchat/src/renderer/src/components/chat-input/ChatInput.vue:552 [] -> modeSelectOpen : ', + 'color: #394483', + modeSelectOpen +) // Initialize history composable first (needed for editor placeholder) const history = useInputHistory(null as any, t) @@ -724,7 +729,8 @@ const acpWorkdir = useAcpWorkdir({ // Unified workspace management (for agent and acp agent modes) const workspace = useAgentWorkspace({ conversationId, - activeModel: activeModelSource + activeModel: activeModelSource, + chatMode }) // Extract isStreaming first so we can pass it to useAcpMode @@ -919,7 +925,29 @@ defineExpose({ clearContent: editorComposable.clearContent, appendText: editorComposable.appendText, appendMention: (name: string) => editorComposable.appendMention(name, mentionData), - restoreFocus + restoreFocus, + getAgentWorkspacePath: () => { + // #region agent log + const mode = chatMode.currentMode.value + const path = mode === 'agent' ? workspace.workspacePath.value : null + fetch('http://127.0.0.1:7242/ingest/96aae794-ae5b-4c8b-839c-d427e7ad0242', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + location: 'ChatInput.vue:925', + message: 'getAgentWorkspacePath called', + data: { mode, path, workspacePath: workspace.workspacePath.value }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'B' + }) + }).catch(() => {}) + // #endregion + if (mode !== 'agent') return null + return workspace.workspacePath.value + }, + getChatMode: () => chatMode.currentMode.value }) diff --git a/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts b/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts index 9554df2b9..8c08863e3 100644 --- a/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts +++ b/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts @@ -14,6 +14,7 @@ import type { Ref } from 'vue' export interface UseAgentWorkspaceOptions { conversationId: Ref activeModel: Ref<{ id: string; providerId: string } | null> + chatMode?: ReturnType } /** @@ -23,7 +24,7 @@ export interface UseAgentWorkspaceOptions { export function useAgentWorkspace(options: UseAgentWorkspaceOptions) { const { t } = useI18n() const threadPresenter = usePresenter('threadPresenter') - const chatMode = useChatMode() + const chatMode = options.chatMode ?? useChatMode() const chatStore = useChatStore() // Use ACP workdir for acp agent mode @@ -54,6 +55,14 @@ export function useAgentWorkspace(options: UseAgentWorkspaceOptions) { } } + const hydrateWorkspaceFromPreference = () => { + if (pendingWorkspacePath.value || agentWorkspacePath.value) return + const storedPath = chatStore.chatConfig.agentWorkspacePath ?? null + if (storedPath) { + agentWorkspacePath.value = storedPath + } + } + // === Computed === const hasWorkspace = computed(() => { if (chatMode.currentMode.value === 'acp agent') { @@ -139,9 +148,7 @@ export function useAgentWorkspace(options: UseAgentWorkspaceOptions) { } if (!options.conversationId.value) { - if (!pendingWorkspacePath.value) { - agentWorkspacePath.value = null - } + hydrateWorkspaceFromPreference() return } @@ -199,15 +206,26 @@ export function useAgentWorkspace(options: UseAgentWorkspaceOptions) { watch( [() => chatMode.currentMode.value, () => options.conversationId.value], async ([newMode, conversationId]) => { - if (newMode === 'agent' && conversationId) { - await loadWorkspacePath() - } else if (newMode === 'acp agent') { + if (newMode === 'agent') { + if (pendingWorkspacePath.value && conversationId) { + await syncPendingWorkspaceWhenReady() + } + if (conversationId) { + await loadWorkspacePath() + } else { + hydrateWorkspaceFromPreference() + } + return + } + + if (newMode === 'acp agent') { // ACP workdir is handled by useAcpWorkdir - } else { - // Clear workspace path when switching to chat mode - agentWorkspacePath.value = null - pendingWorkspacePath.value = null + return } + + // Clear workspace path when switching to chat mode + agentWorkspacePath.value = null + pendingWorkspacePath.value = null }, { immediate: true } ) diff --git a/src/renderer/src/components/chat-input/composables/useChatMode.ts b/src/renderer/src/components/chat-input/composables/useChatMode.ts index 882f311de..e7114d649 100644 --- a/src/renderer/src/components/chat-input/composables/useChatMode.ts +++ b/src/renderer/src/components/chat-input/composables/useChatMode.ts @@ -1,5 +1,6 @@ // === Vue Core === import { ref, onMounted, computed } from 'vue' +import { useI18n } from 'vue-i18n' // === Composables === import { usePresenter } from '@/composables/usePresenter' @@ -12,12 +13,6 @@ const MODE_ICONS = { 'acp agent': 'lucide:bot-message-square' } as const -const MODE_LABELS = { - chat: 'Chat', - agent: 'Agent', - 'acp agent': 'ACP Agent' -} as const - /** * Manages chat mode selection (chat, agent, acp agent) * Similar to useInputSettings, stores mode in database via configPresenter @@ -25,23 +20,28 @@ const MODE_LABELS = { export function useChatMode() { // === Presenters === const configPresenter = usePresenter('configPresenter') + const { t } = useI18n() // === Local State === const currentMode = ref('chat') // === Computed === const currentIcon = computed(() => MODE_ICONS[currentMode.value]) - const currentLabel = computed(() => MODE_LABELS[currentMode.value]) + const currentLabel = computed(() => { + if (currentMode.value === 'chat') return t('chat.mode.chat') + if (currentMode.value === 'agent') return t('chat.mode.agent') + return t('chat.mode.acpAgent') + }) const isAgentMode = computed( () => currentMode.value === 'agent' || currentMode.value === 'acp agent' ) const modes = computed(() => [ - { value: 'chat' as ChatMode, label: MODE_LABELS.chat, icon: MODE_ICONS.chat }, - { value: 'agent' as ChatMode, label: MODE_LABELS.agent, icon: MODE_ICONS.agent }, + { value: 'chat' as ChatMode, label: t('chat.mode.chat'), icon: MODE_ICONS.chat }, + { value: 'agent' as ChatMode, label: t('chat.mode.agent'), icon: MODE_ICONS.agent }, { value: 'acp agent' as ChatMode, - label: MODE_LABELS['acp agent'], + label: t('chat.mode.acpAgent'), icon: MODE_ICONS['acp agent'] } ]) diff --git a/src/renderer/src/stores/chat.ts b/src/renderer/src/stores/chat.ts index 61abbc86e..d8a7a1d47 100644 --- a/src/renderer/src/stores/chat.ts +++ b/src/renderer/src/stores/chat.ts @@ -165,6 +165,21 @@ export const useChatStore = defineStore('chat', () => { const createThread = async (title: string, settings: Partial) => { try { + // #region agent log + fetch('http://127.0.0.1:7242/ingest/96aae794-ae5b-4c8b-839c-d427e7ad0242', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + location: 'chat.ts:166', + message: 'createThread - input settings', + data: { agentWorkspacePath: settings.agentWorkspacePath, allSettings: settings }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'A,C,D' + }) + }).catch(() => {}) + // #endregion const normalizedSettings: Partial = { ...settings } const shouldAttachAcpWorkdir = (!normalizedSettings.acpWorkdirMap || @@ -188,7 +203,24 @@ export const useChatStore = defineStore('chat', () => { normalizedSettings.agentWorkspacePath = pendingWorkspacePath } } - + // #region agent log + fetch('http://127.0.0.1:7242/ingest/96aae794-ae5b-4c8b-839c-d427e7ad0242', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + location: 'chat.ts:190', + message: 'createThread - before createConversation', + data: { + agentWorkspacePath: normalizedSettings.agentWorkspacePath, + allSettings: normalizedSettings + }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'A,C,D' + }) + }).catch(() => {}) + // #endregion const threadId = await threadP.createConversation(title, normalizedSettings, getTabId()) // 因为 createConversation 内部已经调用了 setActiveConversation // 并且可以确定是为当前tab激活,所以在这里可以直接、安全地更新本地状态 From a1f47d04c833614124784fca82c1ecedcad1ece9 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Sat, 20 Dec 2025 23:56:03 +0800 Subject: [PATCH 03/29] feat: add i18n --- .../chat-input/composables/useChatMode.ts | 43 +++++++++++++------ src/renderer/src/i18n/da-DK/chat.json | 40 ++++++++++++++++- src/renderer/src/i18n/fa-IR/chat.json | 40 ++++++++++++++++- src/renderer/src/i18n/fr-FR/chat.json | 40 ++++++++++++++++- src/renderer/src/i18n/he-IL/chat.json | 40 ++++++++++++++++- src/renderer/src/i18n/ja-JP/chat.json | 40 ++++++++++++++++- src/renderer/src/i18n/ko-KR/chat.json | 40 ++++++++++++++++- src/renderer/src/i18n/pt-BR/chat.json | 40 ++++++++++++++++- src/renderer/src/i18n/ru-RU/chat.json | 40 ++++++++++++++++- src/renderer/src/i18n/zh-HK/chat.json | 40 ++++++++++++++++- src/renderer/src/i18n/zh-TW/chat.json | 40 ++++++++++++++++- 11 files changed, 419 insertions(+), 24 deletions(-) diff --git a/src/renderer/src/components/chat-input/composables/useChatMode.ts b/src/renderer/src/components/chat-input/composables/useChatMode.ts index e7114d649..2b0ab615c 100644 --- a/src/renderer/src/components/chat-input/composables/useChatMode.ts +++ b/src/renderer/src/components/chat-input/composables/useChatMode.ts @@ -1,5 +1,5 @@ // === Vue Core === -import { ref, onMounted, computed } from 'vue' +import { ref, computed } from 'vue' import { useI18n } from 'vue-i18n' // === Composables === @@ -13,6 +13,12 @@ const MODE_ICONS = { 'acp agent': 'lucide:bot-message-square' } as const +// Shared state so all callers observe the same mode. +const currentMode = ref('chat') +let hasLoaded = false +let loadPromise: Promise | null = null +let modeUpdateVersion = 0 + /** * Manages chat mode selection (chat, agent, acp agent) * Similar to useInputSettings, stores mode in database via configPresenter @@ -22,9 +28,6 @@ export function useChatMode() { const configPresenter = usePresenter('configPresenter') const { t } = useI18n() - // === Local State === - const currentMode = ref('chat') - // === Computed === const currentIcon = computed(() => MODE_ICONS[currentMode.value]) const currentLabel = computed(() => { @@ -49,37 +52,49 @@ export function useChatMode() { // === Public Methods === const setMode = async (mode: ChatMode) => { const previousValue = currentMode.value + const updateVersion = ++modeUpdateVersion currentMode.value = mode try { await configPresenter.setSetting('input_chatMode', mode) } catch (error) { // Revert to previous value on error - currentMode.value = previousValue + if (modeUpdateVersion === updateVersion) { + currentMode.value = previousValue + } console.error('Failed to save chat mode:', error) // TODO: Show user-facing notification when toast system is available } } const loadMode = async () => { + const loadVersion = modeUpdateVersion try { const saved = await configPresenter.getSetting('input_chatMode') - currentMode.value = (saved as ChatMode) || 'chat' + if (modeUpdateVersion === loadVersion) { + currentMode.value = (saved as ChatMode) || 'chat' + } } catch (error) { // Fall back to safe defaults on error - currentMode.value = 'chat' + if (modeUpdateVersion === loadVersion) { + currentMode.value = 'chat' + } console.error('Failed to load chat mode, using default:', error) + } finally { + hasLoaded = true } } - // === Lifecycle Hooks === - onMounted(async () => { - try { - await loadMode() - } catch (error) { - console.error('Failed to initialize chat mode:', error) + const ensureLoaded = () => { + if (hasLoaded) return + if (!loadPromise) { + loadPromise = loadMode().finally(() => { + loadPromise = null + }) } - }) + } + + ensureLoaded() return { currentMode, diff --git a/src/renderer/src/i18n/da-DK/chat.json b/src/renderer/src/i18n/da-DK/chat.json index 0c04d31ae..d9177817e 100644 --- a/src/renderer/src/i18n/da-DK/chat.json +++ b/src/renderer/src/i18n/da-DK/chat.json @@ -29,7 +29,10 @@ "rateLimitReadyTooltip": "Du kan sende en anmodning med et interval på {interval} sekunder.", "rateLimitWait": "Vent {seconds}s", "rateLimitWaitingTooltip": "Vent {seconds} sekunder, interval {interval} sekunder", - "fileArea": "fil område" + "fileArea": "fil område", + "agentWorkspaceCurrent": "Nuværende arbejdsmappe: {sti}", + "agentWorkspaceSelect": "Vælg arbejdsmappe", + "agentWorkspaceTooltip": "Indstil agentens arbejdsmappe" }, "mcpUi": { "badge": "UI", @@ -119,5 +122,40 @@ "empty": "Ingen output" } } + }, + "mode": { + "acpAgent": "ACP-agent", + "agent": "Agent", + "chat": "snak", + "current": "Aktuel tilstand: {mode}" + }, + "workspace": { + "collapse": "tæt", + "files": { + "contextMenu": { + "insertPath": "Indsæt i inputboksen", + "openFile": "åbne fil", + "revealInFolder": "Åbn i filhåndtering" + }, + "empty": "Ingen filer endnu", + "loading": "Indlæser filer...", + "section": "dokument" + }, + "plan": { + "empty": "Ingen opgaver endnu", + "section": "plan", + "status": { + "completed": "Afsluttet", + "failed": "svigte", + "in_progress": "i gang", + "pending": "Indtil", + "skipped": "sprunget over" + } + }, + "terminal": { + "empty": "Ingen output endnu", + "section": "terminal" + }, + "title": "arbejdsrum" } } diff --git a/src/renderer/src/i18n/fa-IR/chat.json b/src/renderer/src/i18n/fa-IR/chat.json index 6aab5ead7..6ab187995 100644 --- a/src/renderer/src/i18n/fa-IR/chat.json +++ b/src/renderer/src/i18n/fa-IR/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "یک پوشه برای استفاده به عنوان دایرکتوری کاری ACP انتخاب کنید", "acpWorkdirCurrent": "دایرکتوری جاری: {path}", "acpMode": "حالت", - "acpModeTooltip": "حالت فعلی: {mode}" + "acpModeTooltip": "حالت فعلی: {mode}", + "agentWorkspaceCurrent": "فهرست کاری فعلی: {path}", + "agentWorkspaceSelect": "پوشه کاری را انتخاب کنید", + "agentWorkspaceTooltip": "دایرکتوری کاری عامل را تنظیم کنید" }, "features": { "webSearch": "جستجوی وب", @@ -119,5 +122,40 @@ "empty": "خروجی وجود ندارد" } } + }, + "mode": { + "acpAgent": "نماینده ACP", + "agent": "عامل", + "chat": "چت کردن", + "current": "حالت فعلی: {mode}" + }, + "workspace": { + "collapse": "بستن", + "files": { + "contextMenu": { + "insertPath": "در جعبه ورودی وارد کنید", + "openFile": "باز کردن فایل", + "revealInFolder": "در فایل منیجر باز کنید" + }, + "empty": "هنوز فایلی وجود ندارد", + "loading": "در حال بارگیری فایل ها...", + "section": "سند" + }, + "plan": { + "empty": "هنوز هیچ تکلیفی وجود ندارد", + "section": "برنامه ریزی کنید", + "status": { + "completed": "تکمیل شد", + "failed": "شکست بخورد", + "in_progress": "در حال انجام است", + "pending": "در انتظار", + "skipped": "پرش کرد" + } + }, + "terminal": { + "empty": "هنوز خروجی وجود ندارد", + "section": "ترمینال" + }, + "title": "فضای کار" } } diff --git a/src/renderer/src/i18n/fr-FR/chat.json b/src/renderer/src/i18n/fr-FR/chat.json index e336e345b..fe40cdaa2 100644 --- a/src/renderer/src/i18n/fr-FR/chat.json +++ b/src/renderer/src/i18n/fr-FR/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "Select a folder to use as the ACP workdir", "acpWorkdirCurrent": "Current workdir: {path}", "acpMode": "Mode", - "acpModeTooltip": "Mode actuel : {mode}" + "acpModeTooltip": "Mode actuel : {mode}", + "agentWorkspaceCurrent": "Répertoire de travail actuel : {path}", + "agentWorkspaceSelect": "Sélectionnez le répertoire de travail", + "agentWorkspaceTooltip": "Définir le répertoire de travail de l'agent" }, "features": { "webSearch": "Recherche web", @@ -119,5 +122,40 @@ "empty": "Aucune sortie" } } + }, + "mode": { + "acpAgent": "Agent ACP", + "agent": "Agent", + "chat": "chat", + "current": "Mode actuel : {mode}" + }, + "workspace": { + "collapse": "fermer", + "files": { + "contextMenu": { + "insertPath": "Insérer dans la zone de saisie", + "openFile": "ouvrir le fichier", + "revealInFolder": "Ouvrir dans le gestionnaire de fichiers" + }, + "empty": "Aucun fichier pour l'instant", + "loading": "Chargement des fichiers...", + "section": "document" + }, + "plan": { + "empty": "Aucune tâche pour l'instant", + "section": "plan", + "status": { + "completed": "Complété", + "failed": "échouer", + "in_progress": "en cours", + "pending": "En attente", + "skipped": "ignoré" + } + }, + "terminal": { + "empty": "Pas encore de sortie", + "section": "Terminal" + }, + "title": "espace de travail" } } diff --git a/src/renderer/src/i18n/he-IL/chat.json b/src/renderer/src/i18n/he-IL/chat.json index effb8f617..074b32394 100644 --- a/src/renderer/src/i18n/he-IL/chat.json +++ b/src/renderer/src/i18n/he-IL/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "בחר תיקייה שתשמש כתיקיית העבודה של ACP", "acpWorkdirCurrent": "תיקיית עבודה נוכחית: {path}", "acpMode": "דֶגֶם", - "acpModeTooltip": "מצב נוכחי: {mode}" + "acpModeTooltip": "מצב נוכחי: {mode}", + "agentWorkspaceCurrent": "ספריית עבודה נוכחית: {path}", + "agentWorkspaceSelect": "בחר ספריית עבודה", + "agentWorkspaceTooltip": "הגדר את ספריית העבודה של הסוכן" }, "features": { "webSearch": "חיפוש אינטרנט", @@ -119,5 +122,40 @@ }, "title": "סביבת עבודה" } + }, + "mode": { + "acpAgent": "סוכן ACP", + "agent": "סוֹכֵן", + "chat": "לְשׂוֹחֵחַ", + "current": "מצב נוכחי: {mode}" + }, + "workspace": { + "collapse": "לִסְגוֹר", + "files": { + "contextMenu": { + "insertPath": "הכנס לתוך תיבת הקלט", + "openFile": "לפתוח קובץ", + "revealInFolder": "פתח במנהל הקבצים" + }, + "empty": "עדיין אין קבצים", + "loading": "טוען קבצים...", + "section": "מִסְמָך" + }, + "plan": { + "empty": "עדיין אין משימות", + "section": "לְתַכְנֵן", + "status": { + "completed": "הושלם", + "failed": "לְהִכָּשֵׁל", + "in_progress": "בתהליך", + "pending": "תָלוּי וְעוֹמֵד", + "skipped": "דילג" + } + }, + "terminal": { + "empty": "עדיין אין פלט", + "section": "מָסוֹף" + }, + "title": "סביבת עבודה" } } diff --git a/src/renderer/src/i18n/ja-JP/chat.json b/src/renderer/src/i18n/ja-JP/chat.json index 0d7eafd01..1ab092fd2 100644 --- a/src/renderer/src/i18n/ja-JP/chat.json +++ b/src/renderer/src/i18n/ja-JP/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "Select a folder to use as the ACP workdir", "acpWorkdirCurrent": "Current workdir: {path}", "acpMode": "モード", - "acpModeTooltip": "現在のモード:{mode}" + "acpModeTooltip": "現在のモード:{mode}", + "agentWorkspaceCurrent": "現在の作業ディレクトリ: {パス}", + "agentWorkspaceSelect": "作業ディレクトリを選択", + "agentWorkspaceTooltip": "エージェントの作業ディレクトリを設定する" }, "features": { "webSearch": "ウェブ検索", @@ -119,5 +122,40 @@ "empty": "出力はまだありません" } } + }, + "mode": { + "acpAgent": "ACPエージェント", + "agent": "エージェント", + "chat": "チャット", + "current": "現在のモード: {モード}" + }, + "workspace": { + "collapse": "近い", + "files": { + "contextMenu": { + "insertPath": "入力ボックスに挿入", + "openFile": "ファイルを開く", + "revealInFolder": "ファイルマネージャーで開く" + }, + "empty": "まだファイルがありません", + "loading": "ファイルをロード中...", + "section": "書類" + }, + "plan": { + "empty": "まだタスクはありません", + "section": "プラン", + "status": { + "completed": "完了しました", + "failed": "失敗", + "in_progress": "進行中", + "pending": "保留中", + "skipped": "スキップしました" + } + }, + "terminal": { + "empty": "まだ出力はありません", + "section": "ターミナル" + }, + "title": "ワークスペース" } } diff --git a/src/renderer/src/i18n/ko-KR/chat.json b/src/renderer/src/i18n/ko-KR/chat.json index 468ebc9bc..101e4c7fb 100644 --- a/src/renderer/src/i18n/ko-KR/chat.json +++ b/src/renderer/src/i18n/ko-KR/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "Select a folder to use as the ACP workdir", "acpWorkdirCurrent": "Current workdir: {path}", "acpMode": "모드", - "acpModeTooltip": "현재 모드: {mode}" + "acpModeTooltip": "현재 모드: {mode}", + "agentWorkspaceCurrent": "현재 작업 디렉터리: {path}", + "agentWorkspaceSelect": "작업 디렉터리 선택", + "agentWorkspaceTooltip": "에이전트 작업 디렉터리 설정" }, "features": { "webSearch": "웹 검색", @@ -119,5 +122,40 @@ "empty": "출력이 없습니다" } } + }, + "mode": { + "acpAgent": "ACP 에이전트", + "agent": "대리인", + "chat": "채팅", + "current": "현재 모드: {mode}" + }, + "workspace": { + "collapse": "닫다", + "files": { + "contextMenu": { + "insertPath": "입력창에 삽입", + "openFile": "파일 열기", + "revealInFolder": "파일 관리자에서 열기" + }, + "empty": "아직 파일이 없습니다", + "loading": "파일 로드 중...", + "section": "문서" + }, + "plan": { + "empty": "아직 할 일이 없습니다.", + "section": "계획", + "status": { + "completed": "완전한", + "failed": "실패하다", + "in_progress": "진행 중", + "pending": "보류 중", + "skipped": "건너뛰었습니다" + } + }, + "terminal": { + "empty": "아직 출력이 없습니다", + "section": "단말기" + }, + "title": "작업 공간" } } diff --git a/src/renderer/src/i18n/pt-BR/chat.json b/src/renderer/src/i18n/pt-BR/chat.json index aad33f80a..e2baa86f7 100644 --- a/src/renderer/src/i18n/pt-BR/chat.json +++ b/src/renderer/src/i18n/pt-BR/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "Select a folder to use as the ACP workdir", "acpWorkdirCurrent": "Current workdir: {path}", "acpMode": "Modo", - "acpModeTooltip": "Modo atual: {mode}" + "acpModeTooltip": "Modo atual: {mode}", + "agentWorkspaceCurrent": "Diretório de trabalho atual: {path}", + "agentWorkspaceSelect": "Selecione o diretório de trabalho", + "agentWorkspaceTooltip": "Definir diretório de trabalho do agente" }, "features": { "webSearch": "Busca na Web", @@ -119,5 +122,40 @@ "empty": "Nenhuma saída" } } + }, + "mode": { + "acpAgent": "Agente ACP", + "agent": "Agente", + "chat": "bater papo", + "current": "Modo atual: {mode}" + }, + "workspace": { + "collapse": "fechar", + "files": { + "contextMenu": { + "insertPath": "Insira na caixa de entrada", + "openFile": "abrir arquivo", + "revealInFolder": "Abrir no gerenciador de arquivos" + }, + "empty": "Nenhum arquivo ainda", + "loading": "Carregando arquivos...", + "section": "documento" + }, + "plan": { + "empty": "Nenhuma tarefa ainda", + "section": "plano", + "status": { + "completed": "Concluído", + "failed": "falhar", + "in_progress": "em andamento", + "pending": "Pendente", + "skipped": "ignorado" + } + }, + "terminal": { + "empty": "Nenhuma saída ainda", + "section": "terminal" + }, + "title": "área de trabalho" } } diff --git a/src/renderer/src/i18n/ru-RU/chat.json b/src/renderer/src/i18n/ru-RU/chat.json index 02e02bf6d..434afef53 100644 --- a/src/renderer/src/i18n/ru-RU/chat.json +++ b/src/renderer/src/i18n/ru-RU/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "Select a folder to use as the ACP workdir", "acpWorkdirCurrent": "Current workdir: {path}", "acpMode": "Режим", - "acpModeTooltip": "Текущий режим: {mode}" + "acpModeTooltip": "Текущий режим: {mode}", + "agentWorkspaceCurrent": "Текущий рабочий каталог: {path}", + "agentWorkspaceSelect": "Выберите рабочий каталог", + "agentWorkspaceTooltip": "Установить рабочий каталог агента" }, "features": { "webSearch": "Поиск в интернете", @@ -119,5 +122,40 @@ "empty": "Нет вывода" } } + }, + "mode": { + "acpAgent": "Агент АШП", + "agent": "Агент", + "chat": "чат", + "current": "Текущий режим: {mode}" + }, + "workspace": { + "collapse": "закрывать", + "files": { + "contextMenu": { + "insertPath": "Вставить в поле ввода", + "openFile": "открыть файл", + "revealInFolder": "Открыть в файловом менеджере" + }, + "empty": "Файлов пока нет", + "loading": "Загрузка файлов...", + "section": "документ" + }, + "plan": { + "empty": "Заданий пока нет", + "section": "план", + "status": { + "completed": "Завершенный", + "failed": "неудача", + "in_progress": "в ходе выполнения", + "pending": "В ожидании", + "skipped": "пропущен" + } + }, + "terminal": { + "empty": "Выходных данных пока нет", + "section": "Терминал" + }, + "title": "рабочее пространство" } } diff --git a/src/renderer/src/i18n/zh-HK/chat.json b/src/renderer/src/i18n/zh-HK/chat.json index 0012045d8..0b88b32cc 100644 --- a/src/renderer/src/i18n/zh-HK/chat.json +++ b/src/renderer/src/i18n/zh-HK/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "選擇工作目錄", "acpWorkdirCurrent": "目前工作目錄:{path}", "acpMode": "模式", - "acpModeTooltip": "目前模式:{mode}" + "acpModeTooltip": "目前模式:{mode}", + "agentWorkspaceCurrent": "當前工作目錄:{path}", + "agentWorkspaceSelect": "選擇工作目錄", + "agentWorkspaceTooltip": "設置 Agent 工作目錄" }, "features": { "webSearch": "網絡搜索", @@ -119,5 +122,40 @@ "empty": "暫無輸出" } } + }, + "mode": { + "acpAgent": "ACP Agent", + "agent": "Agent", + "chat": "聊天", + "current": "當前模式:{mode}" + }, + "workspace": { + "collapse": "收起", + "files": { + "contextMenu": { + "insertPath": "插入到輸入框", + "openFile": "打開文件", + "revealInFolder": "在文件管理器中打開" + }, + "empty": "暫無文件", + "loading": "加載文件中...", + "section": "文件" + }, + "plan": { + "empty": "暫無任務", + "section": "計劃", + "status": { + "completed": "已完成", + "failed": "失敗", + "in_progress": "進行中", + "pending": "待處理", + "skipped": "已跳過" + } + }, + "terminal": { + "empty": "暫無輸出", + "section": "終端" + }, + "title": "工作區" } } diff --git a/src/renderer/src/i18n/zh-TW/chat.json b/src/renderer/src/i18n/zh-TW/chat.json index de94fc453..93f897ed4 100644 --- a/src/renderer/src/i18n/zh-TW/chat.json +++ b/src/renderer/src/i18n/zh-TW/chat.json @@ -22,7 +22,10 @@ "acpWorkdirSelect": "選擇工作目錄", "acpWorkdirCurrent": "目前工作目錄:{path}", "acpMode": "模式", - "acpModeTooltip": "目前模式:{mode}" + "acpModeTooltip": "目前模式:{mode}", + "agentWorkspaceCurrent": "當前工作目錄:{path}", + "agentWorkspaceSelect": "選擇工作目錄", + "agentWorkspaceTooltip": "設置 Agent 工作目錄" }, "features": { "webSearch": "網路搜尋", @@ -119,5 +122,40 @@ "empty": "暫無輸出" } } + }, + "mode": { + "acpAgent": "ACP Agent", + "agent": "Agent", + "chat": "聊天", + "current": "當前模式:{mode}" + }, + "workspace": { + "collapse": "收起", + "files": { + "contextMenu": { + "insertPath": "插入到輸入框", + "openFile": "打開文件", + "revealInFolder": "在文件管理器中打開" + }, + "empty": "暫無文件", + "loading": "加載文件中...", + "section": "文件" + }, + "plan": { + "empty": "暫無任務", + "section": "計劃", + "status": { + "completed": "已完成", + "failed": "失敗", + "in_progress": "進行中", + "pending": "待處理", + "skipped": "已跳過" + } + }, + "terminal": { + "empty": "暫無輸出", + "section": "終端" + }, + "title": "工作區" } } From 7e40ee2fdafcb1f7a6d7b76aaa251a43ee0c7b1f Mon Sep 17 00:00:00 2001 From: zerob13 Date: Sun, 21 Dec 2025 00:02:16 +0800 Subject: [PATCH 04/29] chore: update doc --- ...\351\207\215\346\236\204_47fd60ee.plan.md" | 619 ------------------ docs/workspace-agent-refactoring-summary.md | 404 ++++++++++++ 2 files changed, 404 insertions(+), 619 deletions(-) delete mode 100644 ".cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" create mode 100644 docs/workspace-agent-refactoring-summary.md diff --git "a/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" "b/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" deleted file mode 100644 index 695b433a2..000000000 --- "a/.cursor/plans/\351\200\232\347\224\250_workspace_\345\222\214_agent_\350\203\275\345\212\233\351\207\215\346\236\204_47fd60ee.plan.md" +++ /dev/null @@ -1,619 +0,0 @@ ---- -name: 通用 Workspace 和 Agent 能力重构 -overview: 将 Workspace 和 Agent 能力从 ACP 专用扩展到所有模型,重构 filesystem MCP 为 Agent 工具,统一管理 MCP 和 Agent 工具调用注入逻辑,并重构 AcpWorkspaceView 为通用 Workspace 组件。 -todos: - - id: create-unified-tool-presenter - content: 创建统一的 ToolPresenter,管理所有工具(MCP、Agent 等),提供统一的工具定义接口和路由映射机制 - status: completed - - id: create-agent-tool-manager - content: 创建 AgentToolManager 类,管理所有 Agent 工具(Yo Browser、FileSystem 等),类似 Yo Browser 的实现方式,只要 Agent 模式启用就全部注入 - status: completed - - id: create-agent-filesystem-handler - content: 创建 AgentFileSystemHandler 类,封装文件系统操作能力,从 FileSystemServer 中提取核心逻辑,工具名称不加前缀(如 read_file, write_file) - status: completed - - id: integrate-tool-routing - content: 在 ToolPresenter 中实现工具路由映射机制,根据工具名称映射到对应的工具源(MCP 或 Agent),统一使用 MCP 规范的工具定义格式 - status: completed - dependencies: - - create-unified-tool-presenter - - create-agent-tool-manager - - id: update-agent-loop-handler - content: 更新 AgentLoopHandler 使用统一的 ToolPresenter,简化工具注入逻辑,Agent 工具只要启用就全部注入 - status: completed - dependencies: - - create-unified-tool-presenter - - id: remove-filesystem-mcp - content: 从 MCP 系统中移除 filesystem server:删除 buildInFileSystem 配置,移除 builder 中的创建逻辑,添加数据迁移 - status: completed - dependencies: - - create-agent-filesystem-handler - - id: add-chat-mode-database - content: 创建 useChatMode composable,使用 configPresenter.setSetting/getSetting 存储和读取 chatMode(参考 useInputSettings 的实现),确保数据库持久化 - status: completed - - id: create-workspace-presenter - content: 创建通用 WorkspacePresenter,从 AcpWorkspacePresenter 重构,移除 ACP 特定依赖 - status: completed - - id: create-workspace-store - content: 创建通用 workspace store,从 acpWorkspace store 重构,支持所有模型的 Agent 模式 - status: completed - dependencies: - - create-workspace-presenter - - id: refactor-workspace-components - content: 重构 Workspace 组件:重命名 AcpWorkspaceView 为 WorkspaceView,更新所有子组件,移除 ACP 依赖 - status: completed - dependencies: - - create-workspace-store - - id: add-chat-mode-switch - content: 在 chatConfig 中添加 chatMode 字段,创建 useChatMode composable,在 ChatInput.vue 中添加 Mode Switch 选择器(支持 chat、agent、acp agent 三种模式) - status: completed - dependencies: - - add-chat-mode-database - - id: add-workspace-path-selection - content: 在 chatConfig 中添加 agentWorkspacePath 字段,创建 useAgentWorkspace composable 统一管理工作目录:acp agent 模式使用 ACP workdir 逻辑,agent 模式使用 filesystem 工具的工作目录。在 ChatInput.vue 的 Tools 区域添加统一的目录选择按钮(仅在 agent 或 acp agent 模式时显示) - status: completed - dependencies: - - add-chat-mode-switch - - id: update-model-selection-logic - content: 更新模型选择逻辑:只有在 acp agent 模式下才能选择 ACP 模型,其他模式隐藏 ACP 模型 - status: completed - dependencies: - - add-chat-mode-switch - - id: update-chat-view-integration - content: 更新 ChatView 使用通用 WorkspaceView,更新事件监听和状态同步逻辑 - status: completed - dependencies: - - refactor-workspace-components - - id: add-i18n-translations - content: 添加所有新增 UI 元素的 i18n 翻译(中文、英文等),更新相关翻译键 - status: completed - dependencies: - - add-agent-mode-config - - add-workspace-path-selection - - id: update-types-definitions - content: 更新 shared/presenter.d.ts 中的类型定义,添加 Agent 模式相关类型,更新 Workspace 相关接口 - status: completed - dependencies: - - create-workspace-presenter - - add-agent-mode-config ---- - -# 通用 Workspace 和 Agent 能力重构计划 - -## 架构概览 - -```mermaid -graph TB - subgraph "Agent Loop" - AL[AgentLoopHandler] - TCP[ToolCallProcessor] - end - - subgraph "统一工具路由" - TP[ToolPresenter] - TM[ToolMapper] - end - - subgraph "工具源" - MCP[MCP Tools] - AGENT[Agent Tools] - end - - subgraph "Agent 工具" - YO[Yo Browser] - FS[FileSystem] - TERM[Terminal 未来] - end - - subgraph "Workspace" - WS[WorkspaceView] - FILES[Files Section] - PLAN[Plan Section] - TERM_UI[Terminal Section] - end - - AL --> TCP - TCP --> TP - TP --> TM - TM --> MCP - TM --> AGENT - AGENT --> YO - AGENT --> FS - AGENT --> TERM - WS --> FILES - WS --> PLAN - WS --> TERM_UI -``` - - - -## 核心变更 - -### 1. 统一工具路由架构 - -**目标**:抽象统一的工具路由层,统一管理所有工具源,使用 MCP 规范的工具定义格式。**实现**: - -- 创建 `ToolPresenter` 类,统一管理所有工具(MCP、Agent 等) -- 创建 `ToolMapper` 类,实现工具名称到工具源的映射机制 -- 所有工具定义统一使用 MCP 规范格式(`MCPToolDefinition`) -- 工具调用时根据映射路由到对应的工具源处理器 -- 未来支持工具去重和映射(如果 MCP 和 Agent 有同名工具,可以映射到 MCP 工具) - -### 2. Agent 工具管理简化 - -**目标**:简化 Agent 工具管理,根据 chatMode 决定工具注入。**实现**: - -- 创建 `AgentToolManager` 类,管理所有 Agent 工具 -- Agent 工具包括: -- **Yo Browser**:保持现有实现,工具名称使用 `browser_` 前缀(如 `browser_navigate`) -- **FileSystem**:新增,工具名称**不加前缀**(如 `read_file`, `write_file`) -- **Terminal**:未来扩展 -- 工具注入逻辑: -- **chat 模式**:不注入 Agent 工具(只有 MCP 工具) -- **agent 模式**:注入所有 Agent 工具(yo browser、filesystem 等) -- **acp agent 模式**:根据 ACP 逻辑决定(后续补充具体实现) -- 保留扩展能力,未来可以扩展成按需注入 - -### 3. 文件系统能力抽象 - -**目标**:将 filesystem MCP 重构为 Agent 工具,从 MCP 系统中移除。**实现**: - -- 创建 `AgentFileSystemHandler` 类,封装文件操作能力 -- 工具名称不加前缀,例如:`read_file`, `write_file`, `list_directory` 等 -- 从 `mcpConfHelper.ts` 中移除 `buildInFileSystem` 配置 -- 从 `inMemoryServers/builder.ts` 中移除 filesystem server 的创建逻辑 - -### 4. 通用 Mode Switch 配置 - -**目标**:添加通用的 Mode Switch,支持三种模式(chat、agent、acp agent),支持数据库持久化。**实现**: - -- 在配置存储(ElectronStore)中添加 `chatMode: 'chat' | 'agent' | 'acp agent'` 字段 -- 在 `chatConfig` 中添加 `chatMode` 字段(从配置存储读取) -- 创建 `useChatMode` composable,管理模式状态 -- 在 `ChatInput.vue` 中添加 Mode Switch 选择器(下拉选择或按钮组) -- 三种模式的区别: -- **chat**:基础聊天模式,只有 MCP 工具,不支持 yo browser、文件读写等功能 -- **agent**:内置 agent 模式,包含 workdir 设置、各种工具(yo browser、文件读写等)、agent loop 定制内容 -- **acp agent**:ACP 模式,只有这个模式才能选择 ACP 模型,loop 和逻辑会有不同(后续补充具体实现) -- 确保配置的持久化和统一化管理 -- **注意**:这个 mode switch 和 ACP agent 里面的 mode(session mode)不是一回事 - -### 5. Workspace 组件通用化 - -**目标**:将 `AcpWorkspaceView` 重构为通用的 `WorkspaceView`,支持所有模型。**实现**: - -- 重命名 `AcpWorkspaceView.vue` → `WorkspaceView.vue` -- 重命名 `acpWorkspace` store → `workspace` store -- 重命名 `AcpWorkspacePresenter` → `WorkspacePresenter` -- 移除 ACP 特定的依赖,改为基于 Agent 模式判断 - -### 6. Workspace 路径选择(统一化) - -**目标**:统一化目录选择按钮,不同模式使用不同的工作目录。**实现**: - -- 在 `chatConfig` 中添加 `agentWorkspacePath: string | null` 字段 -- 创建 `useAgentWorkspace` composable,统一管理工作目录选择 -- 在 `ChatInput.vue` 的 Tools 区域添加目录选择按钮(在 agent 或 acp agent 模式下显示) -- 目录选择按钮逻辑统一化: -- **acp agent 模式**:使用 ACP workdir(现有的 ACP workdir 逻辑) -- **agent 模式**:使用 filesystem 工具的工作目录,以及未来各种工具的工作目录 -- 按钮样式和行为:参考现有的 ACP workdir 按钮 - -## 文件变更清单 - -### 新增文件 - -1. `src/main/presenter/toolPresenter/index.ts` - 统一工具路由 Presenter,管理所有工具源 -2. `src/main/presenter/toolPresenter/toolMapper.ts` - 工具映射器,实现工具名称到工具源的映射 -3. `src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts` - Agent 工具管理器,管理所有 Agent 工具 -4. `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts` - 文件系统能力处理器,工具名称不加前缀 -5. `src/renderer/src/stores/workspace.ts` - 通用 Workspace Store(从 acpWorkspace 重构) -6. `src/main/presenter/workspacePresenter/index.ts` - 通用 Workspace Presenter(从 acpWorkspacePresenter 重构) -7. `src/renderer/src/components/workspace/WorkspaceView.vue` - 通用 Workspace 组件(从 acp-workspace 重构) -8. `src/renderer/src/components/workspace/WorkspaceFiles.vue` - 文件列表组件 -9. `src/renderer/src/components/workspace/WorkspacePlan.vue` - 计划组件 -10. `src/renderer/src/components/workspace/WorkspaceTerminal.vue` - 终端组件 -11. `src/renderer/src/components/chat-input/composables/useChatMode.ts` - Chat Mode Switch composable(参考 useInputSettings 实现,使用 configPresenter.setSetting/getSetting 持久化) -12. `src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts` - Workspace 路径选择 composable(统一化,根据 chatMode 使用不同的逻辑:acp agent 模式使用 ACP workdir,agent 模式使用 filesystem 工作目录) - -### 修改文件 - -1. `src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts` - 使用统一的 ToolPresenter,简化工具注入逻辑 -2. `src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts` - 使用 ToolPresenter 进行工具调用路由 -3. `src/main/presenter/configPresenter/index.ts` - 无需修改,使用现有的 setSetting/getSetting 方法即可(chatMode 通过 'input_chatMode' key 存储) -4. `src/main/presenter/configPresenter/mcpConfHelper.ts` - 移除 buildInFileSystem 配置 -5. `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts` - 移除 filesystem server -6. `src/renderer/src/stores/chat.ts` - 添加 chatMode 和 agentWorkspacePath 配置 -7. `src/shared/presenter.d.ts` - 更新类型定义,添加 ToolPresenter 接口和 ChatMode 类型 -8. `src/renderer/src/components/chat-input/ChatInput.vue` - 添加 Mode Switch 选择器和路径选择器 - -- 添加 Mode Switch 选择器:使用 icon + 下拉选择(类似模型选择器,但更简约) -- Icon 映射:chat 用 `message-circle-more`,agent 用 `bot`,acp agent 用 `bot-message-square` -- 在 agent 或 acp agent 模式时显示统一的目录选择按钮 -- 集成 `useChatMode` 和 `useAgentWorkspace` composables - -9. `src/renderer/src/components/ModelChooser.vue` - 更新模型选择逻辑,只在 acp agent 模式下显示 ACP 模型 -10. `src/renderer/src/components/chat/ChatView.vue` - 使用通用 WorkspaceView - -### 删除/废弃文件 - -1. `src/renderer/src/components/acp-workspace/` - 整个目录(重构为 workspace) -2. `src/renderer/src/stores/acpWorkspace.ts` - 重构为 workspace.ts -3. `src/main/presenter/acpWorkspacePresenter/` - 重构为 workspacePresenter - -## 实施步骤 - -### Phase 1: 统一工具路由架构 - -1. 创建 `ToolPresenter` 类: - -- 统一管理所有工具源(MCP、Agent) -- 提供 `getAllToolDefinitions()` 方法,返回统一的 MCP 规范格式工具定义 -- 提供 `callTool()` 方法,根据工具映射路由到对应的处理器 - -2. 创建 `ToolMapper` 类: - -- 实现工具名称到工具源的映射机制 -- 支持未来扩展工具去重和映射功能 - -3. 创建 `AgentToolManager` 类: - -- 管理所有 Agent 工具(Yo Browser、FileSystem 等) -- 只要 Agent 模式启用,所有工具都注入 -- 工具定义统一使用 MCP 规范格式 - -4. 创建 `AgentFileSystemHandler` 类: - -- 封装文件系统操作能力 -- 工具名称不加前缀(如 `read_file`, `write_file`) -- 工具定义使用 MCP 规范格式 - -### Phase 2: 集成工具路由 - -1. 更新 `AgentLoopHandler`: - -- 使用 `ToolPresenter` 获取所有工具定义 -- 简化工具注入逻辑,不再区分工具源 - -2. 更新 `ToolCallProcessor`: - -- 使用 `ToolPresenter.callTool()` 进行工具调用 -- 根据工具映射自动路由到对应的处理器 - -### Phase 3: 通用 Mode Switch 配置 - -1. 创建 `useChatMode` composable(参考 `useInputSettings` 的实现方式): - -- 使用 `configPresenter.setSetting('input_chatMode', value)` 保存模式 -- 使用 `configPresenter.getSetting('input_chatMode')` 读取模式 -- 管理模式状态(chat、agent、acp agent) -- 默认值:`'chat'` -- 在 `onMounted` 时自动加载保存的模式 -- 提供 `setMode()` 方法切换模式并持久化 - -2. 在 `chatConfig` 中添加 `chatMode` 字段: - -- 从 `useChatMode` composable 读取当前模式 -- 支持会话级别的覆盖(如果需要) - -3. 在 `ChatInput.vue` 中添加 Mode Switch 选择器: - -- 使用下拉选择(类似模型选择器),但样式更简约 -- 在 ChatInput 中只显示一个 icon,hover 时显示当前模式 -- Icon 映射: -- chat: `lucide:message-circle-more` -- agent: `lucide:bot` -- acp agent: `lucide:bot-message-square` -- 点击 icon 或 hover 时显示下拉选择器 -- 集成 `useChatMode` composable -- 根据当前模式显示不同的 UI 和功能 - -4. 更新模型选择逻辑: - -- 只有在 `acp agent` 模式下才显示 ACP 模型 -- 其他模式隐藏 ACP 模型选项 - -### Phase 4: MCP Filesystem 移除 - -1. 从 `mcpConfHelper.ts` 移除 `buildInFileSystem` 配置 -2. 从 `inMemoryServers/builder.ts` 移除 filesystem server -3. 添加数据迁移逻辑,将现有 buildInFileSystem 配置迁移 - -### Phase 5: Workspace 组件通用化 - -1. 创建通用 `WorkspacePresenter` -2. 创建通用 `workspace` store -3. 重构 Workspace 组件,移除 ACP 依赖 -4. 更新事件系统,支持通用 Workspace - -### Phase 6: Workspace 路径选择(统一化) - -1. 在 `chatConfig` 中添加 `agentWorkspacePath` 字段 -2. 创建 `useAgentWorkspace` composable,统一管理工作目录: - -- 根据当前 `chatMode` 决定工作目录的用途 -- **acp agent 模式**:使用现有的 ACP workdir 逻辑(`useAcpWorkdir`) -- **agent 模式**:使用 filesystem 工具的工作目录 -- 支持未来扩展其他工具的工作目录 -- 提供统一的接口和状态管理 - -3. 在 `ChatInput.vue` 中添加统一的目录选择按钮: - -- 在 `agent` 或 `acp agent` 模式下显示 -- 参考现有的 ACP workdir 按钮实现 -- 根据模式显示不同的 tooltip 和逻辑 -- 按钮样式和行为统一 - -4. 实现临时目录创建和管理逻辑 - -### Phase 7: 集成和测试 - -1. 更新所有引用 ACP Workspace 的地方 -2. 添加 i18n 翻译 -3. 测试各种场景(切换不同模式、路径选择、模型选择等) -4. 更新文档 - -## 关键技术点 - -### 工具命名规范 - -- **MCP 工具**:保持原样(如 `read_files`, `write_file`) -- **Agent 工具**:**不加前缀**(如 `read_file`, `write_file`, `browser_navigate`) -- Yo Browser:保持 `browser_` 前缀(已存在) -- FileSystem:不加前缀(如 `read_file`, `write_file`) -- Terminal:未来不加前缀(如 `execute_command`) - -### 工具路由机制 - -- 所有工具定义统一使用 MCP 规范格式(`MCPToolDefinition`) -- `ToolMapper` 维护工具名称到工具源的映射 -- 工具调用时根据映射自动路由: -- 如果工具名称映射到 MCP → 调用 `mcpPresenter.callTool()` -- 如果工具名称映射到 Agent → 调用 `agentToolManager.callTool()` -- 未来支持工具去重:如果 MCP 和 Agent 有同名工具,可以配置映射到 MCP 工具 - -### Agent 工具注入机制(基于 Mode) - -- 根据 `chatMode` 决定工具注入: -- **chat 模式**:不注入 Agent 工具,只有 MCP 工具 -- **agent 模式**:注入所有 Agent 工具(yo browser、filesystem 等) -- **acp agent 模式**:根据 ACP 逻辑决定(后续补充具体实现) -- 工具注入逻辑: -- Yo Browser:在 agent 或 acp agent 模式下,当浏览器窗口打开时注入 -- FileSystem:在 agent 或 acp agent 模式下注入 -- Terminal:未来按需扩展 -- 保留扩展能力,未来可以扩展成按需注入机制 - -### 配置持久化 - -- `chatMode` 通过 `configPresenter.setSetting('input_chatMode', value)` 存储 -- 通过 `configPresenter.getSetting('input_chatMode')` 读取 -- 类型:`'chat' | 'agent' | 'acp agent'` -- 默认值:`'chat'` -- 存储方式:与 `input_webSearch`、`input_deepThinking` 相同,存储在 ElectronStore 中 -- 在 `useChatMode` composable 的 `onMounted` 时自动加载保存的模式 -- 配置统一化管理,通过 `ConfigPresenter` 访问 -- 会话级别的配置可以覆盖全局配置(如果需要) - -### Mode Switch 与 ACP Session Mode 的区别 - -- **Chat Mode Switch**:全局模式选择,决定整个会话的行为和可用功能 -- chat:基础聊天模式 -- agent:内置 agent 模式 -- acp agent:ACP 专用模式 -- **ACP Session Mode**:ACP agent 模式下的会话模式(如 plan、code 等),由 ACP agent 内部定义 -- 两者是不同层级的概念,互不干扰 - -### 路径安全 - -- Agent 模式下的文件操作必须限制在用户选择的 workspace 路径内 -- 临时目录在会话结束后自动清理 -- 所有路径操作都需要验证权限 - -### 向后兼容 - -- 保留 ACP Provider 的现有功能 -- 迁移现有 ACP Workspace 数据到通用 Workspace -- 确保现有 MCP filesystem 配置能平滑迁移 - -## ChatInput.vue 重构细节 - -### Mode Switch 选择器实现 - -**位置**:在 Tools 区域,搜索开关之后,MCP Tools 之前**实现方式**:Icon + 下拉选择(类似模型选择器,但更简约)**Icon 映射**: - -- chat: `lucide:message-circle-more` -- agent: `lucide:bot` -- acp agent: `lucide:bot-message-square` - -**实现示例**: - -```vue - - - - - - - - {{ t('chat.mode.current', { mode: chatMode.currentLabel.value }) }} - - - - -
-
- - {{ mode.label }} - -
-
-
-
-``` - -**useChatMode composable 需要添加**: - -```typescript -const modeIcons = { - chat: 'lucide:message-circle-more', - agent: 'lucide:bot', - 'acp agent': 'lucide:bot-message-square' -} - -const currentIcon = computed(() => modeIcons[currentMode.value]) -``` - -**useChatMode composable 实现示例**(参考 useInputSettings): - -```typescript -export function useChatMode() { - const configPresenter = usePresenter('configPresenter') - const currentMode = ref<'chat' | 'agent' | 'acp agent'>('chat') - - const setMode = async (mode: 'chat' | 'agent' | 'acp agent') => { - const previousValue = currentMode.value - currentMode.value = mode - - try { - await configPresenter.setSetting('input_chatMode', mode) - } catch (error) { - currentMode.value = previousValue - console.error('Failed to save chat mode:', error) - } - } - - const loadMode = async () => { - try { - const saved = await configPresenter.getSetting('input_chatMode') - currentMode.value = (saved as 'chat' | 'agent' | 'acp agent') || 'chat' - } catch (error) { - currentMode.value = 'chat' - console.error('Failed to load chat mode, using default:', error) - } - } - - onMounted(async () => { - await loadMode() - }) - - return { - currentMode, - setMode, - loadMode - } -} -``` - - - -### 目录选择按钮实现(统一化) - -**位置**:在 Tools 区域,Mode Switch 之后,仅在 `agent` 或 `acp agent` 模式下显示**代码位置**:在 Tools 区域添加**显示条件**:`chatMode.currentMode.value === 'agent' || chatMode.currentMode.value === 'acp agent'`**统一化逻辑**: - -- **acp agent 模式**:使用现有的 ACP workdir 逻辑(`useAcpWorkdir`) -- **agent 模式**:使用 filesystem 工具的工作目录(`useAgentWorkspace`) -- 按钮样式和行为统一,但根据模式显示不同的 tooltip 和逻辑 - -**实现示例**: - -```vue - - - - - -

- {{ workspace.tooltipTitle }} -

-

- {{ workspace.tooltipCurrent }} -

-

- {{ workspace.tooltipSelect }} -

-
-
-``` - -**useAgentWorkspace composable 需要根据模式统一化**: - -```typescript -export function useAgentWorkspace(options: { - chatMode: Ref<'chat' | 'agent' | 'acp agent'> - // ... other options -}) { - const acpWorkdir = useAcpWorkdir(...) // 用于 acp agent 模式 - const agentWorkspace = ref(null) // 用于 agent 模式 - - const hasWorkspace = computed(() => { - if (options.chatMode.value === 'acp agent') { - return acpWorkdir.hasWorkdir.value - } - return agentWorkspace.value !== null - }) - - const workspacePath = computed(() => { - if (options.chatMode.value === 'acp agent') { - return acpWorkdir.workdir.value - } - return agentWorkspace.value - }) - - const tooltipTitle = computed(() => { - if (options.chatMode.value === 'acp agent') { - return t('chat.input.acpWorkdirTooltip') - } - return t('chat.input.agentWorkspaceTooltip') - }) - - // ... 其他逻辑 -} -``` - - - -## 注意事项 - -1. **文件大小限制**:确保每个文件不超过 200 行(TypeScript) -2. **文件夹文件数限制**:每个文件夹不超过 8 个文件 -3. **UI 一致性**:Mode Switch 和目录选择按钮的样式和行为应该与现有的 UI 元素保持一致 \ No newline at end of file diff --git a/docs/workspace-agent-refactoring-summary.md b/docs/workspace-agent-refactoring-summary.md new file mode 100644 index 000000000..6e860947a --- /dev/null +++ b/docs/workspace-agent-refactoring-summary.md @@ -0,0 +1,404 @@ +# 通用 Workspace 和 Agent 能力重构实施总结 + +## 概述 + +本次重构将 Workspace 和 Agent 能力从 ACP 专用扩展到所有模型,重构 filesystem MCP 为 Agent 工具,统一管理 MCP 和 Agent 工具调用注入逻辑,并重构 AcpWorkspaceView 为通用 Workspace 组件。 + +## 架构概览 + +```mermaid +graph TB + subgraph "Agent Loop" + AL[AgentLoopHandler] + TCP[ToolCallProcessor] + end + + subgraph "统一工具路由" + TP[ToolPresenter] + TM[ToolMapper] + end + + subgraph "工具源" + MCP[MCP Tools] + AGENT[Agent Tools] + end + + subgraph "Agent 工具" + YO[Yo Browser] + FS[FileSystem] + TERM[Terminal 未来] + end + + subgraph "Workspace" + WS[WorkspaceView] + FILES[Files Section] + PLAN[Plan Section] + TERM_UI[Terminal Section] + end + + AL --> TCP + TCP --> TP + TP --> TM + TM --> MCP + TM --> AGENT + AGENT --> YO + AGENT --> FS + AGENT --> TERM + WS --> FILES + WS --> PLAN + WS --> TERM_UI +``` + +## 已完成的工作 + +### 1. 统一工具路由架构 ✅ + +**实现文件**: +- `src/main/presenter/toolPresenter/index.ts` - 统一工具路由 Presenter +- `src/main/presenter/toolPresenter/toolMapper.ts` - 工具映射器 + +**功能**: +- 创建 `ToolPresenter` 类,统一管理所有工具源(MCP、Agent) +- 创建 `ToolMapper` 类,实现工具名称到工具源的映射机制 +- 所有工具定义统一使用 MCP 规范格式(`MCPToolDefinition`) +- 工具调用时根据映射路由到对应的工具源处理器 +- 支持工具去重和映射(如果 MCP 和 Agent 有同名工具,可以映射到 MCP 工具) + +### 2. Agent 工具管理 ✅ + +**实现文件**: +- `src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts` - Agent 工具管理器 +- `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts` - 文件系统能力处理器 + +**功能**: +- 创建 `AgentToolManager` 类,管理所有 Agent 工具 +- Agent 工具包括: + - **Yo Browser**:保持现有实现,工具名称使用 `browser_` 前缀(如 `browser_navigate`) + - **FileSystem**:新增,工具名称**不加前缀**(如 `read_file`, `write_file`) + - **Terminal**:未来扩展 +- 工具注入逻辑: + - **chat 模式**:不注入 Agent 工具(只有 MCP 工具) + - **agent 模式**:注入所有 Agent 工具(yo browser、filesystem 等) + - **acp agent 模式**:根据 ACP 逻辑决定 + +### 3. 文件系统能力抽象 ✅ + +**实现文件**: +- `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts` - 文件系统处理器 + +**功能**: +- 创建 `AgentFileSystemHandler` 类,封装文件操作能力 +- 工具名称不加前缀,例如:`read_file`, `write_file`, `list_directory` 等 +- 从 `mcpConfHelper.ts` 中移除 `buildInFileSystem` 配置 +- 从 `inMemoryServers/builder.ts` 中移除 filesystem server 的创建逻辑 +- 添加数据迁移逻辑,将现有 buildInFileSystem 配置迁移 + +### 4. 通用 Mode Switch 配置 ✅ + +**实现文件**: +- `src/renderer/src/components/chat-input/composables/useChatMode.ts` - Chat Mode Switch composable +- `src/renderer/src/components/chat-input/ChatInput.vue` - 添加 Mode Switch 选择器 + +**功能**: +- 在配置存储(ElectronStore)中添加 `chatMode: 'chat' | 'agent' | 'acp agent'` 字段 +- 在 `chatConfig` 中添加 `chatMode` 字段(从配置存储读取) +- 创建 `useChatMode` composable,管理模式状态 +- 在 `ChatInput.vue` 中添加 Mode Switch 选择器(Icon + 下拉选择) +- 三种模式的区别: + - **chat**:基础聊天模式,只有 MCP 工具,不支持 yo browser、文件读写等功能 + - **agent**:内置 agent 模式,包含 workdir 设置、各种工具(yo browser、文件读写等)、agent loop 定制内容 + - **acp agent**:ACP 模式,只有这个模式才能选择 ACP 模型,loop 和逻辑会有不同 +- 配置持久化:通过 `configPresenter.setSetting('input_chatMode', value)` 存储 + +### 5. Workspace 组件通用化 ✅ + +**实现文件**: +- `src/main/presenter/workspacePresenter/index.ts` - 通用 Workspace Presenter +- `src/renderer/src/stores/workspace.ts` - 通用 Workspace Store +- `src/renderer/src/components/workspace/WorkspaceView.vue` - 通用 Workspace 组件 +- `src/renderer/src/components/workspace/WorkspaceFiles.vue` - 文件列表组件 +- `src/renderer/src/components/workspace/WorkspacePlan.vue` - 计划组件 +- `src/renderer/src/components/workspace/WorkspaceTerminal.vue` - 终端组件 + +**功能**: +- 重命名 `AcpWorkspaceView.vue` → `WorkspaceView.vue` +- 重命名 `acpWorkspace` store → `workspace` store +- 重命名 `AcpWorkspacePresenter` → `WorkspacePresenter` +- 移除 ACP 特定的依赖,改为基于 Agent 模式判断 +- 支持所有模型的 Agent 模式 + +### 6. Workspace 路径选择(统一化)✅ + +**实现文件**: +- `src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts` - Workspace 路径选择 composable +- `src/renderer/src/components/chat-input/ChatInput.vue` - 添加统一的目录选择按钮 + +**功能**: +- 在 `chatConfig` 中添加 `agentWorkspacePath: string | null` 字段 +- 创建 `useAgentWorkspace` composable,统一管理工作目录选择 +- 在 `ChatInput.vue` 的 Tools 区域添加目录选择按钮(在 agent 或 acp agent 模式下显示) +- 目录选择按钮逻辑统一化: + - **acp agent 模式**:使用 ACP workdir(现有的 ACP workdir 逻辑) + - **agent 模式**:使用 filesystem 工具的工作目录 +- 按钮样式和行为统一 + +### 7. 模型选择逻辑更新 ✅ + +**实现文件**: +- `src/renderer/src/components/ModelChooser.vue` - 更新模型选择逻辑 + +**功能**: +- 只有在 `acp agent` 模式下才显示 ACP 模型 +- 其他模式隐藏 ACP 模型选项 + +### 8. ChatView 集成更新 ✅ + +**实现文件**: +- `src/renderer/src/components/chat/ChatView.vue` - 使用通用 WorkspaceView + +**功能**: +- 更新 ChatView 使用通用 WorkspaceView +- 更新事件监听和状态同步逻辑 + +### 9. Agent Loop Handler 更新 ✅ + +**实现文件**: +- `src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts` - 使用统一的 ToolPresenter +- `src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts` - 使用 ToolPresenter 进行工具调用路由 + +**功能**: +- 更新 `AgentLoopHandler` 使用统一的 ToolPresenter +- 简化工具注入逻辑,不再区分工具源 +- 更新 `ToolCallProcessor` 使用 `ToolPresenter.callTool()` 进行工具调用 +- 根据工具映射自动路由到对应的处理器 + +### 10. 类型定义更新 ✅ + +**实现文件**: +- `src/shared/presenter.d.ts` - 更新类型定义 +- `src/shared/types/presenters/tool.presenter.d.ts` - 添加 ToolPresenter 接口 + +**功能**: +- 添加 ToolPresenter 接口和 ChatMode 类型 +- 更新 Workspace 相关接口 +- 添加 Agent 模式相关类型 + +### 11. i18n 翻译 ✅ + +**实现文件**: +- `src/renderer/src/i18n/*/chat.json` - 添加模式相关翻译 +- `src/renderer/src/i18n/*/toolCall.json` - 添加工具调用相关翻译 + +**功能**: +- 添加所有新增 UI 元素的 i18n 翻译(中文、英文等) +- 更新相关翻译键 + +## 新增文件 + +1. `src/main/presenter/toolPresenter/index.ts` - 统一工具路由 Presenter +2. `src/main/presenter/toolPresenter/toolMapper.ts` - 工具映射器 +3. `src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts` - Agent 工具管理器 +4. `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts` - 文件系统能力处理器 +5. `src/renderer/src/stores/workspace.ts` - 通用 Workspace Store +6. `src/main/presenter/workspacePresenter/index.ts` - 通用 Workspace Presenter +7. `src/renderer/src/components/workspace/WorkspaceView.vue` - 通用 Workspace 组件 +8. `src/renderer/src/components/workspace/WorkspaceFiles.vue` - 文件列表组件 +9. `src/renderer/src/components/workspace/WorkspacePlan.vue` - 计划组件 +10. `src/renderer/src/components/workspace/WorkspaceTerminal.vue` - 终端组件 +11. `src/renderer/src/components/chat-input/composables/useChatMode.ts` - Chat Mode Switch composable +12. `src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts` - Workspace 路径选择 composable +13. `src/shared/types/presenters/tool.presenter.d.ts` - ToolPresenter 类型定义 + +## 修改的文件 + +1. `src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts` - 使用统一的 ToolPresenter +2. `src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts` - 使用 ToolPresenter 进行工具调用路由 +3. `src/main/presenter/configPresenter/mcpConfHelper.ts` - 移除 buildInFileSystem 配置,添加数据迁移 +4. `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts` - 移除 filesystem server +5. `src/renderer/src/stores/chat.ts` - 添加 chatMode 和 agentWorkspacePath 配置 +6. `src/shared/presenter.d.ts` - 更新类型定义 +7. `src/renderer/src/components/chat-input/ChatInput.vue` - 添加 Mode Switch 选择器和路径选择器 +8. `src/renderer/src/components/ModelChooser.vue` - 更新模型选择逻辑 +9. `src/renderer/src/components/chat/ChatView.vue` - 使用通用 WorkspaceView +10. `src/main/presenter/index.ts` - 初始化 ToolPresenter 和 WorkspacePresenter + +## 删除/废弃文件 + +1. `src/renderer/src/components/acp-workspace/` - 整个目录(重构为 workspace) +2. `src/renderer/src/stores/acpWorkspace.ts` - 重构为 workspace.ts +3. `src/main/presenter/acpWorkspacePresenter/` - 重构为 workspacePresenter(保留部分用于向后兼容) + +## 关键技术点 + +### 工具命名规范 + +- **MCP 工具**:保持原样(如 `read_files`, `write_file`) +- **Agent 工具**:**不加前缀**(如 `read_file`, `write_file`) +- Yo Browser:保持 `browser_` 前缀(已存在) +- FileSystem:不加前缀(如 `read_file`, `write_file`) +- Terminal:未来不加前缀(如 `execute_command`) + +### 工具路由机制 + +- 所有工具定义统一使用 MCP 规范格式(`MCPToolDefinition`) +- `ToolMapper` 维护工具名称到工具源的映射 +- 工具调用时根据映射自动路由: + - 如果工具名称映射到 MCP → 调用 `mcpPresenter.callTool()` + - 如果工具名称映射到 Agent → 调用 `agentToolManager.callTool()` +- 支持工具去重:如果 MCP 和 Agent 有同名工具,可以配置映射到 MCP 工具 + +### Agent 工具注入机制(基于 Mode) + +- 根据 `chatMode` 决定工具注入: + - **chat 模式**:不注入 Agent 工具,只有 MCP 工具 + - **agent 模式**:注入所有 Agent 工具(yo browser、filesystem 等) + - **acp agent 模式**:根据 ACP 逻辑决定 +- 工具注入逻辑: + - Yo Browser:在 agent 或 acp agent 模式下,当浏览器窗口打开时注入 + - FileSystem:在 agent 或 acp agent 模式下注入 + - Terminal:未来按需扩展 + +### 配置持久化 + +- `chatMode` 通过 `configPresenter.setSetting('input_chatMode', value)` 存储 +- 通过 `configPresenter.getSetting('input_chatMode')` 读取 +- 类型:`'chat' | 'agent' | 'acp agent'` +- 默认值:`'chat'` +- 存储方式:与 `input_webSearch`、`input_deepThinking` 相同,存储在 ElectronStore 中 +- 在 `useChatMode` composable 的初始化时自动加载保存的模式 + +### Mode Switch 与 ACP Session Mode 的区别 + +- **Chat Mode Switch**:全局模式选择,决定整个会话的行为和可用功能 + - chat:基础聊天模式 + - agent:内置 agent 模式 + - acp agent:ACP 专用模式 +- **ACP Session Mode**:ACP agent 模式下的会话模式(如 plan、code 等),由 ACP agent 内部定义 +- 两者是不同层级的概念,互不干扰 + +### 路径安全 + +- Agent 模式下的文件操作必须限制在用户选择的 workspace 路径内 +- 临时目录在会话结束后自动清理 +- 所有路径操作都需要验证权限 +- `WorkspacePresenter` 维护允许的 workspace 路径列表 + +### 向后兼容 + +- 保留 ACP Provider 的现有功能 +- 迁移现有 ACP Workspace 数据到通用 Workspace +- 确保现有 MCP filesystem 配置能平滑迁移 +- 保留 `AcpWorkspacePresenter` 用于向后兼容(标记为 legacy) + +## 如何测试 + +### 测试 Mode Switch + +```bash +pnpm run dev +``` + +1. 打开应用,在 ChatInput 中找到 Mode Switch 按钮 +2. 点击按钮,选择不同的模式(chat、agent、acp agent) +3. 验证模式切换后,UI 和功能是否正确更新 +4. 重新打开应用,验证模式是否被正确持久化 + +### 测试 Workspace 路径选择 + +1. 切换到 `agent` 或 `acp agent` 模式 +2. 点击目录选择按钮 +3. 选择一个工作目录 +4. 验证目录是否正确设置和显示 +5. 在 `acp agent` 模式下,验证是否使用 ACP workdir 逻辑 +6. 在 `agent` 模式下,验证是否使用 filesystem 工具的工作目录 + +### 测试工具路由 + +1. 在 `agent` 模式下,发送消息触发工具调用 +2. 验证文件系统工具(如 `read_file`, `write_file`)是否正确路由到 Agent 工具处理器 +3. 验证 MCP 工具是否正确路由到 MCP 处理器 +4. 验证工具调用结果是否正确返回 + +### 测试 Workspace 组件 + +1. 在 `agent` 或 `acp agent` 模式下,打开 Workspace 视图 +2. 验证文件列表是否正确显示 +3. 验证计划列表是否正确显示 +4. 验证终端输出是否正确显示 +5. 验证不同模式下的 Workspace 行为是否一致 + +### 测试模型选择 + +1. 切换到 `chat` 或 `agent` 模式,验证 ACP 模型是否隐藏 +2. 切换到 `acp agent` 模式,验证 ACP 模型是否显示 +3. 验证模型选择逻辑是否正确 + +## 架构说明 + +### 数据流 + +``` +用户选择 Mode + ↓ +useChatMode (管理模式状态) + ↓ +ChatInput (显示 Mode Switch) + ↓ +AgentLoopHandler (根据模式注入工具) + ↓ +ToolPresenter (统一工具路由) + ↓ +ToolMapper (路由到对应工具源) + ↓ +MCP Tools / Agent Tools +``` + +### Workspace 数据流 + +``` +用户选择 Workspace 路径 + ↓ +useAgentWorkspace (统一管理工作目录) + ↓ +WorkspacePresenter (注册 workspace) + ↓ +WorkspaceStore (管理状态) + ↓ +WorkspaceView (显示 UI) +``` + +### 工具调用流程 + +``` +Agent Loop + ↓ +ToolCallProcessor + ↓ +ToolPresenter.callTool() + ↓ +ToolMapper (查找工具源) + ↓ +MCP Presenter / Agent Tool Manager + ↓ +工具执行 + ↓ +返回结果 +``` + +## 注意事项 + +1. **文件大小限制**:确保每个文件不超过 200 行(TypeScript) +2. **文件夹文件数限制**:每个文件夹不超过 8 个文件 +3. **UI 一致性**:Mode Switch 和目录选择按钮的样式和行为应该与现有的 UI 元素保持一致 +4. **性能**:工具路由机制应该高效,避免不必要的查找和转换 +5. **安全**:所有文件操作必须限制在允许的 workspace 路径内 +6. **向后兼容**:确保现有功能不受影响,平滑迁移 + +## 未来扩展 + +1. **Terminal 工具**:添加终端命令执行能力 +2. **按需工具注入**:支持更细粒度的工具注入控制 +3. **工具去重优化**:改进工具名称冲突处理机制 +4. **Workspace 模板**:支持预设的 workspace 配置 +5. **多 Workspace 支持**:支持同时管理多个 workspace + From e3ba06e1f3cf1e40a23df3929cc6710c1d322517 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Mon, 22 Dec 2025 12:07:11 +0800 Subject: [PATCH 05/29] feat: add support for yo browser in agent look --- .../presenter/browser/YoBrowserPresenter.ts | 20 +- .../agent/agentFileSystemHandler.ts | 375 ++++++++++++++++++ .../agent/agentToolManager.ts | 154 ++++++- .../managers/agentLoopHandler.ts | 66 +++ .../handlers/streamGenerationHandler.ts | 59 +++ .../threadPresenter/utils/promptBuilder.ts | 23 +- src/main/presenter/windowPresenter/index.ts | 6 +- .../workspace/WorkspaceBrowserTabs.vue | 87 ++++ .../components/workspace/WorkspaceView.vue | 8 + src/renderer/src/i18n/da-DK/chat.json | 4 + src/renderer/src/i18n/en-US/chat.json | 4 + src/renderer/src/i18n/fa-IR/chat.json | 4 + src/renderer/src/i18n/fr-FR/chat.json | 4 + src/renderer/src/i18n/he-IL/chat.json | 4 + src/renderer/src/i18n/ja-JP/chat.json | 4 + src/renderer/src/i18n/ko-KR/chat.json | 4 + src/renderer/src/i18n/pt-BR/chat.json | 4 + src/renderer/src/i18n/ru-RU/chat.json | 4 + src/renderer/src/i18n/zh-CN/chat.json | 4 + src/renderer/src/i18n/zh-HK/chat.json | 4 + src/renderer/src/i18n/zh-TW/chat.json | 4 + src/renderer/src/stores/yoBrowser.ts | 7 + .../types/presenters/legacy.presenters.d.ts | 1 + 23 files changed, 824 insertions(+), 30 deletions(-) create mode 100644 src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index e69e2e4ce..7b0e996f6 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -30,6 +30,7 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { private readonly browserToolManager: BrowserToolManager private readonly windowPresenter: IWindowPresenter private readonly tabPresenter: ITabPresenter + private explicitlyOpened = false constructor(windowPresenter: IWindowPresenter, tabPresenter: ITabPresenter) { this.windowPresenter = windowPresenter @@ -42,23 +43,19 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { // Lazy initialization: only create browser window/tabs when explicitly requested. } - async ensureWindow(): Promise { + async ensureWindow(options?: { showOnReady?: boolean }): Promise { const window = this.getWindow() if (window) return window.id this.windowId = await this.windowPresenter.createShellWindow({ - windowType: 'browser' + windowType: 'browser', + showOnReady: options?.showOnReady ?? false }) const created = this.getWindow() if (created) { created.on('closed', () => this.handleWindowClosed()) this.emitVisibility(created.isVisible()) - - // Auto-create a blank tab when the window is first created - if (this.tabIdToBrowserTab.size === 0) { - await this.createTab('about:blank') - } } return this.windowId @@ -69,7 +66,13 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { } async show(): Promise { - await this.ensureWindow() + if (!this.explicitlyOpened) { + this.explicitlyOpened = true + } + await this.ensureWindow({ showOnReady: true }) + if (this.tabIdToBrowserTab.size === 0) { + await this.createTab('about:blank') + } const window = this.getWindow() if (window && !window.isDestroyed()) { this.windowPresenter.show(window.id) @@ -369,6 +372,7 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { private handleWindowClosed(): void { this.cleanup() + this.explicitlyOpened = false this.emitVisibility(false) this.emitTabCount() } diff --git a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts index 1e170cd75..9aadea177 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts @@ -56,6 +56,61 @@ const FileSearchArgsSchema = z.object({ maxResults: z.number().default(1000) }) +const GrepSearchArgsSchema = z.object({ + path: z.string(), + pattern: z.string(), + filePattern: z.string().optional(), + recursive: z.boolean().default(true), + caseSensitive: z.boolean().default(false), + includeLineNumbers: z.boolean().default(true), + contextLines: z.number().default(0), + maxResults: z.number().default(100) +}) + +const TextReplaceArgsSchema = z.object({ + path: z.string(), + pattern: z.string(), + replacement: z.string(), + global: z.boolean().default(true), + caseSensitive: z.boolean().default(false), + dryRun: z.boolean().default(false) +}) + +const DirectoryTreeArgsSchema = z.object({ + path: z.string() +}) + +const GetFileInfoArgsSchema = z.object({ + path: z.string() +}) + +interface GrepMatch { + file: string + line: number + content: string + beforeContext?: string[] + afterContext?: string[] +} + +interface GrepResult { + totalMatches: number + files: string[] + matches: GrepMatch[] +} + +interface TextReplaceResult { + success: boolean + replacements: number + diff?: string + error?: string +} + +interface TreeEntry { + name: string + type: 'file' | 'directory' + children?: TreeEntry[] +} + export class AgentFileSystemHandler { private allowedDirectories: string[] @@ -72,6 +127,10 @@ export class AgentFileSystemHandler { return path.normalize(p) } + private normalizeLineEndings(text: string): string { + return text.replace(/\r\n/g, '\n') + } + private expandHome(filepath: string): string { if (filepath.startsWith('~/') || filepath === '~') { return path.join(os.homedir(), filepath.slice(1)) @@ -119,6 +178,212 @@ export class AgentFileSystemHandler { } } + private createUnifiedDiff(originalContent: string, newContent: string, filePath: string): string { + const normalizedOriginal = this.normalizeLineEndings(originalContent) + const normalizedNew = this.normalizeLineEndings(newContent) + return createTwoFilesPatch(filePath, filePath, normalizedOriginal, normalizedNew) + } + + private async getFileStats(filePath: string): Promise<{ + size: number + created: Date + modified: Date + accessed: Date + isDirectory: boolean + isFile: boolean + permissions: string + }> { + const stats = await fs.stat(filePath) + return { + size: stats.size, + created: stats.birthtime, + modified: stats.mtime, + accessed: stats.atime, + isDirectory: stats.isDirectory(), + isFile: stats.isFile(), + permissions: stats.mode.toString(8).slice(-3) + } + } + + private async runGrepSearch( + rootPath: string, + pattern: string, + options: { + filePattern?: string + recursive?: boolean + caseSensitive?: boolean + includeLineNumbers?: boolean + contextLines?: number + maxResults?: number + } = {} + ): Promise { + const { + filePattern = '*', + recursive = true, + caseSensitive = false, + includeLineNumbers = true, + contextLines = 0, + maxResults = 100 + } = options + + const result: GrepResult = { + totalMatches: 0, + files: [], + matches: [] + } + + const regexFlags = caseSensitive ? 'g' : 'gi' + let regex: RegExp + try { + regex = new RegExp(pattern, regexFlags) + } catch (error) { + throw new Error(`Invalid regular expression pattern: ${pattern}. Error: ${error}`) + } + + const searchInFile = async (filePath: string): Promise => { + try { + const content = await fs.readFile(filePath, 'utf-8') + const lines = content.split('\n') + const fileMatches: GrepMatch[] = [] + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + regex.lastIndex = 0 + const matches = Array.from(line.matchAll(regex)) + if (matches.length === 0) continue + + const match: GrepMatch = { + file: filePath, + line: includeLineNumbers ? i + 1 : 0, + content: line + } + + if (contextLines > 0) { + const startContext = Math.max(0, i - contextLines) + const endContext = Math.min(lines.length - 1, i + contextLines) + if (startContext < i) { + match.beforeContext = lines.slice(startContext, i) + } + if (endContext > i) { + match.afterContext = lines.slice(i + 1, endContext + 1) + } + } + + fileMatches.push(match) + result.totalMatches += matches.length + if (result.totalMatches >= maxResults) { + break + } + } + + if (fileMatches.length > 0) { + result.files.push(filePath) + result.matches.push(...fileMatches) + } + } catch { + // Skip unreadable files. + } + } + + const searchDirectory = async (currentPath: string): Promise => { + if (result.totalMatches >= maxResults) return + let entries + try { + entries = await fs.readdir(currentPath, { withFileTypes: true }) + } catch { + return + } + + for (const entry of entries) { + if (result.totalMatches >= maxResults) break + const fullPath = path.join(currentPath, entry.name) + try { + await this.validatePath(fullPath) + if (entry.isFile()) { + if (minimatch(entry.name, filePattern, { nocase: !caseSensitive })) { + await searchInFile(fullPath) + } + } else if (entry.isDirectory() && recursive) { + await searchDirectory(fullPath) + } + } catch { + continue + } + } + } + + const validatedPath = await this.validatePath(rootPath) + const stats = await fs.stat(validatedPath) + + if (stats.isFile()) { + if (minimatch(path.basename(validatedPath), filePattern, { nocase: true })) { + await searchInFile(validatedPath) + } + } else if (stats.isDirectory()) { + await searchDirectory(validatedPath) + } + + return result + } + + private async replaceTextInFile( + filePath: string, + pattern: string, + replacement: string, + options: { + global?: boolean + caseSensitive?: boolean + dryRun?: boolean + } = {} + ): Promise { + const { global = true, caseSensitive = false, dryRun = false } = options + try { + const originalContent = await fs.readFile(filePath, 'utf-8') + const normalizedOriginal = this.normalizeLineEndings(originalContent) + const regexFlags = global ? (caseSensitive ? 'g' : 'gi') : caseSensitive ? '' : 'i' + let regex: RegExp + try { + regex = new RegExp(pattern, regexFlags) + } catch (error) { + return { + success: false, + replacements: 0, + error: `Invalid regular expression pattern: ${pattern}. Error: ${error}` + } + } + + const modifiedContent = normalizedOriginal.replace(regex, replacement) + const countRegex = new RegExp(pattern, caseSensitive ? 'g' : 'gi') + const matches = Array.from(normalizedOriginal.matchAll(countRegex)) + const replacements = global ? matches.length : Math.min(1, matches.length) + + if (replacements === 0) { + return { + success: true, + replacements: 0, + diff: 'No matches found for the given pattern.' + } + } + + const diff = this.createUnifiedDiff(normalizedOriginal, modifiedContent, filePath) + if (!dryRun) { + await fs.writeFile(filePath, modifiedContent, 'utf-8') + } + + return { + success: true, + replacements, + diff + } + } catch (error) { + return { + success: false, + replacements: 0, + error: error instanceof Error ? error.message : String(error) + } + } + } + async readFile(args: unknown): Promise { const parsed = ReadFileArgsSchema.safeParse(args) if (!parsed.success) { @@ -226,6 +491,116 @@ export class AgentFileSystemHandler { return diff } + async grepSearch(args: unknown): Promise { + const parsed = GrepSearchArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + + const validPath = await this.validatePath(parsed.data.path) + const result = await this.runGrepSearch(validPath, parsed.data.pattern, { + filePattern: parsed.data.filePattern, + recursive: parsed.data.recursive, + caseSensitive: parsed.data.caseSensitive, + includeLineNumbers: parsed.data.includeLineNumbers, + contextLines: parsed.data.contextLines, + maxResults: parsed.data.maxResults + }) + + if (result.totalMatches === 0) { + return 'No matches found' + } + + const formattedResults = result.matches + .map((match) => { + let output = `${match.file}:${match.line}: ${match.content}` + if (match.beforeContext && match.beforeContext.length > 0) { + const beforeLines = match.beforeContext + .map( + (line, i) => `${match.file}:${match.line - match.beforeContext!.length + i}: ${line}` + ) + .join('\n') + output = beforeLines + '\n' + output + } + if (match.afterContext && match.afterContext.length > 0) { + const afterLines = match.afterContext + .map((line, i) => `${match.file}:${match.line + i + 1}: ${line}`) + .join('\n') + output = output + '\n' + afterLines + } + return output + }) + .join('\n--\n') + + return `Found ${result.totalMatches} matches in ${result.files.length} files:\n\n${formattedResults}` + } + + async textReplace(args: unknown): Promise { + const parsed = TextReplaceArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + + const validPath = await this.validatePath(parsed.data.path) + const result = await this.replaceTextInFile( + validPath, + parsed.data.pattern, + parsed.data.replacement, + { + global: parsed.data.global, + caseSensitive: parsed.data.caseSensitive, + dryRun: parsed.data.dryRun + } + ) + + return result.success ? result.diff || '' : result.error || 'Text replacement failed' + } + + async directoryTree(args: unknown): Promise { + const parsed = DirectoryTreeArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + + const buildTree = async (currentPath: string): Promise => { + const validPath = await this.validatePath(currentPath) + const entries = await fs.readdir(validPath, { withFileTypes: true }) + const result: TreeEntry[] = [] + + for (const entry of entries) { + const entryData: TreeEntry = { + name: entry.name, + type: entry.isDirectory() ? 'directory' : 'file' + } + + if (entry.isDirectory()) { + const subPath = path.join(currentPath, entry.name) + entryData.children = await buildTree(subPath) + } + + result.push(entryData) + } + + return result + } + + const treeData = await buildTree(parsed.data.path) + return JSON.stringify(treeData, null, 2) + } + + async getFileInfo(args: unknown): Promise { + const parsed = GetFileInfoArgsSchema.safeParse(args) + if (!parsed.success) { + throw new Error(`Invalid arguments: ${parsed.error}`) + } + + const validPath = await this.validatePath(parsed.data.path) + const info = await this.getFileStats(validPath) + return Object.entries(info) + .map(([key, value]) => `${key}: ${value}`) + .join('\n') + } + async searchFiles(args: unknown): Promise { const parsed = FileSearchArgsSchema.safeParse(args) if (!parsed.success) { diff --git a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts index 1f19a79f2..8413da751 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts @@ -2,6 +2,9 @@ import type { MCPToolDefinition } from '@shared/presenter' import type { IYoBrowserPresenter } from '@shared/presenter' import { zodToJsonSchema } from 'zod-to-json-schema' import { z } from 'zod' +import fs from 'fs' +import path from 'path' +import { app } from 'electron' import { AgentFileSystemHandler } from './agentFileSystemHandler' interface AgentToolManagerOptions { @@ -31,32 +34,33 @@ export class AgentToolManager { agentWorkspacePath: string | null }): Promise { const defs: MCPToolDefinition[] = [] + const isAgentMode = context.chatMode === 'agent' + const effectiveWorkspacePath = isAgentMode + ? context.agentWorkspacePath?.trim() || this.getDefaultAgentWorkspacePath() + : null // Update filesystem handler if workspace path changed - if (context.agentWorkspacePath !== this.agentWorkspacePath) { - if (context.agentWorkspacePath) { - this.fileSystemHandler = new AgentFileSystemHandler([context.agentWorkspacePath]) + if (effectiveWorkspacePath !== this.agentWorkspacePath) { + if (effectiveWorkspacePath) { + this.fileSystemHandler = new AgentFileSystemHandler([effectiveWorkspacePath]) } else { this.fileSystemHandler = null } - this.agentWorkspacePath = context.agentWorkspacePath + this.agentWorkspacePath = effectiveWorkspacePath } - // 1. Yo Browser tools (only when browser window is open) - if (context.chatMode !== 'chat') { - const hasBrowserWindow = await this.yoBrowserPresenter.hasWindow() - if (hasBrowserWindow) { - try { - const yoDefs = await this.yoBrowserPresenter.getToolDefinitions(context.supportsVision) - defs.push(...yoDefs) - } catch (error) { - console.warn('[AgentToolManager] Failed to load Yo Browser tool definitions', error) - } + // 1. Yo Browser tools (agent mode only) + if (isAgentMode) { + try { + const yoDefs = await this.yoBrowserPresenter.getToolDefinitions(context.supportsVision) + defs.push(...yoDefs) + } catch (error) { + console.warn('[AgentToolManager] Failed to load Yo Browser tool definitions', error) } } - // 2. FileSystem tools (only when workspace path is set) - if (context.chatMode !== 'chat' && this.fileSystemHandler) { + // 2. FileSystem tools (agent mode only) + if (isAgentMode && this.fileSystemHandler) { const fsDefs = this.getFileSystemToolDefinitions() defs.push(...fsDefs) } @@ -137,6 +141,34 @@ export class AgentToolManager { maxResults: z.number().default(1000) }) + const GrepSearchSchema = z.object({ + path: z.string(), + pattern: z.string(), + filePattern: z.string().optional(), + recursive: z.boolean().default(true), + caseSensitive: z.boolean().default(false), + includeLineNumbers: z.boolean().default(true), + contextLines: z.number().default(0), + maxResults: z.number().default(100) + }) + + const TextReplaceSchema = z.object({ + path: z.string(), + pattern: z.string(), + replacement: z.string(), + global: z.boolean().default(true), + caseSensitive: z.boolean().default(false), + dryRun: z.boolean().default(false) + }) + + const DirectoryTreeSchema = z.object({ + path: z.string() + }) + + const GetFileInfoSchema = z.object({ + path: z.string() + }) + return [ { type: 'function', @@ -256,6 +288,74 @@ export class AgentToolManager { icons: '📁', description: 'Agent FileSystem tools' } + }, + { + type: 'function', + function: { + name: 'directory_tree', + description: 'Get a recursive directory tree as JSON', + parameters: zodToJsonSchema(DirectoryTreeSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'get_file_info', + description: 'Get detailed metadata about a file or directory', + parameters: zodToJsonSchema(GetFileInfoSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'grep_search', + description: 'Search file contents using a regular expression', + parameters: zodToJsonSchema(GrepSearchSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'text_replace', + description: 'Replace text in a file using a regular expression', + parameters: zodToJsonSchema(TextReplaceSchema) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } } ] } @@ -283,8 +383,30 @@ export class AgentToolManager { return await this.fileSystemHandler.editText(args) case 'search_files': return await this.fileSystemHandler.searchFiles(args) + case 'directory_tree': + return await this.fileSystemHandler.directoryTree(args) + case 'get_file_info': + return await this.fileSystemHandler.getFileInfo(args) + case 'grep_search': + return await this.fileSystemHandler.grepSearch(args) + case 'text_replace': + return await this.fileSystemHandler.textReplace(args) default: throw new Error(`Unknown FileSystem tool: ${toolName}`) } } + + private getDefaultAgentWorkspacePath(): string { + const tempDir = path.join(app.getPath('temp'), 'deepchat-agent', 'workspaces') + try { + fs.mkdirSync(tempDir, { recursive: true }) + } catch (error) { + console.warn( + '[AgentToolManager] Failed to create default workspace, using system temp:', + error + ) + return app.getPath('temp') + } + return tempDir + } } diff --git a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts index 5234e7f32..93ce67190 100644 --- a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts +++ b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts @@ -7,6 +7,9 @@ import { StreamState } from '../types' import { RateLimitManager } from './rateLimitManager' import { ToolCallProcessor } from './toolCallProcessor' import { ToolPresenter } from '../../toolPresenter' +import fs from 'fs' +import path from 'path' +import { app } from 'electron' interface AgentLoopHandlerOptions { configPresenter: IConfigPresenter @@ -53,6 +56,13 @@ export class AgentLoopHandler { } } + if (chatMode === 'agent') { + agentWorkspacePath = await this.resolveAgentWorkspacePath( + context.conversationId, + agentWorkspacePath + ) + } + return await this.getToolPresenter().getAllToolDefinitions({ enabledMcpTools: context.enabledMcpTools, chatMode, @@ -88,6 +98,55 @@ export class AgentLoopHandler { return this.toolPresenter } + private getDefaultAgentWorkspacePath(conversationId?: string | null): string { + const tempRoot = path.join(app.getPath('temp'), 'deepchat-agent', 'workspaces') + try { + fs.mkdirSync(tempRoot, { recursive: true }) + } catch (error) { + console.warn( + '[AgentLoopHandler] Failed to create default workspace root, using system temp:', + error + ) + return app.getPath('temp') + } + + if (!conversationId) { + return tempRoot + } + + const workspaceDir = path.join(tempRoot, conversationId) + try { + fs.mkdirSync(workspaceDir, { recursive: true }) + return workspaceDir + } catch (error) { + console.warn( + '[AgentLoopHandler] Failed to create conversation workspace, using root temp workspace:', + error + ) + return tempRoot + } + } + + private async resolveAgentWorkspacePath( + conversationId: string | undefined, + currentPath: string | null + ): Promise { + const trimmedPath = currentPath?.trim() + if (trimmedPath) return trimmedPath + + const fallback = this.getDefaultAgentWorkspacePath(conversationId ?? null) + if (conversationId) { + try { + await presenter.threadPresenter.updateConversationSettings(conversationId, { + agentWorkspacePath: fallback + }) + } catch (error) { + console.warn('[AgentLoopHandler] Failed to persist agent workspace path:', error) + } + } + return fallback + } + private requiresReasoningField(modelId: string): boolean { const lower = modelId.toLowerCase() return lower.includes('deepseek-reasoner') || lower.includes('kimi-k2-thinking') @@ -231,6 +290,13 @@ export class AgentLoopHandler { } } + if (chatMode === 'agent') { + agentWorkspacePath = await this.resolveAgentWorkspacePath( + conversationId, + agentWorkspacePath + ) + } + // Get all tool definitions using ToolPresenter const toolDefs = await this.getToolPresenter().getAllToolDefinitions({ enabledMcpTools, diff --git a/src/main/presenter/threadPresenter/handlers/streamGenerationHandler.ts b/src/main/presenter/threadPresenter/handlers/streamGenerationHandler.ts index 9c13252a3..33474e20d 100644 --- a/src/main/presenter/threadPresenter/handlers/streamGenerationHandler.ts +++ b/src/main/presenter/threadPresenter/handlers/streamGenerationHandler.ts @@ -21,6 +21,9 @@ import { presenter } from '@/presenter' import type { SearchHandler } from './searchHandler' import { BaseHandler, type ThreadHandlerContext } from './baseHandler' import type { LLMEventHandler } from './llmEventHandler' +import fs from 'fs' +import path from 'path' +import { app } from 'electron' interface StreamGenerationHandlerDeps { searchHandler: SearchHandler @@ -47,6 +50,53 @@ export class StreamGenerationHandler extends BaseHandler { void this.llmEventHandler } + private getDefaultAgentWorkspacePath(conversationId?: string | null): string { + const tempRoot = path.join(app.getPath('temp'), 'deepchat-agent', 'workspaces') + try { + fs.mkdirSync(tempRoot, { recursive: true }) + } catch (error) { + console.warn( + '[StreamGenerationHandler] Failed to create default workspace root, using system temp:', + error + ) + return app.getPath('temp') + } + + if (!conversationId) { + return tempRoot + } + + const workspaceDir = path.join(tempRoot, conversationId) + try { + fs.mkdirSync(workspaceDir, { recursive: true }) + return workspaceDir + } catch (error) { + console.warn( + '[StreamGenerationHandler] Failed to create conversation workspace, using root temp workspace:', + error + ) + return tempRoot + } + } + + private async ensureAgentWorkspacePath( + conversationId: string, + conversation: CONVERSATION + ): Promise { + const currentPath = conversation.settings.agentWorkspacePath?.trim() + if (currentPath) return + + const fallback = this.getDefaultAgentWorkspacePath(conversationId) + try { + await presenter.threadPresenter.updateConversationSettings(conversationId, { + agentWorkspacePath: fallback + }) + } catch (error) { + console.warn('[StreamGenerationHandler] Failed to persist agent workspace path:', error) + } + conversation.settings.agentWorkspacePath = fallback + } + async startStreamCompletion( conversationId: string, queryMsgId?: string, @@ -67,6 +117,15 @@ export class StreamGenerationHandler extends BaseHandler { selectedVariantsMap ) + const chatMode = + ((await this.ctx.configPresenter.getSetting('input_chatMode')) as + | 'chat' + | 'agent' + | 'acp agent') || 'chat' + if (chatMode === 'agent') { + await this.ensureAgentWorkspacePath(conversationId, conversation) + } + const { providerId, modelId } = conversation.settings const modelConfig = this.ctx.configPresenter.getModelConfig(modelId, providerId) if (!modelConfig) { diff --git a/src/main/presenter/threadPresenter/utils/promptBuilder.ts b/src/main/presenter/threadPresenter/utils/promptBuilder.ts index abb467c63..e2b5cfda3 100644 --- a/src/main/presenter/threadPresenter/utils/promptBuilder.ts +++ b/src/main/presenter/threadPresenter/utils/promptBuilder.ts @@ -78,6 +78,12 @@ export async function preparePromptContent({ promptTokens: number }> { const { systemPrompt, contextLength, artifacts, enabledMcpTools } = conversation.settings + const chatMode = + ((await presenter.configPresenter.getSetting('input_chatMode')) as + | 'chat' + | 'agent' + | 'acp agent') || 'chat' + const isAgentMode = chatMode === 'agent' const isImageGeneration = modelType === ModelType.ImageGeneration const enrichedUserMessage = @@ -86,6 +92,13 @@ export async function preparePromptContent({ : '' const finalSystemPrompt = enhanceSystemPromptWithDateTime(systemPrompt, isImageGeneration) + const agentWorkspacePath = conversation.settings.agentWorkspacePath?.trim() + const finalSystemPromptWithWorkspace = + isAgentMode && agentWorkspacePath + ? finalSystemPrompt + ? `${finalSystemPrompt}\n\nCurrent working directory: ${agentWorkspacePath}` + : `Current working directory: ${agentWorkspacePath}` + : finalSystemPrompt let mcpTools: MCPToolDefinition[] = [] if (!isImageGeneration) { @@ -102,9 +115,7 @@ export async function preparePromptContent({ let browserContextPrompt = '' const { providerId, modelId } = conversation.settings - // Check if browser window is open - independent of MCP - const hasBrowserWindow = await presenter.yoBrowserPresenter.hasWindow() - if (!isImageGeneration && hasBrowserWindow) { + if (!isImageGeneration && isAgentMode) { try { const supportsVision = modelCapabilities.supportsVision(providerId, modelId) const browserTools = await presenter.yoBrowserPresenter.getToolDefinitions(supportsVision) @@ -121,8 +132,10 @@ export async function preparePromptContent({ } const finalSystemPromptWithBrowser = browserContextPrompt - ? `${finalSystemPrompt}\n${browserContextPrompt}` - : finalSystemPrompt + ? finalSystemPromptWithWorkspace + ? `${finalSystemPromptWithWorkspace}\n${browserContextPrompt}` + : browserContextPrompt + : finalSystemPromptWithWorkspace const systemPromptTokens = !isImageGeneration && finalSystemPromptWithBrowser diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index ccda1f77e..53c8ee97a 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -669,11 +669,13 @@ export class WindowPresenter implements IWindowPresenter { icon?: string } windowType?: 'chat' | 'browser' + showOnReady?: boolean // ready-to-show 时是否自动显示 x?: number // 初始 X 坐标 y?: number // 初始 Y 坐标 }): Promise { console.log('Creating new shell window.') const windowType = options?.windowType ?? 'chat' + const showOnReady = options?.showOnReady ?? true // 根据平台选择图标 const iconFile = nativeImage.createFromPath(process.platform === 'win32' ? iconWin : icon) @@ -757,7 +759,9 @@ export class WindowPresenter implements IWindowPresenter { shellWindow.on('ready-to-show', () => { console.log(`Window ${windowId} is ready to show.`) if (!shellWindow.isDestroyed()) { - shellWindow.show() // 显示窗口避免白屏 + if (showOnReady) { + shellWindow.show() // 显示窗口避免白屏 + } eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CREATED, windowId) } else { console.warn(`Window ${windowId} was destroyed before ready-to-show.`) diff --git a/src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue b/src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue new file mode 100644 index 000000000..0587612fe --- /dev/null +++ b/src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue @@ -0,0 +1,87 @@ + + + + + diff --git a/src/renderer/src/components/workspace/WorkspaceView.vue b/src/renderer/src/components/workspace/WorkspaceView.vue index 32e599b15..ad10d7ee9 100644 --- a/src/renderer/src/components/workspace/WorkspaceView.vue +++ b/src/renderer/src/components/workspace/WorkspaceView.vue @@ -25,6 +25,9 @@ + + + @@ -35,15 +38,20 @@ diff --git a/src/renderer/src/components/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue index e84f7c491..b4d4871c7 100644 --- a/src/renderer/src/components/chat-input/ChatInput.vue +++ b/src/renderer/src/components/chat-input/ChatInput.vue @@ -735,7 +735,7 @@ const activeModelSource = computed(() => { } return config.activeModel.value }) -const isModeLocked = computed(() => props.variant === 'chat' && !!conversationId.value) +const isModeLocked = computed(() => false) const acpWorkdir = useAcpWorkdir({ activeModel: activeModelSource, @@ -816,6 +816,13 @@ const onWebSearchClick = async () => { const handleModeSelect = async (mode: ChatMode) => { if (isModeLocked.value) return await chatMode.setMode(mode) + if (conversationId.value && chatMode.currentMode.value === mode) { + try { + await chatStore.updateChatConfig({ chatMode: mode }) + } catch (error) { + console.warn('Failed to update chat mode in conversation settings:', error) + } + } modeSelectOpen.value = false } diff --git a/src/renderer/src/components/chat-input/composables/useAcpWorkdir.ts b/src/renderer/src/components/chat-input/composables/useAcpWorkdir.ts index 67a4a7d59..654845cbb 100644 --- a/src/renderer/src/components/chat-input/composables/useAcpWorkdir.ts +++ b/src/renderer/src/components/chat-input/composables/useAcpWorkdir.ts @@ -33,6 +33,19 @@ export function useAcpWorkdir(options: UseAcpWorkdirOptions) { chatStore.setAcpWorkdirPreference(agentId.value, value) } + const hydrateFromPreference = () => { + if (!agentId.value) return + const stored = chatStore.chatConfig.acpWorkdirMap?.[agentId.value] ?? null + if (stored) { + workdir.value = stored + isCustom.value = true + pendingWorkdir.value = stored + } else { + pendingWorkdir.value = null + resetToDefault() + } + } + const resetToDefault = () => { workdir.value = '' isCustom.value = false @@ -65,7 +78,7 @@ export function useAcpWorkdir(options: UseAcpWorkdirOptions) { if (!options.conversationId.value || !agentId.value) { if (!pendingWorkdir.value) { - resetToDefault() + hydrateFromPreference() } return } @@ -126,8 +139,7 @@ export function useAcpWorkdir(options: UseAcpWorkdirOptions) { watch(agentId, () => { if (!hasConversation.value) { - pendingWorkdir.value = null - resetToDefault() + hydrateFromPreference() } lastWarmupKey.value = null }) diff --git a/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts b/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts index 8c08863e3..b2e8956da 100644 --- a/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts +++ b/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts @@ -222,10 +222,6 @@ export function useAgentWorkspace(options: UseAgentWorkspaceOptions) { // ACP workdir is handled by useAcpWorkdir return } - - // Clear workspace path when switching to chat mode - agentWorkspacePath.value = null - pendingWorkspacePath.value = null }, { immediate: true } ) diff --git a/src/renderer/src/components/chat-input/composables/useChatMode.ts b/src/renderer/src/components/chat-input/composables/useChatMode.ts index 48b8aa37a..164994ba4 100644 --- a/src/renderer/src/components/chat-input/composables/useChatMode.ts +++ b/src/renderer/src/components/chat-input/composables/useChatMode.ts @@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n' // === Composables === import { usePresenter } from '@/composables/usePresenter' +import { CONFIG_EVENTS } from '@/events' export type ChatMode = 'chat' | 'agent' | 'acp agent' @@ -19,6 +20,7 @@ const hasAcpAgents = ref(false) let hasLoaded = false let loadPromise: Promise | null = null let modeUpdateVersion = 0 +let hasAcpListener = false /** * Manages chat mode selection (chat, agent, acp agent) @@ -83,6 +85,11 @@ export function useChatMode() { const checkAcpAgents = async () => { try { + const acpEnabled = await configPresenter.getAcpEnabled() + if (!acpEnabled) { + hasAcpAgents.value = false + return + } const agents = await configPresenter.getAcpAgents() hasAcpAgents.value = agents.length > 0 } catch (error) { @@ -131,6 +138,15 @@ export function useChatMode() { ensureLoaded() + if (!hasAcpListener && window.electron?.ipcRenderer) { + hasAcpListener = true + window.electron.ipcRenderer.on(CONFIG_EVENTS.MODEL_LIST_CHANGED, (_, providerId?: string) => { + if (!providerId || providerId === 'acp') { + void checkAcpAgents() + } + }) + } + // Watch for ACP agents changes and update availability // This will be triggered when ACP agents are added/removed watch( diff --git a/src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue b/src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue index 61db8fa56..82da148a2 100644 --- a/src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue +++ b/src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue @@ -28,16 +28,27 @@
  • @@ -57,6 +68,7 @@ import { useYoBrowserStore } from '@/stores/yoBrowser' const { t } = useI18n() const store = useYoBrowserStore() const showTabs = ref(true) +const faviconError = ref>({}) const tabCount = computed(() => store.tabs.length) diff --git a/src/renderer/src/events.ts b/src/renderer/src/events.ts index b478e459a..4a03b8964 100644 --- a/src/renderer/src/events.ts +++ b/src/renderer/src/events.ts @@ -156,6 +156,7 @@ export const YO_BROWSER_EVENTS = { TAB_CLOSED: 'yo-browser:tab-closed', TAB_ACTIVATED: 'yo-browser:tab-activated', TAB_NAVIGATED: 'yo-browser:tab-navigated', + TAB_UPDATED: 'yo-browser:tab-updated', TAB_COUNT_CHANGED: 'yo-browser:tab-count-changed', WINDOW_VISIBILITY_CHANGED: 'yo-browser:window-visibility-changed' } diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 63c6e6c3a..91c4db846 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -882,7 +882,7 @@ "builtinSectionDescription": "Hver indbygget agent kan have flere startprofiler. Kun aktiverede agenter med en aktiv profil vises i modellisten.", "builtinHint": "Start {name} med disse indstillinger.", "disabledBadge": "Deaktiveret", - "manageProfiles": "Administrér profiler", + "manageProfiles": "Profiler", "addProfile": "Tilføj profil", "activeProfile": "Aktiv profil", "profilePlaceholder": "Vælg en profil", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 66565cea9..477b03129 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -882,7 +882,7 @@ "builtinSectionDescription": "Each built-in agent can have multiple launch profiles. Only enabled agents with an active profile appear in the model list.", "builtinHint": "Launch {name} with these settings.", "disabledBadge": "Disabled", - "manageProfiles": "Manage Profiles", + "manageProfiles": "Profiles", "addProfile": "Add Profile", "activeProfile": "Active Profile", "profilePlaceholder": "Select a profile", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 5ae4a9337..f7792193a 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -926,7 +926,7 @@ "customSectionTitle": "عامل سفارشی", "disabledBadge": "فعال نیست", "loading": "در حال بارگیری داده های ACP...", - "manageProfiles": "پیکربندی مدیریت", + "manageProfiles": "پروفایل ها", "none": "هیچ کدام", "profileDialog": { "addBuiltinTitle": "پیکربندی {name} را اضافه کنید", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index ea1ba4a7e..d06768c34 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -953,7 +953,7 @@ "profilePlaceholder": "Veuillez sélectionner la configuration", "profileSwitched": "Configuration commutée", "disabledBadge": "Non activé", - "manageProfiles": "Gestion de la configuration", + "manageProfiles": "Profils", "initialize": "initialisation", "initializeDescription": "Ouvert dans le terminal et commande d'initialisation exécutée", "initializeFailed": "L'initialisation a échoué", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index ab3bcbab4..a9a9f2f7c 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -882,7 +882,7 @@ "builtinSectionDescription": "לכל סוכן מובנה יכולים להיות מספר פרופילי הפעלה. רק סוכנים מופעלים עם פרופיל פעיל מופיעים ברשימת המודלים.", "builtinHint": "הפעל את {name} עם הגדרות אלה.", "disabledBadge": "מושבת", - "manageProfiles": "נהל פרופילים", + "manageProfiles": "פרופילים", "addProfile": "הוסף פרופיל", "activeProfile": "פרופיל פעיל", "profilePlaceholder": "בחר פרופיל", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index b93d9f779..26164c3d1 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -926,7 +926,7 @@ "customSectionTitle": "カスタムエージェント", "disabledBadge": "未有効化", "loading": "ACPデータを読み込み中...", - "manageProfiles": "管理設定", + "manageProfiles": "構成", "none": "なし", "profileDialog": { "addBuiltinTitle": "{name} 設定を追加しました", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 7c55a178b..082c3e916 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -926,7 +926,7 @@ "customSectionTitle": "사용자 정의 에이전트", "disabledBadge": "사용 안 함", "loading": "ACP 데이터를 로드하는 중...", - "manageProfiles": "관리 구성", + "manageProfiles": "구성", "none": "없음", "profileDialog": { "addBuiltinTitle": "{name} 구성 추가", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index 7a7e80439..2427468c8 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -926,7 +926,7 @@ "customSectionTitle": "Agente Personalizado", "disabledBadge": "Não ativado", "loading": "Carregando dados ACP...", - "manageProfiles": "Configuração de gerenciamento", + "manageProfiles": "Perfis", "profileDialog": { "addBuiltinTitle": "Adicionar nova configuração {name}", "addCustomTitle": "Adicionar Agente Personalizado", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index 6cd02e48f..1ebbcb137 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -926,7 +926,7 @@ "customSectionTitle": "Пользовательский агент", "disabledBadge": "Не активировано", "loading": "Загрузка данных ACP...", - "manageProfiles": "Управление конфигурацией", + "manageProfiles": "Профили", "none": "никто", "profileDialog": { "addBuiltinTitle": "Добавлена новая конфигурация {name}", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index b2e7c8622..0977182e4 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -882,7 +882,7 @@ "builtinSectionDescription": "每个内置 Agent 都可以保存多套启动配置。只有启用且有激活配置的 Agent 才会出现在模型列表。", "builtinHint": "使用以下设置启动 {name}。", "disabledBadge": "未启用", - "manageProfiles": "管理配置", + "manageProfiles": "配置", "addProfile": "新增配置", "activeProfile": "激活配置", "profilePlaceholder": "请选择配置", diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index 443a6d1c5..a63d7f600 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -908,7 +908,7 @@ "builtinSectionDescription": "每個內建 Agent 都可以儲存多組啟動設定。只有啟用兼已激活嘅 Agent 先會出現在模型清單。", "builtinHint": "用以下設定嚟啟動 {name}。", "disabledBadge": "未啟用", - "manageProfiles": "管理設定", + "manageProfiles": "配置", "addProfile": "新增設定", "activeProfile": "激活設定", "profilePlaceholder": "揀一個設定", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index 84430038c..df0433e27 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -908,7 +908,7 @@ "builtinSectionDescription": "每個內建 Agent 都可以儲存多組啟動設定。只有啟用且有激活設定的 Agent 才會出現在模型列表。", "builtinHint": "使用以下設定啟動 {name}。", "disabledBadge": "未啟用", - "manageProfiles": "管理設定", + "manageProfiles": "配置", "addProfile": "新增設定", "activeProfile": "激活設定", "profilePlaceholder": "請選擇設定", diff --git a/src/renderer/src/stores/yoBrowser.ts b/src/renderer/src/stores/yoBrowser.ts index 20925570a..b642829e9 100644 --- a/src/renderer/src/stores/yoBrowser.ts +++ b/src/renderer/src/stores/yoBrowser.ts @@ -70,6 +70,13 @@ export const useYoBrowserStore = defineStore('yoBrowser', () => { ) } + const handleTabUpdated = (_event: unknown, tab: BrowserTabInfo) => { + tabs.value = upsertTab(tabs.value, tab) + if (tab.isActive) { + activeTabId.value = tab.id + } + } + const handleTabCountChanged = async () => { await loadState() } @@ -107,6 +114,7 @@ export const useYoBrowserStore = defineStore('yoBrowser', () => { window.electron.ipcRenderer.on(YO_BROWSER_EVENTS.TAB_CLOSED, handleTabClosed) window.electron.ipcRenderer.on(YO_BROWSER_EVENTS.TAB_ACTIVATED, handleTabActivated) window.electron.ipcRenderer.on(YO_BROWSER_EVENTS.TAB_NAVIGATED, handleTabNavigated) + window.electron.ipcRenderer.on(YO_BROWSER_EVENTS.TAB_UPDATED, handleTabUpdated) window.electron.ipcRenderer.on(YO_BROWSER_EVENTS.TAB_COUNT_CHANGED, handleTabCountChanged) window.electron.ipcRenderer.on( YO_BROWSER_EVENTS.WINDOW_VISIBILITY_CHANGED, @@ -127,6 +135,7 @@ export const useYoBrowserStore = defineStore('yoBrowser', () => { YO_BROWSER_EVENTS.TAB_NAVIGATED, handleTabNavigated ) + window.electron.ipcRenderer.removeListener(YO_BROWSER_EVENTS.TAB_UPDATED, handleTabUpdated) window.electron.ipcRenderer.removeListener( YO_BROWSER_EVENTS.TAB_COUNT_CHANGED, handleTabCountChanged diff --git a/src/renderer/src/views/ChatTabView.vue b/src/renderer/src/views/ChatTabView.vue index 9282f4ac7..44e7cd98a 100644 --- a/src/renderer/src/views/ChatTabView.vue +++ b/src/renderer/src/views/ChatTabView.vue @@ -3,7 +3,7 @@
    @@ -69,7 +69,7 @@ diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspaceFiles.vue b/src/renderer/src/components/acp-workspace/AcpWorkspaceFiles.vue deleted file mode 100644 index d71f49dc8..000000000 --- a/src/renderer/src/components/acp-workspace/AcpWorkspaceFiles.vue +++ /dev/null @@ -1,102 +0,0 @@ - - - - - diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspacePlan.vue b/src/renderer/src/components/acp-workspace/AcpWorkspacePlan.vue deleted file mode 100644 index 7395820dc..000000000 --- a/src/renderer/src/components/acp-workspace/AcpWorkspacePlan.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - - - diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspaceTerminal.vue b/src/renderer/src/components/acp-workspace/AcpWorkspaceTerminal.vue deleted file mode 100644 index 7db8f3df2..000000000 --- a/src/renderer/src/components/acp-workspace/AcpWorkspaceTerminal.vue +++ /dev/null @@ -1,87 +0,0 @@ - - - - - diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspaceView.vue b/src/renderer/src/components/acp-workspace/AcpWorkspaceView.vue deleted file mode 100644 index dd10bf5d9..000000000 --- a/src/renderer/src/components/acp-workspace/AcpWorkspaceView.vue +++ /dev/null @@ -1,50 +0,0 @@ - - - diff --git a/src/renderer/src/components/message/MessageList.vue b/src/renderer/src/components/message/MessageList.vue index 7142e2d2f..5d8fbb4d1 100644 --- a/src/renderer/src/components/message/MessageList.vue +++ b/src/renderer/src/components/message/MessageList.vue @@ -89,7 +89,7 @@ import { useMessageRetry } from '@/composables/message/useMessageRetry' // === Stores === import { useChatStore } from '@/stores/chat' import { useReferenceStore } from '@/stores/reference' -import { useAcpWorkspaceStore } from '@/stores/acpWorkspace' +import { useWorkspaceStore } from '@/stores/workspace' // === Props & Emits === const props = defineProps<{ @@ -99,7 +99,7 @@ const props = defineProps<{ // === Stores === const chatStore = useChatStore() const referenceStore = useReferenceStore() -const acpWorkspaceStore = useAcpWorkspaceStore() +const workspaceStore = useWorkspaceStore() // === Composable Integrations === // Scroll management @@ -185,13 +185,13 @@ const showCancelButton = computed(() => { return chatStore.generatingThreadIds.has(chatStore.getActiveThreadId() ?? '') }) -// Show workspace button only in ACP mode when workspace is closed +// Show workspace button only in agent mode when workspace is closed const showWorkspaceButton = computed(() => { - return acpWorkspaceStore.isAcpMode && !acpWorkspaceStore.isOpen + return workspaceStore.isAgentMode && !workspaceStore.isOpen }) const handleOpenWorkspace = () => { - acpWorkspaceStore.setOpen(true) + workspaceStore.setOpen(true) } const handleTrace = (messageId: string) => { diff --git a/src/renderer/src/components/workspace/WorkspaceFileNode.vue b/src/renderer/src/components/workspace/WorkspaceFileNode.vue index 3f32d365f..5a8bf436b 100644 --- a/src/renderer/src/components/workspace/WorkspaceFileNode.vue +++ b/src/renderer/src/components/workspace/WorkspaceFileNode.vue @@ -62,6 +62,7 @@ import { computed } from 'vue' import { Icon } from '@iconify/vue' import { useI18n } from 'vue-i18n' import { usePresenter } from '@/composables/usePresenter' +import { useChatMode } from '@/components/chat-input/composables/useChatMode' import { ContextMenu, ContextMenuContent, @@ -82,7 +83,13 @@ const emit = defineEmits<{ }>() const { t } = useI18n() +const chatMode = useChatMode() const workspacePresenter = usePresenter('workspacePresenter') +const acpWorkspacePresenter = usePresenter('acpWorkspacePresenter') + +const presenter = computed(() => + chatMode.currentMode.value === 'acp agent' ? acpWorkspacePresenter : workspacePresenter +) const extensionIconMap: Record = { pdf: 'lucide:file-text', @@ -139,7 +146,7 @@ const handleOpenFile = async () => { } try { - await workspacePresenter.openFile(props.node.path) + await presenter.value.openFile(props.node.path) } catch (error) { console.error(`[Workspace] Failed to open file: ${props.node.path}`, error) } @@ -147,7 +154,7 @@ const handleOpenFile = async () => { const handleRevealInFolder = async () => { try { - await workspacePresenter.revealFileInFolder(props.node.path) + await presenter.value.revealFileInFolder(props.node.path) } catch (error) { console.error(`[Workspace] Failed to reveal path: ${props.node.path}`, error) } diff --git a/src/renderer/src/components/workspace/WorkspaceFiles.vue b/src/renderer/src/components/workspace/WorkspaceFiles.vue index 633d01d0f..b3510604b 100644 --- a/src/renderer/src/components/workspace/WorkspaceFiles.vue +++ b/src/renderer/src/components/workspace/WorkspaceFiles.vue @@ -9,7 +9,7 @@ - {{ t('chat.workspace.files.section') }} + {{ t(sectionKey) }} {{ fileCount }} @@ -23,7 +23,7 @@
    - {{ t('chat.workspace.files.loading') }} + {{ t(loadingKey) }}
    - {{ t('chat.workspace.files.empty') }} + {{ t(emptyKey) }}
    @@ -48,12 +48,23 @@ import { ref, computed } from 'vue' import { Icon } from '@iconify/vue' import { useI18n } from 'vue-i18n' import { useWorkspaceStore } from '@/stores/workspace' +import { useChatMode } from '@/components/chat-input/composables/useChatMode' import WorkspaceFileNode from './WorkspaceFileNode.vue' import type { WorkspaceFileNode as WorkspaceFileNodeType } from '@shared/presenter' const { t } = useI18n() const store = useWorkspaceStore() +const chatMode = useChatMode() const showFiles = ref(true) + +const i18nPrefix = computed(() => + chatMode.currentMode.value === 'acp agent' ? 'chat.acp.workspace' : 'chat.workspace' +) + +const sectionKey = computed(() => `${i18nPrefix.value}.files.section`) +const loadingKey = computed(() => `${i18nPrefix.value}.files.loading`) +const emptyKey = computed(() => `${i18nPrefix.value}.files.empty`) + const emit = defineEmits<{ 'append-path': [filePath: string] }>() diff --git a/src/renderer/src/components/workspace/WorkspacePlan.vue b/src/renderer/src/components/workspace/WorkspacePlan.vue index ecf237d8e..f59fcee07 100644 --- a/src/renderer/src/components/workspace/WorkspacePlan.vue +++ b/src/renderer/src/components/workspace/WorkspacePlan.vue @@ -9,7 +9,7 @@ - {{ t('chat.workspace.plan.section') }} + {{ t(sectionKey) }} {{ store.completedPlanCount }}/{{ store.totalPlanCount }} @@ -56,16 +56,24 @@ From ce116a2850ac66db3d669e0dec427fd9ac4030a3 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 00:17:26 +0800 Subject: [PATCH 18/29] fix: workspace agent regex and few ai review issues --- package.json | 1 + .../agent/agentFileSystemHandler.ts | 66 +- .../agent/agentToolManager.ts | 31 +- .../managers/agentLoopHandler.ts | 116 +- .../inMemoryServers/filesystem.ts | 1353 ----------------- .../presenter/workspacePresenter/index.ts | 40 +- .../src/components/chat-input/ChatInput.vue | 5 - src/renderer/src/i18n/da-DK/chat.json | 2 +- test/main/presenter/filesystem.test.ts | 510 ------- 9 files changed, 177 insertions(+), 1947 deletions(-) delete mode 100644 src/main/presenter/mcpPresenter/inMemoryServers/filesystem.ts delete mode 100644 test/main/presenter/filesystem.test.ts diff --git a/package.json b/package.json index 410eec19e..e1bbee87f 100644 --- a/package.json +++ b/package.json @@ -92,6 +92,7 @@ "openai": "^5.23.2", "pdf-parse-new": "^1.4.1", "run-applescript": "^7.1.0", + "safe-regex2": "^5.0.0", "sharp": "^0.33.5", "together-ai": "^0.16.0", "tokenx": "^0.4.1", diff --git a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts index 9aadea177..78334035a 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts @@ -4,6 +4,7 @@ import os from 'os' import { z } from 'zod' import { minimatch } from 'minimatch' import { createTwoFilesPatch } from 'diff' +import safeRegex from 'safe-regex2' const ReadFileArgsSchema = z.object({ paths: z.array(z.string()).min(1).describe('Array of file paths to read') @@ -131,6 +132,37 @@ export class AgentFileSystemHandler { return text.replace(/\r\n/g, '\n') } + /** + * Validate regex pattern for ReDoS safety + * @param pattern The regex pattern to validate + * @throws Error if pattern is unsafe or exceeds length limit + */ + private validateRegexPattern(pattern: string): void { + const MAX_PATTERN_LENGTH = 1000 + + // Check length limit + if (pattern.length > MAX_PATTERN_LENGTH) { + throw new Error( + `Regular expression pattern exceeds maximum length of ${MAX_PATTERN_LENGTH} characters. Pattern length: ${pattern.length}` + ) + } + + // Check for ReDoS vulnerability using safe-regex2 + if (!safeRegex(pattern)) { + throw new Error( + `Regular expression pattern is potentially unsafe and may cause ReDoS (Regular Expression Denial of Service). Please use a simpler, safer pattern.` + ) + } + } + + private isPathAllowed(candidatePath: string): boolean { + return this.allowedDirectories.some((dir) => { + if (candidatePath === dir) return true + const dirWithSeparator = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}` + return candidatePath.startsWith(dirWithSeparator) + }) + } + private expandHome(filepath: string): string { if (filepath.startsWith('~/') || filepath === '~') { return path.join(os.homedir(), filepath.slice(1)) @@ -144,7 +176,7 @@ export class AgentFileSystemHandler { ? path.resolve(expandedPath) : path.resolve(process.cwd(), expandedPath) const normalizedRequested = this.normalizePath(absolute) - const isAllowed = this.allowedDirectories.some((dir) => normalizedRequested.startsWith(dir)) + const isAllowed = this.isPathAllowed(normalizedRequested) if (!isAllowed) { throw new Error( `Access denied - path outside allowed directories: ${absolute} not in ${this.allowedDirectories.join(', ')}` @@ -153,9 +185,7 @@ export class AgentFileSystemHandler { try { const realPath = await fs.realpath(absolute) const normalizedReal = this.normalizePath(realPath) - const isRealPathAllowed = this.allowedDirectories.some((dir) => - normalizedReal.startsWith(dir) - ) + const isRealPathAllowed = this.isPathAllowed(normalizedReal) if (!isRealPathAllowed) { throw new Error('Access denied - symlink target outside allowed directories') } @@ -165,9 +195,7 @@ export class AgentFileSystemHandler { try { const realParentPath = await fs.realpath(parentDir) const normalizedParent = this.normalizePath(realParentPath) - const isParentAllowed = this.allowedDirectories.some((dir) => - normalizedParent.startsWith(dir) - ) + const isParentAllowed = this.isPathAllowed(normalizedParent) if (!isParentAllowed) { throw new Error('Access denied - parent directory outside allowed directories') } @@ -232,6 +260,9 @@ export class AgentFileSystemHandler { matches: [] } + // Validate pattern for ReDoS safety before constructing RegExp + this.validateRegexPattern(pattern) + const regexFlags = caseSensitive ? 'g' : 'gi' let regex: RegExp try { @@ -338,6 +369,17 @@ export class AgentFileSystemHandler { ): Promise { const { global = true, caseSensitive = false, dryRun = false } = options try { + // Validate pattern for ReDoS safety before constructing RegExp + try { + this.validateRegexPattern(pattern) + } catch (error) { + return { + success: false, + replacements: 0, + error: error instanceof Error ? error.message : String(error) + } + } + const originalContent = await fs.readFile(filePath, 'utf-8') const normalizedOriginal = this.normalizeLineEndings(originalContent) const regexFlags = global ? (caseSensitive ? 'g' : 'gi') : caseSensitive ? '' : 'i' @@ -353,6 +395,7 @@ export class AgentFileSystemHandler { } const modifiedContent = normalizedOriginal.replace(regex, replacement) + // Pattern already validated above, safe to create count regex const countRegex = new RegExp(pattern, caseSensitive ? 'g' : 'gi') const matches = Array.from(normalizedOriginal.matchAll(countRegex)) const replacements = global ? matches.length : Math.min(1, matches.length) @@ -479,6 +522,15 @@ export class AgentFileSystemHandler { modifiedContent = modifiedContent.replace(edit.oldText, edit.newText) } } else if (parsed.data.operation === 'replace_pattern' && parsed.data.pattern) { + // Validate pattern for ReDoS safety before constructing RegExp + try { + this.validateRegexPattern(parsed.data.pattern) + } catch (error) { + throw new Error( + error instanceof Error ? error.message : `Invalid pattern: ${String(error)}` + ) + } + const flags = parsed.data.caseSensitive ? 'g' : 'gi' const regex = new RegExp(parsed.data.pattern, flags) modifiedContent = modifiedContent.replace(regex, parsed.data.replacement || '') diff --git a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts index 8413da751..6f3e5e7f6 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts @@ -117,7 +117,13 @@ export class AgentToolManager { const EditTextSchema = z.object({ path: z.string(), operation: z.enum(['replace_pattern', 'edit_lines']), - pattern: z.string().optional(), + pattern: z + .string() + .max(1000) + .describe( + 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS). Required when operation is "replace_pattern"' + ) + .optional(), replacement: z.string().optional(), global: z.boolean().default(true), caseSensitive: z.boolean().default(false), @@ -143,7 +149,12 @@ export class AgentToolManager { const GrepSearchSchema = z.object({ path: z.string(), - pattern: z.string(), + pattern: z + .string() + .max(1000) + .describe( + 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS)' + ), filePattern: z.string().optional(), recursive: z.boolean().default(true), caseSensitive: z.boolean().default(false), @@ -154,7 +165,12 @@ export class AgentToolManager { const TextReplaceSchema = z.object({ path: z.string(), - pattern: z.string(), + pattern: z + .string() + .max(1000) + .describe( + 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS)' + ), replacement: z.string(), global: z.boolean().default(true), caseSensitive: z.boolean().default(false), @@ -259,7 +275,8 @@ export class AgentToolManager { type: 'function', function: { name: 'edit_text', - description: 'Edit text files using pattern replacement or line-based editing', + description: + 'Edit text files using pattern replacement or line-based editing. When using "replace_pattern" operation, the pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks.', parameters: zodToJsonSchema(EditTextSchema) as { type: string properties: Record @@ -327,7 +344,8 @@ export class AgentToolManager { type: 'function', function: { name: 'grep_search', - description: 'Search file contents using a regular expression', + description: + 'Search file contents using a regular expression. The pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks.', parameters: zodToJsonSchema(GrepSearchSchema) as { type: string properties: Record @@ -344,7 +362,8 @@ export class AgentToolManager { type: 'function', function: { name: 'text_replace', - description: 'Replace text in a file using a regular expression', + description: + 'Replace text in a file using a regular expression. The pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks.', parameters: zodToJsonSchema(TextReplaceSchema) as { type: string properties: Record diff --git a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts index 9ebb732da..6c7373478 100644 --- a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts +++ b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts @@ -27,41 +27,23 @@ export class AgentLoopHandler { constructor(private readonly options: AgentLoopHandlerOptions) { this.toolCallProcessor = new ToolCallProcessor({ getAllToolDefinitions: async (context) => { - // Get chatMode from global config (default to 'chat') - const chatMode = - ((await this.options.configPresenter.getSetting('input_chatMode')) as - | 'chat' - | 'agent' - | 'acp agent') || 'chat' - - // Get agentWorkspacePath from conversation settings (if available) - let agentWorkspacePath: string | null = null + // Get modelId from conversation + let modelId: string | undefined if (context.conversationId) { try { const conversation = await presenter.threadPresenter.getConversation( context.conversationId ) - if (conversation) { - // For acp agent mode, use acpWorkdirMap - if (chatMode === 'acp agent' && conversation.settings.acpWorkdirMap) { - const modelId = conversation.settings.modelId - agentWorkspacePath = conversation.settings.acpWorkdirMap[modelId] ?? null - } else { - // For agent mode, use agentWorkspacePath - agentWorkspacePath = conversation.settings.agentWorkspacePath ?? null - } - } - } catch (error) { - console.warn('[AgentLoopHandler] Failed to get conversation settings:', error) + modelId = conversation?.settings.modelId + } catch { + // Ignore errors, modelId will be undefined } } - if (chatMode === 'agent') { - agentWorkspacePath = await this.resolveAgentWorkspacePath( - context.conversationId, - agentWorkspacePath - ) - } + const { chatMode, agentWorkspacePath } = await this.resolveWorkspaceContext( + context.conversationId, + modelId + ) return await this.getToolPresenter().getAllToolDefinitions({ enabledMcpTools: context.enabledMcpTools, @@ -151,6 +133,49 @@ export class AgentLoopHandler { return fallback } + /** + * Resolve workspace context (chatMode and agentWorkspacePath) for tool definitions + * @param conversationId Optional conversation ID + * @param modelId Optional model ID (required for acp agent mode) + * @returns Resolved workspace context + */ + private async resolveWorkspaceContext( + conversationId?: string, + modelId?: string + ): Promise<{ chatMode: 'chat' | 'agent' | 'acp agent'; agentWorkspacePath: string | null }> { + // Get chatMode from global config (default to 'chat') + const chatMode = + ((await this.options.configPresenter.getSetting('input_chatMode')) as + | 'chat' + | 'agent' + | 'acp agent') || 'chat' + + // Get agentWorkspacePath from conversation settings + let agentWorkspacePath: string | null = null + if (conversationId) { + try { + const conversation = await presenter.threadPresenter.getConversation(conversationId) + if (conversation) { + // For acp agent mode, use acpWorkdirMap + if (chatMode === 'acp agent' && conversation.settings.acpWorkdirMap && modelId) { + agentWorkspacePath = conversation.settings.acpWorkdirMap[modelId] ?? null + } else { + // For agent mode, use agentWorkspacePath + agentWorkspacePath = conversation.settings.agentWorkspacePath ?? null + } + } + } catch (error) { + console.warn('[AgentLoopHandler] Failed to get conversation settings:', error) + } + } + + if (chatMode === 'agent') { + agentWorkspacePath = await this.resolveAgentWorkspacePath(conversationId, agentWorkspacePath) + } + + return { chatMode, agentWorkspacePath } + } + private notifyWorkspaceFilesChanged(conversationId?: string): void { if (!conversationId) return eventBus.sendToRenderer(WORKSPACE_EVENTS.FILES_CHANGED, SendTarget.ALL_WINDOWS, { @@ -275,38 +300,11 @@ export class AgentLoopHandler { try { console.log(`[Agent Loop] Iteration ${toolCallCount + 1} for event: ${eventId}`) - // Get chatMode from global config - const chatMode = - ((await this.options.configPresenter.getSetting('input_chatMode')) as - | 'chat' - | 'agent' - | 'acp agent') || 'chat' - - // Get agentWorkspacePath from conversation settings - let agentWorkspacePath: string | null = null - if (conversationId) { - try { - const conversation = await presenter.threadPresenter.getConversation(conversationId) - if (conversation) { - // For acp agent mode, use acpWorkdirMap - if (chatMode === 'acp agent' && conversation.settings.acpWorkdirMap) { - agentWorkspacePath = conversation.settings.acpWorkdirMap[modelId] ?? null - } else { - // For agent mode, use agentWorkspacePath - agentWorkspacePath = conversation.settings.agentWorkspacePath ?? null - } - } - } catch (error) { - console.warn('[AgentLoopHandler] Failed to get conversation settings:', error) - } - } - - if (chatMode === 'agent') { - agentWorkspacePath = await this.resolveAgentWorkspacePath( - conversationId, - agentWorkspacePath - ) - } + // Resolve workspace context + const { chatMode, agentWorkspacePath } = await this.resolveWorkspaceContext( + conversationId, + modelId + ) // Get all tool definitions using ToolPresenter const toolDefs = await this.getToolPresenter().getAllToolDefinitions({ diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/filesystem.ts b/src/main/presenter/mcpPresenter/inMemoryServers/filesystem.ts deleted file mode 100644 index 07febfd57..000000000 --- a/src/main/presenter/mcpPresenter/inMemoryServers/filesystem.ts +++ /dev/null @@ -1,1353 +0,0 @@ -import { Server } from '@modelcontextprotocol/sdk/server/index.js' -import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js' -import fs from 'fs/promises' -import path from 'path' -import os from 'os' -import { z } from 'zod' -import { zodToJsonSchema } from 'zod-to-json-schema' -import { createTwoFilesPatch } from 'diff' -import { minimatch } from 'minimatch' -import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' -import { glob } from 'glob' - -// Schema definitions -const ReadFilesArgsSchema = z.object({ - paths: z - .array(z.string()) - .min(1) - .describe('Array of file paths to read (can be single or multiple files)') -}) - -const WriteFileArgsSchema = z.object({ - path: z.string(), - content: z.string() -}) - -// Enhanced text search schema for grep functionality -const GrepSearchArgsSchema = z.object({ - path: z.string().describe('Directory path to search in'), - pattern: z.string().describe('Regular expression pattern to search for'), - filePattern: z.string().optional().describe('File name pattern to filter files (glob pattern)'), - recursive: z.boolean().default(true).describe('Whether to search recursively in subdirectories'), - caseSensitive: z.boolean().default(false).describe('Whether the search should be case sensitive'), - includeLineNumbers: z - .boolean() - .default(true) - .describe('Whether to include line numbers in results'), - contextLines: z - .number() - .default(0) - .describe('Number of context lines to show before and after matches'), - maxResults: z.number().default(100).describe('Maximum number of results to return') -}) - -// Enhanced text replacement schema -const TextReplaceArgsSchema = z.object({ - path: z.string().describe('Path to the file to edit'), - pattern: z.string().describe('Regular expression pattern to find'), - replacement: z.string().describe('Text to replace matches with'), - global: z - .boolean() - .default(true) - .describe('Whether to replace all occurrences or just the first'), - caseSensitive: z.boolean().default(false).describe('Whether the search should be case sensitive'), - dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') -}) - -// Consolidated file search schema (combines search_files and glob_search) -const FileSearchArgsSchema = z.object({ - path: z - .string() - .optional() - .describe('Directory to search in (optional, defaults to current directory)'), - pattern: z - .string() - .describe('Search pattern - can be a glob pattern (e.g., "**/*.ts") or simple text match'), - searchType: z - .enum(['glob', 'name']) - .default('glob') - .describe('Type of search: "glob" for glob patterns, "name" for filename matching'), - excludePatterns: z - .array(z.string()) - .optional() - .default([]) - .describe('Array of patterns to exclude'), - caseSensitive: z.boolean().default(false).describe('Whether the search should be case-sensitive'), - respectGitIgnore: z.boolean().default(true).describe('Whether to respect .gitignore patterns'), - sortByModified: z - .boolean() - .default(true) - .describe('Sort results by modification time (newest first)'), - maxResults: z.number().default(1000).describe('Maximum number of results to return') -}) - -// Consolidated move files schema (combines move_file and move_multiple_files) -const MoveFilesArgsSchema = z.object({ - sources: z.array(z.string()).min(1).describe('Array of source file/directory paths to move'), - destination: z.string().describe('Destination directory or file path') -}) - -// Consolidated text editing schema (combines edit_file and text_replace) -const EditTextArgsSchema = z.object({ - path: z.string().describe('Path to the file to edit'), - operation: z.enum(['replace_pattern', 'edit_lines']).describe('Type of edit operation'), - // For pattern replacement - pattern: z - .string() - .optional() - .describe('Regular expression pattern to find (for replace_pattern operation)'), - replacement: z - .string() - .optional() - .describe('Text to replace matches with (for replace_pattern operation)'), - global: z - .boolean() - .default(true) - .describe('Whether to replace all occurrences (for replace_pattern operation)'), - caseSensitive: z - .boolean() - .default(false) - .describe('Whether the search should be case sensitive (for replace_pattern operation)'), - // For line-based editing - edits: z - .array( - z.object({ - oldText: z.string().describe('Text to search for - must match exactly'), - newText: z.string().describe('Text to replace with') - }) - ) - .optional() - .describe('Array of line-based edits (for edit_lines operation)'), - dryRun: z.boolean().default(false).describe('Preview changes using git-style diff format') -}) - -const CreateDirectoryArgsSchema = z.object({ - path: z.string() -}) - -const ListDirectoryArgsSchema = z.object({ - path: z.string().describe('Directory path to list'), - showDetails: z - .boolean() - .default(false) - .describe('Show detailed file information (size, modified time, permissions)'), - sortBy: z - .enum(['name', 'size', 'modified']) - .default('name') - .describe('Sort criteria for results'), - ignorePatterns: z - .array(z.string()) - .optional() - .describe('Array of glob patterns to ignore (e.g., ["*.tmp", "node_modules"])'), - respectGitIgnore: z.boolean().default(false).describe('Whether to respect .gitignore patterns') -}) - -const DirectoryTreeArgsSchema = z.object({ - path: z.string() -}) - -const GetFileInfoArgsSchema = z.object({ - path: z.string() -}) - -interface FileInfo { - size: number - created: Date - modified: Date - accessed: Date - isDirectory: boolean - isFile: boolean - permissions: string -} - -interface TreeEntry { - name: string - type: 'file' | 'directory' - children?: TreeEntry[] -} - -// New interfaces for enhanced text functionality -interface GrepMatch { - file: string - line: number - content: string - beforeContext?: string[] - afterContext?: string[] -} - -interface GrepResult { - totalMatches: number - files: string[] - matches: GrepMatch[] -} - -interface TextReplaceResult { - success: boolean - replacements: number - diff?: string - error?: string -} - -// Enhanced file entry interface for detailed directory listing -interface EnhancedFileEntry { - name: string - path: string - isDirectory: boolean - size: number - modifiedTime: Date - permissions: string -} - -// Glob search result interface -interface GlobSearchResult { - files: string[] - totalCount: number - gitIgnoredCount?: number -} - -export class FileSystemServer { - private server: Server - private allowedDirectories: string[] - - constructor(allowedDirectories: string[]) { - if (allowedDirectories.length === 0) { - throw new Error('At least one allowed directory must be provided') - } - - // 将目录路径标准化 - this.allowedDirectories = allowedDirectories.map((dir) => - this.normalizePath(path.resolve(this.expandHome(dir))) - ) - - // 创建服务器实例 - this.server = new Server( - { - name: 'secure-filesystem-server', - version: '0.3.0' - }, - { - capabilities: { - tools: {} - } - } - ) - - // 设置请求处理器 - this.setupRequestHandlers() - } - - // 初始化方法,验证所有目录是否存在且可访问 - public async initialize(): Promise { - await Promise.all( - this.allowedDirectories.map(async (dir) => { - try { - const stats = await fs.stat(dir) - if (!stats.isDirectory()) { - throw new Error(`Error: ${dir} is not a directory`) - } - } catch (error) { - throw new Error(`Error accessing directory ${dir}: ${error}`) - } - }) - ) - } - - // 启动服务器 - public startServer(transport: Transport): void { - this.server.connect(transport) - } - - // 辅助方法:标准化路径 - private normalizePath(p: string): string { - return path.normalize(p) - } - - // 辅助方法:扩展主目录路径 - private expandHome(filepath: string): string { - if (filepath.startsWith('~/') || filepath === '~') { - return path.join(os.homedir(), filepath.slice(1)) - } - return filepath - } - - // 安全验证:验证路径是否在允许的目录范围内 - private async validatePath(requestedPath: string): Promise { - const expandedPath = this.expandHome(requestedPath) - const absolute = path.isAbsolute(expandedPath) - ? path.resolve(expandedPath) - : path.resolve(process.cwd(), expandedPath) - - const normalizedRequested = this.normalizePath(absolute) - - // Check if path is within allowed directories - const isAllowed = this.allowedDirectories.some((dir) => normalizedRequested.startsWith(dir)) - if (!isAllowed) { - throw new Error( - `Access denied - path outside allowed directories: ${absolute} not in ${this.allowedDirectories.join(', ')}` - ) - } - - // Handle symlinks by checking their real path - try { - const realPath = await fs.realpath(absolute) - const normalizedReal = this.normalizePath(realPath) - const isRealPathAllowed = this.allowedDirectories.some((dir) => - normalizedReal.startsWith(dir) - ) - if (!isRealPathAllowed) { - throw new Error('Access denied - symlink target outside allowed directories') - } - return realPath - } catch { - // For new files that don't exist yet, verify parent directory - const parentDir = path.dirname(absolute) - try { - const realParentPath = await fs.realpath(parentDir) - const normalizedParent = this.normalizePath(realParentPath) - const isParentAllowed = this.allowedDirectories.some((dir) => - normalizedParent.startsWith(dir) - ) - if (!isParentAllowed) { - throw new Error('Access denied - parent directory outside allowed directories') - } - return absolute - } catch { - throw new Error(`Parent directory does not exist: ${parentDir}`) - } - } - } - - // 获取文件统计信息 - private async getFileStats(filePath: string): Promise { - const stats = await fs.stat(filePath) - return { - size: stats.size, - created: stats.birthtime, - modified: stats.mtime, - accessed: stats.atime, - isDirectory: stats.isDirectory(), - isFile: stats.isFile(), - permissions: stats.mode.toString(8).slice(-3) - } - } - - // 文件搜索功能 - private async searchFiles( - rootPath: string, - pattern: string, - excludePatterns: string[] = [] - ): Promise { - const results: string[] = [] - - const search = async (currentPath: string) => { - let entries - try { - entries = await fs.readdir(currentPath, { withFileTypes: true }) - } catch (error) { - console.error(`[searchFiles] Error reading directory ${currentPath}:`, error) - return // Skip this directory if unreadable - } - - for (const entry of entries) { - const fullPath = path.join(currentPath, entry.name) - - try { - // 验证每个路径是否合法 - await this.validatePath(fullPath) - - // 检查路径是否匹配排除模式 - const relativePath = path.relative(rootPath, fullPath) - const shouldExclude = excludePatterns.some((excludePattern) => { - // 修正: 使用更适合文件和目录的 glob 模式 - const globPattern = excludePattern.includes('/') - ? excludePattern - : `**/${excludePattern}` - const isMatch = minimatch(relativePath, globPattern, { dot: true, matchBase: true }) - return isMatch - }) - - if (shouldExclude) { - continue - } - - // 修改匹配逻辑: 检查文件名是否 *包含* 模式(不区分大小写) - // 或者,如果模式包含通配符,则使用 minimatch 进行匹配 - let isPatternMatch = false - const lowerCaseEntryName = entry.name.toLowerCase() - const lowerCasePattern = pattern.toLowerCase() - - if (pattern.includes('*') || pattern.includes('?')) { - // 使用 minimatch 进行通配符匹配 - isPatternMatch = minimatch(entry.name, pattern, { dot: true, nocase: true }) - } else { - // 否则,执行包含检查(不区分大小写) - isPatternMatch = lowerCaseEntryName.includes(lowerCasePattern) - } - - if (isPatternMatch) { - results.push(fullPath) - } - - if (entry.isDirectory()) { - await search(fullPath) - } - } catch (error) { - // 搜索过程中跳过无效路径 - console.error(`[searchFiles] Error processing path ${fullPath}:`, error) - continue - } - } - } - - try { - await search(rootPath) - } catch (error) { - console.error(`[searchFiles] Error during search execution starting from ${rootPath}:`, error) - } - return results - } - - // Enhanced text content search functionality (grep-like) - private async grepSearch( - rootPath: string, - pattern: string, - options: { - filePattern?: string - recursive?: boolean - caseSensitive?: boolean - includeLineNumbers?: boolean - contextLines?: number - maxResults?: number - } = {} - ): Promise { - const { - filePattern = '*', - recursive = true, - caseSensitive = false, - includeLineNumbers = true, - contextLines = 0, - maxResults = 100 - } = options - - const result: GrepResult = { - totalMatches: 0, - files: [], - matches: [] - } - - // Create regex pattern with appropriate flags - const regexFlags = caseSensitive ? 'g' : 'gi' - let regex: RegExp - try { - regex = new RegExp(pattern, regexFlags) - } catch (error) { - throw new Error(`Invalid regular expression pattern: ${pattern}. Error: ${error}`) - } - - // Helper function to search within a single file - const searchInFile = async (filePath: string): Promise => { - try { - const content = await fs.readFile(filePath, 'utf-8') - const lines = content.split('\n') - const fileMatches: GrepMatch[] = [] - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - const matches = Array.from(line.matchAll(regex)) - - if (matches.length > 0) { - const match: GrepMatch = { - file: filePath, - line: includeLineNumbers ? i + 1 : 0, - content: line - } - - // Add context lines if requested - if (contextLines > 0) { - const startContext = Math.max(0, i - contextLines) - const endContext = Math.min(lines.length - 1, i + contextLines) - - if (startContext < i) { - match.beforeContext = lines.slice(startContext, i) - } - if (endContext > i) { - match.afterContext = lines.slice(i + 1, endContext + 1) - } - } - - fileMatches.push(match) - result.totalMatches += matches.length - - // Stop if we've reached the maximum results - if (result.totalMatches >= maxResults) { - break - } - } - } - - if (fileMatches.length > 0) { - result.files.push(filePath) - result.matches.push(...fileMatches) - } - } catch (error) { - // Skip files that can't be read (binary files, permission issues, etc.) - console.error(`[grepSearch] Error reading file ${filePath}:`, error) - } - } - - // Recursive directory traversal - const searchDirectory = async (currentPath: string): Promise => { - if (result.totalMatches >= maxResults) { - return - } - - try { - const entries = await fs.readdir(currentPath, { withFileTypes: true }) - - for (const entry of entries) { - if (result.totalMatches >= maxResults) { - break - } - - const fullPath = path.join(currentPath, entry.name) - - try { - // Validate path is within allowed directories - await this.validatePath(fullPath) - - if (entry.isFile()) { - // Check if file matches the file pattern - if (minimatch(entry.name, filePattern, { nocase: true })) { - await searchInFile(fullPath) - } - } else if (entry.isDirectory() && recursive) { - await searchDirectory(fullPath) - } - } catch (error) { - // Skip invalid paths - console.error(`[grepSearch] Error processing path ${fullPath}:`, error) - continue - } - } - } catch (error) { - console.error(`[grepSearch] Error reading directory ${currentPath}:`, error) - } - } - - // Start the search - const validatedPath = await this.validatePath(rootPath) - const stats = await fs.stat(validatedPath) - - if (stats.isFile()) { - // Search in a single file - if (minimatch(path.basename(validatedPath), filePattern, { nocase: true })) { - await searchInFile(validatedPath) - } - } else if (stats.isDirectory()) { - // Search in directory - await searchDirectory(validatedPath) - } - - return result - } - - // Enhanced text replacement functionality - private async replaceTextInFile( - filePath: string, - pattern: string, - replacement: string, - options: { - global?: boolean - caseSensitive?: boolean - dryRun?: boolean - } = {} - ): Promise { - const { global = true, caseSensitive = false, dryRun = false } = options - - try { - const originalContent = await fs.readFile(filePath, 'utf-8') - const normalizedOriginal = this.normalizeLineEndings(originalContent) - - // Create regex pattern with appropriate flags - const regexFlags = global ? (caseSensitive ? 'g' : 'gi') : caseSensitive ? '' : 'i' - let regex: RegExp - try { - regex = new RegExp(pattern, regexFlags) - } catch (error) { - return { - success: false, - replacements: 0, - error: `Invalid regular expression pattern: ${pattern}. Error: ${error}` - } - } - - // Perform the replacement - const modifiedContent = normalizedOriginal.replace(regex, replacement) - const matches = Array.from(normalizedOriginal.matchAll(new RegExp(pattern, regexFlags + 'g'))) - const replacements = matches.length - - if (replacements === 0) { - return { - success: true, - replacements: 0, - diff: 'No matches found for the given pattern.' - } - } - - // Create diff - const diff = this.createUnifiedDiff(normalizedOriginal, modifiedContent, filePath) - - // Write file if not dry run - if (!dryRun) { - await fs.writeFile(filePath, modifiedContent, 'utf-8') - } - - return { - success: true, - replacements, - diff - } - } catch (error) { - return { - success: false, - replacements: 0, - error: error instanceof Error ? error.message : String(error) - } - } - } - - // 文件编辑和差异显示功能 - private normalizeLineEndings(text: string): string { - return text.replace(/\r\n/g, '\n') - } - - private createUnifiedDiff( - originalContent: string, - newContent: string, - filepath: string = 'file' - ): string { - // 确保行尾一致性 - const normalizedOriginal = this.normalizeLineEndings(originalContent) - const normalizedNew = this.normalizeLineEndings(newContent) - - return createTwoFilesPatch( - filepath, - filepath, - normalizedOriginal, - normalizedNew, - 'original', - 'modified' - ) - } - - private async applyFileEdits( - filePath: string, - edits: Array<{ oldText: string; newText: string }>, - dryRun = false - ): Promise { - // 读取文件内容并标准化行尾 - const content = this.normalizeLineEndings(await fs.readFile(filePath, 'utf-8')) - - // 按顺序应用编辑 - let modifiedContent = content - for (const edit of edits) { - const normalizedOld = this.normalizeLineEndings(edit.oldText) - const normalizedNew = this.normalizeLineEndings(edit.newText) - - // 如果存在精确匹配,使用它 - if (modifiedContent.includes(normalizedOld)) { - modifiedContent = modifiedContent.replace(normalizedOld, normalizedNew) - continue - } - - // 否则,逐行匹配,对空白字符具有灵活性 - const oldLines = normalizedOld.split('\n') - const contentLines = modifiedContent.split('\n') - let matchFound = false - - for (let i = 0; i <= contentLines.length - oldLines.length; i++) { - const potentialMatch = contentLines.slice(i, i + oldLines.length) - - // 比较标准化后空白字符的行 - const isMatch = oldLines.every((oldLine, j) => { - const contentLine = potentialMatch[j] - return oldLine.trim() === contentLine.trim() - }) - - if (isMatch) { - // 保留第一行的原始缩进 - const originalIndent = contentLines[i].match(/^\s*/)?.[0] || '' - const newLines = normalizedNew.split('\n').map((line, j) => { - if (j === 0) return originalIndent + line.trimStart() - // 对后续行,尝试保留相对缩进 - const oldIndent = oldLines[j]?.match(/^\s*/)?.[0] || '' - const newIndent = line.match(/^\s*/)?.[0] || '' - if (oldIndent && newIndent) { - const relativeIndent = newIndent.length - oldIndent.length - return originalIndent + ' '.repeat(Math.max(0, relativeIndent)) + line.trimStart() - } - return line - }) - - contentLines.splice(i, oldLines.length, ...newLines) - modifiedContent = contentLines.join('\n') - matchFound = true - break - } - } - - if (!matchFound) { - throw new Error(`Cannot find exact matching content to edit:\n${edit.oldText}`) - } - } - - // 创建统一差异 - const diff = this.createUnifiedDiff(content, modifiedContent, filePath) - - // 格式化差异,使用适当数量的反引号 - let numBackticks = 3 - while (diff.includes('`'.repeat(numBackticks))) { - numBackticks++ - } - const formattedDiff = `${'`'.repeat(numBackticks)}diff\n${diff}${'`'.repeat(numBackticks)}\n\n` - - if (!dryRun) { - await fs.writeFile(filePath, modifiedContent, 'utf-8') - } - - return formattedDiff - } - - // 设置请求处理器 - private setupRequestHandlers(): void { - // 设置工具列表处理器 - this.server.setRequestHandler(ListToolsRequestSchema, async () => { - return { - tools: [ - { - name: 'read_files', - description: - 'Read the complete contents of multiple files simultaneously. This is more ' + - 'efficient than reading files one by one when you need to analyze ' + - "or compare multiple files. Each file's content is returned with its " + - "path as a reference. Failed reads for individual files won't stop " + - 'the entire operation. Only works within allowed directories.', - inputSchema: zodToJsonSchema(ReadFilesArgsSchema) - }, - { - name: 'write_file', - description: - 'Create a new file or completely overwrite an existing file with new content. ' + - 'Use with caution as it will overwrite existing files without warning. ' + - 'Handles text content with proper encoding. Only works within allowed directories.', - inputSchema: zodToJsonSchema(WriteFileArgsSchema) - }, - { - name: 'edit_text', - description: - 'Edit text files using two methods: 1) Pattern replacement with regex support for bulk find-and-replace operations, or 2) Line-based editing for precise text modifications. ' + - 'Supports dry-run mode to preview changes. Returns git-style diff showing modifications. ' + - 'Perfect for code refactoring, configuration updates, or bulk text replacements. Only works within allowed directories.', - inputSchema: zodToJsonSchema(EditTextArgsSchema) - }, - { - name: 'create_directory', - description: - 'Create a new directory or ensure a directory exists. Can create multiple ' + - 'nested directories in one operation. If the directory already exists, ' + - 'this operation will succeed silently. Perfect for setting up directory ' + - 'structures for projects or ensuring required paths exist. Only works within allowed directories.', - inputSchema: zodToJsonSchema(CreateDirectoryArgsSchema) - }, - { - name: 'list_directory', - description: - 'Get a detailed listing of all files and directories in a specified path. ' + - 'Results clearly distinguish between files and directories with [FILE] and [DIR] ' + - 'prefixes. Supports detailed information display (size, modification time, permissions), ' + - 'custom sorting (by name, size, or modification time), ignore patterns, and git-aware filtering. ' + - 'This tool is essential for understanding directory structure and finding specific files. ' + - 'Only works within allowed directories.', - inputSchema: zodToJsonSchema(ListDirectoryArgsSchema) - }, - { - name: 'directory_tree', - description: - 'Get a recursive tree view of files and directories as a JSON structure. ' + - "Each entry includes 'name', 'type' (file/directory), and 'children' for directories. " + - 'Files have no children array, while directories always have a children array (which may be empty). ' + - 'The output is formatted with 2-space indentation for readability. Only works within allowed directories.', - inputSchema: zodToJsonSchema(DirectoryTreeArgsSchema) - }, - { - name: 'move_files', - description: - 'Move or rename one or more files and directories in a single operation. ' + - 'Can move files between directories and rename them simultaneously. ' + - 'If any destination exists, that specific operation will fail but others will continue. ' + - 'Supports both single file moves and batch operations. Both sources and destination must be within allowed directories.', - inputSchema: zodToJsonSchema(MoveFilesArgsSchema) - }, - { - name: 'get_file_info', - description: - 'Retrieve detailed metadata about a file or directory. Returns comprehensive ' + - 'information including size, creation time, last modified time, permissions, ' + - 'and type. This tool is perfect for understanding file characteristics ' + - 'without reading the actual content. Only works within allowed directories.', - inputSchema: zodToJsonSchema(GetFileInfoArgsSchema) - }, - { - name: 'list_allowed_directories', - description: - 'Returns the list of directories that this server is allowed to access. ' + - 'Use this to understand which directories are available before trying to access files.', - inputSchema: { - type: 'object', - properties: {}, - required: [] - } - }, - { - name: 'grep_search', - description: - 'Search for text patterns within file contents using regular expressions. ' + - 'Similar to the Unix grep command, this tool searches through files recursively ' + - 'and returns matching lines with optional context. Supports file filtering, ' + - 'case sensitivity options, and result limiting. Perfect for finding specific ' + - 'code patterns, function definitions, or text content across multiple files. ' + - 'Only searches within allowed directories.', - inputSchema: zodToJsonSchema(GrepSearchArgsSchema) - }, - { - name: 'text_replace', - description: - 'Replace text patterns in files using regular expressions. This tool provides ' + - 'powerful find-and-replace functionality with regex support, case sensitivity ' + - 'options, and dry-run mode for previewing changes. Shows a git-style diff ' + - 'of the changes made. Use this for bulk text replacements, code refactoring, ' + - 'or updating configuration files. Only works within allowed directories.', - inputSchema: zodToJsonSchema(TextReplaceArgsSchema) - }, - { - name: 'file_search', - description: - 'Search for files and directories matching a pattern. This tool searches through files and directories ' + - "recursively and returns full paths to all matching items. Great for finding files when you don't know their exact location.", - inputSchema: zodToJsonSchema(FileSearchArgsSchema) - } - ] - } - }) - - // 设置工具调用处理器 - this.server.setRequestHandler(CallToolRequestSchema, async (request) => { - try { - const { name, arguments: args } = request.params - - switch (name) { - case 'read_files': { - const parsed = ReadFilesArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for read_files: ${parsed.error}`) - } - const results = await Promise.all( - parsed.data.paths.map(async (filePath: string) => { - try { - const validPath = await this.validatePath(filePath) - const content = await fs.readFile(validPath, 'utf-8') - return `${filePath}:\n${content}\n` - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return `${filePath}: Error - ${errorMessage}` - } - }) - ) - return { - content: [{ type: 'text', text: results.join('\n---\n') }] - } - } - - case 'write_file': { - const parsed = WriteFileArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for write_file: ${parsed.error}`) - } - const validPath = await this.validatePath(parsed.data.path) - await fs.writeFile(validPath, parsed.data.content, 'utf-8') - return { - content: [{ type: 'text', text: `Successfully wrote to ${parsed.data.path}` }] - } - } - - case 'edit_text': { - const parsed = EditTextArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for edit_text: ${parsed.error}`) - } - const validPath = await this.validatePath(parsed.data.path) - - if (parsed.data.operation === 'edit_lines') { - // Line-based editing - if (!parsed.data.edits || parsed.data.edits.length === 0) { - throw new Error('edits array is required for edit_lines operation') - } - const result = await this.applyFileEdits( - validPath, - parsed.data.edits, - parsed.data.dryRun - ) - return { - content: [{ type: 'text', text: result }] - } - } else if (parsed.data.operation === 'replace_pattern') { - // Pattern replacement - if (!parsed.data.pattern || parsed.data.replacement === undefined) { - throw new Error( - 'pattern and replacement are required for replace_pattern operation' - ) - } - const result = await this.replaceTextInFile( - validPath, - parsed.data.pattern, - parsed.data.replacement, - { - global: parsed.data.global, - caseSensitive: parsed.data.caseSensitive, - dryRun: parsed.data.dryRun - } - ) - return { - content: [{ type: 'text', text: result.success ? result.diff : result.error }], - isError: !result.success - } - } else { - throw new Error(`Unknown operation: ${parsed.data.operation}`) - } - } - - case 'create_directory': { - const parsed = CreateDirectoryArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for create_directory: ${parsed.error}`) - } - const validPath = await this.validatePath(parsed.data.path) - await fs.mkdir(validPath, { recursive: true }) - return { - content: [ - { type: 'text', text: `Successfully created directory ${parsed.data.path}` } - ] - } - } - - case 'list_directory': { - const parsed = ListDirectoryArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for list_directory: ${parsed.error}`) - } - - const entries = await this.enhancedListDirectory(parsed.data.path, { - ignorePatterns: parsed.data.ignorePatterns, - respectGitIgnore: parsed.data.respectGitIgnore, - showDetails: parsed.data.showDetails, - sortBy: parsed.data.sortBy - }) - - if (entries.length === 0) { - return { - content: [{ type: 'text', text: 'Directory is empty or all files are ignored' }] - } - } - - const formatted = entries - .map((entry) => { - const prefix = entry.isDirectory ? '[DIR]' : '[FILE]' - let result = `${prefix} ${entry.name}` - - if (parsed.data.showDetails && !entry.isDirectory) { - const sizeKB = (entry.size / 1024).toFixed(1) - const modifiedDate = entry.modifiedTime.toLocaleDateString() - const modifiedTime = entry.modifiedTime.toLocaleTimeString() - result += ` (${sizeKB}KB, ${entry.permissions}, ${modifiedDate} ${modifiedTime})` - } else if (parsed.data.showDetails && entry.isDirectory) { - result += ` (${entry.permissions})` - } - - return result - }) - .join('\n') - - const summary = `Directory listing for ${parsed.data.path} (${entries.length} items):\n\n${formatted}` - return { - content: [{ type: 'text', text: summary }] - } - } - - case 'directory_tree': { - const parsed = DirectoryTreeArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for directory_tree: ${parsed.error}`) - } - - const buildTree = async (currentPath: string): Promise => { - const validPath = await this.validatePath(currentPath) - const entries = await fs.readdir(validPath, { withFileTypes: true }) - const result: TreeEntry[] = [] - - for (const entry of entries) { - const entryData: TreeEntry = { - name: entry.name, - type: entry.isDirectory() ? 'directory' : 'file' - } - - if (entry.isDirectory()) { - const subPath = path.join(currentPath, entry.name) - entryData.children = await buildTree(subPath) - } - - result.push(entryData) - } - - return result - } - - const treeData = await buildTree(parsed.data.path) - return { - content: [ - { - type: 'text', - text: JSON.stringify(treeData, null, 2) - } - ] - } - } - - case 'move_files': { - const parsed = MoveFilesArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for move_files: ${parsed.error}`) - } - const destInfo = await this.getFileStats(parsed.data.destination) - const results = await Promise.all( - parsed.data.sources.map(async (source) => { - const validSourcePath = await this.validatePath(source) - let validDestPath = '' - if (destInfo.isFile) { - validDestPath = await this.validatePath(parsed.data.destination) - } else { - validDestPath = await this.validatePath( - path.join(parsed.data.destination, path.basename(source)) - ) - } - try { - await fs.rename(validSourcePath, validDestPath) - return `Successfully moved ${source} to ${parsed.data.destination}` - } catch (e) { - return `Move ${source} to ${parsed.data.destination} failed: ${JSON.stringify(e)}` - } - }) - ) - return { - content: [ - { - type: 'text', - text: results.join('\n') - } - ] - } - } - - case 'get_file_info': { - const parsed = GetFileInfoArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for get_file_info: ${parsed.error}`) - } - const validPath = await this.validatePath(parsed.data.path) - const info = await this.getFileStats(validPath) - return { - content: [ - { - type: 'text', - text: Object.entries(info) - .map(([key, value]) => `${key}: ${value}`) - .join('\n') - } - ] - } - } - - case 'list_allowed_directories': { - return { - content: [ - { - type: 'text', - text: `Allowed directories:\n${this.allowedDirectories.join('\n')}` - } - ] - } - } - - case 'grep_search': { - const parsed = GrepSearchArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for grep_search: ${parsed.error}`) - } - const validPath = await this.validatePath(parsed.data.path) - const result = await this.grepSearch(validPath, parsed.data.pattern, { - filePattern: parsed.data.filePattern, - recursive: parsed.data.recursive, - caseSensitive: parsed.data.caseSensitive, - includeLineNumbers: parsed.data.includeLineNumbers, - contextLines: parsed.data.contextLines, - maxResults: parsed.data.maxResults - }) - - if (result.totalMatches === 0) { - return { - content: [{ type: 'text', text: 'No matches found' }] - } - } - - // Format the results - const formattedResults = result.matches - .map((match) => { - let output = `${match.file}:${match.line}: ${match.content}` - - if (match.beforeContext && match.beforeContext.length > 0) { - const beforeLines = match.beforeContext - .map( - (line, i) => - `${match.file}:${match.line - match.beforeContext!.length + i}: ${line}` - ) - .join('\n') - output = beforeLines + '\n' + output - } - - if (match.afterContext && match.afterContext.length > 0) { - const afterLines = match.afterContext - .map((line, i) => `${match.file}:${match.line + i + 1}: ${line}`) - .join('\n') - output = output + '\n' + afterLines - } - - return output - }) - .join('\n--\n') - - const summary = `Found ${result.totalMatches} matches in ${result.files.length} files:\n\n${formattedResults}` - - return { - content: [{ type: 'text', text: summary }] - } - } - - case 'file_search': { - const parsed = FileSearchArgsSchema.safeParse(args) - if (!parsed.success) { - throw new Error(`Invalid arguments for file_search: ${parsed.error}`) - } - const validPath = await this.validatePath(parsed.data.path || '.') - - let results: string[] - if (parsed.data.searchType === 'glob') { - // Use glob search for glob patterns - const globResult = await this.globSearch(parsed.data.pattern, validPath, { - caseSensitive: parsed.data.caseSensitive, - respectGitIgnore: parsed.data.respectGitIgnore, - sortByModified: parsed.data.sortByModified, - maxResults: parsed.data.maxResults - }) - results = globResult.files - } else { - // Use name search for simple text matching - results = await this.searchFiles( - validPath, - parsed.data.pattern, - parsed.data.excludePatterns - ) - } - - return { - content: [ - { type: 'text', text: results.length > 0 ? results.join('\n') : 'No matches found' } - ] - } - } - - default: - throw new Error(`Unknown tool: ${name}`) - } - } catch (error) { - const errorMessage = error instanceof Error ? error.message : String(error) - return { - content: [{ type: 'text', text: `Error: ${errorMessage}` }], - isError: true - } - } - }) - } - - // Enhanced glob search functionality inspired by Google Gemini CLI - private async globSearch( - pattern: string, - searchPath: string = '.', - options: { - caseSensitive?: boolean - respectGitIgnore?: boolean - sortByModified?: boolean - maxResults?: number - } = {} - ): Promise { - const { - caseSensitive = false, - respectGitIgnore = true, - sortByModified = true, - maxResults = 1000 - } = options - - const validatedPath = await this.validatePath(searchPath) - - try { - // Use the glob library for proper glob pattern matching - const globResults = await glob(pattern, { - cwd: validatedPath, - nodir: false, // Include directories - dot: true, // Include hidden files - nocase: !caseSensitive, - ignore: respectGitIgnore ? ['**/node_modules/**', '**/.git/**'] : ['**/node_modules/**'], - absolute: true, - maxDepth: 20, // Reasonable depth limit - follow: false // Don't follow symlinks for security - }) - - // Limit results - const limitedResults = globResults.slice(0, maxResults) - - // Validate all paths are within allowed directories - const validResults: string[] = [] - for (const result of limitedResults) { - try { - await this.validatePath(result) - validResults.push(result) - } catch (error) { - // Skip paths outside allowed directories - console.error(`[globSearch] Path validation failed for ${result}:`, error) - continue - } - } - - // Sort results if requested - if (sortByModified && validResults.length > 0) { - const fileStats = await Promise.all( - validResults.map(async (filePath) => { - try { - const stats = await fs.stat(filePath) - return { path: filePath, mtime: stats.mtime.getTime() } - } catch { - return { path: filePath, mtime: 0 } - } - }) - ) - - fileStats.sort((a, b) => b.mtime - a.mtime) - return { - files: fileStats.map((f) => f.path), - totalCount: fileStats.length - } - } - - return { - files: validResults, - totalCount: validResults.length - } - } catch (error) { - console.error(`[globSearch] Error during glob search:`, error) - throw new Error( - `Glob search failed: ${error instanceof Error ? error.message : String(error)}` - ) - } - } - - // Enhanced directory listing functionality - private async enhancedListDirectory( - dirPath: string, - options: { - ignorePatterns?: string[] - respectGitIgnore?: boolean - showDetails?: boolean - sortBy?: 'name' | 'size' | 'modified' - } = {} - ): Promise { - const { - ignorePatterns = [], - respectGitIgnore: _respectGitIgnore = false, - showDetails: _showDetails = false, - sortBy = 'name' - } = options - - const validatedPath = await this.validatePath(dirPath) - const entries = await fs.readdir(validatedPath, { withFileTypes: true }) - const results: EnhancedFileEntry[] = [] - - // Helper function to check if filename matches ignore patterns - const shouldIgnore = (filename: string): boolean => { - return ignorePatterns.some((pattern) => { - const regexPattern = pattern - .replace(/[.+^${}()|[\]\\]/g, '\\$&') - .replace(/\*/g, '.*') - .replace(/\?/g, '.') - const regex = new RegExp(`^${regexPattern}$`) - return regex.test(filename) - }) - } - - for (const entry of entries) { - if (shouldIgnore(entry.name)) { - continue - } - - const fullPath = path.join(validatedPath, entry.name) - - try { - const stats = await fs.stat(fullPath) - const enhancedEntry: EnhancedFileEntry = { - name: entry.name, - path: fullPath, - isDirectory: entry.isDirectory(), - size: entry.isDirectory() ? 0 : stats.size, - modifiedTime: stats.mtime, - permissions: stats.mode.toString(8).slice(-3) - } - - results.push(enhancedEntry) - } catch (error) { - console.error(`[enhancedListDirectory] Error getting stats for ${fullPath}:`, error) - continue - } - } - - // Sort results - results.sort((a, b) => { - // Directories first - if (a.isDirectory && !b.isDirectory) return -1 - if (!a.isDirectory && b.isDirectory) return 1 - - // Then by requested sort criteria - switch (sortBy) { - case 'size': - return b.size - a.size - case 'modified': - return b.modifiedTime.getTime() - a.modifiedTime.getTime() - default: - return a.name.localeCompare(b.name) - } - }) - - return results - } -} - -// 使用示例 -// const server = new FileSystemServer(['/path/to/allowed/directory']) -// await server.initialize() -// server.startServer() diff --git a/src/main/presenter/workspacePresenter/index.ts b/src/main/presenter/workspacePresenter/index.ts index d0dc6fd7b..909fe9044 100644 --- a/src/main/presenter/workspacePresenter/index.ts +++ b/src/main/presenter/workspacePresenter/index.ts @@ -1,4 +1,5 @@ import path from 'path' +import fs from 'fs' import { shell } from 'electron' import { eventBus, SendTarget } from '@/eventbus' import { WORKSPACE_EVENTS } from '@/events' @@ -36,16 +37,43 @@ export class WorkspacePresenter implements IWorkspacePresenter { /** * Check if a path is within allowed workspaces + * Uses realpathSync to resolve symlinks and prevent bypass attacks */ private isPathAllowed(targetPath: string): boolean { - const normalized = path.resolve(targetPath) - for (const workspace of this.allowedWorkspaces) { - // Check if targetPath is equal to or under the workspace - if (normalized === workspace || normalized.startsWith(workspace + path.sep)) { - return true + try { + // Resolve symlinks for target path + const realTargetPath = fs.realpathSync(targetPath) + const normalizedTarget = path.normalize(realTargetPath) + const targetWithSep = normalizedTarget.endsWith(path.sep) + ? normalizedTarget + : `${normalizedTarget}${path.sep}` + + for (const workspace of this.allowedWorkspaces) { + try { + // Resolve symlinks for each allowed workspace + const realWorkspace = fs.realpathSync(workspace) + const normalizedWorkspace = path.normalize(realWorkspace) + const workspaceWithSep = normalizedWorkspace.endsWith(path.sep) + ? normalizedWorkspace + : `${normalizedWorkspace}${path.sep}` + + // Check if targetPath is equal to or under the workspace + if ( + normalizedTarget === normalizedWorkspace || + targetWithSep.startsWith(workspaceWithSep) + ) { + return true + } + } catch { + // If workspace path resolution fails, skip this workspace + continue + } } + return false + } catch { + // If target path resolution fails, treat as not allowed + return false } - return false } /** diff --git a/src/renderer/src/components/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue index 350e2964e..c7d43837d 100644 --- a/src/renderer/src/components/chat-input/ChatInput.vue +++ b/src/renderer/src/components/chat-input/ChatInput.vue @@ -565,11 +565,6 @@ const { settings, toggleWebSearch } = useInputSettings() // Initialize chat mode management const chatMode = useChatMode() const modeSelectOpen = ref(false) -console.log( - '%c🤪 ~ file: /Users/zerob13/Documents/deepchat/src/renderer/src/components/chat-input/ChatInput.vue:552 [] -> modeSelectOpen : ', - 'color: #394483', - modeSelectOpen -) // Initialize history composable first (needed for editor placeholder) const history = useInputHistory(null as any, t) diff --git a/src/renderer/src/i18n/da-DK/chat.json b/src/renderer/src/i18n/da-DK/chat.json index f89788303..e8d2c56bd 100644 --- a/src/renderer/src/i18n/da-DK/chat.json +++ b/src/renderer/src/i18n/da-DK/chat.json @@ -30,7 +30,7 @@ "rateLimitWait": "Vent {seconds}s", "rateLimitWaitingTooltip": "Vent {seconds} sekunder, interval {interval} sekunder", "fileArea": "fil område", - "agentWorkspaceCurrent": "Nuværende arbejdsmappe: {sti}", + "agentWorkspaceCurrent": "Nuværende arbejdsmappe: {path}", "agentWorkspaceSelect": "Vælg arbejdsmappe", "agentWorkspaceTooltip": "Indstil agentens arbejdsmappe" }, diff --git a/test/main/presenter/filesystem.test.ts b/test/main/presenter/filesystem.test.ts deleted file mode 100644 index eb9cd15e5..000000000 --- a/test/main/presenter/filesystem.test.ts +++ /dev/null @@ -1,510 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { FileSystemServer } from '../../../src/main/presenter/mcpPresenter/inMemoryServers/filesystem' -import fs from 'fs/promises' -import path from 'path' -import os from 'os' - -describe('Enhanced FileSystem Server', () => { - let server: FileSystemServer - let tempDir: string - let testFile1: string - let testFile2: string - let subDir: string - - beforeEach(async () => { - // Create a temporary directory for testing - tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'filesystem-test-')) - server = new FileSystemServer([tempDir]) - await server.initialize() - - // Create test files and directories - testFile1 = path.join(tempDir, 'test1.txt') - testFile2 = path.join(tempDir, 'test2.js') - subDir = path.join(tempDir, 'subdir') - - await fs.writeFile( - testFile1, - 'Hello World\nThis is a test file\nWith multiple lines\nHello again', - 'utf-8' - ) - await fs.writeFile( - testFile2, - 'function test() {\n console.log("Hello");\n return true;\n}', - 'utf-8' - ) - await fs.mkdir(subDir) - await fs.writeFile( - path.join(subDir, 'nested.txt'), - 'Nested file content\nAnother line', - 'utf-8' - ) - }) - - afterEach(async () => { - // Clean up temporary directory - await fs.rm(tempDir, { recursive: true, force: true }) - }) - - describe('read_files tool', () => { - it('should read single file', async () => { - // Test that the server can handle the request structure - expect(server).toBeDefined() - - // Verify file content directly - const content = await fs.readFile(testFile1, 'utf-8') - expect(content).toContain('Hello World') - expect(content).toContain('test file') - }) - - it('should read multiple files', async () => { - const content1 = await fs.readFile(testFile1, 'utf-8') - const content2 = await fs.readFile(testFile2, 'utf-8') - - expect(content1).toContain('Hello World') - expect(content2).toContain('function test()') - expect(content2).toContain('console.log') - }) - - it('should handle non-existent files gracefully', async () => { - const nonExistentFile = path.join(tempDir, 'nonexistent.txt') - - try { - await fs.readFile(nonExistentFile, 'utf-8') - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).toBeDefined() - } - }) - }) - - describe('write_file tool', () => { - it('should create new file', async () => { - const newFile = path.join(tempDir, 'new.txt') - const content = 'New file content' - - await fs.writeFile(newFile, content, 'utf-8') - - const readContent = await fs.readFile(newFile, 'utf-8') - expect(readContent).toBe(content) - }) - - it('should overwrite existing file', async () => { - const newContent = 'Overwritten content' - - await fs.writeFile(testFile1, newContent, 'utf-8') - - const readContent = await fs.readFile(testFile1, 'utf-8') - expect(readContent).toBe(newContent) - }) - }) - - describe('edit_text tool', () => { - it('should support line-based editing', async () => { - const originalContent = await fs.readFile(testFile1, 'utf-8') - expect(originalContent).toContain('Hello World') - - // Test basic edit operation structure - const edits = [{ oldText: 'Hello World', newText: 'Hi Universe' }] - - expect(edits).toHaveLength(1) - expect(edits[0].oldText).toBe('Hello World') - expect(edits[0].newText).toBe('Hi Universe') - }) - - it('should support pattern replacement', async () => { - const originalContent = await fs.readFile(testFile2, 'utf-8') - expect(originalContent).toContain('console.log') - - // Test regex pattern matching - const pattern = 'console\\.log' - const replacement = 'console.error' - - expect(pattern).toBe('console\\.log') - expect(replacement).toBe('console.error') - }) - - it('should handle dry-run mode', async () => { - const originalContent = await fs.readFile(testFile1, 'utf-8') - - // In dry-run mode, file should remain unchanged - const contentAfter = await fs.readFile(testFile1, 'utf-8') - expect(contentAfter).toBe(originalContent) - }) - }) - - describe('create_directory tool', () => { - it('should create new directory', async () => { - const newDir = path.join(tempDir, 'newdir') - - await fs.mkdir(newDir) - - const stats = await fs.stat(newDir) - expect(stats.isDirectory()).toBe(true) - }) - - it('should create nested directories', async () => { - const nestedDir = path.join(tempDir, 'level1', 'level2', 'level3') - - await fs.mkdir(nestedDir, { recursive: true }) - - const stats = await fs.stat(nestedDir) - expect(stats.isDirectory()).toBe(true) - }) - - it('should succeed silently if directory exists', async () => { - // Creating the same directory twice should not throw - await fs.mkdir(subDir, { recursive: true }) - - const stats = await fs.stat(subDir) - expect(stats.isDirectory()).toBe(true) - }) - }) - - describe('list_directory tool', () => { - it('should list files and directories', async () => { - const entries = await fs.readdir(tempDir, { withFileTypes: true }) - - const fileNames = entries.map((entry) => entry.name) - expect(fileNames).toContain('test1.txt') - expect(fileNames).toContain('test2.js') - expect(fileNames).toContain('subdir') - - const dirs = entries.filter((entry) => entry.isDirectory()) - const files = entries.filter((entry) => entry.isFile()) - - expect(dirs).toHaveLength(1) - expect(files).toHaveLength(2) - }) - - it('should provide detailed file information', async () => { - const stats1 = await fs.stat(testFile1) - const stats2 = await fs.stat(testFile2) - - expect(stats1.size).toBeGreaterThan(0) - expect(stats2.size).toBeGreaterThan(0) - expect(stats1.mtime).toBeInstanceOf(Date) - expect(stats2.mtime).toBeInstanceOf(Date) - }) - - it('should support sorting options', async () => { - const entries = await fs.readdir(tempDir, { withFileTypes: true }) - const sortedByName = entries.sort((a, b) => a.name.localeCompare(b.name)) - - expect(sortedByName[0].name).toBe('subdir') - expect(sortedByName[1].name).toBe('test1.txt') - expect(sortedByName[2].name).toBe('test2.js') - }) - }) - - describe('directory_tree tool', () => { - it('should create recursive tree structure', async () => { - // Test the structure we created - const subdirStats = await fs.stat(subDir) - expect(subdirStats.isDirectory()).toBe(true) - - const nestedFile = path.join(subDir, 'nested.txt') - const nestedStats = await fs.stat(nestedFile) - expect(nestedStats.isFile()).toBe(true) - }) - - it('should distinguish between files and directories', async () => { - const entries = await fs.readdir(tempDir, { withFileTypes: true }) - - const treeEntries = entries.map((entry) => ({ - name: entry.name, - type: entry.isDirectory() ? 'directory' : ('file' as 'directory' | 'file') - })) - - const dirEntry = treeEntries.find((e) => e.name === 'subdir') - const fileEntry = treeEntries.find((e) => e.name === 'test1.txt') - - expect(dirEntry?.type).toBe('directory') - expect(fileEntry?.type).toBe('file') - }) - }) - - describe('move_files tool', () => { - it('should move single file', async () => { - const sourceFile = path.join(tempDir, 'source.txt') - const destFile = path.join(tempDir, 'dest.txt') - - await fs.writeFile(sourceFile, 'Content to move', 'utf-8') - await fs.rename(sourceFile, destFile) - - // Source should not exist - try { - await fs.stat(sourceFile) - expect.fail('Source file should not exist') - } catch (error) { - expect(error).toBeDefined() - } - - // Destination should exist - const content = await fs.readFile(destFile, 'utf-8') - expect(content).toBe('Content to move') - }) - - it('should move multiple files', async () => { - const file1 = path.join(tempDir, 'move1.txt') - const file2 = path.join(tempDir, 'move2.txt') - const targetDir = path.join(tempDir, 'target') - - await fs.writeFile(file1, 'Content 1', 'utf-8') - await fs.writeFile(file2, 'Content 2', 'utf-8') - await fs.mkdir(targetDir) - - await fs.rename(file1, path.join(targetDir, 'move1.txt')) - await fs.rename(file2, path.join(targetDir, 'move2.txt')) - - const targetEntries = await fs.readdir(targetDir) - expect(targetEntries).toContain('move1.txt') - expect(targetEntries).toContain('move2.txt') - }) - - it('should handle directory moves', async () => { - const sourceDir = path.join(tempDir, 'sourcedir') - const destDir = path.join(tempDir, 'destdir') - - await fs.mkdir(sourceDir) - await fs.writeFile(path.join(sourceDir, 'file.txt'), 'content', 'utf-8') - - await fs.rename(sourceDir, destDir) - - const destFile = path.join(destDir, 'file.txt') - const content = await fs.readFile(destFile, 'utf-8') - expect(content).toBe('content') - }) - }) - - describe('get_file_info tool', () => { - it('should return comprehensive file metadata', async () => { - const stats = await fs.stat(testFile1) - - expect(stats.size).toBeGreaterThan(0) - expect(stats.birthtime).toBeInstanceOf(Date) - expect(stats.mtime).toBeInstanceOf(Date) - expect(stats.atime).toBeInstanceOf(Date) - expect(stats.isFile()).toBe(true) - expect(stats.isDirectory()).toBe(false) - }) - - it('should handle directory metadata', async () => { - const stats = await fs.stat(subDir) - - expect(stats.isDirectory()).toBe(true) - expect(stats.isFile()).toBe(false) - expect(stats.mtime).toBeInstanceOf(Date) - }) - - it('should include permissions information', async () => { - const stats = await fs.stat(testFile1) - - expect(stats.mode).toBeDefined() - expect(typeof stats.mode).toBe('number') - - const permissions = stats.mode.toString(8).slice(-3) - expect(permissions).toMatch(/^[0-7]{3}$/) - }) - }) - - describe('list_allowed_directories tool', () => { - it('should return configured allowed directories', async () => { - // Test that server was initialized with temp directory - expect(server).toBeInstanceOf(FileSystemServer) - - // Verify temp directory is accessible - const stats = await fs.stat(tempDir) - expect(stats.isDirectory()).toBe(true) - }) - }) - - describe('grep_search tool', () => { - it('should find text patterns in files', async () => { - const content1 = await fs.readFile(testFile1, 'utf-8') - const content2 = await fs.readFile(testFile2, 'utf-8') - - // Test pattern matching - expect(content1).toMatch(/Hello/g) - expect(content2).toMatch(/function/g) - expect(content2).toMatch(/console\.log/g) - }) - - it('should support regex patterns', async () => { - const content = await fs.readFile(testFile2, 'utf-8') - - // Test regex patterns - expect(content).toMatch(/function\s+\w+\s*\(/g) - expect(content).toMatch(/console\.\w+/g) - expect(content).toMatch(/return\s+\w+/g) - }) - - it('should search recursively', async () => { - const nestedContent = await fs.readFile(path.join(subDir, 'nested.txt'), 'utf-8') - expect(nestedContent).toContain('Nested file') - expect(nestedContent).toContain('Another line') - }) - - it('should support case sensitivity options', async () => { - const content = await fs.readFile(testFile1, 'utf-8') - - // Case sensitive - expect(content).toMatch(/Hello/) - expect(content).not.toMatch(/HELLO/) - - // Case insensitive - expect(content.toLowerCase()).toMatch(/hello/) - }) - - it('should provide line numbers and context', async () => { - const content = await fs.readFile(testFile1, 'utf-8') - const lines = content.split('\n') - - const helloLineIndex = lines.findIndex((line) => line.includes('Hello World')) - expect(helloLineIndex).toBe(0) - - const testLineIndex = lines.findIndex((line) => line.includes('test file')) - expect(testLineIndex).toBe(1) - }) - }) - - describe('file_search tool', () => { - it('should find files by name pattern', async () => { - const entries = await fs.readdir(tempDir, { withFileTypes: true }) - - const txtFiles = entries.filter((entry) => entry.name.endsWith('.txt')) - const jsFiles = entries.filter((entry) => entry.name.endsWith('.js')) - - expect(txtFiles).toHaveLength(1) - expect(jsFiles).toHaveLength(1) - expect(txtFiles[0].name).toBe('test1.txt') - expect(jsFiles[0].name).toBe('test2.js') - }) - - it('should support glob patterns', async () => { - // Test glob-like pattern matching - const entries = await fs.readdir(tempDir, { withFileTypes: true }) - - const testFiles = entries.filter((entry) => entry.name.startsWith('test')) - expect(testFiles).toHaveLength(2) - - const txtFiles = entries.filter((entry) => entry.name.includes('.txt')) - expect(txtFiles).toHaveLength(1) - }) - - it('should search recursively in subdirectories', async () => { - const nestedFile = path.join(subDir, 'nested.txt') - const stats = await fs.stat(nestedFile) - expect(stats.isFile()).toBe(true) - - // Test that we can find nested files - const subdirEntries = await fs.readdir(subDir) - expect(subdirEntries).toContain('nested.txt') - }) - - it('should respect exclude patterns', async () => { - // Create files to test exclusion - await fs.writeFile(path.join(tempDir, 'exclude.tmp'), 'temp content', 'utf-8') - await fs.writeFile(path.join(tempDir, 'include.txt'), 'include content', 'utf-8') - - const allEntries = await fs.readdir(tempDir) - expect(allEntries).toContain('exclude.tmp') - expect(allEntries).toContain('include.txt') - - // Filter out .tmp files - const filteredEntries = allEntries.filter((name) => !name.endsWith('.tmp')) - expect(filteredEntries).toContain('include.txt') - expect(filteredEntries).not.toContain('exclude.tmp') - }) - - it('should support case sensitivity options', async () => { - const entries = await fs.readdir(tempDir) - - // Test case sensitivity - const testFiles = entries.filter((name) => name.toLowerCase().includes('test')) - expect(testFiles).toHaveLength(2) - - const upperTestFiles = entries.filter((name) => name.includes('TEST')) - expect(upperTestFiles).toHaveLength(0) - }) - - it('should sort results by modification time', async () => { - // Create files with different timestamps - const file1 = path.join(tempDir, 'old.txt') - const file2 = path.join(tempDir, 'new.txt') - - await fs.writeFile(file1, 'old content', 'utf-8') - - // Wait a bit to ensure different timestamps - await new Promise((resolve) => setTimeout(resolve, 10)) - - await fs.writeFile(file2, 'new content', 'utf-8') - - const stats1 = await fs.stat(file1) - const stats2 = await fs.stat(file2) - - expect(stats2.mtime.getTime()).toBeGreaterThan(stats1.mtime.getTime()) - }) - - it('should limit results appropriately', async () => { - // Create multiple files - const filePromises = [] - for (let i = 0; i < 5; i++) { - filePromises.push(fs.writeFile(path.join(tempDir, `file${i}.txt`), `content ${i}`, 'utf-8')) - } - await Promise.all(filePromises) - - const entries = await fs.readdir(tempDir) - const txtFiles = entries.filter((name) => name.endsWith('.txt')) - - // Should have original test1.txt plus 5 new files - expect(txtFiles.length).toBeGreaterThanOrEqual(5) - - // Test limiting (simulate maxResults) - const limitedResults = txtFiles.slice(0, 3) - expect(limitedResults).toHaveLength(3) - }) - }) - - describe('Path validation and security', () => { - it('should only allow access to configured directories', async () => { - // Test that temp directory is accessible - const stats = await fs.stat(tempDir) - expect(stats.isDirectory()).toBe(true) - }) - - it('should handle path normalization', async () => { - const normalPath = path.normalize(testFile1) - const stats = await fs.stat(normalPath) - expect(stats.isFile()).toBe(true) - }) - - it('should handle relative paths correctly', async () => { - const relativePath = path.relative(tempDir, testFile1) - expect(relativePath).toBe('test1.txt') - }) - }) - - describe('Error handling', () => { - it('should handle non-existent files gracefully', async () => { - const nonExistentFile = path.join(tempDir, 'does-not-exist.txt') - - try { - await fs.stat(nonExistentFile) - expect.fail('Should have thrown an error') - } catch (error) { - expect(error).toBeDefined() - } - }) - - it('should handle permission errors', async () => { - // This test depends on the OS and permissions setup - expect(server).toBeDefined() - }) - - it('should validate file paths properly', async () => { - // Test that server validates paths within allowed directories - expect(tempDir).toBeTruthy() - expect(path.isAbsolute(tempDir)).toBe(true) - }) - }) -}) From 16c2a3ba45e77f42ba9aab271bb6515940d99c1c Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 00:26:08 +0800 Subject: [PATCH 19/29] fix: add safe check for regex patterns --- .../agent/agentFileSystemHandler.ts | 31 ++---------- .../inMemoryServers/autoPromptingServer.ts | 12 ++++- .../conversationSearchServer.ts | 11 ++++- .../components/MessageNavigationSidebar.vue | 10 +++- src/renderer/src/stores/mcp.ts | 7 +++ src/shared/regexValidator.ts | 47 +++++++++++++++++++ 6 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 src/shared/regexValidator.ts diff --git a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts index 78334035a..6e27877b3 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts @@ -4,7 +4,7 @@ import os from 'os' import { z } from 'zod' import { minimatch } from 'minimatch' import { createTwoFilesPatch } from 'diff' -import safeRegex from 'safe-regex2' +import { validateRegexPattern } from '@shared/regexValidator' const ReadFileArgsSchema = z.object({ paths: z.array(z.string()).min(1).describe('Array of file paths to read') @@ -132,29 +132,6 @@ export class AgentFileSystemHandler { return text.replace(/\r\n/g, '\n') } - /** - * Validate regex pattern for ReDoS safety - * @param pattern The regex pattern to validate - * @throws Error if pattern is unsafe or exceeds length limit - */ - private validateRegexPattern(pattern: string): void { - const MAX_PATTERN_LENGTH = 1000 - - // Check length limit - if (pattern.length > MAX_PATTERN_LENGTH) { - throw new Error( - `Regular expression pattern exceeds maximum length of ${MAX_PATTERN_LENGTH} characters. Pattern length: ${pattern.length}` - ) - } - - // Check for ReDoS vulnerability using safe-regex2 - if (!safeRegex(pattern)) { - throw new Error( - `Regular expression pattern is potentially unsafe and may cause ReDoS (Regular Expression Denial of Service). Please use a simpler, safer pattern.` - ) - } - } - private isPathAllowed(candidatePath: string): boolean { return this.allowedDirectories.some((dir) => { if (candidatePath === dir) return true @@ -261,7 +238,7 @@ export class AgentFileSystemHandler { } // Validate pattern for ReDoS safety before constructing RegExp - this.validateRegexPattern(pattern) + validateRegexPattern(pattern) const regexFlags = caseSensitive ? 'g' : 'gi' let regex: RegExp @@ -371,7 +348,7 @@ export class AgentFileSystemHandler { try { // Validate pattern for ReDoS safety before constructing RegExp try { - this.validateRegexPattern(pattern) + validateRegexPattern(pattern) } catch (error) { return { success: false, @@ -524,7 +501,7 @@ export class AgentFileSystemHandler { } else if (parsed.data.operation === 'replace_pattern' && parsed.data.pattern) { // Validate pattern for ReDoS safety before constructing RegExp try { - this.validateRegexPattern(parsed.data.pattern) + validateRegexPattern(parsed.data.pattern) } catch (error) { throw new Error( error instanceof Error ? error.message : `Invalid pattern: ${String(error)}` diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/autoPromptingServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/autoPromptingServer.ts index 8feaa3fe2..100e77b61 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/autoPromptingServer.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/autoPromptingServer.ts @@ -9,6 +9,7 @@ import { z } from 'zod' import { zodToJsonSchema } from 'zod-to-json-schema' import { presenter } from '@/presenter' import { Prompt } from '@shared/presenter' +import { isSafeRegexPattern } from '@shared/regexValidator' // --- 类型定义和 Schema (合并后) --- @@ -184,7 +185,16 @@ export class AutoPromptingServer { if (templateArgs && template.parameters) { for (const param of template.parameters) { const value = templateArgs[param.name] || '' - filledContent = filledContent.replace(new RegExp(`{{${param.name}}}`, 'g'), value) + // Validate regex pattern for ReDoS safety + // Escape special characters in param.name to create a safe pattern + const escapedParamName = param.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = `{{${escapedParamName}}}` + if (!isSafeRegexPattern(pattern)) { + throw new Error( + `Template parameter name "${param.name}" creates an unsafe regex pattern. Please use a simpler parameter name.` + ) + } + filledContent = filledContent.replace(new RegExp(pattern, 'g'), value) } } diff --git a/src/main/presenter/mcpPresenter/inMemoryServers/conversationSearchServer.ts b/src/main/presenter/mcpPresenter/inMemoryServers/conversationSearchServer.ts index 81d549b15..d5c79bbf2 100644 --- a/src/main/presenter/mcpPresenter/inMemoryServers/conversationSearchServer.ts +++ b/src/main/presenter/mcpPresenter/inMemoryServers/conversationSearchServer.ts @@ -7,6 +7,7 @@ import { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { presenter } from '@/presenter' // 导入全局的 presenter 对象 import { eventBus } from '@/eventbus' // 引入 eventBus import { TAB_EVENTS } from '@/events' +import { isSafeRegexPattern } from '@shared/regexValidator' // Schema definitions const SearchConversationsArgsSchema = z.object({ @@ -458,8 +459,14 @@ export class ConversationSearchServer { if (start > 0) snippet = '...' + snippet if (end < content.length) snippet = snippet + '...' - // 高亮关键词 - const regex = new RegExp(`(${query})`, 'gi') + // 高亮关键词 - 转义特殊字符并验证安全性 + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = `(${escapedQuery})` + if (!isSafeRegexPattern(pattern)) { + // If pattern is unsafe, return snippet without highlighting + return snippet + } + const regex = new RegExp(pattern, 'gi') snippet = snippet.replace(regex, '**$1**') return snippet diff --git a/src/renderer/src/components/MessageNavigationSidebar.vue b/src/renderer/src/components/MessageNavigationSidebar.vue index 10aee7903..1083f6cc3 100644 --- a/src/renderer/src/components/MessageNavigationSidebar.vue +++ b/src/renderer/src/components/MessageNavigationSidebar.vue @@ -132,6 +132,7 @@ import { useI18n } from 'vue-i18n' import { useThemeStore } from '@/stores/theme' import ModelIcon from '@/components/icons/ModelIcon.vue' import type { Message } from '@shared/chat' +import { isSafeRegexPattern } from '@shared/regexValidator' interface Props { messages: Message[] @@ -241,7 +242,14 @@ const highlightSearchQuery = (text: string): string => { } const query = searchQuery.value.trim() - const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi') + // Escape special characters and validate pattern for ReDoS safety + const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + const pattern = `(${escapedQuery})` + if (!isSafeRegexPattern(pattern)) { + // If pattern is unsafe, return text without highlighting + return text + } + const regex = new RegExp(pattern, 'gi') return text.replace( regex, '$1' diff --git a/src/renderer/src/stores/mcp.ts b/src/renderer/src/stores/mcp.ts index e113b3dc4..85099d859 100644 --- a/src/renderer/src/stores/mcp.ts +++ b/src/renderer/src/stores/mcp.ts @@ -7,6 +7,7 @@ import { MCP_EVENTS } from '@/events' import { useI18n } from 'vue-i18n' import { useChatStore } from './chat' import { useQuery, type UseMutationReturn, type UseQueryReturn } from '@pinia/colada' +import { isSafeRegexPattern } from '@shared/regexValidator' import type { McpClient, MCPConfig, @@ -744,6 +745,12 @@ export const useMcpStore = defineStore('mcp', () => { if (toolName === 'search_files') { if (!params.regex) params.regex = '\\.md$' if (!params.path) params.path = '.' + // Validate regex pattern for ReDoS safety + if (params.regex && typeof params.regex === 'string' && !isSafeRegexPattern(params.regex)) { + throw new Error( + 'Regular expression pattern is potentially unsafe and may cause ReDoS. Please use a simpler, safer pattern.' + ) + } if (!params.file_pattern) { const match = params.regex.match(/\.(\w+)\$/) if (match) { diff --git a/src/shared/regexValidator.ts b/src/shared/regexValidator.ts new file mode 100644 index 000000000..5aca2530e --- /dev/null +++ b/src/shared/regexValidator.ts @@ -0,0 +1,47 @@ +import safeRegex from 'safe-regex2' + +const DEFAULT_MAX_PATTERN_LENGTH = 1000 + +/** + * Validate regex pattern for ReDoS safety + * @param pattern The regex pattern to validate + * @param maxLength Maximum allowed pattern length (default: 1000) + * @throws Error if pattern is unsafe or exceeds length limit + */ +export function validateRegexPattern( + pattern: string, + maxLength: number = DEFAULT_MAX_PATTERN_LENGTH +): void { + // Check length limit + if (pattern.length > maxLength) { + throw new Error( + `Regular expression pattern exceeds maximum length of ${maxLength} characters. Pattern length: ${pattern.length}` + ) + } + + // Check for ReDoS vulnerability using safe-regex2 + if (!safeRegex(pattern)) { + throw new Error( + `Regular expression pattern is potentially unsafe and may cause ReDoS (Regular Expression Denial of Service). Please use a simpler, safer pattern.` + ) + } +} + +/** + * Check if a regex pattern is safe (non-throwing version) + * @param pattern The regex pattern to check + * @param maxLength Maximum allowed pattern length (default: 1000) + * @returns true if pattern is safe, false otherwise + */ +export function isSafeRegexPattern( + pattern: string, + maxLength: number = DEFAULT_MAX_PATTERN_LENGTH +): boolean { + // Check length limit + if (pattern.length > maxLength) { + return false + } + + // Check for ReDoS vulnerability using safe-regex2 + return safeRegex(pattern) +} From 1f9df33794035bef478bfa45dd5937950d5e9526 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 00:47:57 +0800 Subject: [PATCH 20/29] fix: remove deprecated code --- .../agent/agentToolManager.ts | 22 ++++++++++++++++++- .../src/components/chat-input/ChatInput.vue | 13 +---------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts index 6f3e5e7f6..381a148fd 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts @@ -82,7 +82,10 @@ export class AgentToolManager { } // Route to FileSystem tools - if (this.fileSystemHandler) { + if (this.isFileSystemTool(toolName)) { + if (!this.fileSystemHandler) { + throw new Error(`FileSystem handler not initialized for tool: ${toolName}`) + } return await this.callFileSystemTool(toolName, args) } @@ -379,6 +382,23 @@ export class AgentToolManager { ] } + private isFileSystemTool(toolName: string): boolean { + const filesystemTools = [ + 'read_file', + 'write_file', + 'list_directory', + 'create_directory', + 'move_files', + 'edit_text', + 'search_files', + 'directory_tree', + 'get_file_info', + 'grep_search', + 'text_replace' + ] + return filesystemTools.includes(toolName) + } + private async callFileSystemTool( toolName: string, args: Record diff --git a/src/renderer/src/components/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue index c7d43837d..99a2dcdec 100644 --- a/src/renderer/src/components/chat-input/ChatInput.vue +++ b/src/renderer/src/components/chat-input/ChatInput.vue @@ -80,18 +80,15 @@ variant="outline" :class="[ 'w-7 h-7 text-xs rounded-lg', - variant === 'chat' ? 'text-accent-foreground' : '', - isModeLocked ? 'cursor-not-allowed opacity-60' : '' + variant === 'chat' ? 'text-accent-foreground' : '' ]" size="icon" :title="t('chat.mode.current', { mode: chatMode.currentLabel.value })" - :disabled="isModeLocked" > @@ -730,7 +727,6 @@ const activeModelSource = computed(() => { } return config.activeModel.value }) -const isModeLocked = computed(() => false) const acpWorkdir = useAcpWorkdir({ activeModel: activeModelSource, @@ -809,7 +805,6 @@ const onWebSearchClick = async () => { } const handleModeSelect = async (mode: ChatMode) => { - if (isModeLocked.value) return await chatMode.setMode(mode) if (conversationId.value && chatMode.currentMode.value === mode) { try { @@ -939,12 +934,6 @@ watch( } ) -watch(isModeLocked, (locked) => { - if (locked) { - modeSelectOpen.value = false - } -}) - watch( () => [conversationId.value, chatStore.chatConfig.chatMode] as const, async ([activeId, storedMode]) => { From fbca79fdaaec3a260537168c5367fd4e9215198b Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 10:07:54 +0800 Subject: [PATCH 21/29] chore: use logger to replace console --- .../presenter/llmProviderPresenter/agent/agentToolManager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts index 381a148fd..5a47db8a2 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts @@ -5,6 +5,7 @@ import { z } from 'zod' import fs from 'fs' import path from 'path' import { app } from 'electron' +import logger from '@shared/logger' import { AgentFileSystemHandler } from './agentFileSystemHandler' interface AgentToolManagerOptions { @@ -55,7 +56,7 @@ export class AgentToolManager { const yoDefs = await this.yoBrowserPresenter.getToolDefinitions(context.supportsVision) defs.push(...yoDefs) } catch (error) { - console.warn('[AgentToolManager] Failed to load Yo Browser tool definitions', error) + logger.warn('[AgentToolManager] Failed to load Yo Browser tool definitions', { error }) } } From a773ebc6fec7fa459660b16d4d2c4f351ae2a44c Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 11:31:24 +0800 Subject: [PATCH 22/29] fix: add error handle for tool call --- .../managers/errorClassification.ts | 102 ++++++++++++++++++ .../managers/toolCallProcessor.ts | 14 +++ 2 files changed, 116 insertions(+) create mode 100644 src/main/presenter/llmProviderPresenter/managers/errorClassification.ts diff --git a/src/main/presenter/llmProviderPresenter/managers/errorClassification.ts b/src/main/presenter/llmProviderPresenter/managers/errorClassification.ts new file mode 100644 index 000000000..e31cf9dd1 --- /dev/null +++ b/src/main/presenter/llmProviderPresenter/managers/errorClassification.ts @@ -0,0 +1,102 @@ +/** + * Error classification utility to identify non-retryable errors. + * Non-retryable errors should stop the agent loop, while all other errors + * should allow the loop to continue so LLM can decide whether to retry. + */ + +/** + * Checks if an error is non-retryable (should stop the agent loop). + * + * Non-retryable errors are those that won't be resolved by retrying: + * - Invalid input format (invalid URL, malformed JSON, etc.) + * - Explicit permission denied + * - Schema validation failures + * - Authentication errors that can't be resolved by retry + * - Malformed requests that won't work on retry + * + * All other errors (network errors, timeouts, destroyed objects, etc.) + * are considered retryable by default and should allow the loop to continue. + * + * @param error - The error to classify (Error object or string) + * @returns true if the error is non-retryable (should stop loop), false otherwise + */ +export function isNonRetryableError(error: Error | string): boolean { + const errorMessage = error instanceof Error ? error.message : String(error) + const lowerMessage = errorMessage.toLowerCase() + + // Invalid URL format - won't work on retry + if ( + lowerMessage.includes('invalid url') || + lowerMessage.includes('malformed url') || + lowerMessage.includes('url parse error') || + lowerMessage.includes('invalid uri') + ) { + return true + } + + // Invalid JSON format in arguments - won't work on retry + if ( + lowerMessage.includes('invalid json') || + lowerMessage.includes('json parse error') || + lowerMessage.includes('unexpected token') || + lowerMessage.includes('malformed json') + ) { + return true + } + + // Schema validation failures - wrong parameter types, missing required fields + if ( + lowerMessage.includes('schema validation') || + lowerMessage.includes('validation error') || + lowerMessage.includes('invalid argument') || + lowerMessage.includes('invalid parameter') || + lowerMessage.includes('required field') || + lowerMessage.includes('missing required') || + lowerMessage.includes('type error') || + lowerMessage.includes('type mismatch') + ) { + return true + } + + // Explicit permission denied (user explicitly denied, not a transient error) + if ( + lowerMessage.includes('permission denied') && + (lowerMessage.includes('explicitly') || lowerMessage.includes('user denied')) + ) { + return true + } + + // Authentication errors that can't be resolved by retry + if ( + lowerMessage.includes('authentication failed') && + (lowerMessage.includes('invalid credentials') || + lowerMessage.includes('invalid api key') || + lowerMessage.includes('unauthorized')) + ) { + return true + } + + // Malformed requests that won't work on retry + if ( + lowerMessage.includes('malformed request') || + lowerMessage.includes('invalid request format') || + lowerMessage.includes('bad request format') + ) { + return true + } + + // Tool definition not found - won't work on retry + if (lowerMessage.includes('tool definition not found') || lowerMessage.includes('unknown tool')) { + return true + } + + // All other errors are considered retryable by default + // This includes: + // - "Object has been destroyed" / "WebContents destroyed" + // - Network errors (SSL failures, connection errors, error codes -3, -100) + // - Timeout errors + // - Loading errors + // - Transient service errors + // - Rate limiting + return false +} diff --git a/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts b/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts index b16dee2c3..fec270392 100644 --- a/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts +++ b/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts @@ -6,6 +6,7 @@ import { MCPToolResponse, ModelConfig } from '@shared/presenter' +import { isNonRetryableError } from './errorClassification' interface ToolCallProcessorOptions { getAllToolDefinitions: (context: ToolCallExecutionContext) => Promise @@ -230,6 +231,11 @@ export class ToolCallProcessor { ) const errorMessage = toolError instanceof Error ? toolError.message : String(toolError) + // Check if error is non-retryable (should stop the loop) + const errorForClassification: Error | string = + toolError instanceof Error ? toolError : String(toolError) + const isNonRetryable = isNonRetryableError(errorForClassification) + this.appendToolError( context.conversationMessages, context.modelConfig, @@ -251,6 +257,14 @@ export class ToolCallProcessor { tool_call_server_description: toolDef.server.description } } + + // If error is non-retryable, stop the loop + // Otherwise, keep needContinueConversation = true (default) to let LLM decide + if (isNonRetryable) { + needContinueConversation = false + break + } + // For retryable errors, continue the loop (needContinueConversation remains true) } } From 74d90f13b7a0f553e510225b16e3ca523326a1d1 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 13:24:22 +0800 Subject: [PATCH 23/29] fix: first time open browser stop loop --- .../presenter/browser/YoBrowserPresenter.ts | 6 +- src/main/presenter/browser/tools/navigate.ts | 103 ++++++++++++++++-- src/main/presenter/index.ts | 8 +- .../managers/agentLoopHandler.ts | 5 +- .../managers/toolCallProcessor.ts | 3 +- src/renderer/src/components/NewThread.vue | 1 - .../src/components/chat-input/ChatInput.vue | 1 - 7 files changed, 111 insertions(+), 16 deletions(-) diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index 7f74aa08d..71ae902e4 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -114,7 +114,8 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { await this.syncActiveTabId() if (!this.activeTabId) return null const tab = this.tabIdToBrowserTab.get(this.activeTabId) - return tab ? this.toTabInfo(tab) : null + const result = tab ? this.toTabInfo(tab) : null + return result } async getTabById(tabId: string): Promise { @@ -184,7 +185,8 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { this.setupTabListeners(tabKey, viewId as number, view.webContents) this.emitTabCreated(browserTab) this.emitTabCount() - return this.toTabInfo(browserTab) + const result = this.toTabInfo(browserTab) + return result } async navigateTab(tabId: string, url: string, timeoutMs?: number): Promise { diff --git a/src/main/presenter/browser/tools/navigate.ts b/src/main/presenter/browser/tools/navigate.ts index 805c3fc21..b75c0065f 100644 --- a/src/main/presenter/browser/tools/navigate.ts +++ b/src/main/presenter/browser/tools/navigate.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import type { BrowserToolDefinition } from './types' +import type { BrowserToolDefinition, ToolResult } from './types' const NavigateArgsSchema = z.object({ url: z.string().url().describe('URL to navigate to'), @@ -93,36 +93,125 @@ export function createNavigateTools(): BrowserToolDefinition[] { if (context.createTab) { const newTab = await context.createTab(parsed.url) if (newTab) { - return { + // Add a small delay to ensure BrowserTab is fully initialized + // This is especially important on first call when browser window is just created + await new Promise((resolve) => setTimeout(resolve, 100)) + // Get the BrowserTab object and wait for navigation to complete + // Note: createTab already started navigation via tabPresenter.createTab, + // so we just need to wait for it to complete + const browserTab = await context.getTab(newTab.id) + if (browserTab) { + try { + // createTab already started navigation via tabPresenter.createTab + // If tab is loading, wait for it to complete instead of calling navigate again + if (browserTab.contents.isLoading()) { + // Wait for current navigation to complete + await new Promise((resolve, reject) => { + let timeout: ReturnType + let onStopLoading: () => void + let onFailLoad: ( + _event: unknown, + errorCode: number, + errorDescription: string + ) => void + + const cleanup = () => { + clearTimeout(timeout) + browserTab.contents.removeListener('did-stop-loading', onStopLoading) + browserTab.contents.removeListener('did-fail-load', onFailLoad) + } + + onStopLoading = () => { + cleanup() + resolve() + } + + onFailLoad = (_event, errorCode, errorDescription) => { + cleanup() + reject(new Error(`Navigation failed ${errorCode}: ${errorDescription}`)) + } + + timeout = setTimeout(() => { + cleanup() + reject(new Error('Timeout waiting for page load')) + }, 15000) + + browserTab.contents.once('did-stop-loading', onStopLoading) + browserTab.contents.once('did-fail-load', onFailLoad) + }) + + // Check if URL matches after loading + const finalUrl = browserTab.contents.getURL() + if (finalUrl !== parsed.url) { + // URL doesn't match, need to navigate + await browserTab.navigate(parsed.url, 15000) // 15 second timeout + } + } else { + // Tab is not loading, check if URL matches + const currentUrl = browserTab.contents.getURL() + if (currentUrl !== parsed.url) { + // URL doesn't match, need to navigate + await browserTab.navigate(parsed.url, 15000) // 15 second timeout + } + } + + const result: ToolResult = { + content: [ + { + type: 'text' as const, + text: `Created new tab and navigated to ${parsed.url}\nTitle: ${browserTab.title || 'unknown'}` + } + ] + } + return result + } catch (error) { + // If navigation fails, still return success since tab was created + // The tab might still be loading or the URL might be correct + const result: ToolResult = { + content: [ + { + type: 'text' as const, + text: `Created new tab and navigated to ${parsed.url}\nTitle: ${browserTab.title || 'unknown'}` + } + ] + } + return result + } + } + // Fallback if getTab fails + const result: ToolResult = { content: [ { - type: 'text', + type: 'text' as const, text: `Created new tab and navigated to ${parsed.url}\nTitle: ${newTab.title || 'unknown'}` } ] } + return result } } - return { + const errorResult: ToolResult = { content: [ { - type: 'text', + type: 'text' as const, text: 'No active tab available' } ], isError: true } + return errorResult } await tab.navigate(parsed.url) - return { + const result: ToolResult = { content: [ { - type: 'text', + type: 'text' as const, text: `Navigated to ${parsed.url}\nTitle: ${tab.title || 'unknown'}` } ] } + return result } }, { diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts index 3cab1d5a2..4f4a898e0 100644 --- a/src/main/presenter/index.ts +++ b/src/main/presenter/index.ts @@ -166,9 +166,13 @@ export class Presenter implements IPresenter { // 设置特殊事件的处理逻辑 this.setupSpecialEventHandlers() - // 应用主窗口准备就绪时触发初始化 + // 应用主窗口准备就绪时触发初始化(只执行一次) + let initCalled = false eventBus.on(WINDOW_EVENTS.READY_TO_SHOW, () => { - this.init() + if (!initCalled) { + initCalled = true + this.init() + } }) } diff --git a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts index 6c7373478..23ccb3e99 100644 --- a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts +++ b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts @@ -640,7 +640,9 @@ export class AgentLoopHandler { yield value } - if (abortController.signal.aborted) break // Check after tool loop + if (abortController.signal.aborted) { + break // Check after tool loop + } if (!needContinueConversation) { // If max tool calls reached or explicit stop, break outer loop @@ -667,7 +669,6 @@ export class AgentLoopHandler { needContinueConversation = false // Stop loop on inner error } } // --- End of Agent Loop (while) --- - console.log( `[Agent Loop] Agent loop completed for event: ${eventId}, iterations: ${toolCallCount}` ) diff --git a/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts b/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts index fec270392..656e1d584 100644 --- a/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts +++ b/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts @@ -308,9 +308,10 @@ export class ToolCallProcessor { }) } + const toolContent = this.stringifyToolContent(toolResponse.content) conversationMessages.push({ role: 'tool', - content: this.stringifyToolContent(toolResponse.content), + content: toolContent, tool_call_id: toolCall.id }) } diff --git a/src/renderer/src/components/NewThread.vue b/src/renderer/src/components/NewThread.vue index 1342b1867..62f4ca676 100644 --- a/src/renderer/src/components/NewThread.vue +++ b/src/renderer/src/components/NewThread.vue @@ -503,7 +503,6 @@ onBeforeUnmount(() => { }) const handleSend = async (content: UserMessageContent) => { - // #region agent log const chatInput = chatInputRef.value const pathFromInput = chatInput?.getAgentWorkspacePath?.() const pathFromStore = chatStore.chatConfig.agentWorkspacePath diff --git a/src/renderer/src/components/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue index 99a2dcdec..26ba39223 100644 --- a/src/renderer/src/components/chat-input/ChatInput.vue +++ b/src/renderer/src/components/chat-input/ChatInput.vue @@ -960,7 +960,6 @@ defineExpose({ appendMention: (name: string) => editorComposable.appendMention(name, mentionData), restoreFocus, getAgentWorkspacePath: () => { - // #region agent log const mode = chatMode.currentMode.value if (mode !== 'agent') return null return workspace.workspacePath.value From 289464d151b839ebd662be4c472950a579b79de8 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 14:21:53 +0800 Subject: [PATCH 24/29] fix: log and no blocking async --- src/main/presenter/browser/tools/navigate.ts | 9 +- .../agent/agentToolManager.ts | 240 +++++++++--------- .../managers/agentLoopHandler.ts | 10 +- 3 files changed, 132 insertions(+), 127 deletions(-) diff --git a/src/main/presenter/browser/tools/navigate.ts b/src/main/presenter/browser/tools/navigate.ts index b75c0065f..2d6e95938 100644 --- a/src/main/presenter/browser/tools/navigate.ts +++ b/src/main/presenter/browser/tools/navigate.ts @@ -165,15 +165,16 @@ export function createNavigateTools(): BrowserToolDefinition[] { } return result } catch (error) { - // If navigation fails, still return success since tab was created - // The tab might still be loading or the URL might be correct + console.error('[browser_navigate] Failed to navigate newly created tab:', error) + const errorMessage = error instanceof Error ? error.message : String(error) const result: ToolResult = { content: [ { type: 'text' as const, - text: `Created new tab and navigated to ${parsed.url}\nTitle: ${browserTab.title || 'unknown'}` + text: `Failed to navigate new tab ${browserTab.tabId} to ${parsed.url}\nError: ${errorMessage}\nTitle: ${browserTab.title || 'unknown'}` } - ] + ], + isError: true } return result } diff --git a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts index 5a47db8a2..43c566ab1 100644 --- a/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts @@ -17,6 +17,92 @@ export class AgentToolManager { private readonly yoBrowserPresenter: IYoBrowserPresenter private agentWorkspacePath: string | null private fileSystemHandler: AgentFileSystemHandler | null = null + private readonly fileSystemSchemas = { + read_file: z.object({ + paths: z.array(z.string()).min(1) + }), + write_file: z.object({ + path: z.string(), + content: z.string() + }), + list_directory: z.object({ + path: z.string(), + showDetails: z.boolean().default(false), + sortBy: z.enum(['name', 'size', 'modified']).default('name') + }), + create_directory: z.object({ + path: z.string() + }), + move_files: z.object({ + sources: z.array(z.string()).min(1), + destination: z.string() + }), + edit_text: z.object({ + path: z.string(), + operation: z.enum(['replace_pattern', 'edit_lines']), + pattern: z + .string() + .max(1000) + .describe( + 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS). Required when operation is "replace_pattern"' + ) + .optional(), + replacement: z.string().optional(), + global: z.boolean().default(true), + caseSensitive: z.boolean().default(false), + edits: z + .array( + z.object({ + oldText: z.string(), + newText: z.string() + }) + ) + .optional(), + dryRun: z.boolean().default(false) + }), + search_files: z.object({ + path: z.string().optional(), + pattern: z.string(), + searchType: z.enum(['glob', 'name']).default('glob'), + excludePatterns: z.array(z.string()).optional().default([]), + caseSensitive: z.boolean().default(false), + maxResults: z.number().default(1000) + }), + grep_search: z.object({ + path: z.string(), + pattern: z + .string() + .max(1000) + .describe( + 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS)' + ), + filePattern: z.string().optional(), + recursive: z.boolean().default(true), + caseSensitive: z.boolean().default(false), + includeLineNumbers: z.boolean().default(true), + contextLines: z.number().default(0), + maxResults: z.number().default(100) + }), + text_replace: z.object({ + path: z.string(), + pattern: z + .string() + .max(1000) + .describe( + 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS)' + ), + replacement: z.string(), + global: z.boolean().default(true), + caseSensitive: z.boolean().default(false), + dryRun: z.boolean().default(false) + }), + directory_tree: z.object({ + path: z.string() + }), + get_file_info: z.object({ + path: z.string() + }) + } constructor(options: AgentToolManagerOptions) { this.yoBrowserPresenter = options.yoBrowserPresenter @@ -94,108 +180,14 @@ export class AgentToolManager { } private getFileSystemToolDefinitions(): MCPToolDefinition[] { - const ReadFileSchema = z.object({ - paths: z.array(z.string()).min(1) - }) - - const WriteFileSchema = z.object({ - path: z.string(), - content: z.string() - }) - - const ListDirectorySchema = z.object({ - path: z.string(), - showDetails: z.boolean().default(false), - sortBy: z.enum(['name', 'size', 'modified']).default('name') - }) - - const CreateDirectorySchema = z.object({ - path: z.string() - }) - - const MoveFilesSchema = z.object({ - sources: z.array(z.string()).min(1), - destination: z.string() - }) - - const EditTextSchema = z.object({ - path: z.string(), - operation: z.enum(['replace_pattern', 'edit_lines']), - pattern: z - .string() - .max(1000) - .describe( - 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS). Required when operation is "replace_pattern"' - ) - .optional(), - replacement: z.string().optional(), - global: z.boolean().default(true), - caseSensitive: z.boolean().default(false), - edits: z - .array( - z.object({ - oldText: z.string(), - newText: z.string() - }) - ) - .optional(), - dryRun: z.boolean().default(false) - }) - - const FileSearchSchema = z.object({ - path: z.string().optional(), - pattern: z.string(), - searchType: z.enum(['glob', 'name']).default('glob'), - excludePatterns: z.array(z.string()).optional().default([]), - caseSensitive: z.boolean().default(false), - maxResults: z.number().default(1000) - }) - - const GrepSearchSchema = z.object({ - path: z.string(), - pattern: z - .string() - .max(1000) - .describe( - 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS)' - ), - filePattern: z.string().optional(), - recursive: z.boolean().default(true), - caseSensitive: z.boolean().default(false), - includeLineNumbers: z.boolean().default(true), - contextLines: z.number().default(0), - maxResults: z.number().default(100) - }) - - const TextReplaceSchema = z.object({ - path: z.string(), - pattern: z - .string() - .max(1000) - .describe( - 'Regular expression pattern (max 1000 characters, must be safe and not cause ReDoS)' - ), - replacement: z.string(), - global: z.boolean().default(true), - caseSensitive: z.boolean().default(false), - dryRun: z.boolean().default(false) - }) - - const DirectoryTreeSchema = z.object({ - path: z.string() - }) - - const GetFileInfoSchema = z.object({ - path: z.string() - }) - + const schemas = this.fileSystemSchemas return [ { type: 'function', function: { name: 'read_file', description: 'Read the contents of one or more files', - parameters: zodToJsonSchema(ReadFileSchema) as { + parameters: zodToJsonSchema(schemas.read_file) as { type: string properties: Record required?: string[] @@ -212,7 +204,7 @@ export class AgentToolManager { function: { name: 'write_file', description: 'Write content to a file', - parameters: zodToJsonSchema(WriteFileSchema) as { + parameters: zodToJsonSchema(schemas.write_file) as { type: string properties: Record required?: string[] @@ -229,7 +221,7 @@ export class AgentToolManager { function: { name: 'list_directory', description: 'List files and directories in a path', - parameters: zodToJsonSchema(ListDirectorySchema) as { + parameters: zodToJsonSchema(schemas.list_directory) as { type: string properties: Record required?: string[] @@ -246,7 +238,7 @@ export class AgentToolManager { function: { name: 'create_directory', description: 'Create a directory', - parameters: zodToJsonSchema(CreateDirectorySchema) as { + parameters: zodToJsonSchema(schemas.create_directory) as { type: string properties: Record required?: string[] @@ -263,7 +255,7 @@ export class AgentToolManager { function: { name: 'move_files', description: 'Move or rename files and directories', - parameters: zodToJsonSchema(MoveFilesSchema) as { + parameters: zodToJsonSchema(schemas.move_files) as { type: string properties: Record required?: string[] @@ -281,7 +273,7 @@ export class AgentToolManager { name: 'edit_text', description: 'Edit text files using pattern replacement or line-based editing. When using "replace_pattern" operation, the pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks.', - parameters: zodToJsonSchema(EditTextSchema) as { + parameters: zodToJsonSchema(schemas.edit_text) as { type: string properties: Record required?: string[] @@ -298,7 +290,7 @@ export class AgentToolManager { function: { name: 'search_files', description: 'Search for files matching a pattern', - parameters: zodToJsonSchema(FileSearchSchema) as { + parameters: zodToJsonSchema(schemas.search_files) as { type: string properties: Record required?: string[] @@ -315,7 +307,7 @@ export class AgentToolManager { function: { name: 'directory_tree', description: 'Get a recursive directory tree as JSON', - parameters: zodToJsonSchema(DirectoryTreeSchema) as { + parameters: zodToJsonSchema(schemas.directory_tree) as { type: string properties: Record required?: string[] @@ -332,7 +324,7 @@ export class AgentToolManager { function: { name: 'get_file_info', description: 'Get detailed metadata about a file or directory', - parameters: zodToJsonSchema(GetFileInfoSchema) as { + parameters: zodToJsonSchema(schemas.get_file_info) as { type: string properties: Record required?: string[] @@ -350,7 +342,7 @@ export class AgentToolManager { name: 'grep_search', description: 'Search file contents using a regular expression. The pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks.', - parameters: zodToJsonSchema(GrepSearchSchema) as { + parameters: zodToJsonSchema(schemas.grep_search) as { type: string properties: Record required?: string[] @@ -368,7 +360,7 @@ export class AgentToolManager { name: 'text_replace', description: 'Replace text in a file using a regular expression. The pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks.', - parameters: zodToJsonSchema(TextReplaceSchema) as { + parameters: zodToJsonSchema(schemas.text_replace) as { type: string properties: Record required?: string[] @@ -408,29 +400,41 @@ export class AgentToolManager { throw new Error('FileSystem handler not initialized') } + const schema = this.fileSystemSchemas[toolName as keyof typeof this.fileSystemSchemas] + if (!schema) { + throw new Error(`No schema found for FileSystem tool: ${toolName}`) + } + + const validationResult = schema.safeParse(args) + if (!validationResult.success) { + throw new Error(`Invalid arguments for ${toolName}: ${validationResult.error.message}`) + } + + const parsedArgs = validationResult.data + switch (toolName) { case 'read_file': - return await this.fileSystemHandler.readFile(args) + return await this.fileSystemHandler.readFile(parsedArgs) case 'write_file': - return await this.fileSystemHandler.writeFile(args) + return await this.fileSystemHandler.writeFile(parsedArgs) case 'list_directory': - return await this.fileSystemHandler.listDirectory(args) + return await this.fileSystemHandler.listDirectory(parsedArgs) case 'create_directory': - return await this.fileSystemHandler.createDirectory(args) + return await this.fileSystemHandler.createDirectory(parsedArgs) case 'move_files': - return await this.fileSystemHandler.moveFiles(args) + return await this.fileSystemHandler.moveFiles(parsedArgs) case 'edit_text': - return await this.fileSystemHandler.editText(args) + return await this.fileSystemHandler.editText(parsedArgs) case 'search_files': - return await this.fileSystemHandler.searchFiles(args) + return await this.fileSystemHandler.searchFiles(parsedArgs) case 'directory_tree': - return await this.fileSystemHandler.directoryTree(args) + return await this.fileSystemHandler.directoryTree(parsedArgs) case 'get_file_info': - return await this.fileSystemHandler.getFileInfo(args) + return await this.fileSystemHandler.getFileInfo(parsedArgs) case 'grep_search': - return await this.fileSystemHandler.grepSearch(args) + return await this.fileSystemHandler.grepSearch(parsedArgs) case 'text_replace': - return await this.fileSystemHandler.textReplace(args) + return await this.fileSystemHandler.textReplace(parsedArgs) default: throw new Error(`Unknown FileSystem tool: ${toolName}`) } @@ -441,7 +445,7 @@ export class AgentToolManager { try { fs.mkdirSync(tempDir, { recursive: true }) } catch (error) { - console.warn( + logger.warn( '[AgentToolManager] Failed to create default workspace, using system temp:', error ) diff --git a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts index 23ccb3e99..8d6e5ed95 100644 --- a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts +++ b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts @@ -84,10 +84,10 @@ export class AgentLoopHandler { return this.toolPresenter } - private getDefaultAgentWorkspacePath(conversationId?: string | null): string { + private async getDefaultAgentWorkspacePath(conversationId?: string | null): Promise { const tempRoot = path.join(app.getPath('temp'), 'deepchat-agent', 'workspaces') try { - fs.mkdirSync(tempRoot, { recursive: true }) + await fs.promises.mkdir(tempRoot, { recursive: true }) } catch (error) { console.warn( '[AgentLoopHandler] Failed to create default workspace root, using system temp:', @@ -102,7 +102,7 @@ export class AgentLoopHandler { const workspaceDir = path.join(tempRoot, conversationId) try { - fs.mkdirSync(workspaceDir, { recursive: true }) + await fs.promises.mkdir(workspaceDir, { recursive: true }) return workspaceDir } catch (error) { console.warn( @@ -120,8 +120,8 @@ export class AgentLoopHandler { const trimmedPath = currentPath?.trim() if (trimmedPath) return trimmedPath - const fallback = this.getDefaultAgentWorkspacePath(conversationId ?? null) - if (conversationId) { + const fallback = await this.getDefaultAgentWorkspacePath(conversationId ?? null) + if (conversationId && fallback) { try { await presenter.threadPresenter.updateConversationSettings(conversationId, { agentWorkspacePath: fallback From ff8701a1fc5e854ad03c7d48a62698e4a0e9c2f0 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 17:35:33 +0800 Subject: [PATCH 25/29] fix: add browser position --- .../presenter/browser/YoBrowserPresenter.ts | 115 +++++++++++++++++- src/main/presenter/tabPresenter.ts | 3 +- src/main/presenter/windowPresenter/index.ts | 2 +- 3 files changed, 112 insertions(+), 8 deletions(-) diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index 71ae902e4..04836c62e 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -1,4 +1,5 @@ -import { BrowserWindow, WebContents } from 'electron' +import { BrowserWindow, WebContents, screen } from 'electron' +import type { Rectangle } from 'electron' import { eventBus, SendTarget } from '@/eventbus' import { TAB_EVENTS, YO_BROWSER_EVENTS } from '@/events' import { BrowserTabInfo, BrowserContextSnapshot, ScreenshotOptions } from '@shared/types/browser' @@ -43,13 +44,19 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { // Lazy initialization: only create browser window/tabs when explicitly requested. } - async ensureWindow(options?: { showOnReady?: boolean }): Promise { + async ensureWindow(options?: { + showOnReady?: boolean + x?: number + y?: number + }): Promise { const window = this.getWindow() if (window) return window.id this.windowId = await this.windowPresenter.createShellWindow({ windowType: 'browser', - showOnReady: options?.showOnReady ?? false + showOnReady: options?.showOnReady ?? false, + x: options?.x, + y: options?.y }) const created = this.getWindow() @@ -69,14 +76,56 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { if (!this.explicitlyOpened) { this.explicitlyOpened = true } - await this.ensureWindow({ showOnReady: true }) + + const existingWindow = this.getWindow() + const referenceBounds = existingWindow + ? this.getReferenceBounds(existingWindow.id) + : this.getReferenceBounds() + + // Calculate position before creating window if it doesn't exist + let initialPosition: { x: number; y: number } | undefined + if (!existingWindow && referenceBounds) { + // Use default window size for calculation (will be adjusted by windowStateManager) + const defaultBounds: Rectangle = { + x: 0, + y: 0, + width: 800, + height: 620 + } + initialPosition = this.calculateWindowPosition(defaultBounds, referenceBounds) + } + + await this.ensureWindow({ + showOnReady: false, + x: initialPosition?.x, + y: initialPosition?.y + }) + if (this.tabIdToBrowserTab.size === 0) { await this.createTab('about:blank') } + const window = this.getWindow() if (window && !window.isDestroyed()) { - this.windowPresenter.show(window.id) - this.emitVisibility(true) + // If window already existed, recalculate position based on actual bounds + if (existingWindow) { + const currentReferenceBounds = this.getReferenceBounds(window.id) + const position = this.calculateWindowPosition(window.getBounds(), currentReferenceBounds) + window.setPosition(position.x, position.y) + } + + const reveal = () => { + if (!window.isDestroyed()) { + this.windowPresenter.show(window.id) + this.emitVisibility(true) + } + } + // If window is already visible, it's ready to show + if (window.isVisible()) { + reveal() + } else { + window.once('ready-to-show', reveal) + } } } @@ -372,6 +421,60 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { return window } + private getReferenceBounds(excludeWindowId?: number): Rectangle | undefined { + const focused = this.windowPresenter.getFocusedWindow() + if (focused && !focused.isDestroyed() && focused.id !== excludeWindowId) { + return focused.getBounds() + } + const fallback = this.windowPresenter + .getAllWindows() + .find((candidate) => candidate.id !== excludeWindowId) + return fallback?.getBounds() + } + + private calculateWindowPosition( + windowBounds: Rectangle, + referenceBounds?: Rectangle + ): { x: number; y: number } { + const gap = 20 + const display = referenceBounds + ? screen.getDisplayMatching(referenceBounds) + : screen.getDisplayMatching(windowBounds) + const { workArea } = display + + let targetX: number + if (referenceBounds) { + const rightX = referenceBounds.x + referenceBounds.width + gap + if (rightX + windowBounds.width <= workArea.x + workArea.width) { + targetX = rightX + } else { + const leftX = referenceBounds.x - windowBounds.width - gap + if (leftX >= workArea.x) { + targetX = leftX + } else { + targetX = workArea.x + workArea.width - windowBounds.width + } + } + } else { + targetX = workArea.x + workArea.width - windowBounds.width - gap + } + + const idealY = referenceBounds + ? referenceBounds.y + (referenceBounds.height - windowBounds.height) / 2 + : workArea.y + (workArea.height - windowBounds.height) / 2 + + const clampedX = Math.max( + workArea.x, + Math.min(targetX, workArea.x + workArea.width - windowBounds.width) + ) + const clampedY = Math.max( + workArea.y, + Math.min(idealY, workArea.y + workArea.height - windowBounds.height) + ) + + return { x: Math.round(clampedX), y: Math.round(clampedY) } + } + private handleWindowClosed(): void { this.cleanup() this.explicitlyOpened = false diff --git a/src/main/presenter/tabPresenter.ts b/src/main/presenter/tabPresenter.ts index 6db1ad6a3..1f12b36ea 100644 --- a/src/main/presenter/tabPresenter.ts +++ b/src/main/presenter/tabPresenter.ts @@ -743,7 +743,8 @@ export class TabPresenter implements ITabPresenter { // Re-adding ensures it's on top in most view hierarchies window.contentView.addChildView(view) this.updateViewBounds(window, view) - if (!view.webContents.isDestroyed()) { + const shouldFocus = window.isVisible() || this.getWindowType(window.id) !== 'browser' + if (shouldFocus && !view.webContents.isDestroyed()) { view.webContents.focus() } } diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 53c8ee97a..1dc63dd49 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -675,7 +675,7 @@ export class WindowPresenter implements IWindowPresenter { }): Promise { console.log('Creating new shell window.') const windowType = options?.windowType ?? 'chat' - const showOnReady = options?.showOnReady ?? true + const showOnReady = options?.showOnReady ?? windowType !== 'browser' // 根据平台选择图标 const iconFile = nativeImage.createFromPath(process.platform === 'win32' ? iconWin : icon) From 2f9cf7b1cbad935b02e5367f151a08df256bb2ae Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 19:46:50 +0800 Subject: [PATCH 26/29] fix: fix renderer event loop --- .../presenter/browser/YoBrowserPresenter.ts | 108 ++++++++++-------- src/main/presenter/tabPresenter.ts | 23 ++-- src/main/presenter/windowPresenter/index.ts | 15 +-- src/renderer/src/stores/chat.ts | 3 +- .../types/presenters/legacy.presenters.d.ts | 1 - 5 files changed, 88 insertions(+), 62 deletions(-) diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index 04836c62e..8a1f8a52c 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -31,7 +31,6 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { private readonly browserToolManager: BrowserToolManager private readonly windowPresenter: IWindowPresenter private readonly tabPresenter: ITabPresenter - private explicitlyOpened = false constructor(windowPresenter: IWindowPresenter, tabPresenter: ITabPresenter) { this.windowPresenter = windowPresenter @@ -44,17 +43,12 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { // Lazy initialization: only create browser window/tabs when explicitly requested. } - async ensureWindow(options?: { - showOnReady?: boolean - x?: number - y?: number - }): Promise { + async ensureWindow(options?: { x?: number; y?: number }): Promise { const window = this.getWindow() if (window) return window.id this.windowId = await this.windowPresenter.createShellWindow({ windowType: 'browser', - showOnReady: options?.showOnReady ?? false, x: options?.x, y: options?.y }) @@ -73,10 +67,6 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { } async show(): Promise { - if (!this.explicitlyOpened) { - this.explicitlyOpened = true - } - const existingWindow = this.getWindow() const referenceBounds = existingWindow ? this.getReferenceBounds(existingWindow.id) @@ -85,18 +75,17 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { // Calculate position before creating window if it doesn't exist let initialPosition: { x: number; y: number } | undefined if (!existingWindow && referenceBounds) { - // Use default window size for calculation (will be adjusted by windowStateManager) + // Use default window size for calculation (browser window is 600px wide) const defaultBounds: Rectangle = { x: 0, y: 0, - width: 800, + width: 600, height: 620 } initialPosition = this.calculateWindowPosition(defaultBounds, referenceBounds) } await this.ensureWindow({ - showOnReady: false, x: initialPosition?.x, y: initialPosition?.y }) @@ -114,17 +103,25 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { window.setPosition(position.x, position.y) } - const reveal = () => { - if (!window.isDestroyed()) { - this.windowPresenter.show(window.id) - this.emitVisibility(true) - } - } - // If window is already visible, it's ready to show - if (window.isVisible()) { - reveal() + // For existing windows, directly show them (they're already ready) + // For new windows, wait for ready-to-show event + if (existingWindow) { + // Window already exists, just show it directly + this.windowPresenter.show(window.id) + this.emitVisibility(true) } else { - window.once('ready-to-show', reveal) + // New window, wait for ready-to-show + const reveal = () => { + if (!window.isDestroyed()) { + this.windowPresenter.show(window.id) + this.emitVisibility(true) + } + } + if (window.isVisible()) { + reveal() + } else { + window.once('ready-to-show', reveal) + } } } } @@ -234,6 +231,7 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { this.setupTabListeners(tabKey, viewId as number, view.webContents) this.emitTabCreated(browserTab) this.emitTabCount() + const result = this.toTabInfo(browserTab) return result } @@ -436,40 +434,59 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { windowBounds: Rectangle, referenceBounds?: Rectangle ): { x: number; y: number } { + if (!referenceBounds) { + // 如果没有参考窗口,使用默认位置 + const display = screen.getDisplayMatching(windowBounds) + const { workArea } = display + return { + x: workArea.x + workArea.width - windowBounds.width - 20, + y: workArea.y + (workArea.height - windowBounds.height) / 2 + } + } + const gap = 20 - const display = referenceBounds - ? screen.getDisplayMatching(referenceBounds) - : screen.getDisplayMatching(windowBounds) + const display = screen.getDisplayMatching(referenceBounds) const { workArea } = display + // Browser 窗口尺寸 + const browserWidth = windowBounds.width + const browserHeight = windowBounds.height + + // 计算主窗口右侧和左侧的空间 + const spaceOnRight = workArea.x + workArea.width - (referenceBounds.x + referenceBounds.width) + const spaceOnLeft = referenceBounds.x - workArea.x + let targetX: number - if (referenceBounds) { - const rightX = referenceBounds.x + referenceBounds.width + gap - if (rightX + windowBounds.width <= workArea.x + workArea.width) { - targetX = rightX + let targetY: number + + if (spaceOnRight >= browserWidth + gap) { + // 显示在主窗口右侧 + targetX = referenceBounds.x + referenceBounds.width + gap + targetY = referenceBounds.y + (referenceBounds.height - browserHeight) / 2 + } else if (spaceOnLeft >= browserWidth + gap) { + // 显示在主窗口左侧 + targetX = referenceBounds.x - browserWidth - gap + targetY = referenceBounds.y + (referenceBounds.height - browserHeight) / 2 + } else { + // 空间不够,显示在主窗口下方 + targetX = referenceBounds.x + const spaceBelow = workArea.y + workArea.height - (referenceBounds.y + referenceBounds.height) + if (spaceBelow >= browserHeight + gap) { + targetY = referenceBounds.y + referenceBounds.height + gap } else { - const leftX = referenceBounds.x - windowBounds.width - gap - if (leftX >= workArea.x) { - targetX = leftX - } else { - targetX = workArea.x + workArea.width - windowBounds.width - } + // 下方空间也不够,显示在主窗口上方 + targetY = referenceBounds.y - browserHeight - gap } - } else { - targetX = workArea.x + workArea.width - windowBounds.width - gap } - const idealY = referenceBounds - ? referenceBounds.y + (referenceBounds.height - windowBounds.height) / 2 - : workArea.y + (workArea.height - windowBounds.height) / 2 - + // 确保窗口在屏幕范围内 const clampedX = Math.max( workArea.x, - Math.min(targetX, workArea.x + workArea.width - windowBounds.width) + Math.min(targetX, workArea.x + workArea.width - browserWidth) ) const clampedY = Math.max( workArea.y, - Math.min(idealY, workArea.y + workArea.height - windowBounds.height) + Math.min(targetY, workArea.y + workArea.height - browserHeight) ) return { x: Math.round(clampedX), y: Math.round(clampedY) } @@ -477,7 +494,6 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { private handleWindowClosed(): void { this.cleanup() - this.explicitlyOpened = false this.emitVisibility(false) this.emitTabCount() } diff --git a/src/main/presenter/tabPresenter.ts b/src/main/presenter/tabPresenter.ts index 1f12b36ea..de2adc1b5 100644 --- a/src/main/presenter/tabPresenter.ts +++ b/src/main/presenter/tabPresenter.ts @@ -654,6 +654,7 @@ export class TabPresenter implements ITabPresenter { // 检查是否是窗口的第一个标签页 const isFirstTab = this.windowTabs.get(windowId)?.length === 1 + const windowType = this.getWindowType(windowId) // 页面加载完成 if (isFirstTab) { @@ -661,12 +662,16 @@ export class TabPresenter implements ITabPresenter { // Once did-finish-load happens, emit first content loaded webContents.once('did-finish-load', () => { eventBus.sendToMain(WINDOW_EVENTS.FIRST_CONTENT_LOADED, windowId) - setTimeout(() => { - const windowPresenter = presenter.windowPresenter as any - if (windowPresenter && typeof windowPresenter.focusActiveTab === 'function') { - windowPresenter.focusActiveTab(windowId, 'initial') - } - }, 300) + // Only call focusActiveTab for chat windows, not browser windows + // Browser windows should stay hidden when created via tool calls + if (windowType !== 'browser') { + setTimeout(() => { + const windowPresenter = presenter.windowPresenter as any + if (windowPresenter && typeof windowPresenter.focusActiveTab === 'function') { + windowPresenter.focusActiveTab(windowId, 'initial') + } + }, 300) + } }) } @@ -743,7 +748,11 @@ export class TabPresenter implements ITabPresenter { // Re-adding ensures it's on top in most view hierarchies window.contentView.addChildView(view) this.updateViewBounds(window, view) - const shouldFocus = window.isVisible() || this.getWindowType(window.id) !== 'browser' + const windowType = this.getWindowType(window.id) + const isVisible = window.isVisible() + + // Focus the view if window is visible or it's not a browser window + const shouldFocus = isVisible || windowType !== 'browser' if (shouldFocus && !view.webContents.isDestroyed()) { view.webContents.focus() } diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 1dc63dd49..c39615676 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -669,21 +669,23 @@ export class WindowPresenter implements IWindowPresenter { icon?: string } windowType?: 'chat' | 'browser' - showOnReady?: boolean // ready-to-show 时是否自动显示 x?: number // 初始 X 坐标 y?: number // 初始 Y 坐标 }): Promise { console.log('Creating new shell window.') const windowType = options?.windowType ?? 'chat' - const showOnReady = options?.showOnReady ?? windowType !== 'browser' // 根据平台选择图标 const iconFile = nativeImage.createFromPath(process.platform === 'win32' ? iconWin : icon) + // 根据窗口类型设置默认宽度 + const defaultWidth = windowType === 'browser' ? 600 : 800 + const defaultHeight = 620 + // 使用窗口状态管理器恢复位置和尺寸 const shellWindowState = windowStateManager({ - defaultWidth: 800, - defaultHeight: 620 + defaultWidth, + defaultHeight }) // 计算初始位置,确保窗口完全在屏幕范围内 @@ -759,9 +761,8 @@ export class WindowPresenter implements IWindowPresenter { shellWindow.on('ready-to-show', () => { console.log(`Window ${windowId} is ready to show.`) if (!shellWindow.isDestroyed()) { - if (showOnReady) { - shellWindow.show() // 显示窗口避免白屏 - } + shellWindow.show() + shellWindow.focus() eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CREATED, windowId) } else { console.warn(`Window ${windowId} was destroyed before ready-to-show.`) diff --git a/src/renderer/src/stores/chat.ts b/src/renderer/src/stores/chat.ts index 792884456..55a99d353 100644 --- a/src/renderer/src/stores/chat.ts +++ b/src/renderer/src/stores/chat.ts @@ -907,7 +907,8 @@ export const useChatStore = defineStore('chat', () => { const updateChatConfig = async (newConfig: Partial) => { chatConfig.value = { ...chatConfig.value, ...newConfig } await saveChatConfig() - await loadChatConfig() // 加载对话配置 + // Removed loadChatConfig() call to avoid triggering watch loops + // loadChatConfig() should only be called when switching conversations, not after every config update } const deleteMessage = async (messageId: string) => { diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index 4ba2f064c..b3669ca9c 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -229,7 +229,6 @@ export interface IWindowPresenter { } forMovedTab?: boolean windowType?: 'chat' | 'browser' - showOnReady?: boolean x?: number y?: number }): Promise From 8cf69faa0ce73aedefaf16ac1a19b60f90874c1e Mon Sep 17 00:00:00 2001 From: zerob13 Date: Tue, 23 Dec 2025 23:54:46 +0800 Subject: [PATCH 27/29] fix: tool call activateTab should not focus browser window --- .../presenter/browser/YoBrowserPresenter.ts | 43 +++++++++++++++++-- src/main/presenter/tabPresenter.ts | 10 +++-- src/main/presenter/windowPresenter/index.ts | 19 ++++++-- src/renderer/shell/components/AppBar.vue | 2 +- src/renderer/src/stores/yoBrowser.ts | 4 +- .../types/presenters/legacy.presenters.d.ts | 4 +- 6 files changed, 67 insertions(+), 15 deletions(-) diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index 8a1f8a52c..282d290fc 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -66,7 +66,7 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { return this.windowId !== null && this.getWindow() !== null } - async show(): Promise { + async show(shouldFocus: boolean = true): Promise { const existingWindow = this.getWindow() const referenceBounds = existingWindow ? this.getReferenceBounds(existingWindow.id) @@ -107,13 +107,13 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { // For new windows, wait for ready-to-show event if (existingWindow) { // Window already exists, just show it directly - this.windowPresenter.show(window.id) + this.windowPresenter.show(window.id, shouldFocus) this.emitVisibility(true) } else { // New window, wait for ready-to-show const reveal = () => { if (!window.isDestroyed()) { - this.windowPresenter.show(window.id) + this.windowPresenter.show(window.id, shouldFocus) this.emitVisibility(true) } } @@ -257,9 +257,46 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { } async activateTab(tabId: string): Promise { + // #region agent log + const window = this.getWindow() + fetch('http://127.0.0.1:7242/ingest/30ee3286-ed05-472e-bc14-9d2d7e3d12d9', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + location: 'browser/YoBrowserPresenter.ts:259', + message: 'YoBrowserPresenter.activateTab called', + data: { + tabId, + windowExists: !!window, + isVisible: window?.isVisible(), + isFocused: window?.isFocused() + }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'E' + }) + }).catch(() => {}) + // #endregion const viewId = this.tabIds.get(tabId) if (viewId === undefined) return await this.tabPresenter.switchTab(viewId) + // #region agent log + const windowAfter = this.getWindow() + fetch('http://127.0.0.1:7242/ingest/30ee3286-ed05-472e-bc14-9d2d7e3d12d9', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + location: 'browser/YoBrowserPresenter.ts:264', + message: 'After tabPresenter.switchTab', + data: { tabId, isVisible: windowAfter?.isVisible(), isFocused: windowAfter?.isFocused() }, + timestamp: Date.now(), + sessionId: 'debug-session', + runId: 'run1', + hypothesisId: 'E' + }) + }).catch(() => {}) + // #endregion this.activeTabId = tabId this.emitTabActivated(tabId) } diff --git a/src/main/presenter/tabPresenter.ts b/src/main/presenter/tabPresenter.ts index de2adc1b5..8efad1040 100644 --- a/src/main/presenter/tabPresenter.ts +++ b/src/main/presenter/tabPresenter.ts @@ -48,7 +48,7 @@ export class TabPresenter implements ITabPresenter { this.windowTypes.set(windowId, type) } - private getWindowType(windowId: number): 'chat' | 'browser' { + getWindowType(windowId: number): 'chat' | 'browser' { return this.windowTypes.get(windowId) ?? TabPresenter.DEFAULT_WINDOW_TYPE } @@ -750,9 +750,13 @@ export class TabPresenter implements ITabPresenter { this.updateViewBounds(window, view) const windowType = this.getWindowType(window.id) const isVisible = window.isVisible() + const isFocused = window.isFocused() + + // For browser windows, only focus if window is already focused + // This prevents focus stealing when tools call activateTab() on hidden browser windows + // For chat windows, focus if visible (normal behavior) + const shouldFocus = windowType === 'browser' ? isVisible && isFocused : isVisible - // Focus the view if window is visible or it's not a browser window - const shouldFocus = isVisible || windowType !== 'browser' if (shouldFocus && !view.webContents.isDestroyed()) { view.webContents.focus() } diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index c39615676..67e007d69 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -387,8 +387,9 @@ export class WindowPresenter implements IWindowPresenter { /** * 显示指定 ID 的窗口。如果未指定 ID,则显示焦点窗口或第一个窗口。 * @param windowId 可选。要显示的窗口 ID。 + * @param shouldFocus 可选。是否获取焦点,默认为 true。 */ - show(windowId?: number): void { + show(windowId?: number, shouldFocus: boolean = true): void { let targetWindow: BrowserWindow | undefined if (windowId === undefined) { // 未指定 ID,查找焦点窗口或第一个窗口 @@ -410,7 +411,9 @@ export class WindowPresenter implements IWindowPresenter { } targetWindow.show() - targetWindow.focus() // Bring to foreground + if (shouldFocus) { + targetWindow.focus() // Bring to foreground + } // 触发恢复逻辑以确保活动标签页可见且位置正确 this.handleWindowRestore(targetWindow.id).catch((error) => { console.error(`Error handling restore logic after showing window ${targetWindow!.id}:`, error) @@ -761,8 +764,16 @@ export class WindowPresenter implements IWindowPresenter { shellWindow.on('ready-to-show', () => { console.log(`Window ${windowId} is ready to show.`) if (!shellWindow.isDestroyed()) { - shellWindow.show() - shellWindow.focus() + // For browser windows, don't auto-show/focus to prevent stealing focus from chat windows + // Browser windows should only be shown when explicitly requested by user (e.g., clicking browser button) + const tabPresenterInstance = presenter.tabPresenter as TabPresenter + const windowType = tabPresenterInstance.getWindowType(windowId) + const shouldAutoShow = windowType !== 'browser' + + if (shouldAutoShow) { + shellWindow.show() + shellWindow.focus() + } eventBus.sendToMain(WINDOW_EVENTS.WINDOW_CREATED, windowId) } else { console.warn(`Window ${windowId} was destroyed before ready-to-show.`) diff --git a/src/renderer/shell/components/AppBar.vue b/src/renderer/shell/components/AppBar.vue index c29ebf42f..66b4f0b5d 100644 --- a/src/renderer/shell/components/AppBar.vue +++ b/src/renderer/shell/components/AppBar.vue @@ -696,7 +696,7 @@ const openSettings = () => { const onBrowserClick = async () => { try { - await yoBrowserPresenter.show() + await yoBrowserPresenter.show(true) } catch (error) { console.warn('Failed to open browser window.', error) } diff --git a/src/renderer/src/stores/yoBrowser.ts b/src/renderer/src/stores/yoBrowser.ts index b642829e9..bb5048244 100644 --- a/src/renderer/src/stores/yoBrowser.ts +++ b/src/renderer/src/stores/yoBrowser.ts @@ -86,7 +86,7 @@ export const useYoBrowserStore = defineStore('yoBrowser', () => { } const show = async () => { - await yoBrowserPresenter.show() + await yoBrowserPresenter.show(true) await loadState() } @@ -103,7 +103,7 @@ export const useYoBrowserStore = defineStore('yoBrowser', () => { const openTab = async (tabId: string): Promise => { await yoBrowserPresenter.activateTab(tabId) - await yoBrowserPresenter.show() + await yoBrowserPresenter.show(true) await loadState() } diff --git a/src/shared/types/presenters/legacy.presenters.d.ts b/src/shared/types/presenters/legacy.presenters.d.ts index b3669ca9c..2c112f700 100644 --- a/src/shared/types/presenters/legacy.presenters.d.ts +++ b/src/shared/types/presenters/legacy.presenters.d.ts @@ -191,7 +191,7 @@ export interface IYoBrowserPresenter { initialize(): Promise ensureWindow(): Promise hasWindow(): Promise - show(): Promise + show(shouldFocus?: boolean): Promise hide(): Promise toggleVisibility(): Promise isVisible(): Promise @@ -241,7 +241,7 @@ export interface IWindowPresenter { closeSettingsWindow(): void getSettingsWindowId(): number | null hide(windowId: number): void - show(windowId?: number): void + show(windowId?: number, shouldFocus?: boolean): void isMaximized(windowId: number): boolean isMainWindowFocused(windowId: number): boolean sendToAllWindows(channel: string, ...args: unknown[]): void From d6298324e0bd128fd21219962e2ecb592dc5e94b Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 24 Dec 2025 00:01:51 +0800 Subject: [PATCH 28/29] feat: disable preload for browser window --- src/main/presenter/browser/BrowserTab.ts | 6 ++++++ src/main/presenter/tabPresenter.ts | 11 +++++++++-- src/main/presenter/windowPresenter/index.ts | 5 +---- 3 files changed, 16 insertions(+), 6 deletions(-) diff --git a/src/main/presenter/browser/BrowserTab.ts b/src/main/presenter/browser/BrowserTab.ts index f97ce3845..11bcfcaea 100644 --- a/src/main/presenter/browser/BrowserTab.ts +++ b/src/main/presenter/browser/BrowserTab.ts @@ -628,6 +628,12 @@ export class BrowserTab { throw new Error('WebContents destroyed') } + // 安全检查:只有加载外部网页的 browser tab 才允许绑定 CDP + const currentUrl = this.webContents.getURL() + if (currentUrl.startsWith('local://')) { + throw new Error('CDP is not allowed for local:// URLs') + } + if (!this.isAttached) { try { await this.cdpManager.createSession(this.webContents) diff --git a/src/main/presenter/tabPresenter.ts b/src/main/presenter/tabPresenter.ts index 8efad1040..0b4fa725a 100644 --- a/src/main/presenter/tabPresenter.ts +++ b/src/main/presenter/tabPresenter.ts @@ -180,11 +180,16 @@ export class TabPresenter implements ITabPresenter { } const webPreferences: WebPreferences = { - preload: join(__dirname, '../preload/index.mjs'), sandbox: false, devTools: is.dev } + // 对于 browser 窗口,不注入 preload(安全考虑) + // 对于 chat 窗口,注入 preload + if (windowType !== 'browser') { + webPreferences.preload = join(__dirname, '../preload/index.mjs') + } + if (windowType === 'browser') { webPreferences.session = getYoBrowserSession() } @@ -211,7 +216,9 @@ export class TabPresenter implements ITabPresenter { view.webContents.loadURL(url) } - if (is.dev) { + // 对于 browser 窗口,不自动打开 DevTools + // 对于 chat 窗口,开发模式下可以自动打开 + if (is.dev && windowType !== 'browser') { view.webContents.openDevTools({ mode: 'detach' }) } diff --git a/src/main/presenter/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index 67e007d69..6b38d27bb 100644 --- a/src/main/presenter/windowPresenter/index.ts +++ b/src/main/presenter/windowPresenter/index.ts @@ -1045,10 +1045,7 @@ export class WindowPresenter implements IWindowPresenter { }) } - // 开发模式下可选开启 DevTools - if (is.dev) { - shellWindow.webContents.openDevTools({ mode: 'detach' }) - } + // DevTools 不再自动打开,需要手动通过菜单或快捷键打开 console.log(`Shell window ${windowId} created successfully.`) From bba70028d99888adbe2c17e958c9108aa78cb4d5 Mon Sep 17 00:00:00 2001 From: zerob13 Date: Wed, 24 Dec 2025 00:15:19 +0800 Subject: [PATCH 29/29] fix: remove cursor debug fetch code --- docs/workspace-agent-refactoring-summary.md | 376 +++++++----------- .../presenter/browser/YoBrowserPresenter.ts | 37 -- 2 files changed, 146 insertions(+), 267 deletions(-) diff --git a/docs/workspace-agent-refactoring-summary.md b/docs/workspace-agent-refactoring-summary.md index 6e860947a..ccac911a5 100644 --- a/docs/workspace-agent-refactoring-summary.md +++ b/docs/workspace-agent-refactoring-summary.md @@ -2,7 +2,7 @@ ## 概述 -本次重构将 Workspace 和 Agent 能力从 ACP 专用扩展到所有模型,重构 filesystem MCP 为 Agent 工具,统一管理 MCP 和 Agent 工具调用注入逻辑,并重构 AcpWorkspaceView 为通用 Workspace 组件。 +本次重构围绕“统一工具路由 + 通用 Workspace 视图 + Mode 化能力开关”推进:工具调用统一经 ToolPresenter/ToolMapper 管控,Agent 工具拆为 Yo Browser + Agent FileSystem(仅 agent 模式启用),ACP agent 仍走 ACP provider 内置工具流;Workspace UI 对 agent/acp agent 通用,路径选择与会话设置同步,并补齐安全边界与文件刷新机制。 ## 架构概览 @@ -20,13 +20,12 @@ graph TB subgraph "工具源" MCP[MCP Tools] - AGENT[Agent Tools] + AGENT[Agent Tools (agent mode)] end subgraph "Agent 工具" YO[Yo Browser] - FS[FileSystem] - TERM[Terminal 未来] + FS[Agent FileSystem] end subgraph "Workspace" @@ -34,6 +33,7 @@ graph TB FILES[Files Section] PLAN[Plan Section] TERM_UI[Terminal Section] + BROWSER_UI[Browser Tabs Section] end AL --> TCP @@ -43,328 +43,251 @@ graph TB TM --> AGENT AGENT --> YO AGENT --> FS - AGENT --> TERM WS --> FILES WS --> PLAN WS --> TERM_UI + WS --> BROWSER_UI ``` +> Browser Tabs 仅在 agent 模式展示;acp agent 模式仍使用 ACP workdir 与 ACP provider 工具流。 + ## 已完成的工作 ### 1. 统一工具路由架构 ✅ **实现文件**: -- `src/main/presenter/toolPresenter/index.ts` - 统一工具路由 Presenter -- `src/main/presenter/toolPresenter/toolMapper.ts` - 工具映射器 +- `src/main/presenter/toolPresenter/index.ts` +- `src/main/presenter/toolPresenter/toolMapper.ts` **功能**: -- 创建 `ToolPresenter` 类,统一管理所有工具源(MCP、Agent) -- 创建 `ToolMapper` 类,实现工具名称到工具源的映射机制 -- 所有工具定义统一使用 MCP 规范格式(`MCPToolDefinition`) -- 工具调用时根据映射路由到对应的工具源处理器 -- 支持工具去重和映射(如果 MCP 和 Agent 有同名工具,可以映射到 MCP 工具) +- `ToolPresenter` 统一汇总 MCP + Agent 工具,输出 MCP 规范 `MCPToolDefinition` +- `ToolMapper` 维护工具名 → 来源映射,冲突时优先 MCP +- 工具调用统一经 `ToolPresenter.callTool()`,参数解析失败时尝试 `jsonrepair` ### 2. Agent 工具管理 ✅ **实现文件**: -- `src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts` - Agent 工具管理器 -- `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts` - 文件系统能力处理器 +- `src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts` **功能**: -- 创建 `AgentToolManager` 类,管理所有 Agent 工具 -- Agent 工具包括: - - **Yo Browser**:保持现有实现,工具名称使用 `browser_` 前缀(如 `browser_navigate`) - - **FileSystem**:新增,工具名称**不加前缀**(如 `read_file`, `write_file`) - - **Terminal**:未来扩展 -- 工具注入逻辑: - - **chat 模式**:不注入 Agent 工具(只有 MCP 工具) - - **agent 模式**:注入所有 Agent 工具(yo browser、filesystem 等) - - **acp agent 模式**:根据 ACP 逻辑决定 - -### 3. 文件系统能力抽象 ✅ +- Agent 工具包含 Yo Browser + Agent FileSystem +- **仅在 `agent` 模式下注入**(`acp agent` 不注入 Agent 工具) +- Yo Browser 工具根据 `supportsVision` 动态注入 +- 缺省工作目录生成于 `temp/deepchat-agent/workspaces` + +### 3. Agent 文件系统能力 ✅ **实现文件**: -- `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts` - 文件系统处理器 +- `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts` **功能**: -- 创建 `AgentFileSystemHandler` 类,封装文件操作能力 -- 工具名称不加前缀,例如:`read_file`, `write_file`, `list_directory` 等 -- 从 `mcpConfHelper.ts` 中移除 `buildInFileSystem` 配置 -- 从 `inMemoryServers/builder.ts` 中移除 filesystem server 的创建逻辑 -- 添加数据迁移逻辑,将现有 buildInFileSystem 配置迁移 +- 内置文件工具:`read_file`, `write_file`, `list_directory`, `create_directory`, `move_files`, + `edit_text`, `search_files`, `grep_search`, `text_replace`, `directory_tree`, `get_file_info` +- 强制路径白名单 + `realpath` 校验,阻断越界与 symlink 绕过 +- 正则工具使用 `validateRegexPattern` 防 ReDoS;`text_replace`/`edit_text` 支持 diff +- 工具以 `agent-filesystem` server 标识返回 -### 4. 通用 Mode Switch 配置 ✅ +### 4. Chat Mode Switch 配置 ✅ **实现文件**: -- `src/renderer/src/components/chat-input/composables/useChatMode.ts` - Chat Mode Switch composable -- `src/renderer/src/components/chat-input/ChatInput.vue` - 添加 Mode Switch 选择器 +- `src/renderer/src/components/chat-input/composables/useChatMode.ts` +- `src/renderer/src/components/chat-input/ChatInput.vue` **功能**: -- 在配置存储(ElectronStore)中添加 `chatMode: 'chat' | 'agent' | 'acp agent'` 字段 -- 在 `chatConfig` 中添加 `chatMode` 字段(从配置存储读取) -- 创建 `useChatMode` composable,管理模式状态 -- 在 `ChatInput.vue` 中添加 Mode Switch 选择器(Icon + 下拉选择) -- 三种模式的区别: - - **chat**:基础聊天模式,只有 MCP 工具,不支持 yo browser、文件读写等功能 - - **agent**:内置 agent 模式,包含 workdir 设置、各种工具(yo browser、文件读写等)、agent loop 定制内容 - - **acp agent**:ACP 模式,只有这个模式才能选择 ACP 模型,loop 和逻辑会有不同 -- 配置持久化:通过 `configPresenter.setSetting('input_chatMode', value)` 存储 +- `chatMode` 存储在 `input_chatMode` +- 无 ACP agents 时隐藏 `acp agent`,并自动回退到 `chat` +- `isAgentMode` 用于统一控制 UI 与工具注入 ### 5. Workspace 组件通用化 ✅ **实现文件**: -- `src/main/presenter/workspacePresenter/index.ts` - 通用 Workspace Presenter -- `src/renderer/src/stores/workspace.ts` - 通用 Workspace Store -- `src/renderer/src/components/workspace/WorkspaceView.vue` - 通用 Workspace 组件 -- `src/renderer/src/components/workspace/WorkspaceFiles.vue` - 文件列表组件 -- `src/renderer/src/components/workspace/WorkspacePlan.vue` - 计划组件 -- `src/renderer/src/components/workspace/WorkspaceTerminal.vue` - 终端组件 +- `src/main/presenter/workspacePresenter/index.ts` +- `src/renderer/src/stores/workspace.ts` +- `src/renderer/src/components/workspace/WorkspaceView.vue` +- `src/renderer/src/components/workspace/WorkspaceFiles.vue` +- `src/renderer/src/components/workspace/WorkspaceFileNode.vue` +- `src/renderer/src/components/workspace/WorkspacePlan.vue` +- `src/renderer/src/components/workspace/WorkspaceTerminal.vue` +- `src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue` +- `src/renderer/src/components/ChatView.vue` **功能**: -- 重命名 `AcpWorkspaceView.vue` → `WorkspaceView.vue` -- 重命名 `acpWorkspace` store → `workspace` store -- 重命名 `AcpWorkspacePresenter` → `WorkspacePresenter` -- 移除 ACP 特定的依赖,改为基于 Agent 模式判断 -- 支持所有模型的 Agent 模式 +- Workspace UI 对 agent/acp agent 统一开放,Files/Plan/Terminal 共用 +- agent 模式额外展示 Browser Tabs(Yo Browser) +- Store 根据 `chatMode` 选择 `workspacePresenter` 或 `acpWorkspacePresenter` +- 文件树按需展开(lazy loading),支持打开文件/定位路径/插入路径 ### 6. Workspace 路径选择(统一化)✅ **实现文件**: -- `src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts` - Workspace 路径选择 composable -- `src/renderer/src/components/chat-input/ChatInput.vue` - 添加统一的目录选择按钮 +- `src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts` **功能**: -- 在 `chatConfig` 中添加 `agentWorkspacePath: string | null` 字段 -- 创建 `useAgentWorkspace` composable,统一管理工作目录选择 -- 在 `ChatInput.vue` 的 Tools 区域添加目录选择按钮(在 agent 或 acp agent 模式下显示) -- 目录选择按钮逻辑统一化: - - **acp agent 模式**:使用 ACP workdir(现有的 ACP workdir 逻辑) - - **agent 模式**:使用 filesystem 工具的工作目录 -- 按钮样式和行为统一 +- `agent` 模式通过 `devicePresenter.selectDirectory` 选择目录 +- `acp agent` 模式走 ACP workdir(`useAcpWorkdir`) +- 路径与会话设置同步(会话未创建时暂存并补写) ### 7. 模型选择逻辑更新 ✅ **实现文件**: -- `src/renderer/src/components/ModelChooser.vue` - 更新模型选择逻辑 +- `src/renderer/src/components/ModelChooser.vue` +- `src/renderer/src/components/ModelSelect.vue` **功能**: -- 只有在 `acp agent` 模式下才显示 ACP 模型 -- 其他模式隐藏 ACP 模型选项 +- `acp agent` 模式仅展示 ACP provider +- 其他模式隐藏 ACP provider -### 8. ChatView 集成更新 ✅ +### 8. Agent Loop / 提示词与工具执行 ✅ **实现文件**: -- `src/renderer/src/components/chat/ChatView.vue` - 使用通用 WorkspaceView +- `src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts` +- `src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts` +- `src/main/presenter/threadPresenter/utils/promptBuilder.ts` +- `src/main/presenter/threadPresenter/handlers/streamGenerationHandler.ts` **功能**: -- 更新 ChatView 使用通用 WorkspaceView -- 更新事件监听和状态同步逻辑 +- `agent` 模式自动补全默认工作区并落库 +- system prompt 在 `agent` 模式追加当前工作目录 +- Yo Browser context 仅在 `agent` 模式下注入 +- ACP provider 的 tool call 由 provider 侧执行,流中直接返回结果 -### 9. Agent Loop Handler 更新 ✅ +### 9. Workspace 文件刷新机制 ✅ **实现文件**: -- `src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts` - 使用统一的 ToolPresenter -- `src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts` - 使用 ToolPresenter 进行工具调用路由 +- `src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts` +- `src/renderer/src/stores/workspace.ts` **功能**: -- 更新 `AgentLoopHandler` 使用统一的 ToolPresenter -- 简化工具注入逻辑,不再区分工具源 -- 更新 `ToolCallProcessor` 使用 `ToolPresenter.callTool()` 进行工具调用 -- 根据工具映射自动路由到对应的处理器 +- `agent-filesystem` 调用完成时触发 `WORKSPACE_EVENTS.FILES_CHANGED` +- Workspace Store 对文件刷新做防抖合并 +- ACP provider 在流结束后触发刷新 -### 10. 类型定义更新 ✅ +### 10. 类型定义与 i18n ✅ **实现文件**: -- `src/shared/presenter.d.ts` - 更新类型定义 -- `src/shared/types/presenters/tool.presenter.d.ts` - 添加 ToolPresenter 接口 +- `src/shared/types/presenters/tool.presenter.d.ts` +- `src/shared/types/presenters/workspace.d.ts` +- `src/renderer/src/i18n/*/chat.json` +- `src/renderer/src/i18n/*/toolCall.json` **功能**: -- 添加 ToolPresenter 接口和 ChatMode 类型 -- 更新 Workspace 相关接口 -- 添加 Agent 模式相关类型 +- ToolPresenter、Workspace、ChatMode 相关类型补齐 +- 新增模式/Workspace/工具调用相关文案 -### 11. i18n 翻译 ✅ +## 关键文件 -**实现文件**: -- `src/renderer/src/i18n/*/chat.json` - 添加模式相关翻译 -- `src/renderer/src/i18n/*/toolCall.json` - 添加工具调用相关翻译 +- `src/main/presenter/toolPresenter/index.ts`:统一工具定义与路由 +- `src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts`:Agent 工具装配 +- `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts`:文件系统工具实现 +- `src/main/presenter/workspacePresenter/index.ts`:通用 Workspace Presenter +- `src/renderer/src/stores/workspace.ts`:Workspace 状态与事件同步 +- `src/renderer/src/components/workspace/WorkspaceView.vue`:Workspace 入口 UI +- `src/renderer/src/components/chat-input/composables/useChatMode.ts`:Mode 管理 +- `src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts`:Workspace 路径选择 -**功能**: -- 添加所有新增 UI 元素的 i18n 翻译(中文、英文等) -- 更新相关翻译键 - -## 新增文件 - -1. `src/main/presenter/toolPresenter/index.ts` - 统一工具路由 Presenter -2. `src/main/presenter/toolPresenter/toolMapper.ts` - 工具映射器 -3. `src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts` - Agent 工具管理器 -4. `src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts` - 文件系统能力处理器 -5. `src/renderer/src/stores/workspace.ts` - 通用 Workspace Store -6. `src/main/presenter/workspacePresenter/index.ts` - 通用 Workspace Presenter -7. `src/renderer/src/components/workspace/WorkspaceView.vue` - 通用 Workspace 组件 -8. `src/renderer/src/components/workspace/WorkspaceFiles.vue` - 文件列表组件 -9. `src/renderer/src/components/workspace/WorkspacePlan.vue` - 计划组件 -10. `src/renderer/src/components/workspace/WorkspaceTerminal.vue` - 终端组件 -11. `src/renderer/src/components/chat-input/composables/useChatMode.ts` - Chat Mode Switch composable -12. `src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts` - Workspace 路径选择 composable -13. `src/shared/types/presenters/tool.presenter.d.ts` - ToolPresenter 类型定义 - -## 修改的文件 - -1. `src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts` - 使用统一的 ToolPresenter -2. `src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts` - 使用 ToolPresenter 进行工具调用路由 -3. `src/main/presenter/configPresenter/mcpConfHelper.ts` - 移除 buildInFileSystem 配置,添加数据迁移 -4. `src/main/presenter/mcpPresenter/inMemoryServers/builder.ts` - 移除 filesystem server -5. `src/renderer/src/stores/chat.ts` - 添加 chatMode 和 agentWorkspacePath 配置 -6. `src/shared/presenter.d.ts` - 更新类型定义 -7. `src/renderer/src/components/chat-input/ChatInput.vue` - 添加 Mode Switch 选择器和路径选择器 -8. `src/renderer/src/components/ModelChooser.vue` - 更新模型选择逻辑 -9. `src/renderer/src/components/chat/ChatView.vue` - 使用通用 WorkspaceView -10. `src/main/presenter/index.ts` - 初始化 ToolPresenter 和 WorkspacePresenter - -## 删除/废弃文件 - -1. `src/renderer/src/components/acp-workspace/` - 整个目录(重构为 workspace) -2. `src/renderer/src/stores/acpWorkspace.ts` - 重构为 workspace.ts -3. `src/main/presenter/acpWorkspacePresenter/` - 重构为 workspacePresenter(保留部分用于向后兼容) +## 遗留/兼容 + +- `src/main/presenter/acpWorkspacePresenter/` 仍保留并在 `acp agent` 模式使用 +- Renderer 的 ACP Workspace 旧组件已移除,统一使用通用 Workspace 组件 ## 关键技术点 ### 工具命名规范 -- **MCP 工具**:保持原样(如 `read_files`, `write_file`) -- **Agent 工具**:**不加前缀**(如 `read_file`, `write_file`) -- Yo Browser:保持 `browser_` 前缀(已存在) -- FileSystem:不加前缀(如 `read_file`, `write_file`) -- Terminal:未来不加前缀(如 `execute_command`) +- MCP 工具:保持原始命名 +- Agent FileSystem 工具:不加前缀(`read_file` 等) +- Yo Browser:保留 `browser_` 前缀 ### 工具路由机制 -- 所有工具定义统一使用 MCP 规范格式(`MCPToolDefinition`) -- `ToolMapper` 维护工具名称到工具源的映射 -- 工具调用时根据映射自动路由: - - 如果工具名称映射到 MCP → 调用 `mcpPresenter.callTool()` - - 如果工具名称映射到 Agent → 调用 `agentToolManager.callTool()` -- 支持工具去重:如果 MCP 和 Agent 有同名工具,可以配置映射到 MCP 工具 +- ToolPresenter 统一输出 MCP 规范 `MCPToolDefinition` +- ToolMapper 维护工具名 → 来源映射,冲突时偏向 MCP +- Agent 工具参数解析失败时尝试 `jsonrepair` ### Agent 工具注入机制(基于 Mode) -- 根据 `chatMode` 决定工具注入: - - **chat 模式**:不注入 Agent 工具,只有 MCP 工具 - - **agent 模式**:注入所有 Agent 工具(yo browser、filesystem 等) - - **acp agent 模式**:根据 ACP 逻辑决定 -- 工具注入逻辑: - - Yo Browser:在 agent 或 acp agent 模式下,当浏览器窗口打开时注入 - - FileSystem:在 agent 或 acp agent 模式下注入 - - Terminal:未来按需扩展 +- `chat`:仅 MCP 工具 +- `agent`:MCP + Yo Browser + Agent FileSystem +- `acp agent`:MCP 工具;ACP provider 自执行工具调用 ### 配置持久化 -- `chatMode` 通过 `configPresenter.setSetting('input_chatMode', value)` 存储 -- 通过 `configPresenter.getSetting('input_chatMode')` 读取 -- 类型:`'chat' | 'agent' | 'acp agent'` -- 默认值:`'chat'` -- 存储方式:与 `input_webSearch`、`input_deepThinking` 相同,存储在 ElectronStore 中 -- 在 `useChatMode` composable 的初始化时自动加载保存的模式 +- `chatMode` 存储为 `input_chatMode` +- `agentWorkspacePath` 持久化到会话 `settings` +- `agent` 模式缺省路径自动写入会话设置 ### Mode Switch 与 ACP Session Mode 的区别 -- **Chat Mode Switch**:全局模式选择,决定整个会话的行为和可用功能 - - chat:基础聊天模式 - - agent:内置 agent 模式 - - acp agent:ACP 专用模式 -- **ACP Session Mode**:ACP agent 模式下的会话模式(如 plan、code 等),由 ACP agent 内部定义 -- 两者是不同层级的概念,互不干扰 +- Chat Mode Switch:全局模式(chat/agent/acp agent) +- ACP Session Mode:ACP agent 内部会话模式,互不干扰 ### 路径安全 -- Agent 模式下的文件操作必须限制在用户选择的 workspace 路径内 -- 临时目录在会话结束后自动清理 -- 所有路径操作都需要验证权限 -- `WorkspacePresenter` 维护允许的 workspace 路径列表 +- WorkspacePresenter:基于 `allowedWorkspaces` + `realpath` 限制访问 +- AgentFileSystemHandler:路径白名单 + symlink 校验 + regex 安全验证 -### 向后兼容 - -- 保留 ACP Provider 的现有功能 -- 迁移现有 ACP Workspace 数据到通用 Workspace -- 确保现有 MCP filesystem 配置能平滑迁移 -- 保留 `AcpWorkspacePresenter` 用于向后兼容(标记为 legacy) +### 默认工作区路径 -## 如何测试 +- `agent` 模式缺省使用 `temp/deepchat-agent/workspaces[/conversationId]` +- 路径会持久化到会话设置,供后续恢复 -### 测试 Mode Switch +### 向后兼容 -```bash -pnpm run dev -``` +- ACP provider 与 ACP workspace 逻辑保留 +- UI 统一收口到通用 Workspace 组件 -1. 打开应用,在 ChatInput 中找到 Mode Switch 按钮 -2. 点击按钮,选择不同的模式(chat、agent、acp agent) -3. 验证模式切换后,UI 和功能是否正确更新 -4. 重新打开应用,验证模式是否被正确持久化 +## 如何测试 -### 测试 Workspace 路径选择 +### Mode Switch -1. 切换到 `agent` 或 `acp agent` 模式 -2. 点击目录选择按钮 -3. 选择一个工作目录 -4. 验证目录是否正确设置和显示 -5. 在 `acp agent` 模式下,验证是否使用 ACP workdir 逻辑 -6. 在 `agent` 模式下,验证是否使用 filesystem 工具的工作目录 +1. 进入 ChatInput,确认 `acp agent` 仅在配置 ACP agents 时出现 +2. 切换模式,确认 UI 与模型列表同步更新 -### 测试工具路由 +### Agent Workspace -1. 在 `agent` 模式下,发送消息触发工具调用 -2. 验证文件系统工具(如 `read_file`, `write_file`)是否正确路由到 Agent 工具处理器 -3. 验证 MCP 工具是否正确路由到 MCP 处理器 -4. 验证工具调用结果是否正确返回 +1. 切换到 `agent` 模式,选择目录 +2. 切换/重启应用后确认路径恢复 +3. 切换到 `acp agent`,确认使用 ACP workdir -### 测试 Workspace 组件 +### 工具路由 -1. 在 `agent` 或 `acp agent` 模式下,打开 Workspace 视图 -2. 验证文件列表是否正确显示 -3. 验证计划列表是否正确显示 -4. 验证终端输出是否正确显示 -5. 验证不同模式下的 Workspace 行为是否一致 +1. `agent` 模式调用 `read_file` 等文件工具,确认走 Agent FileSystem +2. MCP 工具调用仍走 MCP Presenter +3. ACP provider 下 tool call 直接显示执行结果(不再本地执行) -### 测试模型选择 +### Workspace UI -1. 切换到 `chat` 或 `agent` 模式,验证 ACP 模型是否隐藏 -2. 切换到 `acp agent` 模式,验证 ACP 模型是否显示 -3. 验证模型选择逻辑是否正确 +1. `agent`/`acp agent` 模式下打开 Workspace +2. 文件树可展开并通过右键菜单打开/定位 +3. Browser Tabs 仅在 `agent` 模式显示 +4. 执行文件工具后文件树自动刷新 ## 架构说明 ### 数据流 ``` -用户选择 Mode - ↓ -useChatMode (管理模式状态) - ↓ -ChatInput (显示 Mode Switch) - ↓ -AgentLoopHandler (根据模式注入工具) +ChatMode ↓ -ToolPresenter (统一工具路由) +ChatInput (Mode Switch) ↓ -ToolMapper (路由到对应工具源) +AgentLoopHandler (resolve workspace & tools) ↓ -MCP Tools / Agent Tools +ToolPresenter → ToolMapper → MCP/Agent tools ``` ### Workspace 数据流 ``` -用户选择 Workspace 路径 +Workspace Path Select ↓ -useAgentWorkspace (统一管理工作目录) +useAgentWorkspace / useAcpWorkdir ↓ -WorkspacePresenter (注册 workspace) +WorkspacePresenter (register) ↓ -WorkspaceStore (管理状态) +WorkspaceStore ↓ -WorkspaceView (显示 UI) +WorkspaceView ``` ### 工具调用流程 @@ -376,29 +299,22 @@ ToolCallProcessor ↓ ToolPresenter.callTool() ↓ -ToolMapper (查找工具源) +MCP Presenter / AgentToolManager ↓ -MCP Presenter / Agent Tool Manager - ↓ -工具执行 - ↓ -返回结果 +Tool response → Workspace refresh (agent-filesystem) ``` +> ACP provider 的 tool call 由 provider 侧执行,流中直接返回结果。 + ## 注意事项 -1. **文件大小限制**:确保每个文件不超过 200 行(TypeScript) -2. **文件夹文件数限制**:每个文件夹不超过 8 个文件 -3. **UI 一致性**:Mode Switch 和目录选择按钮的样式和行为应该与现有的 UI 元素保持一致 -4. **性能**:工具路由机制应该高效,避免不必要的查找和转换 -5. **安全**:所有文件操作必须限制在允许的 workspace 路径内 -6. **向后兼容**:确保现有功能不受影响,平滑迁移 +1. Agent 工具仅在 `agent` 模式生效,`acp agent` 走 ACP provider 工具流 +2. Workspace 访问必须先注册允许路径 +3. 正则相关工具调用需遵循安全限制(pattern 长度与验证) ## 未来扩展 -1. **Terminal 工具**:添加终端命令执行能力 -2. **按需工具注入**:支持更细粒度的工具注入控制 -3. **工具去重优化**:改进工具名称冲突处理机制 -4. **Workspace 模板**:支持预设的 workspace 配置 -5. **多 Workspace 支持**:支持同时管理多个 workspace - +1. Terminal 工具执行与 Workspace Terminal 的联动 +2. 工具注入更细粒度控制(按需加载) +3. 工具去重策略可配置化 +4. 多 Workspace 支持与模板化配置 diff --git a/src/main/presenter/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index 282d290fc..d87029b3f 100644 --- a/src/main/presenter/browser/YoBrowserPresenter.ts +++ b/src/main/presenter/browser/YoBrowserPresenter.ts @@ -257,46 +257,9 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { } async activateTab(tabId: string): Promise { - // #region agent log - const window = this.getWindow() - fetch('http://127.0.0.1:7242/ingest/30ee3286-ed05-472e-bc14-9d2d7e3d12d9', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - location: 'browser/YoBrowserPresenter.ts:259', - message: 'YoBrowserPresenter.activateTab called', - data: { - tabId, - windowExists: !!window, - isVisible: window?.isVisible(), - isFocused: window?.isFocused() - }, - timestamp: Date.now(), - sessionId: 'debug-session', - runId: 'run1', - hypothesisId: 'E' - }) - }).catch(() => {}) - // #endregion const viewId = this.tabIds.get(tabId) if (viewId === undefined) return await this.tabPresenter.switchTab(viewId) - // #region agent log - const windowAfter = this.getWindow() - fetch('http://127.0.0.1:7242/ingest/30ee3286-ed05-472e-bc14-9d2d7e3d12d9', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - location: 'browser/YoBrowserPresenter.ts:264', - message: 'After tabPresenter.switchTab', - data: { tabId, isVisible: windowAfter?.isVisible(), isFocused: windowAfter?.isFocused() }, - timestamp: Date.now(), - sessionId: 'debug-session', - runId: 'run1', - hypothesisId: 'E' - }) - }).catch(() => {}) - // #endregion this.activeTabId = tabId this.emitTabActivated(tabId) }