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 @@ Pull Requests Badge Issues Badge License Badge + Downloads Ask DeepWiki

+
+ ThinkInAIXYZ%2Fdeepchat | Trendshift +
+
中文 / English / 日本語
diff --git a/README.md b/README.md index 0cbf47079..6b8fa9db8 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,14 @@ Pull Requests Badge Issues Badge License Badge + Downloads Ask DeepWiki

+
+ ThinkInAIXYZ%2Fdeepchat | Trendshift +
+
中文 / English / 日本語
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 @@ Pull Requests Badge Issues Badge License Badge + Downloads Ask DeepWiki

+
+ ThinkInAIXYZ%2Fdeepchat | Trendshift +
+
中文 / English / 日本語
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 @@ { 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 @@