diff --git a/docs/workspace-agent-refactoring-summary.md b/docs/workspace-agent-refactoring-summary.md new file mode 100644 index 000000000..ccac911a5 --- /dev/null +++ b/docs/workspace-agent-refactoring-summary.md @@ -0,0 +1,320 @@ +# 通用 Workspace 和 Agent 能力重构实施总结 + +## 概述 + +本次重构围绕“统一工具路由 + 通用 Workspace 视图 + Mode 化能力开关”推进:工具调用统一经 ToolPresenter/ToolMapper 管控,Agent 工具拆为 Yo Browser + Agent FileSystem(仅 agent 模式启用),ACP agent 仍走 ACP provider 内置工具流;Workspace UI 对 agent/acp 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 (agent mode)] + end + + subgraph "Agent 工具" + YO[Yo Browser] + FS[Agent FileSystem] + end + + subgraph "Workspace" + WS[WorkspaceView] + FILES[Files Section] + PLAN[Plan Section] + TERM_UI[Terminal Section] + BROWSER_UI[Browser Tabs Section] + end + + AL --> TCP + TCP --> TP + TP --> TM + TM --> MCP + TM --> AGENT + AGENT --> YO + AGENT --> FS + 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` +- `src/main/presenter/toolPresenter/toolMapper.ts` + +**功能**: +- `ToolPresenter` 统一汇总 MCP + Agent 工具,输出 MCP 规范 `MCPToolDefinition` +- `ToolMapper` 维护工具名 → 来源映射,冲突时优先 MCP +- 工具调用统一经 `ToolPresenter.callTool()`,参数解析失败时尝试 `jsonrepair` + +### 2. Agent 工具管理 ✅ + +**实现文件**: +- `src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts` + +**功能**: +- 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` + +**功能**: +- 内置文件工具:`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. Chat Mode Switch 配置 ✅ + +**实现文件**: +- `src/renderer/src/components/chat-input/composables/useChatMode.ts` +- `src/renderer/src/components/chat-input/ChatInput.vue` + +**功能**: +- `chatMode` 存储在 `input_chatMode` +- 无 ACP agents 时隐藏 `acp agent`,并自动回退到 `chat` +- `isAgentMode` 用于统一控制 UI 与工具注入 + +### 5. Workspace 组件通用化 ✅ + +**实现文件**: +- `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` + +**功能**: +- 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` + +**功能**: +- `agent` 模式通过 `devicePresenter.selectDirectory` 选择目录 +- `acp agent` 模式走 ACP workdir(`useAcpWorkdir`) +- 路径与会话设置同步(会话未创建时暂存并补写) + +### 7. 模型选择逻辑更新 ✅ + +**实现文件**: +- `src/renderer/src/components/ModelChooser.vue` +- `src/renderer/src/components/ModelSelect.vue` + +**功能**: +- `acp agent` 模式仅展示 ACP provider +- 其他模式隐藏 ACP provider + +### 8. Agent Loop / 提示词与工具执行 ✅ + +**实现文件**: +- `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` + +**功能**: +- `agent` 模式自动补全默认工作区并落库 +- system prompt 在 `agent` 模式追加当前工作目录 +- Yo Browser context 仅在 `agent` 模式下注入 +- ACP provider 的 tool call 由 provider 侧执行,流中直接返回结果 + +### 9. Workspace 文件刷新机制 ✅ + +**实现文件**: +- `src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts` +- `src/renderer/src/stores/workspace.ts` + +**功能**: +- `agent-filesystem` 调用完成时触发 `WORKSPACE_EVENTS.FILES_CHANGED` +- Workspace Store 对文件刷新做防抖合并 +- ACP provider 在流结束后触发刷新 + +### 10. 类型定义与 i18n ✅ + +**实现文件**: +- `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、Workspace、ChatMode 相关类型补齐 +- 新增模式/Workspace/工具调用相关文案 + +## 关键文件 + +- `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 路径选择 + +## 遗留/兼容 + +- `src/main/presenter/acpWorkspacePresenter/` 仍保留并在 `acp agent` 模式使用 +- Renderer 的 ACP Workspace 旧组件已移除,统一使用通用 Workspace 组件 + +## 关键技术点 + +### 工具命名规范 + +- MCP 工具:保持原始命名 +- Agent FileSystem 工具:不加前缀(`read_file` 等) +- Yo Browser:保留 `browser_` 前缀 + +### 工具路由机制 + +- ToolPresenter 统一输出 MCP 规范 `MCPToolDefinition` +- ToolMapper 维护工具名 → 来源映射,冲突时偏向 MCP +- Agent 工具参数解析失败时尝试 `jsonrepair` + +### Agent 工具注入机制(基于 Mode) + +- `chat`:仅 MCP 工具 +- `agent`:MCP + Yo Browser + Agent FileSystem +- `acp agent`:MCP 工具;ACP provider 自执行工具调用 + +### 配置持久化 + +- `chatMode` 存储为 `input_chatMode` +- `agentWorkspacePath` 持久化到会话 `settings` +- `agent` 模式缺省路径自动写入会话设置 + +### Mode Switch 与 ACP Session Mode 的区别 + +- Chat Mode Switch:全局模式(chat/agent/acp agent) +- ACP Session Mode:ACP agent 内部会话模式,互不干扰 + +### 路径安全 + +- WorkspacePresenter:基于 `allowedWorkspaces` + `realpath` 限制访问 +- AgentFileSystemHandler:路径白名单 + symlink 校验 + regex 安全验证 + +### 默认工作区路径 + +- `agent` 模式缺省使用 `temp/deepchat-agent/workspaces[/conversationId]` +- 路径会持久化到会话设置,供后续恢复 + +### 向后兼容 + +- ACP provider 与 ACP workspace 逻辑保留 +- UI 统一收口到通用 Workspace 组件 + +## 如何测试 + +### Mode Switch + +1. 进入 ChatInput,确认 `acp agent` 仅在配置 ACP agents 时出现 +2. 切换模式,确认 UI 与模型列表同步更新 + +### Agent Workspace + +1. 切换到 `agent` 模式,选择目录 +2. 切换/重启应用后确认路径恢复 +3. 切换到 `acp agent`,确认使用 ACP workdir + +### 工具路由 + +1. `agent` 模式调用 `read_file` 等文件工具,确认走 Agent FileSystem +2. MCP 工具调用仍走 MCP Presenter +3. ACP provider 下 tool call 直接显示执行结果(不再本地执行) + +### Workspace UI + +1. `agent`/`acp agent` 模式下打开 Workspace +2. 文件树可展开并通过右键菜单打开/定位 +3. Browser Tabs 仅在 `agent` 模式显示 +4. 执行文件工具后文件树自动刷新 + +## 架构说明 + +### 数据流 + +``` +ChatMode + ↓ +ChatInput (Mode Switch) + ↓ +AgentLoopHandler (resolve workspace & tools) + ↓ +ToolPresenter → ToolMapper → MCP/Agent tools +``` + +### Workspace 数据流 + +``` +Workspace Path Select + ↓ +useAgentWorkspace / useAcpWorkdir + ↓ +WorkspacePresenter (register) + ↓ +WorkspaceStore + ↓ +WorkspaceView +``` + +### 工具调用流程 + +``` +Agent Loop + ↓ +ToolCallProcessor + ↓ +ToolPresenter.callTool() + ↓ +MCP Presenter / AgentToolManager + ↓ +Tool response → Workspace refresh (agent-filesystem) +``` + +> ACP provider 的 tool call 由 provider 侧执行,流中直接返回结果。 + +## 注意事项 + +1. Agent 工具仅在 `agent` 模式生效,`acp agent` 走 ACP provider 工具流 +2. Workspace 访问必须先注册允许路径 +3. 正则相关工具调用需遵循安全限制(pattern 长度与验证) + +## 未来扩展 + +1. Terminal 工具执行与 Workspace Terminal 的联动 +2. 工具注入更细粒度控制(按需加载) +3. 工具去重策略可配置化 +4. 多 Workspace 支持与模板化配置 diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 95a39ec38..b32eae5f7 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -88,7 +88,12 @@ export default defineConfig({ } }), svgLoader(), - vueDevTools() + vueDevTools( + { + appendTo:'src/renderer/src/main.ts' + // appendTo:'src/renderer/shell/main.ts' + } + ) ], worker: { format: 'es' 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/events.ts b/src/main/events.ts index 8b04a4e1f..7e1911206 100644 --- a/src/main/events.ts +++ b/src/main/events.ts @@ -192,6 +192,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' } @@ -243,11 +244,15 @@ export const LIFECYCLE_EVENTS = { SHUTDOWN_REQUESTED: 'lifecycle:shutdown-requested' // Application shutdown requested } -// ACP Workspace events +// Workspace events +export const WORKSPACE_EVENTS = { + PLAN_UPDATED: 'workspace:plan-updated', // Plan entries updated + TERMINAL_OUTPUT: 'workspace:terminal-output', // Terminal output snippet + FILES_CHANGED: 'workspace:files-changed' // File tree changed +} + +// ACP-specific workspace events export const ACP_WORKSPACE_EVENTS = { - PLAN_UPDATED: 'acp-workspace:plan-updated', // Plan entries updated - TERMINAL_OUTPUT: 'acp-workspace:terminal-output', // Terminal output snippet - FILES_CHANGED: 'acp-workspace:files-changed', // File tree changed SESSION_MODES_READY: 'acp-workspace:session-modes-ready' // Session modes available } diff --git a/src/main/presenter/acpWorkspacePresenter/index.ts b/src/main/presenter/acpWorkspacePresenter/index.ts index 20ce6ed99..fee2b4df3 100644 --- a/src/main/presenter/acpWorkspacePresenter/index.ts +++ b/src/main/presenter/acpWorkspacePresenter/index.ts @@ -1,7 +1,7 @@ import path from 'path' import { shell } from 'electron' import { eventBus, SendTarget } from '@/eventbus' -import { ACP_WORKSPACE_EVENTS } from '@/events' +import { WORKSPACE_EVENTS } from '@/events' import { readDirectoryShallow } from './directoryReader' import { PlanStateManager } from './planStateManager' import type { @@ -128,7 +128,7 @@ export class AcpWorkspacePresenter implements IAcpWorkspacePresenter { const updated = this.planManager.updateEntries(conversationId, entries) // Send event to renderer - eventBus.sendToRenderer(ACP_WORKSPACE_EVENTS.PLAN_UPDATED, SendTarget.ALL_WINDOWS, { + eventBus.sendToRenderer(WORKSPACE_EVENTS.PLAN_UPDATED, SendTarget.ALL_WINDOWS, { conversationId, entries: updated }) @@ -138,7 +138,7 @@ export class AcpWorkspacePresenter implements IAcpWorkspacePresenter { * Emit terminal output snippet (called by acpContentMapper) */ async emitTerminalSnippet(conversationId: string, snippet: AcpTerminalSnippet): Promise { - eventBus.sendToRenderer(ACP_WORKSPACE_EVENTS.TERMINAL_OUTPUT, SendTarget.ALL_WINDOWS, { + eventBus.sendToRenderer(WORKSPACE_EVENTS.TERMINAL_OUTPUT, SendTarget.ALL_WINDOWS, { conversationId, snippet }) 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/browser/YoBrowserPresenter.ts b/src/main/presenter/browser/YoBrowserPresenter.ts index e69e2e4ce..d87029b3f 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' @@ -42,23 +43,20 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { // Lazy initialization: only create browser window/tabs when explicitly requested. } - async ensureWindow(): 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' + windowType: 'browser', + x: options?.x, + y: options?.y }) 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 @@ -68,12 +66,63 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { return this.windowId !== null && this.getWindow() !== null } - async show(): Promise { - await this.ensureWindow() + async show(shouldFocus: boolean = true): Promise { + 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 (browser window is 600px wide) + const defaultBounds: Rectangle = { + x: 0, + y: 0, + width: 600, + height: 620 + } + initialPosition = this.calculateWindowPosition(defaultBounds, referenceBounds) + } + + await this.ensureWindow({ + 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) + } + + // 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, shouldFocus) + this.emitVisibility(true) + } else { + // New window, wait for ready-to-show + const reveal = () => { + if (!window.isDestroyed()) { + this.windowPresenter.show(window.id, shouldFocus) + this.emitVisibility(true) + } + } + if (window.isVisible()) { + reveal() + } else { + window.once('ready-to-show', reveal) + } + } } } @@ -111,7 +160,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 { @@ -181,7 +231,9 @@ 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 { @@ -297,14 +349,14 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { async callTool(toolName: string, params: Record): Promise { const result = await this.browserToolManager.executeTool(toolName, params) + const textParts = result.content + .filter((c): c is { type: 'text'; text: string } => c.type === 'text') + .map((c) => c.text) + const textContent = textParts.join('\n\n') if (result.isError) { - const textContent = result.content.find((c) => c.type === 'text') - throw new Error( - textContent && 'text' in textContent ? textContent.text : 'Tool execution failed' - ) + throw new Error(textContent || 'Tool execution failed') } - const textContent = result.content.find((c) => c.type === 'text') - return textContent && 'text' in textContent ? textContent.text : '' + return textContent } async captureScreenshot(tabId: string, options?: ScreenshotOptions): Promise { @@ -367,6 +419,79 @@ 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 } { + 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 = 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 + 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 { + // 下方空间也不够,显示在主窗口上方 + targetY = referenceBounds.y - browserHeight - gap + } + } + + // 确保窗口在屏幕范围内 + const clampedX = Math.max( + workArea.x, + Math.min(targetX, workArea.x + workArea.width - browserWidth) + ) + const clampedY = Math.max( + workArea.y, + Math.min(targetY, workArea.y + workArea.height - browserHeight) + ) + + return { x: Math.round(clampedX), y: Math.round(clampedY) } + } + private handleWindowClosed(): void { this.cleanup() this.emitVisibility(false) @@ -386,6 +511,20 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { const tab = this.tabIdToBrowserTab.get(tabId) if (!tab) return tab.title = title || tab.url + tab.updatedAt = Date.now() + this.emitTabUpdated(tab) + }) + + contents.on('page-favicon-updated', (_event, favicons) => { + if (favicons.length > 0) { + const tab = this.tabIdToBrowserTab.get(tabId) + if (!tab) return + if (tab.favicon !== favicons[0]) { + tab.favicon = favicons[0] + tab.updatedAt = Date.now() + this.emitTabUpdated(tab) + } + } }) contents.on('destroyed', () => { @@ -513,6 +652,11 @@ export class YoBrowserPresenter implements IYoBrowserPresenter { }) } + private emitTabUpdated(tab: BrowserTab) { + const info = this.toTabInfo(tab) + eventBus.sendToRenderer(YO_BROWSER_EVENTS.TAB_UPDATED, SendTarget.ALL_WINDOWS, info) + } + private emitTabCount() { eventBus.sendToRenderer( YO_BROWSER_EVENTS.TAB_COUNT_CHANGED, diff --git a/src/main/presenter/browser/tools/navigate.ts b/src/main/presenter/browser/tools/navigate.ts index 805c3fc21..2d6e95938 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,126 @@ 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) { + 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: `Failed to navigate new tab ${browserTab.tabId} to ${parsed.url}\nError: ${errorMessage}\nTitle: ${browserTab.title || 'unknown'}` + } + ], + isError: true + } + 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/configPresenter/mcpConfHelper.ts b/src/main/presenter/configPresenter/mcpConfHelper.ts index f0e1a6dfa..a3f65e464 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) } @@ -873,56 +866,51 @@ export class McpConfHelper { // 删除旧的defaultServer字段,防止重复迁移 this.mcpStore.delete('defaultServer') } + } - // 迁移 filesystem 服务器到 buildInFileSystem + // Migrate filesystem/buildInFileSystem servers - these are now provided via Agent tools + // Remove for all versions < 0.6.0 + if (oldVersion && compare(oldVersion, '0.6.0', '<')) { try { const mcpServers = this.mcpStore.get('mcpServers') || {} - // console.log('mcpServers', mcpServers) - if (mcpServers.filesystem) { - console.log( - 'Detected old version filesystem MCP server, starting migration to buildInFileSystem' - ) + const defaultServers = this.mcpStore.get('defaultServers') || [] + let hasChanges = false - // 检查 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 - } - } + // Check if servers exist before deletion (for tracking) + const hadFilesystem = !!mcpServers.filesystem + const hadBuildInFileSystem = !!mcpServers.buildInFileSystem - // 如果 filesystem 的 args 长度大于 2,将第三个参数及以后的参数迁移 - if (mcpServers.filesystem.args && mcpServers.filesystem.args.length > 2) { - mcpServers.buildInFileSystem.args = mcpServers.filesystem.args.slice(2) - } + // Remove old filesystem server + if (mcpServers.filesystem) { + console.log('Removing old filesystem MCP server (now provided via Agent tools)') + delete mcpServers.filesystem + hasChanges = true + } - // 迁移 autoApprove 设置 - if (mcpServers.filesystem.autoApprove) { - mcpServers.buildInFileSystem.autoApprove = [...mcpServers.filesystem.autoApprove] - } + // Remove buildInFileSystem server + if (mcpServers.buildInFileSystem) { + console.log('Removing buildInFileSystem MCP server (now provided via Agent tools)') + delete mcpServers.buildInFileSystem + hasChanges = true + } - delete mcpServers.filesystem - // 更新 mcpServers - this.mcpStore.set('mcpServers', mcpServers) + // 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 + } - // 如果 filesystem 是默认服务器,将 buildInFileSystem 添加到默认服务器列表 - const defaultServers = this.mcpStore.get('defaultServers') || [] - if ( - defaultServers.includes('filesystem') && - !defaultServers.includes('buildInFileSystem') - ) { - defaultServers.push('buildInFileSystem') - this.mcpStore.set('defaultServers', defaultServers) - } + // Mark as removed for tracking + if (hadFilesystem || hadBuildInFileSystem) { + this.markBuiltInServerRemoved('buildInFileSystem') + } - console.log('Migration from filesystem to buildInFileSystem completed') + if (hasChanges) { + this.mcpStore.set('mcpServers', mcpServers) + 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..4f4a898e0 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() // 设置事件总线监听 } @@ -150,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/agent/acpProcessManager.ts b/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts index 0ee653a51..6639b1d0e 100644 --- a/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts @@ -19,7 +19,7 @@ import { buildClientCapabilities } from './acpCapabilities' import { AcpFsHandler } from './acpFsHandler' import { AcpTerminalManager } from './acpTerminalManager' import { eventBus, SendTarget } from '@/eventbus' -import { ACP_WORKSPACE_EVENTS } from '@/events' +import { ACP_WORKSPACE_EVENTS, WORKSPACE_EVENTS } from '@/events' export interface AcpProcessHandle extends AgentProcessHandle { child: ChildProcessWithoutNullStreams @@ -78,6 +78,7 @@ export class AcpProcessManager implements AgentProcessManager() + private readonly sessionConversations = new Map() private readonly fsHandlers = new Map() private readonly agentLocks = new Map>() private readonly preferredModes = new Map() @@ -94,8 +95,11 @@ export class AcpProcessManager implements AgentProcessManager { const handler = this.getFsHandler(params.sessionId) - return handler.readTextFile(params) + try { + return await handler.readTextFile(params) + } finally { + this.notifyWorkspaceFilesChanged(params.sessionId) + } }, writeTextFile: async (params) => { const handler = this.getFsHandler(params.sessionId) - return handler.writeTextFile(params) + try { + return await handler.writeTextFile(params) + } finally { + this.notifyWorkspaceFilesChanged(params.sessionId) + } }, // Terminal operations createTerminal: async (params) => { diff --git a/src/main/presenter/llmProviderPresenter/agent/acpSessionManager.ts b/src/main/presenter/llmProviderPresenter/agent/acpSessionManager.ts index a33417560..c02c31072 100644 --- a/src/main/presenter/llmProviderPresenter/agent/acpSessionManager.ts +++ b/src/main/presenter/llmProviderPresenter/agent/acpSessionManager.ts @@ -181,7 +181,7 @@ export class AcpSessionManager { const detachListeners = this.attachSessionHooks(agent.id, session.sessionId, hooks) // Register session workdir for fs/terminal operations - this.processManager.registerSessionWorkdir(session.sessionId, workdir) + this.processManager.registerSessionWorkdir(session.sessionId, workdir, conversationId) void this.sessionPersistence .saveSessionData(conversationId, agent.id, session.sessionId, workdir, 'active', { diff --git a/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts new file mode 100644 index 000000000..6e27877b3 --- /dev/null +++ b/src/main/presenter/llmProviderPresenter/agent/agentFileSystemHandler.ts @@ -0,0 +1,673 @@ +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' +import { validateRegexPattern } from '@shared/regexValidator' + +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) +}) + +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[] + + 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 normalizeLineEndings(text: string): string { + return text.replace(/\r\n/g, '\n') + } + + 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)) + } + 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.isPathAllowed(normalizedRequested) + 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.isPathAllowed(normalizedReal) + 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.isPathAllowed(normalizedParent) + 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 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: [] + } + + // Validate pattern for ReDoS safety before constructing RegExp + validateRegexPattern(pattern) + + 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 { + // Validate pattern for ReDoS safety before constructing RegExp + try { + 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' + 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) + // 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) + + 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) { + 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) { + // Validate pattern for ReDoS safety before constructing RegExp + try { + 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 || '') + } + + const diff = createTwoFilesPatch(validPath, validPath, content, modifiedContent) + if (!parsed.data.dryRun) { + await fs.writeFile(validPath, modifiedContent, 'utf-8') + } + 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) { + 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..43c566ab1 --- /dev/null +++ b/src/main/presenter/llmProviderPresenter/agent/agentToolManager.ts @@ -0,0 +1,456 @@ +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 logger from '@shared/logger' +import { AgentFileSystemHandler } from './agentFileSystemHandler' + +interface AgentToolManagerOptions { + yoBrowserPresenter: IYoBrowserPresenter + agentWorkspacePath: string | null +} + +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 + 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[] = [] + const isAgentMode = context.chatMode === 'agent' + const effectiveWorkspacePath = isAgentMode + ? context.agentWorkspacePath?.trim() || this.getDefaultAgentWorkspacePath() + : null + + // Update filesystem handler if workspace path changed + if (effectiveWorkspacePath !== this.agentWorkspacePath) { + if (effectiveWorkspacePath) { + this.fileSystemHandler = new AgentFileSystemHandler([effectiveWorkspacePath]) + } else { + this.fileSystemHandler = null + } + this.agentWorkspacePath = effectiveWorkspacePath + } + + // 1. Yo Browser tools (agent mode only) + if (isAgentMode) { + try { + const yoDefs = await this.yoBrowserPresenter.getToolDefinitions(context.supportsVision) + defs.push(...yoDefs) + } catch (error) { + logger.warn('[AgentToolManager] Failed to load Yo Browser tool definitions', { error }) + } + } + + // 2. FileSystem tools (agent mode only) + if (isAgentMode && 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.isFileSystemTool(toolName)) { + if (!this.fileSystemHandler) { + throw new Error(`FileSystem handler not initialized for tool: ${toolName}`) + } + return await this.callFileSystemTool(toolName, args) + } + + throw new Error(`Unknown Agent tool: ${toolName}`) + } + + private getFileSystemToolDefinitions(): MCPToolDefinition[] { + const schemas = this.fileSystemSchemas + return [ + { + type: 'function', + function: { + name: 'read_file', + description: 'Read the contents of one or more files', + parameters: zodToJsonSchema(schemas.read_file) 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(schemas.write_file) 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(schemas.list_directory) 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(schemas.create_directory) 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(schemas.move_files) 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. 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(schemas.edit_text) 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(schemas.search_files) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + }, + { + type: 'function', + function: { + name: 'directory_tree', + description: 'Get a recursive directory tree as JSON', + parameters: zodToJsonSchema(schemas.directory_tree) 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(schemas.get_file_info) 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. The pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks.', + parameters: zodToJsonSchema(schemas.grep_search) 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. The pattern must be safe and not exceed 1000 characters to prevent ReDoS (Regular Expression Denial of Service) attacks.', + parameters: zodToJsonSchema(schemas.text_replace) as { + type: string + properties: Record + required?: string[] + } + }, + server: { + name: 'agent-filesystem', + icons: '📁', + description: 'Agent FileSystem tools' + } + } + ] + } + + 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 + ): Promise { + if (!this.fileSystemHandler) { + 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(parsedArgs) + case 'write_file': + return await this.fileSystemHandler.writeFile(parsedArgs) + case 'list_directory': + return await this.fileSystemHandler.listDirectory(parsedArgs) + case 'create_directory': + return await this.fileSystemHandler.createDirectory(parsedArgs) + case 'move_files': + return await this.fileSystemHandler.moveFiles(parsedArgs) + case 'edit_text': + return await this.fileSystemHandler.editText(parsedArgs) + case 'search_files': + return await this.fileSystemHandler.searchFiles(parsedArgs) + case 'directory_tree': + return await this.fileSystemHandler.directoryTree(parsedArgs) + case 'get_file_info': + return await this.fileSystemHandler.getFileInfo(parsedArgs) + case 'grep_search': + return await this.fileSystemHandler.grepSearch(parsedArgs) + case 'text_replace': + return await this.fileSystemHandler.textReplace(parsedArgs) + 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) { + logger.warn( + '[AgentToolManager] Failed to create default workspace, using system temp:', + error + ) + return app.getPath('temp') + } + return tempDir + } +} diff --git a/src/main/presenter/llmProviderPresenter/baseProvider.ts b/src/main/presenter/llmProviderPresenter/baseProvider.ts index 86b32dad4..9e55c06cb 100644 --- a/src/main/presenter/llmProviderPresenter/baseProvider.ts +++ b/src/main/presenter/llmProviderPresenter/baseProvider.ts @@ -29,7 +29,7 @@ import { CONFIG_EVENTS } from '@/events' */ export abstract class BaseLLMProvider { // Maximum tool calls limit in a single conversation turn - protected static readonly MAX_TOOL_CALLS = 50 + protected static readonly MAX_TOOL_CALLS = 200 protected static readonly DEFAULT_MODEL_FETCH_TIMEOUT = 12000 // Increased to 12 seconds as universal default protected provider: LLM_PROVIDER diff --git a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts index 3443baf42..8d6e5ed95 100644 --- a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts +++ b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts @@ -1,17 +1,15 @@ -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' +import { WORKSPACE_EVENTS } from '@/events' import { BaseLLMProvider } from '../baseProvider' 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 @@ -23,46 +21,165 @@ 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 modelId from conversation + let modelId: string | undefined + if (context.conversationId) { try { - const yoDefs = await presenter.yoBrowserPresenter.getToolDefinitions( - this.currentSupportsVision + const conversation = await presenter.threadPresenter.getConversation( + context.conversationId ) - defs.push(...yoDefs) - } catch (error) { - console.warn('[AgentLoop] Failed to load Yo Browser tool definitions', error) + modelId = conversation?.settings.modelId + } catch { + // Ignore errors, modelId will be undefined } } - return defs + const { chatMode, agentWorkspacePath } = await this.resolveWorkspaceContext( + context.conversationId, + modelId + ) + + 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 this.getToolPresenter().callTool(request) + }, + onToolCallFinished: ({ toolServerName, conversationId }) => { + if (toolServerName !== 'agent-filesystem') return + this.notifyWorkspaceFilesChanged(conversationId) + } + }) + } + + /** + * 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 async getDefaultAgentWorkspacePath(conversationId?: string | null): Promise { + const tempRoot = path.join(app.getPath('temp'), 'deepchat-agent', 'workspaces') + try { + await fs.promises.mkdir(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 { + await fs.promises.mkdir(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 = await this.getDefaultAgentWorkspacePath(conversationId ?? null) + if (conversationId && fallback) { + try { + await presenter.threadPresenter.updateConversationSettings(conversationId, { + agentWorkspacePath: fallback + }) + } catch (error) { + console.warn('[AgentLoopHandler] Failed to persist agent workspace path:', error) + } + } + 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 } } - return await presenter.mcpPresenter.callTool(request) + } 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, { + conversationId }) } @@ -183,19 +300,19 @@ 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) { - try { - const yoDefs = await presenter.yoBrowserPresenter.getToolDefinitions( - this.currentSupportsVision - ) - toolDefs = [...toolDefs, ...yoDefs] - } catch (error) { - console.warn('[AgentLoop] Failed to load Yo Browser tool definitions', error) - } - } + // Resolve workspace context + const { chatMode, agentWorkspacePath } = await this.resolveWorkspaceContext( + conversationId, + modelId + ) + + // 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) { @@ -509,7 +626,8 @@ export class AgentLoopHandler { modelConfig, abortSignal: abortController.signal, currentToolCallCount: toolCallCount, - maxToolCalls: MAX_TOOL_CALLS + maxToolCalls: MAX_TOOL_CALLS, + conversationId }) while (true) { @@ -522,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 @@ -549,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}` ) @@ -587,10 +706,8 @@ export class AgentLoopHandler { console.log('Agent loop finished for event:', eventId, 'User stopped:', userStop) // Trigger ACP workspace file refresh (only for ACP provider) - if (providerId === 'acp' && conversationId) { - eventBus.sendToRenderer(ACP_WORKSPACE_EVENTS.FILES_CHANGED, SendTarget.ALL_WINDOWS, { - conversationId - }) + if (providerId === 'acp') { + this.notifyWorkspaceFilesChanged(conversationId) } } } 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 362c64ce5..656e1d584 100644 --- a/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts +++ b/src/main/presenter/llmProviderPresenter/managers/toolCallProcessor.ts @@ -6,10 +6,18 @@ import { MCPToolResponse, ModelConfig } from '@shared/presenter' +import { isNonRetryableError } from './errorClassification' interface ToolCallProcessorOptions { getAllToolDefinitions: (context: ToolCallExecutionContext) => Promise callTool: (request: MCPToolCall) => Promise<{ content: unknown; rawData: MCPToolResponse }> + onToolCallFinished?: (info: { + toolName: string + toolCallId: string + toolServerName?: string + conversationId?: string + status: 'success' | 'error' | 'permission' + }) => void } interface ToolCallExecutionContext { @@ -21,6 +29,7 @@ interface ToolCallExecutionContext { abortSignal: AbortSignal currentToolCallCount: number maxToolCalls: number + conversationId?: string } interface ToolCallProcessResult { @@ -92,6 +101,21 @@ export class ToolCallProcessor { continue } + const notifyToolCallFinished = (status: 'success' | 'error' | 'permission') => { + if (!this.options.onToolCallFinished) return + try { + this.options.onToolCallFinished({ + toolName: toolCall.name, + toolCallId: toolCall.id, + toolServerName: toolDef.server?.name, + conversationId: context.conversationId, + status + }) + } catch (error) { + console.warn('[ToolCallProcessor] onToolCallFinished handler failed:', error) + } + } + const mcpToolInput: MCPToolCall = { id: toolCall.id, type: 'function', @@ -118,10 +142,10 @@ export class ToolCallProcessor { try { const toolResponse = await this.options.callTool(mcpToolInput) + const requiresPermission = Boolean(toolResponse.rawData?.requiresPermission) - if (context.abortSignal.aborted) break - - if (toolResponse.rawData?.requiresPermission) { + if (requiresPermission) { + notifyToolCallFinished('permission') console.log( `[Agent Loop] Permission required for tool ${toolCall.name}, creating permission request` ) @@ -146,6 +170,10 @@ export class ToolCallProcessor { break } + notifyToolCallFinished('success') + + if (context.abortSignal.aborted) break + const supportsFunctionCall = context.modelConfig?.functionCall || false if (supportsFunctionCall) { @@ -194,6 +222,7 @@ export class ToolCallProcessor { } } } catch (toolError) { + notifyToolCallFinished('error') if (context.abortSignal.aborted) break console.error( @@ -202,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, @@ -223,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) } } @@ -266,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/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/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/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/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/sqlitePresenter/tables/conversations.ts b/src/main/presenter/sqlitePresenter/tables/conversations.ts index ee6e2c4ee..21a0a7bea 100644 --- a/src/main/presenter/sqlitePresenter/tables/conversations.ts +++ b/src/main/presenter/sqlitePresenter/tables/conversations.ts @@ -118,12 +118,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 +157,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,7 +186,11 @@ 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 ) return conv_id } @@ -206,17 +220,49 @@ 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) + } return { id: result.id, title: result.title, @@ -224,33 +270,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 +354,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 +417,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 +458,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/tabPresenter.ts b/src/main/presenter/tabPresenter.ts index 6db1ad6a3..0b4fa725a 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 } @@ -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' }) } @@ -654,6 +661,7 @@ export class TabPresenter implements ITabPresenter { // 检查是否是窗口的第一个标签页 const isFirstTab = this.windowTabs.get(windowId)?.length === 1 + const windowType = this.getWindowType(windowId) // 页面加载完成 if (isFirstTab) { @@ -661,12 +669,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 +755,16 @@ 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 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 + + if (shouldFocus && !view.webContents.isDestroyed()) { view.webContents.focus() } } 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/managers/conversationManager.ts b/src/main/presenter/threadPresenter/managers/conversationManager.ts index f5b7d32ee..90e15e1a0 100644 --- a/src/main/presenter/threadPresenter/managers/conversationManager.ts +++ b/src/main/presenter/threadPresenter/managers/conversationManager.ts @@ -193,7 +193,6 @@ export class ConversationManager { delete sanitizedSettings[typedKey] } }) - const mergedSettings = { ...defaultSettings } const previewSettings = { ...mergedSettings, ...sanitizedSettings } @@ -223,7 +222,6 @@ export class ConversationManager { if (mergedSettings.temperature === undefined || mergedSettings.temperature === null) { mergedSettings.temperature = defaultModelsSettings?.temperature ?? 0.7 } - const conversationId = await this.sqlitePresenter.createConversation(title, mergedSettings) if (options.forceNewAndActivate) { 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/toolPresenter/index.ts b/src/main/presenter/toolPresenter/index.ts new file mode 100644 index 000000000..41ec59fb1 --- /dev/null +++ b/src/main/presenter/toolPresenter/index.ts @@ -0,0 +1,145 @@ +import type { + IConfigPresenter, + IMCPPresenter, + IYoBrowserPresenter, + MCPToolDefinition, + MCPToolCall, + MCPToolResponse +} from '@shared/presenter' +import { ToolMapper } from './toolMapper' +import { AgentToolManager } from '../llmProviderPresenter/agent/agentToolManager' +import { jsonrepair } from 'jsonrepair' + +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 + }) + 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) + } + } + + 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') { + if (!this.agentToolManager) { + throw new Error(`Agent tool manager not initialized for tool ${toolName}`) + } + // Route to Agent tool manager + 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), + 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/windowPresenter/index.ts b/src/main/presenter/windowPresenter/index.ts index ccda1f77e..6b38d27bb 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) @@ -678,10 +681,14 @@ export class WindowPresenter implements IWindowPresenter { // 根据平台选择图标 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 }) // 计算初始位置,确保窗口完全在屏幕范围内 @@ -757,7 +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() // 显示窗口避免白屏 + // 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.`) @@ -1029,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.`) diff --git a/src/main/presenter/workspacePresenter/index.ts b/src/main/presenter/workspacePresenter/index.ts new file mode 100644 index 000000000..909fe9044 --- /dev/null +++ b/src/main/presenter/workspacePresenter/index.ts @@ -0,0 +1,193 @@ +import path from 'path' +import fs from 'fs' +import { shell } from 'electron' +import { eventBus, SendTarget } from '@/eventbus' +import { 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 + * Uses realpathSync to resolve symlinks and prevent bypass attacks + */ + private isPathAllowed(targetPath: string): boolean { + 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 + } + } + + /** + * 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(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(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/settings/components/AcpSettings.vue b/src/renderer/settings/components/AcpSettings.vue index 52e02ded1..5e515b1b0 100644 --- a/src/renderer/settings/components/AcpSettings.vue +++ b/src/renderer/settings/components/AcpSettings.vue @@ -119,7 +119,7 @@
-
-
{{ t('settings.provider.bedrockLimitTip') }}
+
+ {{ t('settings.provider.bedrockLimitTip') }} +
{ await initData() } - - 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/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/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/components/ModelChooser.vue b/src/renderer/src/components/ModelChooser.vue index c48fb2ef3..65544f4a9 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,20 @@ 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) => { + // In 'acp agent' mode, only show ACP provider + if (currentMode === 'acp agent' && provider.id !== 'acp') { + return null + } + // In other modes, hide ACP provider + if (currentMode !== 'acp agent' && provider.id === 'acp') { + 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/ModelSelect.vue b/src/renderer/src/components/ModelSelect.vue index 2e73c532b..4ee01e73f 100644 --- a/src/renderer/src/components/ModelSelect.vue +++ b/src/renderer/src/components/ModelSelect.vue @@ -56,6 +56,7 @@ import { useProviderStore } from '@/stores/providerStore' import { useModelStore } from '@/stores/modelStore' import { useThemeStore } from '@/stores/theme' import { useLanguageStore } from '@/stores/language' +import { useChatMode } from '@/components/chat-input/composables/useChatMode' const { t } = useI18n() const keyword = ref('') const chatStore = useChatStore() @@ -63,6 +64,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 }>() @@ -80,9 +82,20 @@ 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 && !props.excludeProviders.includes(provider.id)) .map((provider) => { + // In 'acp agent' mode, only show ACP provider + if (currentMode === 'acp agent' && provider.id !== 'acp') { + return null + } + // In other modes, hide ACP provider + if (currentMode !== 'acp agent' && provider.id === 'acp') { + return null + } + const enabledProvider = enabledModels.find((ep) => ep.providerId === provider.id) if (!enabledProvider || enabledProvider.models.length === 0) { return null diff --git a/src/renderer/src/components/NewThread.vue b/src/renderer/src/components/NewThread.vue index 70ea69fb5..62f4ca676 100644 --- a/src/renderer/src/components/NewThread.vue +++ b/src/renderer/src/components/NewThread.vue @@ -110,6 +110,7 @@ import { Badge } from '@shadcn/components/ui/badge' import { Icon } from '@iconify/vue' import ModelSelect from './ModelSelect.vue' import { useChatStore } from '@/stores/chat' +import { useWorkspaceStore } from '@/stores/workspace' import { MODEL_META } from '@shared/presenter' import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { UserMessageContent } from '@shared/chat' @@ -121,9 +122,11 @@ import type { IpcRendererEvent } from 'electron' import { CONFIG_EVENTS } from '@/events' import { useModelStore } from '@/stores/modelStore' import { useUiSettingsStore } from '@/stores/uiSettingsStore' +import { useChatMode } from '@/components/chat-input/composables/useChatMode' const configPresenter = usePresenter('configPresenter') const themeStore = useThemeStore() +const chatMode = useChatMode() // 定义偏好模型的类型 interface PreferredModel { modelId: string @@ -132,6 +135,7 @@ interface PreferredModel { const { t } = useI18n() const chatStore = useChatStore() +const workspaceStore = useWorkspaceStore() const modelStore = useModelStore() const uiSettingsStore = useUiSettingsStore() const activeModel = ref({ @@ -233,6 +237,28 @@ const pickFirstEnabledModel = () => { return found } +const pickFirstAcpModel = () => { + const found = modelStore.enabledModels + .flatMap((p) => p.models.map((m) => ({ ...m, providerId: p.providerId }))) + .find( + (m) => + m.providerId === 'acp' && + (m.type === ModelType.Chat || m.type === ModelType.ImageGeneration) + ) + return found +} + +const pickFirstNonAcpModel = () => { + const found = modelStore.enabledModels + .flatMap((p) => p.models.map((m) => ({ ...m, providerId: p.providerId }))) + .find( + (m) => + m.providerId !== 'acp' && + (m.type === ModelType.Chat || m.type === ModelType.ImageGeneration) + ) + return found +} + const setActiveFromEnabled = (m: { name: string id: string @@ -323,6 +349,47 @@ watch( { immediate: false, deep: true } ) +// 监听 chat mode 变化,自动切换模型 +watch( + () => chatMode.currentMode.value, + async (newMode, oldMode) => { + // 只在 mode 真正变化时切换模型,避免初始化时触发 + if (!initialized.value || newMode === oldMode) { + return + } + + const currentProviderId = activeModel.value.providerId + const isCurrentAcp = currentProviderId === 'acp' + const shouldBeAcp = newMode === 'acp agent' + + // 如果当前模型类型与 mode 不匹配,需要切换 + if (isCurrentAcp !== shouldBeAcp) { + let targetModel + if (shouldBeAcp) { + // 切换到 ACP 模型 + targetModel = pickFirstAcpModel() + } else { + // 切换到非 ACP 模型 + targetModel = pickFirstNonAcpModel() + } + + if (targetModel) { + setActiveFromEnabled(targetModel) + // 更新 chat config 和偏好设置 + chatStore.updateChatConfig({ + modelId: targetModel.id, + providerId: targetModel.providerId + }) + configPresenter.setSetting('preferredModel', { + modelId: targetModel.id, + providerId: targetModel.providerId + }) + } + } + }, + { immediate: false } +) + const modelSelectOpen = ref(false) const settingsPopoverOpen = ref(false) const chatInputRef = ref | null>(null) @@ -436,9 +503,15 @@ onBeforeUnmount(() => { }) const handleSend = async (content: UserMessageContent) => { + const chatInput = chatInputRef.value + const pathFromInput = chatInput?.getAgentWorkspacePath?.() + const pathFromStore = chatStore.chatConfig.agentWorkspacePath + const chatMode = chatInput?.getChatMode?.() + const agentWorkspacePath = pathFromInput ?? pathFromStore ?? undefined const threadId = await chatStore.createThread(content.text, { providerId: activeModel.value.providerId, modelId: activeModel.value.id, + chatMode, systemPrompt: systemPrompt.value, temperature: temperature.value, contextLength: contextLength.value, @@ -451,12 +524,16 @@ 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) console.log('threadId', threadId, activeModel.value) + if (chatMode === 'agent' || chatMode === 'acp agent') { + await workspaceStore.refreshFileTree() + } chatStore.sendMessage(content) } 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/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue index 072c9e13f..26ba39223 100644 --- a/src/renderer/src/components/chat-input/ChatInput.vue +++ b/src/renderer/src/components/chat-input/ChatInput.vue @@ -70,6 +70,93 @@
+ + + + + + + + + +
+
+ + {{ mode.label }} + +
+
+
+
+
+
+ + {{ t('chat.mode.current', { mode: chatMode.currentLabel.value }) }} + +
+ + + + + + + +

+ {{ workspace.tooltipTitle }} +

+

+ {{ workspace.tooltipCurrent }} +

+

+ {{ workspace.tooltipSelect }} +

+
+
+ - - -

- {{ t('chat.input.acpWorkdirTooltip') }} -

-

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

-

- {{ t('chat.input.acpWorkdirSelect') }} -

-
-
-
@@ -404,6 +457,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 +559,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 +733,13 @@ const acpWorkdir = useAcpWorkdir({ conversationId }) +// Unified workspace management (for agent and acp agent modes) +const workspace = useAgentWorkspace({ + conversationId, + activeModel: activeModelSource, + chatMode +}) + // Extract isStreaming first so we can pass it to useAcpMode const { disabledSend, isStreaming } = sendButtonState @@ -738,6 +804,18 @@ const onWebSearchClick = async () => { await toggleWebSearch() } +const handleModeSelect = async (mode: ChatMode) => { + 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 +} + const onKeydown = (e: KeyboardEvent) => { if (e.code === 'Enter' && !e.shiftKey) { editorComposable.handleEditorEnter(e, disabledSend.value, emitSend) @@ -856,12 +934,37 @@ watch( } ) +watch( + () => [conversationId.value, chatStore.chatConfig.chatMode] as const, + async ([activeId, storedMode]) => { + if (!activeId) return + try { + if (!storedMode) { + await chatStore.updateChatConfig({ chatMode: chatMode.currentMode.value }) + return + } + if (chatMode.currentMode.value !== storedMode) { + await chatMode.setMode(storedMode) + } + } catch (error) { + console.warn('Failed to sync chat mode for conversation:', error) + } + }, + { immediate: true } +) + // === Expose === defineExpose({ clearContent: editorComposable.clearContent, appendText: editorComposable.appendText, appendMention: (name: string) => editorComposable.appendMention(name, mentionData), - restoreFocus + restoreFocus, + getAgentWorkspacePath: () => { + const mode = chatMode.currentMode.value + if (mode !== 'agent') return null + return workspace.workspacePath.value + }, + getChatMode: () => chatMode.currentMode.value }) 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 new file mode 100644 index 000000000..b2e8956da --- /dev/null +++ b/src/renderer/src/components/chat-input/composables/useAgentWorkspace.ts @@ -0,0 +1,239 @@ +// === 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> + chatMode?: ReturnType +} + +/** + * 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 = options.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 }) + } + } + + 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') { + 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) { + hydrateWorkspaceFromPreference() + 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') { + if (pendingWorkspacePath.value && conversationId) { + await syncPendingWorkspaceWhenReady() + } + if (conversationId) { + await loadWorkspacePath() + } else { + hydrateWorkspaceFromPreference() + } + return + } + + if (newMode === 'acp agent') { + // ACP workdir is handled by useAcpWorkdir + return + } + }, + { 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..164994ba4 --- /dev/null +++ b/src/renderer/src/components/chat-input/composables/useChatMode.ts @@ -0,0 +1,178 @@ +// === Vue Core === +import { ref, computed, watch } from 'vue' +import { useI18n } from 'vue-i18n' + +// === Composables === +import { usePresenter } from '@/composables/usePresenter' +import { CONFIG_EVENTS } from '@/events' + +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 + +// Shared state so all callers observe the same mode. +const currentMode = ref('chat') +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) + * Similar to useInputSettings, stores mode in database via configPresenter + */ +export function useChatMode() { + // === Presenters === + const configPresenter = usePresenter('configPresenter') + const { t } = useI18n() + + // === Computed === + const currentIcon = computed(() => MODE_ICONS[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(() => { + const allModes = [ + { 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: t('chat.mode.acpAgent'), + icon: MODE_ICONS['acp agent'] + } + ] + // Filter out 'acp agent' mode if no ACP agents are configured + if (!hasAcpAgents.value) { + return allModes.filter((mode) => mode.value !== 'acp agent') + } + return allModes + }) + + // === Public Methods === + const setMode = async (mode: ChatMode) => { + // Prevent setting 'acp agent' mode if no agents are configured + if (mode === 'acp agent' && !hasAcpAgents.value) { + console.warn('Cannot set acp agent mode: no ACP agents configured') + return + } + + 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 + 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 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) { + console.warn('Failed to check ACP agents:', error) + hasAcpAgents.value = false + } + } + + const loadMode = async () => { + const loadVersion = modeUpdateVersion + try { + // Check ACP agents availability first + await checkAcpAgents() + + const saved = await configPresenter.getSetting('input_chatMode') + if (modeUpdateVersion === loadVersion) { + const savedMode = (saved as ChatMode) || 'chat' + // If saved mode is 'acp agent' but no agents are configured, fall back to 'chat' + if (savedMode === 'acp agent' && !hasAcpAgents.value) { + currentMode.value = 'chat' + // Save the fallback mode + await configPresenter.setSetting('input_chatMode', 'chat') + } else { + currentMode.value = savedMode + } + } + } catch (error) { + // Fall back to safe defaults on error + if (modeUpdateVersion === loadVersion) { + currentMode.value = 'chat' + } + console.error('Failed to load chat mode, using default:', error) + } finally { + hasLoaded = true + } + } + + const ensureLoaded = () => { + if (hasLoaded) return + if (!loadPromise) { + loadPromise = loadMode().finally(() => { + loadPromise = null + }) + } + } + + 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( + () => hasAcpAgents.value, + (hasAgents) => { + // If current mode is 'acp agent' but agents are removed, switch to 'chat' + if (!hasAgents && currentMode.value === 'acp agent') { + setMode('chat') + } + } + ) + + // Periodically check for ACP agents changes (in case they're updated elsewhere) + // This is a simple approach; in production, you might want to use events + const refreshAcpAgents = async () => { + await checkAcpAgents() + } + + return { + currentMode, + currentIcon, + currentLabel, + isAgentMode, + modes, + setMode, + loadMode, + refreshAcpAgents + } +} 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/WorkspaceBrowserTabs.vue b/src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue new file mode 100644 index 000000000..82da148a2 --- /dev/null +++ b/src/renderer/src/components/workspace/WorkspaceBrowserTabs.vue @@ -0,0 +1,99 @@ + + + + + diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue b/src/renderer/src/components/workspace/WorkspaceFileNode.vue similarity index 75% rename from src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue rename to src/renderer/src/components/workspace/WorkspaceFileNode.vue index e02ac2e49..5a8bf436b 100644 --- a/src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue +++ b/src/renderer/src/components/workspace/WorkspaceFileNode.vue @@ -29,23 +29,23 @@ - {{ t('chat.acp.workspace.files.contextMenu.openFile') }} + {{ t('chat.workspace.files.contextMenu.openFile') }} - {{ t('chat.acp.workspace.files.contextMenu.revealInFolder') }} + {{ t('chat.workspace.files.contextMenu.revealInFolder') }} - {{ t('chat.acp.workspace.files.contextMenu.insertPath') }} + {{ t('chat.workspace.files.contextMenu.insertPath') }}