Skip to content
1,053 changes: 1,053 additions & 0 deletions docs/ACP_PROTOCOL_GAP_ANALYSIS.md

Large diffs are not rendered by default.

148 changes: 148 additions & 0 deletions docs/ACP_UI_IMPLEMENTATION_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
# ACP UI 集成实施总结

## 已完成的工作

### 1. 类型定义更新 ✅
- 在 `src/shared/chat.d.ts` 中添加了 `'plan'` 类型到 `AssistantMessageBlock`

### 2. Content Mapper 调整 ✅
- 修改了 `src/main/presenter/llmProviderPresenter/agent/acpContentMapper.ts`
- `handlePlanUpdate` 现在创建独立的 `plan` 类型块
- 移除了未使用的 `getStatusIcon` 方法

### 3. 新增 Plan 展示组件 ✅
- 创建了 `src/renderer/src/components/message/MessageBlockPlan.vue`
- 功能包括:
- 任务列表展示(带状态图标:○ pending, ◐ in_progress, ● done, ⊘ skipped, ✕ failed)
- 进度条和完成统计
- 可折叠/展开功能
- 优先级标签显示

### 4. Mode 指示器增强 ✅
- 修改了 `src/renderer/src/components/message/MessageBlockThink.vue`
- 添加了 `isModeChange` 和 `modeChangeId` computed 属性
- 标题会自动显示 "模式已切换至:{mode}" 当检测到模式变化时

### 5. 终端输出和文件操作增强 ✅
- 修改了 `src/renderer/src/components/message/MessageBlockToolCall.vue`
- 添加的功能:
- **终端输出**:使用 xterm.js 渲染终端输出(黑色背景,monospace 字体)
- **文件系统操作**:显示文件路径、操作类型(读/写)和结果(成功/失败)
- 自动检测工具名称以应用对应的 UI

### 6. 注册新组件 ✅
- 修改了 `src/renderer/src/components/message/MessageItemAssistant.vue`
- 添加了 `MessageBlockPlan` 组件的导入和渲染

### 7. i18n 翻译 ✅
- 为所有 11 种语言添加了翻译:
- zh-CN(中文简体)
- en-US(英语)
- ja-JP(日语)
- ko-KR(韩语)
- ru-RU(俄语)
- fr-FR(法语)
- da-DK(丹麦语)
- fa-IR(波斯语)
- pt-BR(葡萄牙语)
- zh-HK(中文繁体 香港)
- zh-TW(中文繁体 台湾)

- 翻译键包括:
- `chat.features.modeChanged`: 模式切换提示
- `toolCall.terminalOutput`: 终端输出
- `toolCall.fileOperation/fileRead/fileWrite`: 文件操作
- `toolCall.filePath/success/failed`: 文件路径和结果
- `plan.title/completed`: 计划标题和完成状态

### 8. 代码质量检查 ✅
- 运行 `pnpm run format` - 通过 ✅
- 运行 `pnpm run lint` - 0 warnings, 0 errors ✅
- 运行 `pnpm run typecheck` - 通过 ✅

## 新增文件

1. `src/renderer/src/components/message/MessageBlockPlan.vue` - Plan 展示组件
2. `src/renderer/src/i18n/*/plan.json` - 11 个语言的 plan 翻译文件

## 修改的文件

1. `src/shared/chat.d.ts` - 添加 plan 类型
2. `src/main/presenter/llmProviderPresenter/agent/acpContentMapper.ts` - 修改 plan 处理逻辑
3. `src/renderer/src/components/message/MessageBlockThink.vue` - 添加 mode 检测
4. `src/renderer/src/components/message/MessageBlockToolCall.vue` - 添加终端和文件系统 UI
5. `src/renderer/src/components/message/MessageItemAssistant.vue` - 注册 plan 组件
6. `src/renderer/src/i18n/*/chat.json` (11 个文件) - 添加 modeChanged
7. `src/renderer/src/i18n/*/toolCall.json` (11 个文件) - 添加新的工具调用翻译
8. `src/renderer/src/i18n/*/index.ts` (11 个文件) - 导入 plan 模块

