diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index abb95362f..bc9e0eba8 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -40,7 +40,7 @@ We use GitHub to host code, to track issues and feature requests, as well as acc
1. Clone the repository:
```bash
- git clone https://github.com/yourusername/deepchat.git
+ git clone https://github.com/ThinkInAIXYZ/deepchat.git
cd deepchat
```
@@ -95,31 +95,81 @@ We use GitHub to host code, to track issues and feature requests, as well as acc
## Project Structure
-- `/src` - Main source code
-- `/scripts` - Build and development scripts
-- `/resources` - Application resources
-- `/build` - Build configuration
-- `/out` - Build output
+- `src/main/`: Electron main process. Presenters live here (window/tab/thread/config/llmProvider/mcp/config/knowledge/sync/floating button/deeplink/OAuth, etc.).
+- `src/preload/`: Context-isolated IPC bridge; only exposes whitelisted APIs to the renderer.
+- `src/renderer/`: Vue 3 + Pinia app. App code under `src/renderer/src` (components, stores, views, lib, i18n). Shell UI lives in `src/renderer/shell/`.
+- `src/shared/`: Shared types/utilities and presenter contracts used by both processes.
+- `runtime/`: Bundled runtimes used by MCP and agent tooling (Node/uv).
+- `scripts/`, `resources/`: Build, packaging, and asset pipelines.
+- `build/`, `out/`, `dist/`: Build outputs (do not edit manually).
+- `docs/`: Design docs and guides.
+- `test/`: Vitest suites for main/renderer.
-## Code Style
+## Architecture Overview
-- We use ESLint for JavaScript/TypeScript linting
-- Prettier for code formatting
-- EditorConfig for maintaining consistent coding styles
+### Design Principles
-Please ensure your code follows our style guidelines by running:
+- **Presenter pattern**: All system capabilities are in main-process presenters; the renderer calls them via the typed `usePresenter` hook and preload bridge.
+- **Multi-window + multi-tab shell**: WindowPresenter and TabPresenter manage true Electron windows/BrowserViews with detach/move support; an EventBus fans out cross-process events.
+- **Clear data boundaries**: Chat data lives in SQLite (`app_db/chat.db`), settings in Electron Store, knowledge bases in DuckDB, and backups via SyncPresenter. Renderer never touches the filesystem directly.
+- **Tooling-first runtime**: LLMProviderPresenter handles streaming, rate limits, and provider instances (cloud/local/ACP agent). MCPPresenter boots MCP servers, router marketplace, and in-memory tools with a bundled Node runtime.
+- **Safety & resilience**: contextIsolation is on; file access is gated behind presenters (e.g., ACP workdir registration); backup/import pipelines validate inputs; rate-limit guards prevent provider overload.
-```bash
-pnpm run lint
-pnpm run build
-pnpm run i18n
```
+┌─────────────────────────────────────────────────────────────┐
+│ Electron Main (TS) │
+│ Presenters: window/tab/thread/config/llm/mcp/knowledge/ │
+│ sync/oauth/deeplink/floating button │
+│ Storage: SQLite chat.db, ElectronStore settings, backups │
+└───────────────┬─────────────────────────────────────────────┘
+ │ IPC (contextBridge + EventBus)
+┌───────────────▼─────────────────────────────────────────────┐
+│ Preload (strict API) │
+└───────────────┬─────────────────────────────────────────────┘
+ │ Typed presenters via `usePresenter`
+┌───────────────▼─────────────────────────────────────────────┐
+│ Renderer (Vue 3 + Pinia + Tailwind + shadcn/ui) │
+│ Shell UI, chat flow, ACP workspace, MCP console, settings │
+└───────────────┬─────────────────────────────────────────────┘
+ │
+┌───────────────▼─────────────────────────────────────────────┐
+│ Runtime add-ons: MCP Node runtime, Ollama controls, ACP │
+│ agent processes, DuckDB knowledge, sync backups │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### Domain Modules & Feature Notes
+
+- **LLM pipeline**: `LLMProviderPresenter` orchestrates providers with rate-limit guards, per-provider instances, model discovery, ModelScope sync, custom model import, Ollama lifecycle, embeddings, and the agent loop (tool calls, streaming states). Session persistence for ACP agents lives in `AcpSessionPersistence`.
+- **MCP stack**: `McpPresenter` uses ServerManager/ToolManager/McpRouterManager to start/stop servers, choose npm registries, auto-start default/builtin servers, and surface tools/prompts/resources. Supports StreamableHTTP/SSE/Stdio transports and a debugging UI.
+- **ACP (Agent Client Protocol)**: ACP providers spawn agent processes, map notifications into chat blocks, and feed the **ACP Workspace** (plan panel with incremental updates, terminal output, and a guarded file tree that requires `registerWorkdir`). PlanStateManager deduplicates plan items and keeps recent completions.
+- **Knowledge & search**: Built-in knowledge bases use DuckDB/vector pipelines with text splitters and MCP-backed configs; search assistants auto-select models and support API + simulated-browser engines via MCP or custom templates.
+- **Shell & UX**: Multi-window/multi-tab navigation, floating chat window, deeplink handling, sync/backup/restore (SQLite + configs zipped with manifest), notifications, and upgrade channel selection.
+
+## Best Practices
+
+- **Use presenters, not Node APIs in the renderer**: All OS/network/filesystem work should go through the preload bridge and presenters. Keep features multi-window-safe by scoping state to `tabId`/`windowId`.
+- **i18n everywhere**: All user-visible strings belong in `src/renderer/src/i18n`; avoid hardcoded text in components.
+- **State & UI**: Favor Pinia stores and composition utilities; keep components stateless where possible and compatible with detached tabs. Consider artifacts, variants, and streaming states when touching chat flows.
+- **LLM/MCP/ACP changes**: Respect rate limits; clean up active streams before switching providers; emit events via `eventBus`. For MCP, persist changes through `configPresenter` and surface server start/stop events. For ACP, always call `registerWorkdir` before reading the filesystem and clear plan/workspace state when sessions end.
+- **Data & persistence**: Use `sqlitePresenter` for conversation data, `configPresenter` for settings/providers, and `syncPresenter` for backup/import. Do not write directly into `appData` from the renderer.
+- **Testing & quality gates**: Before sending a PR, run `pnpm run format`, `pnpm run lint`, `pnpm run typecheck`, and relevant `pnpm test*` suites. Use `pnpm run i18n` to validate locale keys when adding strings.
+
+## Code Style
+
+- TypeScript + Vue 3 Composition API + Pinia; Tailwind + shadcn/ui for styling.
+- Prettier enforces single quotes and no semicolons; `pnpm run format` before committing.
+- OxLint is used for linting (`pnpm run lint`). Type checking via `pnpm run typecheck` (node + web targets).
+- Tests use Vitest (`test/main`, `test/renderer`). Name tests `*.test.ts`/`*.spec.ts`.
+- Follow naming conventions: PascalCase components/types, camelCase variables/functions, SCREAMING_SNAKE_CASE constants.
## Pull Request Process
-1. Update the README.md with details of changes to the interface, if applicable.
-2. Update documentation in the `/docs` directory if needed.
-3. The PR will be merged once you have the sign-off of at least one maintainer.
+1. Keep PRs focused; describe what changed and which issues are addressed.
+2. Include screenshots/GIFs for UI changes and note any docs updates (README/CONTRIBUTING/docs).
+3. Verify format + lint + typecheck + relevant tests locally; note anything not run.
+4. Target the `dev` branch; external contributors should fork-first and open PRs against `dev`.
+5. At least one maintainer approval is required before merge.
## Any Questions?
diff --git a/CONTRIBUTING.zh.md b/CONTRIBUTING.zh.md
index 395b10e24..4c5ed4e07 100644
--- a/CONTRIBUTING.zh.md
+++ b/CONTRIBUTING.zh.md
@@ -40,7 +40,7 @@
1. 克隆仓库:
```bash
- git clone https://github.com/yourusername/deepchat.git
+ git clone https://github.com/ThinkInAIXYZ/deepchat.git
cd deepchat
```
@@ -95,31 +95,81 @@ pnpm run dev
## 项目结构
-- `/src` - 主要源代码
-- `/scripts` - 构建和开发脚本
-- `/resources` - 应用资源
-- `/build` - 构建配置
-- `/out` - 构建输出
+- `src/main/`:Electron 主进程。Presenter 全部集中于此(window/tab/thread/config/llmProvider/mcp/knowledge/sync/浮窗/deeplink/OAuth 等)。
+- `src/preload/`:开启 contextIsolation 的 IPC 桥,只暴露白名单 API 给渲染进程。
+- `src/renderer/`:Vue 3 + Pinia 应用。业务代码在 `src/renderer/src`(components、stores、views、lib、i18n),Shell UI 在 `src/renderer/shell/`。
+- `src/shared/`:主渲染共享的类型、工具和 presenter 契约。
+- `runtime/`:随应用发布的 MCP/Agent 运行时(Node/uv)。
+- `scripts/`、`resources/`:构建、打包与资产管线。
+- `build/`、`out/`、`dist/`:构建产物(请勿直接修改)。
+- `docs/`:设计文档与指南。
+- `test/`:Vitest 测试(main/renderer)。
-## 代码风格
+## 架构概览
-- 使用 ESLint 进行 JavaScript/TypeScript 代码检查
-- 使用 Prettier 进行代码格式化
-- 使用 EditorConfig 维护一致的编码风格
+### 设计原则
-请确保您的代码符合我们的代码风格指南,可以运行以下命令:
+- **Presenter 模式**:系统能力集中在主进程 Presenter,通过 preload 的 `usePresenter` 类型化调用。
+- **多窗口 + 多 Tab Shell**:WindowPresenter 与 TabPresenter 管理真正的 Electron 窗口/BrowserView,可分离/移动;EventBus 负责跨进程广播。
+- **清晰数据边界**:聊天数据在 SQLite(`app_db/chat.db`),设置在 Electron Store,知识库在 DuckDB,备份由 SyncPresenter 负责;渲染进程不直接读写文件系统。
+- **工具优先运行时**:LLMProviderPresenter 统一流式处理、限流、实例管理(云/本地/ACP Agent);MCPPresenter 启动 MCP 服务器、Router 市场和内置工具,捆绑 Node 运行时。
+- **安全与韧性**:开启 contextIsolation;文件访问经 Presenter 授权(如 ACP 需要 `registerWorkdir`);备份/导入校验输入;限流保护避免 Provider 过载。
-```bash
-pnpm run lint
-pnpm run i18n
-pnpm run build
```
+┌─────────────────────────────────────────────────────────────┐
+│ Electron Main (TS) │
+│ Presenters: window/tab/thread/config/llm/mcp/knowledge/ │
+│ sync/oauth/deeplink/浮窗 │
+│ 存储: SQLite chat.db, ElectronStore, 备份 │
+└───────────────┬─────────────────────────────────────────────┘
+ │ IPC (contextBridge + EventBus)
+┌───────────────▼─────────────────────────────────────────────┐
+│ Preload (strict API) │
+└───────────────┬─────────────────────────────────────────────┘
+ │ Typed presenters via `usePresenter`
+┌───────────────▼─────────────────────────────────────────────┐
+│ Renderer (Vue 3 + Pinia + Tailwind + shadcn/ui) │
+│ Shell UI, 聊天流转, ACP 工作区, MCP 控制台, 设置 │
+└───────────────┬─────────────────────────────────────────────┘
+ │
+┌───────────────▼─────────────────────────────────────────────┐
+│ 运行时扩展: MCP Node 运行时, Ollama 控制, ACP Agent 进程, │
+│ DuckDB 知识库, 同步备份 │
+└─────────────────────────────────────────────────────────────┘
+```
+
+### 模块与特性要点
+
+- **LLM 管线**:`LLMProviderPresenter` 负责 Provider 编排、限流守卫、实例管理、模型发现、ModelScope 同步、自定义模型导入、Ollama 生命周期、Embedding、Agent Loop(工具调用、流式状态),ACP Agent 会话持久化在 `AcpSessionPersistence`。
+- **MCP 栈**:`McpPresenter` 搭配 ServerManager/ToolManager/McpRouterManager 启停服务,选择 npm registry,自动拉起默认/内置服务器,并在 UI 中呈现 Tools/Prompts/Resources,支持 StreamableHTTP/SSE/Stdio 传输及调试窗口。
+- **ACP(Agent Client Protocol)**:ACP Provider 启动 Agent 进程,将通知映射为聊天区块,并驱动 **ACP Workspace**(计划面板增量更新、终端输出、受控文件树,需先 `registerWorkdir`)。PlanStateManager 去重计划项并保留最近完成记录。
+- **知识与搜索**:内置知识库采用 DuckDB + 文本切分 + MCP 配置;搜索助手自动择模,支持 API 搜索和模拟浏览搜索引擎,亦可用自定义模板。
+- **Shell & 体验**:多窗口/多 Tab 导航、悬浮聊天窗、deeplink 启动、同步/备份/恢复(SQLite+配置清单打包 zip)、通知、升级通道、隐私开关。
+
+## 最佳实践
+
+- **渲染层勿直接用 Node API**:所有 OS/网络/文件操作经 preload Presenter;注意使用 `tabId`/`windowId` 保障多窗口安全。
+- **全量 i18n**:用户可见文案放在 `src/renderer/src/i18n`,避免组件内硬编码。
+- **状态与 UI**:倾向 Pinia store 与组合式工具,保持组件尽量无状态并兼容 tab 分离;修改聊天流时留意 artifacts、variants、流式状态。
+- **LLM/MCP/ACP 变更**:尊重限流;切换 Provider 前清理活跃流;通过 eventBus 派发事件。MCP 相关改动要落盘到 `configPresenter`,并呈现 server start/stop 事件。ACP 访问文件前调用 `registerWorkdir`,会话结束需清理计划/工作区状态。
+- **数据与持久化**:会话用 `sqlitePresenter`,设置/Provider 用 `configPresenter`,备份导入用 `syncPresenter`;不要从渲染进程直接写 `appData`。
+- **质量门槛**:提交前运行 `pnpm run format`、`pnpm run lint`、`pnpm run typecheck` 及相关 `pnpm test*`。新增文案后跑 `pnpm run i18n` 校验 key。
+
+## 代码风格
+
+- TypeScript + Vue 3 Composition API + Pinia;样式使用 Tailwind + shadcn/ui。
+- Prettier:单引号、无分号;提交前请执行 `pnpm run format`。
+- OxLint 用于代码检查(`pnpm run lint`);类型检查 `pnpm run typecheck`(node + web 双目标)。
+- 测试使用 Vitest(`test/main`、`test/renderer`),命名 `*.test.ts` / `*.spec.ts`。
+- 命名约定:组件/类型 PascalCase,变量/函数 camelCase,常量 SCREAMING_SNAKE_CASE。
## Pull Request 流程
-1. 如果涉及接口变更,请更新 README.md
-2. 如有需要,请更新 `/docs` 目录中的文档
-3. 获得至少一位维护者的批准后,PR 将被合并
+1. 保持 PR 聚焦,描述改动内容及关联 Issue。
+2. UI 变更请附截图/GIF,并注明涉及的文档更新(README/CONTRIBUTING/docs)。
+3. 本地确认 format + lint + typecheck + 相关测试,如未执行请在 PR 备注。
+4. 目标分支为 `dev`;外部贡献者请先 Fork,再向 `dev` 提 PR。
+5. 至少需一位维护者批准后合并。
## 有问题?
diff --git a/README.jp.md b/README.jp.md
index 348a9ac80..695c0200c 100644
--- a/README.jp.md
+++ b/README.jp.md
@@ -12,9 +12,14 @@
+
+
+

+
+
diff --git a/README.md b/README.md
index 0cbf47079..6b8fa9db8 100644
--- a/README.md
+++ b/README.md
@@ -12,9 +12,14 @@
+
+
+

+
+
diff --git a/README.zh.md b/README.zh.md
index e1412ba26..68d295dd6 100644
--- a/README.zh.md
+++ b/README.zh.md
@@ -12,9 +12,14 @@
+
+
+

+
+
diff --git a/src/main/events.ts b/src/main/events.ts
index 11eeb7aee..7002f85f7 100644
--- a/src/main/events.ts
+++ b/src/main/events.ts
@@ -230,3 +230,11 @@ export const LIFECYCLE_EVENTS = {
PROGRESS_UPDATED: 'lifecycle:progress-updated', // Lifecycle progress updated
SHUTDOWN_REQUESTED: 'lifecycle:shutdown-requested' // Application shutdown requested
}
+
+// ACP 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/directoryReader.ts b/src/main/presenter/acpWorkspacePresenter/directoryReader.ts
new file mode 100644
index 000000000..483a77be0
--- /dev/null
+++ b/src/main/presenter/acpWorkspacePresenter/directoryReader.ts
@@ -0,0 +1,133 @@
+import fs from 'fs/promises'
+import path from 'path'
+import type { AcpFileNode } from '@shared/presenter'
+
+// Ignored directory/file patterns
+const IGNORED_PATTERNS = [
+ 'node_modules',
+ '.git',
+ '.DS_Store',
+ 'dist',
+ 'build',
+ '__pycache__',
+ '.venv',
+ 'venv',
+ '.idea',
+ '.vscode',
+ '.cache',
+ 'coverage',
+ '.next',
+ '.nuxt',
+ 'out',
+ '.turbo'
+]
+
+/**
+ * Read directory structure shallowly (only first level)
+ * Directories will have children = undefined, indicating not yet loaded
+ * @param dirPath Directory path
+ */
+export async function readDirectoryShallow(dirPath: string): Promise {
+ try {
+ const entries = await fs.readdir(dirPath, { withFileTypes: true })
+ const nodes: AcpFileNode[] = []
+
+ for (const entry of entries) {
+ // Skip ignored files/directories
+ if (IGNORED_PATTERNS.includes(entry.name)) {
+ continue
+ }
+
+ // Skip hidden files (starting with .)
+ if (entry.name.startsWith('.')) {
+ continue
+ }
+
+ const fullPath = path.join(dirPath, entry.name)
+ const node: AcpFileNode = {
+ name: entry.name,
+ path: fullPath,
+ isDirectory: entry.isDirectory()
+ }
+
+ // For directories, leave children as undefined (lazy load)
+ if (entry.isDirectory()) {
+ node.expanded = false
+ // children is intentionally undefined - will be loaded on expand
+ }
+
+ nodes.push(node)
+ }
+
+ // Sort: directories first, files second, same type sorted by name
+ return nodes.sort((a, b) => {
+ if (a.isDirectory !== b.isDirectory) {
+ return a.isDirectory ? -1 : 1
+ }
+ return a.name.localeCompare(b.name)
+ })
+ } catch (error) {
+ console.error(`[AcpWorkspace] Failed to read directory ${dirPath}:`, error)
+ return []
+ }
+}
+
+/**
+ * Recursively read directory structure (deprecated, use readDirectoryShallow for lazy loading)
+ * @param dirPath Directory path
+ * @param currentDepth Current depth
+ * @param maxDepth Maximum depth
+ */
+export async function readDirectoryTree(
+ dirPath: string,
+ currentDepth: number = 0,
+ maxDepth: number = 3
+): Promise {
+ // Boundary check: depth limit
+ if (currentDepth >= maxDepth) {
+ return []
+ }
+
+ try {
+ const entries = await fs.readdir(dirPath, { withFileTypes: true })
+ const nodes: AcpFileNode[] = []
+
+ for (const entry of entries) {
+ // Skip ignored files/directories
+ if (IGNORED_PATTERNS.includes(entry.name)) {
+ continue
+ }
+
+ // Skip hidden files (starting with .)
+ if (entry.name.startsWith('.')) {
+ continue
+ }
+
+ const fullPath = path.join(dirPath, entry.name)
+ const node: AcpFileNode = {
+ name: entry.name,
+ path: fullPath,
+ isDirectory: entry.isDirectory()
+ }
+
+ // Recursively read subdirectories
+ if (entry.isDirectory()) {
+ node.children = await readDirectoryTree(fullPath, currentDepth + 1, maxDepth)
+ node.expanded = false // Default collapsed
+ }
+
+ nodes.push(node)
+ }
+
+ // Sort: directories first, files second, same type sorted by name
+ return nodes.sort((a, b) => {
+ if (a.isDirectory !== b.isDirectory) {
+ return a.isDirectory ? -1 : 1
+ }
+ return a.name.localeCompare(b.name)
+ })
+ } catch (error) {
+ console.error(`[AcpWorkspace] Failed to read directory ${dirPath}:`, error)
+ return []
+ }
+}
diff --git a/src/main/presenter/acpWorkspacePresenter/index.ts b/src/main/presenter/acpWorkspacePresenter/index.ts
new file mode 100644
index 000000000..20ce6ed99
--- /dev/null
+++ b/src/main/presenter/acpWorkspacePresenter/index.ts
@@ -0,0 +1,153 @@
+import path from 'path'
+import { shell } from 'electron'
+import { eventBus, SendTarget } from '@/eventbus'
+import { ACP_WORKSPACE_EVENTS } from '@/events'
+import { readDirectoryShallow } from './directoryReader'
+import { PlanStateManager } from './planStateManager'
+import type {
+ IAcpWorkspacePresenter,
+ AcpFileNode,
+ AcpPlanEntry,
+ AcpTerminalSnippet,
+ AcpRawPlanEntry
+} from '@shared/presenter'
+
+export class AcpWorkspacePresenter implements IAcpWorkspacePresenter {
+ private readonly planManager = new PlanStateManager()
+ // Allowed workdir paths (registered by ACP sessions)
+ private readonly allowedWorkdirs = new Set()
+
+ /**
+ * Register a workdir as allowed for reading
+ * Returns Promise to ensure IPC call completion
+ */
+ async registerWorkdir(workdir: string): Promise {
+ const normalized = path.resolve(workdir)
+ this.allowedWorkdirs.add(normalized)
+ }
+
+ /**
+ * Unregister a workdir
+ */
+ async unregisterWorkdir(workdir: string): Promise {
+ const normalized = path.resolve(workdir)
+ this.allowedWorkdirs.delete(normalized)
+ }
+
+ /**
+ * Check if a path is within allowed workdirs
+ */
+ private isPathAllowed(targetPath: string): boolean {
+ const normalized = path.resolve(targetPath)
+ for (const workdir of this.allowedWorkdirs) {
+ // Check if targetPath is equal to or under the workdir
+ if (normalized === workdir || normalized.startsWith(workdir + path.sep)) {
+ return true
+ }
+ }
+ return false
+ }
+
+ /**
+ * Read directory (shallow, only first level)
+ * Use expandDirectory to load subdirectory contents
+ */
+ async readDirectory(dirPath: string): Promise {
+ // Security check: only allow reading within registered workdirs
+ if (!this.isPathAllowed(dirPath)) {
+ console.warn(`[AcpWorkspace] Blocked read attempt for unauthorized path: ${dirPath}`)
+ return []
+ }
+ return readDirectoryShallow(dirPath)
+ }
+
+ /**
+ * 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 workdirs
+ if (!this.isPathAllowed(dirPath)) {
+ console.warn(`[AcpWorkspace] Blocked expand attempt for unauthorized path: ${dirPath}`)
+ return []
+ }
+ return readDirectoryShallow(dirPath)
+ }
+
+ /**
+ * Reveal a file or directory in the system file manager
+ */
+ async revealFileInFolder(filePath: string): Promise {
+ // Security check: only allow revealing within registered workdirs
+ if (!this.isPathAllowed(filePath)) {
+ console.warn(`[AcpWorkspace] Blocked reveal attempt for unauthorized path: ${filePath}`)
+ return
+ }
+
+ const normalizedPath = path.resolve(filePath)
+
+ try {
+ shell.showItemInFolder(normalizedPath)
+ } catch (error) {
+ console.error(`[AcpWorkspace] 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(`[AcpWorkspace] Blocked open attempt for unauthorized path: ${filePath}`)
+ return
+ }
+
+ const normalizedPath = path.resolve(filePath)
+
+ try {
+ const errorMessage = await shell.openPath(normalizedPath)
+ if (errorMessage) {
+ console.error(`[AcpWorkspace] Failed to open path: ${normalizedPath}`, errorMessage)
+ }
+ } catch (error) {
+ console.error(`[AcpWorkspace] Failed to open path: ${normalizedPath}`, error)
+ }
+ }
+
+ /**
+ * Get plan entries
+ */
+ async getPlanEntries(conversationId: string): Promise {
+ return this.planManager.getEntries(conversationId)
+ }
+
+ /**
+ * Update plan entries (called by acpContentMapper)
+ */
+ async updatePlanEntries(conversationId: string, entries: AcpRawPlanEntry[]): Promise {
+ const updated = this.planManager.updateEntries(conversationId, entries)
+
+ // Send event to renderer
+ eventBus.sendToRenderer(ACP_WORKSPACE_EVENTS.PLAN_UPDATED, SendTarget.ALL_WINDOWS, {
+ conversationId,
+ entries: updated
+ })
+ }
+
+ /**
+ * Emit terminal output snippet (called by acpContentMapper)
+ */
+ async emitTerminalSnippet(conversationId: string, snippet: AcpTerminalSnippet): Promise {
+ eventBus.sendToRenderer(ACP_WORKSPACE_EVENTS.TERMINAL_OUTPUT, SendTarget.ALL_WINDOWS, {
+ conversationId,
+ snippet
+ })
+ }
+
+ /**
+ * Clear workspace data for a conversation
+ */
+ async clearWorkspaceData(conversationId: string): Promise {
+ this.planManager.clear(conversationId)
+ }
+}
diff --git a/src/main/presenter/acpWorkspacePresenter/planStateManager.ts b/src/main/presenter/acpWorkspacePresenter/planStateManager.ts
new file mode 100644
index 000000000..ab548ae20
--- /dev/null
+++ b/src/main/presenter/acpWorkspacePresenter/planStateManager.ts
@@ -0,0 +1,119 @@
+import crypto from 'crypto'
+import { nanoid } from 'nanoid'
+import type { AcpPlanEntry, AcpPlanStatus, AcpRawPlanEntry } from '@shared/presenter'
+
+// Maximum number of completed entries to retain per conversation
+const MAX_COMPLETED_ENTRIES = 10
+
+/**
+ * Plan State Manager
+ * Maintains plan entries for each conversation, supports incremental updates
+ */
+export class PlanStateManager {
+ // Map>
+ private readonly planStore = new Map>()
+
+ /**
+ * Update plan entries (incremental merge)
+ * @param conversationId Conversation ID
+ * @param rawEntries Raw plan entries
+ * @returns Updated complete entries list
+ */
+ updateEntries(conversationId: string, rawEntries: AcpRawPlanEntry[]): AcpPlanEntry[] {
+ if (!this.planStore.has(conversationId)) {
+ this.planStore.set(conversationId, new Map())
+ }
+ const store = this.planStore.get(conversationId)!
+
+ for (const raw of rawEntries) {
+ const contentKey = this.hashContent(raw.content)
+ const existing = store.get(contentKey)
+
+ if (existing) {
+ // Update existing entry status
+ existing.status = this.normalizeStatus(raw.status)
+ existing.priority = raw.priority ?? existing.priority
+ existing.updatedAt = Date.now()
+ } else {
+ // Add new entry
+ store.set(contentKey, {
+ id: nanoid(8),
+ content: raw.content,
+ status: this.normalizeStatus(raw.status),
+ priority: raw.priority ?? null,
+ updatedAt: Date.now()
+ })
+ }
+ }
+
+ // Cleanup: keep only the latest MAX_COMPLETED_ENTRIES completed entries
+ this.pruneCompletedEntries(store)
+
+ return this.getEntries(conversationId)
+ }
+
+ /**
+ * Get all plan entries for a conversation
+ */
+ getEntries(conversationId: string): AcpPlanEntry[] {
+ const store = this.planStore.get(conversationId)
+ if (!store) return []
+ return Array.from(store.values())
+ }
+
+ /**
+ * Clear conversation data
+ */
+ clear(conversationId: string): void {
+ this.planStore.delete(conversationId)
+ }
+
+ /**
+ * Prune completed entries, keeping only the latest MAX_COMPLETED_ENTRIES
+ */
+ private pruneCompletedEntries(store: Map): void {
+ const completedEntries: Array<{ key: string; entry: AcpPlanEntry }> = []
+
+ for (const [key, entry] of store) {
+ if (entry.status === 'completed') {
+ completedEntries.push({ key, entry })
+ }
+ }
+
+ // If completed entries exceed the limit, remove oldest ones
+ if (completedEntries.length > MAX_COMPLETED_ENTRIES) {
+ // Sort by updatedAt ascending (oldest first)
+ completedEntries.sort((a, b) => a.entry.updatedAt - b.entry.updatedAt)
+
+ // Remove oldest entries beyond the limit
+ const toRemove = completedEntries.slice(0, completedEntries.length - MAX_COMPLETED_ENTRIES)
+ for (const { key } of toRemove) {
+ store.delete(key)
+ }
+ }
+ }
+
+ /**
+ * Hash content using SHA-256 for reliable deduplication
+ */
+ private hashContent(content: string): string {
+ const normalized = content.trim().toLowerCase()
+ return crypto.createHash('sha256').update(normalized).digest('hex')
+ }
+
+ private normalizeStatus(status?: string | null): AcpPlanStatus {
+ switch (status) {
+ case 'completed':
+ case 'done':
+ return 'completed'
+ case 'in_progress':
+ return 'in_progress'
+ case 'failed':
+ return 'failed'
+ case 'skipped':
+ return 'skipped'
+ default:
+ return 'pending'
+ }
+ }
+}
diff --git a/src/main/presenter/configPresenter/index.ts b/src/main/presenter/configPresenter/index.ts
index c98b549fa..5c87742cd 100644
--- a/src/main/presenter/configPresenter/index.ts
+++ b/src/main/presenter/configPresenter/index.ts
@@ -686,7 +686,8 @@ export class ConfigPresenter implements IConfigPresenter {
'fr-FR',
'fa-IR',
'pt-BR',
- 'da-DK'
+ 'da-DK',
+ 'he-IL'
]
// Exact match
diff --git a/src/main/presenter/index.ts b/src/main/presenter/index.ts
index 1cb9b3d5e..497a2eddb 100644
--- a/src/main/presenter/index.ts
+++ b/src/main/presenter/index.ts
@@ -22,7 +22,8 @@ import {
ITabPresenter,
IThreadPresenter,
IUpgradePresenter,
- IWindowPresenter
+ IWindowPresenter,
+ IAcpWorkspacePresenter
} from '@shared/presenter'
import { eventBus } from '@/eventbus'
import { LLMProviderPresenter } from './llmProviderPresenter'
@@ -40,6 +41,7 @@ import { OAuthPresenter } from './oauthPresenter'
import { FloatingButtonPresenter } from './floatingButtonPresenter'
import { CONFIG_EVENTS, WINDOW_EVENTS } from '@/events'
import { KnowledgePresenter } from './knowledgePresenter'
+import { AcpWorkspacePresenter } from './acpWorkspacePresenter'
// IPC调用上下文接口
interface IPCCallContext {
@@ -77,6 +79,7 @@ export class Presenter implements IPresenter {
oauthPresenter: OAuthPresenter
floatingButtonPresenter: FloatingButtonPresenter
knowledgePresenter: IKnowledgePresenter
+ acpWorkspacePresenter: IAcpWorkspacePresenter
// llamaCppPresenter: LlamaCppPresenter // 保留原始注释
dialogPresenter: IDialogPresenter
lifecycleManager: ILifecycleManager
@@ -119,6 +122,9 @@ export class Presenter implements IPresenter {
this.filePresenter
)
+ // Initialize ACP Workspace presenter
+ this.acpWorkspacePresenter = new AcpWorkspacePresenter()
+
// this.llamaCppPresenter = new LlamaCppPresenter() // 保留原始注释
this.setupEventBus() // 设置事件总线监听
}
diff --git a/src/main/presenter/llmProviderPresenter/agent/acpFsHandler.ts b/src/main/presenter/llmProviderPresenter/agent/acpFsHandler.ts
index 8c7ea86ec..c81d44b1c 100644
--- a/src/main/presenter/llmProviderPresenter/agent/acpFsHandler.ts
+++ b/src/main/presenter/llmProviderPresenter/agent/acpFsHandler.ts
@@ -8,6 +8,8 @@ export interface FsHandlerOptions {
workspaceRoot: string | null
/** Maximum file size in bytes to read (default: 10MB) */
maxReadSize?: number
+ /** Callback when a file is written */
+ onFileChange?: (filePath: string) => void
}
/**
@@ -21,10 +23,12 @@ export interface FsHandlerOptions {
export class AcpFsHandler {
private readonly workspaceRoot: string | null
private readonly maxReadSize: number
+ private readonly onFileChange?: (filePath: string) => void
constructor(options: FsHandlerOptions) {
this.workspaceRoot = options.workspaceRoot ? path.resolve(options.workspaceRoot) : null
this.maxReadSize = options.maxReadSize ?? 10 * 1024 * 1024 // 10MB default
+ this.onFileChange = options.onFileChange
}
/**
@@ -101,6 +105,12 @@ export class AcpFsHandler {
await fs.mkdir(dir, { recursive: true })
await fs.writeFile(filePath, params.content, 'utf-8')
+
+ // Notify file change
+ if (this.onFileChange) {
+ this.onFileChange(filePath)
+ }
+
return {}
} catch (error) {
if (error instanceof RequestError) {
diff --git a/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts b/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts
index 106a4d4bc..62ccc09e3 100644
--- a/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts
+++ b/src/main/presenter/llmProviderPresenter/agent/acpProcessManager.ts
@@ -24,6 +24,8 @@ export interface AcpProcessHandle extends AgentProcessHandle {
connection: ClientSideConnectionType
agent: AcpAgentConfig
readyAt: number
+ /** The working directory this process was spawned with */
+ workdir: string
}
interface AcpProcessManagerOptions {
@@ -70,6 +72,7 @@ export class AcpProcessManager implements AgentProcessManager()
private readonly fsHandlers = new Map()
+ private readonly agentLocks = new Map>()
constructor(options: AcpProcessManagerOptions) {
this.providerId = options.providerId
@@ -121,28 +124,69 @@ export class AcpProcessManager implements AgentProcessManager {
+ /**
+ * Get or create a connection for the given agent.
+ * If workdir is provided and differs from the existing process's workdir,
+ * the existing process will be released and a new one spawned with the new workdir.
+ */
+ async getConnection(agent: AcpAgentConfig, workdir?: string): Promise {
+ const resolvedWorkdir = this.resolveWorkdir(workdir)
const existing = this.handles.get(agent.id)
- if (existing && this.isHandleAlive(existing)) {
- return existing
- }
- const inflight = this.pendingHandles.get(agent.id)
- if (inflight) {
- return inflight
+ // Fast-path for already-alive handles with matching workdir
+ if (existing && this.isHandleAlive(existing) && existing.workdir === resolvedWorkdir) {
+ return existing
}
- const handlePromise = this.spawnProcess(agent)
- this.pendingHandles.set(agent.id, handlePromise)
+ const releaseLock = await this.acquireAgentLock(agent.id)
try {
- const handle = await handlePromise
- this.handles.set(agent.id, handle)
- return handle
+ const currentHandle = this.handles.get(agent.id)
+ if (currentHandle && this.isHandleAlive(currentHandle)) {
+ if (currentHandle.workdir === resolvedWorkdir) {
+ return currentHandle
+ }
+ console.info(
+ `[ACP] Workdir changed for agent ${agent.id}: "${currentHandle.workdir}" -> "${resolvedWorkdir}", recreating process`
+ )
+ await this.release(agent.id)
+ }
+
+ const inflight = this.pendingHandles.get(agent.id)
+ if (inflight) {
+ const inflightHandle = await inflight
+ if (inflightHandle.workdir === resolvedWorkdir) {
+ return inflightHandle
+ }
+ console.info(
+ `[ACP] Workdir mismatch for inflight agent ${agent.id}, recreating with workdir: "${resolvedWorkdir}"`
+ )
+ await this.release(agent.id)
+ }
+
+ const handlePromise = this.spawnProcess(agent, resolvedWorkdir)
+ this.pendingHandles.set(agent.id, handlePromise)
+ try {
+ const handle = await handlePromise
+ this.handles.set(agent.id, handle)
+ return handle
+ } finally {
+ this.pendingHandles.delete(agent.id)
+ }
} finally {
- this.pendingHandles.delete(agent.id)
+ releaseLock()
}
}
+ /**
+ * Resolve workdir to an absolute path, using fallback if not provided.
+ */
+ private resolveWorkdir(workdir?: string): string {
+ if (workdir && workdir.trim()) {
+ return workdir.trim()
+ }
+ return this.getFallbackWorkdir()
+ }
+
getProcess(agentId: string): AcpProcessHandle | null {
return this.handles.get(agentId) ?? null
}
@@ -224,8 +268,8 @@ export class AcpProcessManager implements AgentProcessManager {
- const child = await this.spawnAgentProcess(agent)
+ private async spawnProcess(agent: AcpAgentConfig, workdir: string): Promise {
+ const child = await this.spawnAgentProcess(agent, workdir)
const stream = this.createAgentStream(child)
const client = this.createClientProxy()
const connection = new ClientSideConnection(() => client, stream)
@@ -317,7 +361,8 @@ export class AcpProcessManager implements AgentProcessManager {
@@ -330,12 +375,12 @@ export class AcpProcessManager implements AgentProcessManager {
- const output = chunk.toString().trim()
- if (output) {
- console.info(`[ACP] ${agent.id} stdout: ${output}`)
- }
- })
+ // child.stdout?.on('data', (chunk: Buffer) => {
+ // const output = chunk.toString().trim()
+ // if (output) {
+ // console.info(`[ACP] ${agent.id} stdout: ${output}`)
+ // }
+ // })
child.stderr?.on('data', (chunk: Buffer) => {
const error = chunk.toString().trim()
@@ -354,7 +399,10 @@ export class AcpProcessManager implements AgentProcessManager {
+ private async spawnAgentProcess(
+ agent: AcpAgentConfig,
+ workdir: string
+ ): Promise {
// Initialize runtime paths if not already done
this.runtimeHelper.initializeRuntimes()
@@ -533,16 +581,15 @@ export class AcpProcessManager implements AgentProcessManager void> {
+ const previousLock = this.agentLocks.get(agentId) ?? Promise.resolve()
+
+ let releaseResolver: (() => void) | undefined
+ const currentLock = new Promise((resolve) => {
+ releaseResolver = resolve
+ })
+
+ this.agentLocks.set(agentId, currentLock)
+ await previousLock
+
+ return () => {
+ releaseResolver?.()
+ if (this.agentLocks.get(agentId) === currentLock) {
+ this.agentLocks.delete(agentId)
+ }
+ }
+ }
+
private isHandleAlive(handle: AcpProcessHandle): boolean {
return !handle.child.killed && !handle.connection.signal.aborted
}
diff --git a/src/main/presenter/llmProviderPresenter/agent/acpSessionManager.ts b/src/main/presenter/llmProviderPresenter/agent/acpSessionManager.ts
index f2448c1c0..06eb7dd7d 100644
--- a/src/main/presenter/llmProviderPresenter/agent/acpSessionManager.ts
+++ b/src/main/presenter/llmProviderPresenter/agent/acpSessionManager.ts
@@ -152,7 +152,8 @@ export class AcpSessionManager {
hooks: SessionHooks,
workdir: string
): Promise {
- const handle = await this.processManager.getConnection(agent)
+ // Pass workdir to process manager so the process runs in the correct directory
+ const handle = await this.processManager.getConnection(agent, workdir)
const session = await this.initializeSession(handle, agent, workdir)
const detachListeners = this.attachSessionHooks(agent.id, session.sessionId, hooks)
@@ -219,15 +220,29 @@ export class AcpSessionManager {
// Extract modes from response if available
const modes = response.modes
+ const availableModes = modes?.availableModes?.map((m) => ({
+ id: m.id,
+ name: m.name,
+ description: m.description ?? ''
+ }))
+ const currentModeId = modes?.currentModeId
+
+ // Log available modes for the agent
+ if (availableModes && availableModes.length > 0) {
+ console.info(
+ `[ACP] Agent "${agent.name}" (${agent.id}) supports modes: [${availableModes.map((m) => m.id).join(', ')}], ` +
+ `current mode: "${currentModeId ?? 'default'}"`
+ )
+ } else {
+ console.info(
+ `[ACP] Agent "${agent.name}" (${agent.id}) does not declare any modes (will use default behavior)`
+ )
+ }
return {
sessionId: response.sessionId,
- availableModes: modes?.availableModes?.map((m) => ({
- id: m.id,
- name: m.name,
- description: m.description ?? ''
- })),
- currentModeId: modes?.currentModeId
+ availableModes,
+ currentModeId
}
} catch (error) {
console.error(`[ACP] Failed to create session for agent ${agent.id}:`, error)
diff --git a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts
index 196f2f831..ededb9d23 100644
--- a/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts
+++ b/src/main/presenter/llmProviderPresenter/managers/agentLoopHandler.ts
@@ -1,5 +1,7 @@
import { ChatMessage, IConfigPresenter, LLMAgentEvent, MCPToolCall } from '@shared/presenter'
import { presenter } from '@/presenter'
+import { eventBus, SendTarget } from '@/eventbus'
+import { ACP_WORKSPACE_EVENTS } from '@/events'
import { BaseLLMProvider } from '../baseProvider'
import { StreamState } from '../types'
import { RateLimitManager } from './rateLimitManager'
@@ -255,25 +257,50 @@ export class AgentLoopHandler {
const completeArgs =
chunk.tool_call_arguments_complete ??
currentToolChunks[chunk.tool_call_id].arguments_chunk
- currentToolCalls.push({
- id: chunk.tool_call_id,
- name: currentToolChunks[chunk.tool_call_id].name,
- arguments: completeArgs
- })
+ const toolCallName = currentToolChunks[chunk.tool_call_id].name
+
+ // For ACP provider, tool call execution is completed on agent side
+ // The tool_call_arguments_complete contains the execution result
+ // So we should immediately send 'end' event to mark it as successful
+ if (providerId === 'acp') {
+ // For ACP, tool_call_arguments_complete contains the execution result
+ // Use it directly as the response
+ yield {
+ type: 'response',
+ data: {
+ eventId,
+ tool_call: 'end',
+ tool_call_id: chunk.tool_call_id,
+ tool_call_name: toolCallName,
+ tool_call_params: completeArgs,
+ tool_call_response: completeArgs
+ }
+ }
- // Send final update event to ensure parameter completeness
- yield {
- type: 'response',
- data: {
- eventId,
- tool_call: 'update',
- tool_call_id: chunk.tool_call_id,
- tool_call_name: currentToolChunks[chunk.tool_call_id].name,
- tool_call_params: completeArgs
+ // Don't add to currentToolCalls for ACP - execution already completed
+ delete currentToolChunks[chunk.tool_call_id]
+ } else {
+ // For non-ACP providers, tool call needs to be executed by ToolCallProcessor
+ currentToolCalls.push({
+ id: chunk.tool_call_id,
+ name: toolCallName,
+ arguments: completeArgs
+ })
+
+ // Send final update event to ensure parameter completeness
+ yield {
+ type: 'response',
+ data: {
+ eventId,
+ tool_call: 'update',
+ tool_call_id: chunk.tool_call_id,
+ tool_call_name: toolCallName,
+ tool_call_params: completeArgs
+ }
}
- }
- delete currentToolChunks[chunk.tool_call_id]
+ delete currentToolChunks[chunk.tool_call_id]
+ }
}
break
case 'permission': {
@@ -504,6 +531,13 @@ export class AgentLoopHandler {
this.options.activeStreams.delete(eventId)
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
+ })
+ }
}
}
}
diff --git a/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts b/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts
index 8ffcbf925..ee1333690 100644
--- a/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts
+++ b/src/main/presenter/llmProviderPresenter/providers/acpProvider.ts
@@ -19,7 +19,7 @@ import {
} from '@shared/types/core/llm-events'
import { ModelType } from '@shared/model'
import { eventBus, SendTarget } from '@/eventbus'
-import { CONFIG_EVENTS } from '@/events'
+import { CONFIG_EVENTS, ACP_WORKSPACE_EVENTS } from '@/events'
import { AcpProcessManager } from '../agent/acpProcessManager'
import { AcpSessionManager } from '../agent/acpSessionManager'
import type { AcpSessionRecord } from '../agent/acpSessionManager'
@@ -327,7 +327,7 @@ export class AcpProvider extends BaseAgentProvider<
agent,
{
onSessionUpdate: (notification) => {
- console.log('[ACP] onSessionUpdate: notification:', JSON.stringify(notification))
+ // console.log('[ACP] onSessionUpdate: notification:', JSON.stringify(notification))
const mapped = this.contentMapper.map(notification)
mapped.events.forEach((event) => queue.push(event))
},
@@ -340,6 +340,19 @@ export class AcpProvider extends BaseAgentProvider<
workdir
)
+ // Notify renderer of available session modes (if any)
+ if (session.availableModes && session.availableModes.length > 0) {
+ eventBus.sendToRenderer(
+ ACP_WORKSPACE_EVENTS.SESSION_MODES_READY,
+ SendTarget.ALL_WINDOWS,
+ {
+ conversationId: conversationKey,
+ current: session.currentModeId ?? 'default',
+ available: session.availableModes
+ }
+ )
+ }
+
const promptBlocks = this.messageFormatter.format(messages, modelConfig)
void this.runPrompt(session, promptBlocks, queue)
}
@@ -666,12 +679,38 @@ export class AcpProvider extends BaseAgentProvider<
throw new Error(`[ACP] No session found for conversation ${conversationId}`)
}
+ const previousMode = session.currentModeId ?? 'default'
+ const availableModes = session.availableModes ?? []
+ const availableModeIds = availableModes.map((m) => m.id)
+
+ // Log available modes for debugging
+ console.info(
+ `[ACP] Agent "${session.agentId}" available modes: [${availableModeIds.join(', ')}]`
+ )
+
+ // Warn if requested mode is not in available modes
+ if (availableModeIds.length > 0 && !availableModeIds.includes(modeId)) {
+ console.warn(
+ `[ACP] Mode "${modeId}" is not in agent's available modes [${availableModeIds.join(', ')}]. ` +
+ `The agent may not support this mode.`
+ )
+ }
+
try {
+ console.info(
+ `[ACP] Changing session mode: "${previousMode}" -> "${modeId}" ` +
+ `(conversation: ${conversationId}, agent: ${session.agentId})`
+ )
await session.connection.setSessionMode({ sessionId: session.sessionId, modeId })
session.currentModeId = modeId
- console.info(`[ACP] Session mode changed to ${modeId} for conversation ${conversationId}`)
+ console.info(
+ `[ACP] Session mode successfully changed to "${modeId}" for conversation ${conversationId}`
+ )
} catch (error) {
- console.error('[ACP] Failed to set session mode:', error)
+ console.error(
+ `[ACP] Failed to set session mode "${modeId}" for agent "${session.agentId}":`,
+ error
+ )
throw error
}
}
@@ -685,12 +724,20 @@ export class AcpProvider extends BaseAgentProvider<
} | null> {
const session = this.sessionManager.getSession(conversationId)
if (!session) {
+ console.warn(`[ACP] getSessionModes: No session found for conversation ${conversationId}`)
return null
}
- return {
+ const result = {
current: session.currentModeId ?? 'default',
available: session.availableModes ?? []
}
+
+ console.info(
+ `[ACP] getSessionModes for agent "${session.agentId}": ` +
+ `current="${result.current}", available=[${result.available.map((m) => m.id).join(', ')}]`
+ )
+
+ return result
}
}
diff --git a/src/main/presenter/threadPresenter/index.ts b/src/main/presenter/threadPresenter/index.ts
index 95962ca97..268b5cc24 100644
--- a/src/main/presenter/threadPresenter/index.ts
+++ b/src/main/presenter/threadPresenter/index.ts
@@ -175,11 +175,13 @@ export class ThreadPresenter implements IThreadPresenter {
if (conversation.is_new === 1) {
try {
- const title = await this.summaryTitles(undefined, state.conversationId)
- if (title) {
- await this.renameConversation(state.conversationId, title)
- return
- }
+ this.summaryTitles(undefined, state.conversationId)
+ .then((title) => {
+ return this.renameConversation(state.conversationId, title)
+ })
+ .then(() => {
+ console.log('renameConversation success')
+ })
} catch (error) {
console.error('[ThreadPresenter] Failed to summarize title', {
conversationId: state.conversationId,
diff --git a/src/main/presenter/threadPresenter/utils/promptBuilder.ts b/src/main/presenter/threadPresenter/utils/promptBuilder.ts
index 8d4e7aba7..a20854c12 100644
--- a/src/main/presenter/threadPresenter/utils/promptBuilder.ts
+++ b/src/main/presenter/threadPresenter/utils/promptBuilder.ts
@@ -445,7 +445,7 @@ function addContextMessages(
contextMessages.forEach((msg) => {
if (msg.role === 'user') {
const msgContent = msg.content as VisionUserMessageContent
- const normalizedText = getNormalizedUserMessageText(msgContent)
+ const finalUserContext = buildUserMessageContext(msgContent)
if (vision && msgContent.images && msgContent.images.length > 0) {
resultMessages.push({
role: 'user',
@@ -454,13 +454,13 @@ function addContextMessages(
type: 'image_url' as const,
image_url: { url: image, detail: 'auto' as const }
})),
- { type: 'text' as const, text: normalizedText }
+ { type: 'text' as const, text: finalUserContext }
]
})
} else {
resultMessages.push({
role: 'user',
- content: normalizedText
+ content: finalUserContext
})
}
} else if (msg.role === 'assistant') {
@@ -532,7 +532,7 @@ function addContextMessages(
contextMessages.forEach((msg) => {
if (msg.role === 'user') {
const msgContent = msg.content as VisionUserMessageContent
- const normalizedText = getNormalizedUserMessageText(msgContent)
+ const finalUserContext = buildUserMessageContext(msgContent)
if (vision && msgContent.images && msgContent.images.length > 0) {
resultMessages.push({
role: 'user',
@@ -541,13 +541,13 @@ function addContextMessages(
type: 'image_url' as const,
image_url: { url: image, detail: 'auto' as const }
})),
- { type: 'text' as const, text: normalizedText }
+ { type: 'text' as const, text: finalUserContext }
]
})
} else {
resultMessages.push({
role: 'user',
- content: normalizedText
+ content: finalUserContext
})
}
} else if (msg.role === 'assistant') {
diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts
index 195757e54..c44294752 100644
--- a/src/preload/index.d.ts
+++ b/src/preload/index.d.ts
@@ -11,6 +11,8 @@ declare global {
getWindowId(): number | null
getWebContentsId(): number
openExternal?(url: string): Promise
+ toRelativePath?(filePath: string, baseDir?: string): string
+ formatPathForInput?(filePath: string): string
}
floatingButtonAPI: typeof floatingButtonAPI
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index f3d34a69b..b4672fa4b 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -1,3 +1,4 @@
+import path from 'path'
import {
clipboard,
contextBridge,
@@ -44,6 +45,45 @@ const api = {
},
openExternal: (url: string) => {
return shell.openExternal(url)
+ },
+ toRelativePath: (filePath: string, baseDir?: string) => {
+ if (!baseDir) return filePath
+
+ try {
+ const relative = path.relative(baseDir, filePath)
+ if (
+ relative === '' ||
+ (relative && !relative.startsWith('..') && !path.isAbsolute(relative))
+ ) {
+ return relative
+ }
+ } catch (error) {
+ console.warn('Preload: Failed to compute relative path', filePath, baseDir, error)
+ }
+ return filePath
+ },
+ formatPathForInput: (filePath: string) => {
+ const containsSpace = /\s/.test(filePath)
+ const hasDoubleQuote = filePath.includes('"')
+ const hasSingleQuote = filePath.includes("'")
+
+ if (!containsSpace && !hasDoubleQuote && !hasSingleQuote) {
+ return filePath
+ }
+
+ // Prefer double quotes; escape any existing ones
+ if (hasDoubleQuote) {
+ const escaped = filePath.replace(/"/g, '\\"')
+ return `"${escaped}"`
+ }
+
+ // Use double quotes when only spaces
+ if (containsSpace) {
+ return `"${filePath}"`
+ }
+
+ // Fallback: no spaces but contains single quotes
+ return `'${filePath.replace(/'/g, `'\\''`)}'`
}
}
exposeElectronAPI()
diff --git a/src/renderer/settings/components/DisplaySettings.vue b/src/renderer/settings/components/DisplaySettings.vue
index 6b6008092..2f84dfd52 100644
--- a/src/renderer/settings/components/DisplaySettings.vue
+++ b/src/renderer/settings/components/DisplaySettings.vue
@@ -340,7 +340,8 @@ const languageOptions = [
{ value: 'fr-FR', label: 'Français' },
{ value: 'fa-IR', label: 'فارسی (ایران)' },
{ value: 'pt-BR', label: 'Português (Brasil)' },
- { value: 'da-DK', label: 'Dansk' }
+ { value: 'da-DK', label: 'Dansk' },
+ { value: 'he-IL', label: 'עברית (ישראל)' }
]
watch(selectedLanguage, async (newValue) => {
diff --git a/src/renderer/settings/components/common/SearchAssistantModelSection.vue b/src/renderer/settings/components/common/SearchAssistantModelSection.vue
index 7a4fbb2f3..1088d94e0 100644
--- a/src/renderer/settings/components/common/SearchAssistantModelSection.vue
+++ b/src/renderer/settings/components/common/SearchAssistantModelSection.vue
@@ -30,6 +30,7 @@
diff --git a/src/renderer/src/components/ChatView.vue b/src/renderer/src/components/ChatView.vue
index 1c6bbd0d4..a508cb3b1 100644
--- a/src/renderer/src/components/ChatView.vue
+++ b/src/renderer/src/components/ChatView.vue
@@ -1,11 +1,24 @@
-
-
+
@@ -42,11 +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 { 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 { useCleanDialog } from '@/composables/message/useCleanDialog'
import { useI18n } from 'vue-i18n'
import {
@@ -63,8 +78,12 @@ const { t } = useI18n()
const route = useRoute()
const uiSettingsStore = useUiSettingsStore()
const chatStore = useChatStore()
+const acpWorkspaceStore = useAcpWorkspaceStore()
const cleanDialog = useCleanDialog()
+// Show workspace only in ACP mode and when open
+const showAcpWorkspace = computed(() => acpWorkspaceStore.isAcpMode && acpWorkspaceStore.isOpen)
+
const messageList = ref()
const chatInput = ref()
@@ -87,6 +106,21 @@ const handleFileUpload = () => {
scrollToBottom()
}
+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 handleAppendFilePath = (filePath: string) => {
+ const relativePath = toRelativePath(filePath)
+ const formattedPath = formatFilePathForEditor(relativePath)
+ chatInput.value?.appendText(`${formattedPath} `)
+ chatInput.value?.restoreFocus()
+}
+
const onStreamEnd = (_, _msg) => {
// 状态处理已移至 store
// 当用户没有主动向上滚动时才自动滚动到底部
@@ -153,3 +187,22 @@ defineExpose({
messageList
})
+
+
diff --git a/src/renderer/src/components/ModelSelect.vue b/src/renderer/src/components/ModelSelect.vue
index 1d2a07aec..2e73c532b 100644
--- a/src/renderer/src/components/ModelSelect.vue
+++ b/src/renderer/src/components/ModelSelect.vue
@@ -71,13 +71,17 @@ const props = defineProps({
type: {
type: Array as PropType
,
default: undefined // ← explicit for clarity
+ },
+ excludeProviders: {
+ type: Array as PropType,
+ default: () => []
}
})
const providers = computed(() => {
const sortedProviders = providerStore.sortedProviders
const enabledModels = modelStore.enabledModels
const orderedProviders = sortedProviders
- .filter((provider) => provider.enable)
+ .filter((provider) => provider.enable && !props.excludeProviders.includes(provider.id))
.map((provider) => {
const enabledProvider = enabledModels.find((ep) => ep.providerId === provider.id)
if (!enabledProvider || enabledProvider.models.length === 0) {
diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue b/src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue
new file mode 100644
index 000000000..26cf09245
--- /dev/null
+++ b/src/renderer/src/components/acp-workspace/AcpWorkspaceFileNode.vue
@@ -0,0 +1,164 @@
+
+
+
+
+
+
+
+
+
+
+ {{ t('chat.acp.workspace.files.contextMenu.openFile') }}
+
+
+
+ {{ t('chat.acp.workspace.files.contextMenu.revealInFolder') }}
+
+
+
+
+ {{ t('chat.acp.workspace.files.contextMenu.insertPath') }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspaceFiles.vue b/src/renderer/src/components/acp-workspace/AcpWorkspaceFiles.vue
new file mode 100644
index 000000000..d71f49dc8
--- /dev/null
+++ b/src/renderer/src/components/acp-workspace/AcpWorkspaceFiles.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+ {{ t('chat.acp.workspace.files.loading') }}
+
+
+
+ {{ t('chat.acp.workspace.files.empty') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspacePlan.vue b/src/renderer/src/components/acp-workspace/AcpWorkspacePlan.vue
new file mode 100644
index 000000000..7395820dc
--- /dev/null
+++ b/src/renderer/src/components/acp-workspace/AcpWorkspacePlan.vue
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+ -
+ {{
+ getStatusIcon(entry.status)
+ }}
+
+ {{ entry.content }}
+
+
+ {{ entry.priority }}
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspaceTerminal.vue b/src/renderer/src/components/acp-workspace/AcpWorkspaceTerminal.vue
new file mode 100644
index 000000000..7db8f3df2
--- /dev/null
+++ b/src/renderer/src/components/acp-workspace/AcpWorkspaceTerminal.vue
@@ -0,0 +1,87 @@
+
+
+
+
+
+
+
+
+ $
+
+ {{ snippet.command }}
+
+
+ {{ snippet.exitCode }}
+
+
+
{{ snippet.output }}
+
+ (truncated)
+
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/acp-workspace/AcpWorkspaceView.vue b/src/renderer/src/components/acp-workspace/AcpWorkspaceView.vue
new file mode 100644
index 000000000..dd10bf5d9
--- /dev/null
+++ b/src/renderer/src/components/acp-workspace/AcpWorkspaceView.vue
@@ -0,0 +1,50 @@
+
+
+
+
+
diff --git a/src/renderer/src/components/chat-input/ChatInput.vue b/src/renderer/src/components/chat-input/ChatInput.vue
index 92901d955..d8d8306f9 100644
--- a/src/renderer/src/components/chat-input/ChatInput.vue
+++ b/src/renderer/src/components/chat-input/ChatInput.vue
@@ -193,8 +193,10 @@
-
-
+
+
{{ getStatusText() }}
@@ -42,7 +42,7 @@
class="w-4 h-4 text-amber-600 dark:text-amber-400"
/>
- {{ block.tool_call?.name }}
+ {{ truncatedName }}
@@ -100,6 +100,23 @@ const props = defineProps<{
const isProcessing = ref(false)
const rememberable = computed(() => props.block.extra?.rememberable !== false)
+// Truncate name to max 200 characters
+const MAX_NAME_LENGTH = 200
+const truncatedName = computed(() => {
+ const name = props.block.tool_call?.name ?? ''
+ const ellipsis = '...'
+
+ if (MAX_NAME_LENGTH <= ellipsis.length) {
+ return ellipsis.slice(0, MAX_NAME_LENGTH)
+ }
+
+ if (name.length > MAX_NAME_LENGTH) {
+ return name.slice(0, MAX_NAME_LENGTH - ellipsis.length) + ellipsis
+ }
+
+ return name
+})
+
const getPermissionIcon = () => {
const permissionType = props.block.extra?.permissionType as string
switch (permissionType) {
diff --git a/src/renderer/src/components/message/MessageBlockPlan.vue b/src/renderer/src/components/message/MessageBlockPlan.vue
index 3b9171208..192842550 100644
--- a/src/renderer/src/components/message/MessageBlockPlan.vue
+++ b/src/renderer/src/components/message/MessageBlockPlan.vue
@@ -1,21 +1,12 @@
-
-
-
-
-
- {{ t('plan.title') }}
-
- {{ completedCount }}/{{ totalCount }} {{ t('plan.completed') }}
-
-
-
+
+
+
+
+ {{ t('plan.title') }}
+
+ {{ completedCount }}/{{ totalCount }} {{ t('plan.completed') }}
+
@@ -25,66 +16,23 @@
:style="{ width: `${progressPercent}%` }"
/>
-
-
-
-
-
-
-
{{ getStatusIcon(entry.status) }}
-
-
-
-
- {{ entry.content }}
-
-
-
-
- {{ entry.priority }}
-
-
-
-
-
diff --git a/src/renderer/src/components/message/MessageList.vue b/src/renderer/src/components/message/MessageList.vue
index d1dc22c86..7142e2d2f 100644
--- a/src/renderer/src/components/message/MessageList.vue
+++ b/src/renderer/src/components/message/MessageList.vue
@@ -39,8 +39,10 @@
{
return chatStore.generatingThreadIds.has(chatStore.getActiveThreadId() ?? '')
})
+// Show workspace button only in ACP mode when workspace is closed
+const showWorkspaceButton = computed(() => {
+ return acpWorkspaceStore.isAcpMode && !acpWorkspaceStore.isOpen
+})
+
+const handleOpenWorkspace = () => {
+ acpWorkspaceStore.setOpen(true)
+}
+
const handleTrace = (messageId: string) => {
traceMessageId.value = messageId
}
diff --git a/src/renderer/src/components/think-content/ThinkContent.vue b/src/renderer/src/components/think-content/ThinkContent.vue
index 84ecc8766..0c0d54af0 100644
--- a/src/renderer/src/components/think-content/ThinkContent.vue
+++ b/src/renderer/src/components/think-content/ThinkContent.vue
@@ -28,10 +28,10 @@
@@ -47,16 +47,22 @@