## 如何测试

### 测试 Plan 展示
```bash
pnpm run dev
```
1. 创建新对话,选择支持 ACP 的 Agent(如 claude-code-acp)
2. 发送消息触发 Agent 返回计划,如:"请制定一个实现用户登录功能的计划"
3. 观察消息流中是否出现 Plan 卡片组件
4. 验证进度条、状态图标、折叠功能是否正常

### 测试文件系统操作
1. 向 Agent 发送:"请读取 package.json 文件的内容"
2. 等待 Agent 执行 `readTextFile` 工具调用
3. 点击工具调用卡片展开详情
4. 验证文件路径和操作状态是否正确显示

### 测试终端命令执行
1. 向 Agent 发送:"请执行 'ls -la' 命令查看当前目录"
2. 等待 Agent 创建终端并执行命令
3. 点击工具调用卡片展开详情
4. 验证终端输出是否在黑色背景的终端窗口中正确显示

### 测试 Mode 变化通知
1. 使用支持 session mode 的 Agent
2. 触发 Agent 切换模式
3. 观察 thinking 块是否显示 "模式已切换至:{mode_id}"

### 测试多语言支持
1. 在设置中切换语言(英文、中文、日文等)
2. 重复上述测试场景
3. 验证所有新增的文本标签是否正确显示对应语言的翻译

## 架构说明

### 数据流
```
Agent (ACP Process)
AcpContentMapper (处理 plan/mode 更新)
MessageBlock (plan 类型块)
MessageBlockPlan.vue (UI 渲染)
```

### 终端输出流程
```
Agent → createTerminal → terminalOutput
Tool Call Response (包含 output)
MessageBlockToolCall (检测 terminal 工具)
xterm.js (渲染终端输出)
```

## 注意事项

1. **性能**:xterm.js 已配置 scrollback: 1000,限制终端输出缓冲
2. **安全**:终端为只读模式 (`disableStdin: true`)
3. **类型安全**:PlanEntry 接口在组件内部定义,避免跨模块导入问题
4. **响应式**:所有组件使用 Tailwind CSS,支持移动端显示

## 下一步建议

1. 在真实的 ACP Agent 环境中进行完整测试
2. 根据实际使用反馈调整 UI 细节
3. 考虑添加更多状态图标或动画效果
4. 优化长输出的显示性能
31 changes: 31 additions & 0 deletions src/main/presenter/llmProviderPresenter/agent/acpCapabilities.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type * as schema from '@agentclientprotocol/sdk/dist/schema.js'

export interface AcpCapabilityOptions {
enableFs?: boolean
enableTerminal?: boolean
}

/**
* Build client capabilities object for ACP initialization.
*
* This determines what features the client (DeepChat) advertises to the agent.
* Agents use these capabilities to decide which operations to request.
*/
export function buildClientCapabilities(
options: AcpCapabilityOptions = {}
): schema.ClientCapabilities {
const caps: schema.ClientCapabilities = {}

if (options.enableFs !== false) {
caps.fs = {
readTextFile: true,
writeTextFile: true
}
}

if (options.enableTerminal !== false) {
caps.terminal = true
}

return caps
}
68 changes: 61 additions & 7 deletions src/main/presenter/llmProviderPresenter/agent/acpContentMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,19 @@ import type * as schema from '@agentclientprotocol/sdk/dist/schema.js'
import type { AssistantMessageBlock } from '@shared/chat'
import { createStreamEvent, type LLMCoreStreamEvent } from '@shared/types/core/llm-events'

export interface PlanEntry {
content: string
priority?: string | null
status?: string | null
}

export interface MappedContent {
events: LLMCoreStreamEvent[]
blocks: AssistantMessageBlock[]
/** Structured plan entries from the agent (optional) */
planEntries?: PlanEntry[]
/** Current mode ID from mode change notification (optional) */
currentModeId?: string
}

interface ToolCallState {
Expand Down Expand Up @@ -37,13 +47,27 @@ export class AcpContentMapper {
this.handleToolCallUpdate(sessionId, update, payload)
break
case 'plan':
console.info('[ACP] Plan update received:', JSON.stringify(update))
this.handlePlanUpdate(update, payload)
break
case 'current_mode_update':
console.info('[ACP] Mode update received:', update)
this.handleModeUpdate(update, payload)
break
case 'available_commands_update':
console.info(
'[ACP] Available commands update:',
JSON.stringify(update.availableCommands?.map((c) => c.name) ?? [])
)
break
case 'user_message_chunk':
// ignore echo
break
default:
console.debug('[ACP] Unhandled session update', update.sessionUpdate)
// Handle any unrecognized session update types
const sessionUpdate = (update as { sessionUpdate?: string }).sessionUpdate
console.warn('[ACP] Unhandled session update type:', sessionUpdate)
console.debug('[ACP] Full update data:', JSON.stringify(update))
break
Comment on lines 67 to 72
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Wrap switch case declaration in a block to fix correctness issue.

The const sessionUpdate declaration in the default case is accessible to other switch clauses, which can lead to subtle bugs. As flagged by static analysis, wrap the declaration in a block to restrict its scope.

       default:
+        {
-        // Handle any unrecognized session update types
-        const sessionUpdate = (update as { sessionUpdate?: string }).sessionUpdate
-        console.warn('[ACP] Unhandled session update type:', sessionUpdate)
-        console.debug('[ACP] Full update data:', JSON.stringify(update))
+          // Handle any unrecognized session update types
+          const sessionUpdate = (update as { sessionUpdate?: string }).sessionUpdate
+          console.warn('[ACP] Unhandled session update type:', sessionUpdate)
+          console.debug('[ACP] Full update data:', JSON.stringify(update))
+        }
         break
🧰 Tools
🪛 Biome (2.1.2)

[error] 68-68: Other switch clauses can erroneously access this declaration.
Wrap the declaration in a block to restrict its access to the switch clause.

The declaration is defined in this switch clause:

Safe fix: Wrap the declaration in a block.

(lint/correctness/noSwitchDeclarations)

🤖 Prompt for AI Agents
In src/main/presenter/llmProviderPresenter/agent/acpContentMapper.ts around
lines 66 to 71, the default switch case declares const sessionUpdate at function
scope which can leak into other cases; fix by wrapping the default case body in
its own block (add { ... } around the const and the console calls) so
sessionUpdate is block-scoped and cannot collide with other case declarations.

}

Expand Down Expand Up @@ -162,13 +186,43 @@ export class AcpContentMapper {
update: Extract<schema.SessionNotification['update'], { sessionUpdate: 'plan' }>,
payload: MappedContent
) {
const summary = (update.entries || [])
.map((entry) => `${entry.content} (${entry.status})`)
.join('; ')
if (!summary) return
const text = `Plan updated: ${summary}`
const entries = update.entries || []
if (!entries.length) return

// Store structured plan entries
payload.planEntries = entries.map((entry) => ({
content: entry.content,
priority: entry.priority ?? null,
status: entry.status ?? null
}))

// Create dedicated plan block
payload.events.push(createStreamEvent.reasoning('')) // Empty event for plan
payload.blocks.push(
this.createBlock('plan', '', {
extra: { plan_entries: payload.planEntries }
})
)
}

private handleModeUpdate(
update: Extract<schema.SessionNotification['update'], { sessionUpdate: 'current_mode_update' }>,
payload: MappedContent
) {
const modeId = update.currentModeId
if (!modeId) return

// Store mode change
payload.currentModeId = modeId

// Emit as reasoning for visibility
const text = `Mode changed to: ${modeId}`
payload.events.push(createStreamEvent.reasoning(text))
payload.blocks.push(this.createBlock('reasoning_content', text))
payload.blocks.push(
this.createBlock('reasoning_content', text, {
extra: { mode_change: modeId }
})
)
}

private formatToolCallContent(
Expand Down
115 changes: 115 additions & 0 deletions src/main/presenter/llmProviderPresenter/agent/acpFsHandler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import * as fs from 'fs/promises'
import * as path from 'path'
import { RequestError } from '@agentclientprotocol/sdk'
import type * as schema from '@agentclientprotocol/sdk/dist/schema.js'

export interface FsHandlerOptions {
/** Session's working directory (workspace root). Null = allow all. */
workspaceRoot: string | null
/** Maximum file size in bytes to read (default: 10MB) */
maxReadSize?: number
}

/**
* Handles file system operations requested by ACP agents.
*
* This handler implements `fs/read_text_file` and `fs/write_text_file` methods
* as specified in the ACP protocol. It enforces workspace boundaries for security.
*
* @see https://agentclientprotocol.com/protocol/file-system
*/
export class AcpFsHandler {
private readonly workspaceRoot: string | null
private readonly maxReadSize: number

constructor(options: FsHandlerOptions) {
this.workspaceRoot = options.workspaceRoot ? path.resolve(options.workspaceRoot) : null
this.maxReadSize = options.maxReadSize ?? 10 * 1024 * 1024 // 10MB default
}

/**
* Validate that the path is within the workspace boundary.
* Throws RequestError if path escapes workspace.
*/
private validatePath(filePath: string): string {
const resolved = path.resolve(filePath)

if (this.workspaceRoot) {
const relative = path.relative(this.workspaceRoot, resolved)
if (relative.startsWith('..') || path.isAbsolute(relative)) {
throw RequestError.invalidParams({ path: filePath }, `Path escapes workspace: ${filePath}`)
}
}

return resolved
}

/**
* Read content from a text file.
*
* Supports optional line offset and limit for reading portions of large files.
*/
async readTextFile(params: schema.ReadTextFileRequest): Promise<schema.ReadTextFileResponse> {
const filePath = this.validatePath(params.path)

try {
const stat = await fs.stat(filePath)
if (stat.size > this.maxReadSize) {
throw RequestError.invalidParams(
{ path: params.path, size: stat.size },
`File too large: ${stat.size} bytes exceeds limit of ${this.maxReadSize}`
)
}

const content = await fs.readFile(filePath, 'utf-8')
const lines = content.split('\n')

// Handle optional line/limit parameters (1-based line numbers per ACP spec)
const startLine = params.line ?? 1
const limit = params.limit ?? lines.length

const startIndex = Math.max(0, startLine - 1)
const endIndex = startIndex + limit
const selectedLines = lines.slice(startIndex, endIndex)

return { content: selectedLines.join('\n') }
} catch (error) {
if (error instanceof RequestError) {
throw error
}
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw RequestError.resourceNotFound(params.path)
}
if ((error as NodeJS.ErrnoException).code === 'EACCES') {
throw RequestError.invalidParams({ path: params.path }, `Permission denied: ${params.path}`)
}
throw error
}
}

/**
* Write content to a text file.
*
* Creates parent directories if they don't exist.
*/
async writeTextFile(params: schema.WriteTextFileRequest): Promise<schema.WriteTextFileResponse> {
const filePath = this.validatePath(params.path)

try {
// Ensure parent directory exists
const dir = path.dirname(filePath)
await fs.mkdir(dir, { recursive: true })

await fs.writeFile(filePath, params.content, 'utf-8')
return {}
} catch (error) {
if (error instanceof RequestError) {
throw error
}
if ((error as NodeJS.ErrnoException).code === 'EACCES') {
throw RequestError.invalidParams({ path: params.path }, `Permission denied: ${params.path}`)
}
throw error
}
}
}
Loading