Skip to content

Commit 5d55ef6

Browse files
committed
feat(工具消息): 添加工具消息支持并增强文件写入预览功能
- 在 MessageRenderer 中添加工具消息的样式配置 - 扩展 SessionContext 支持工具消息类型和添加方法 - 修改 BladeInterface 不再过滤工具消息 - 在 Agent 中实现工具结果回调处理 - 在 useCommandHandler 中添加工具消息显示逻辑 - 增强 write 工具的文件内容预览功能,支持语法高亮和智能截断
1 parent b242f7f commit 5d55ef6

8 files changed

Lines changed: 203 additions & 35 deletions

File tree

.claude/settings.local.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
"Bash(git log:*)",
5252
"Bash(pnpm remove:*)",
5353
"Bash(python3:*)",
54-
"Bash(npm run format:*)"
54+
"Bash(npm run format:*)",
55+
"Bash(git commit -m \"$(cat <<''EOF''\nfeat(Write工具): 添加文件内容预览功能\n\n现在 Write 工具创建文件后会显示完整的文件内容预览,带语法高亮。\n\n## 功能特性\n\n- ✅ 自动检测文件类型(支持 30+ 种编程语言)\n- ✅ Markdown 代码块格式(自动触发语法高亮)\n- ✅ 智能截断(最多 100 行或 5000 字符)\n- ✅ 截断提示(显示完整文件的行数和字符数)\n- ✅ 仅对文本文件生效(binary/base64 编码跳过)\n\n## 支持的语言\n\nTypeScript, JavaScript, Python, Go, Rust, Java, C/C++, C#, Ruby, \nPHP, Swift, Kotlin, Scala, Bash, JSON, YAML, XML, HTML, CSS, \nMarkdown, SQL, GraphQL, Protobuf 等\n\n## 示例输出\n\n```\n✅ 成功写入文件: /path/to/file.ts (1.2 KB)\n\n📄 文件内容:\n\n```typescript\nfunction quickSort(arr: number[]): number[] {\n if (arr.length <= 1) return arr;\n // ...\n}\n```\n\n## 修改文件\n\n- src/tools/builtin/file/write.ts\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n\nCo-Authored-By: Claude <noreply@anthropic.com>\nEOF\n)\")"
5556
],
5657
"deny": [],
5758
"ask": [],

src/agent/Agent.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -220,18 +220,28 @@ export class Agent extends EventEmitter {
220220
/**
221221
* 简单聊天接口
222222
*/
223-
public async chat(message: string, context?: ChatContext): Promise<string> {
223+
public async chat(
224+
message: string,
225+
context?: ChatContext,
226+
options?: LoopOptions
227+
): Promise<string> {
224228
if (!this.isInitialized) {
225229
throw new Error('Agent未初始化');
226230
}
227231

228232
// 如果提供了 context,使用增强的工具调用流程
229233
if (context) {
234+
// 合并 signal 和 options
235+
const loopOptions: LoopOptions = {
236+
signal: context.signal,
237+
...options,
238+
};
239+
230240
// Plan 模式使用专门的 runPlanLoop 方法
231241
const result =
232242
context.permissionMode === 'plan'
233-
? await this.runPlanLoop(message, context, { signal: context.signal })
234-
: await this.runLoop(message, context, { signal: context.signal });
243+
? await this.runPlanLoop(message, context, loopOptions)
244+
: await this.runLoop(message, context, loopOptions);
235245

236246
if (!result.success) {
237247
// 如果是用户中止,触发事件并返回空字符串(不抛出异常)
@@ -256,14 +266,12 @@ export class Agent extends EventEmitter {
256266
};
257267

258268
// 重新执行原始请求(使用新模式)
259-
return this.runLoop(message, newContext, { signal: context.signal }).then(
260-
(newResult) => {
261-
if (!newResult.success) {
262-
throw new Error(newResult.error?.message || '执行失败');
263-
}
264-
return newResult.finalMessage || '';
269+
return this.runLoop(message, newContext, loopOptions).then((newResult) => {
270+
if (!newResult.success) {
271+
throw new Error(newResult.error?.message || '执行失败');
265272
}
266-
);
273+
return newResult.finalMessage || '';
274+
});
267275
}
268276

269277
return result.finalMessage || '';
@@ -685,6 +693,16 @@ export class Agent extends EventEmitter {
685693
turn: turnsCount,
686694
});
687695

696+
// 调用 onToolResult 回调(如果提供)
697+
// 注意: onToolResult 现在在 LoopOptions 中(循环事件回调)
698+
if (options?.onToolResult) {
699+
try {
700+
await options.onToolResult(toolCall, result);
701+
} catch (error) {
702+
console.error('[Agent] onToolResult callback error:', error);
703+
}
704+
}
705+
688706
// === 保存工具结果到 JSONL (tool_result) ===
689707
try {
690708
const contextMgr = this.executionEngine?.getContextManager();

src/agent/types.ts

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,17 +7,24 @@ import type { PermissionConfig } from '../config/types.js';
77
import { PermissionMode } from '../config/types.js';
88
import type { Message } from '../services/OpenAIChatService.js';
99
import type { ConfirmationHandler } from '../tools/types/ExecutionTypes.js';
10+
import type { ToolResult } from '../tools/types/ToolTypes.js';
1011

1112
/**
1213
* 聊天上下文接口
14+
*
15+
* 职责:保存会话相关的数据和状态
16+
* - 消息历史、会话标识、用户标识等数据
17+
* - 会话级别的 UI 交互处理器(如 confirmationHandler)
18+
*
19+
* 不包含:循环过程中的事件回调(这些应该放在 LoopOptions)
1320
*/
1421
export interface ChatContext {
1522
messages: Message[];
1623
userId: string;
1724
sessionId: string;
1825
workspaceRoot: string;
1926
signal?: AbortSignal;
20-
confirmationHandler?: ConfirmationHandler;
27+
confirmationHandler?: ConfirmationHandler; // 会话级别的确认处理器
2128
permissionMode?: string; // 传递当前权限模式(用于 Plan 模式判断)
2229
}
2330

@@ -91,20 +98,34 @@ export interface ContextConfig {
9198

9299
// ===== Agentic Loop Types =====
93100

101+
/**
102+
* Agentic Loop 选项
103+
*
104+
* 职责:控制循环行为和监听循环事件
105+
* - 循环控制参数(maxTurns, autoCompact 等)
106+
* - 循环过程中的事件回调(onTurnStart, onToolResult 等)
107+
*
108+
* 设计原则:
109+
* - 所有循环相关的回调统一放在这里,保持语义一致性
110+
* - 和 ChatContext 职责分离:LoopOptions = 行为控制,ChatContext = 数据状态
111+
*/
94112
export interface LoopOptions {
113+
// 循环控制参数
95114
maxTurns?: number;
96115
autoCompact?: boolean;
97116
signal?: AbortSignal;
98117
stream?: boolean;
118+
119+
// 循环事件回调(监听循环过程)
99120
onTurnStart?: (data: { turn: number; maxTurns: number }) => void;
100121
onToolUse?: (
101122
toolCall: ChatCompletionMessageToolCall
102123
) => Promise<ChatCompletionMessageToolCall | void>;
103124
onToolApprove?: (toolCall: ChatCompletionMessageToolCall) => Promise<boolean>;
104125
onToolResult?: (
105126
toolCall: ChatCompletionMessageToolCall,
106-
result: any
107-
) => Promise<any | void>;
127+
result: ToolResult
128+
) => Promise<ToolResult | void>;
108129
}
109130

110131
export interface LoopResult {

src/tools/builtin/file/write.ts

Lines changed: 96 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,7 +174,7 @@ export const writeTool = createTool({
174174
last_modified: stats.mtime.toISOString(),
175175
};
176176

177-
const displayMessage = formatDisplayMessage(file_path, metadata);
177+
const displayMessage = formatDisplayMessage(file_path, metadata, content);
178178

179179
return {
180180
success: true,
@@ -233,7 +233,11 @@ export const writeTool = createTool({
233233
/**
234234
* 格式化显示消息
235235
*/
236-
function formatDisplayMessage(filePath: string, metadata: Record<string, any>): string {
236+
function formatDisplayMessage(
237+
filePath: string,
238+
metadata: Record<string, any>,
239+
content?: string
240+
): string {
237241
let message = `✅ 成功写入文件: ${filePath}`;
238242

239243
if (metadata.file_size !== undefined) {
@@ -248,9 +252,99 @@ function formatDisplayMessage(filePath: string, metadata: Record<string, any>):
248252
message += `\n🔐 使用编码: ${metadata.encoding}`;
249253
}
250254

255+
// 添加内容预览(仅对文本文件)
256+
if (content && metadata.encoding === 'utf8') {
257+
const preview = generateContentPreview(filePath, content);
258+
if (preview) {
259+
message += '\n\n' + preview;
260+
}
261+
}
262+
251263
return message;
252264
}
253265

266+
/**
267+
* 生成文件内容预览(Markdown 代码块格式)
268+
*/
269+
function generateContentPreview(filePath: string, content: string): string | null {
270+
// 获取文件扩展名,用于语法高亮
271+
const ext = extname(filePath).toLowerCase();
272+
const languageMap: Record<string, string> = {
273+
'.ts': 'typescript',
274+
'.tsx': 'tsx',
275+
'.js': 'javascript',
276+
'.jsx': 'jsx',
277+
'.py': 'python',
278+
'.go': 'go',
279+
'.rs': 'rust',
280+
'.java': 'java',
281+
'.c': 'c',
282+
'.cpp': 'cpp',
283+
'.h': 'c',
284+
'.hpp': 'cpp',
285+
'.cs': 'csharp',
286+
'.rb': 'ruby',
287+
'.php': 'php',
288+
'.swift': 'swift',
289+
'.kt': 'kotlin',
290+
'.scala': 'scala',
291+
'.sh': 'bash',
292+
'.bash': 'bash',
293+
'.zsh': 'zsh',
294+
'.json': 'json',
295+
'.yaml': 'yaml',
296+
'.yml': 'yaml',
297+
'.toml': 'toml',
298+
'.xml': 'xml',
299+
'.html': 'html',
300+
'.css': 'css',
301+
'.scss': 'scss',
302+
'.sass': 'sass',
303+
'.less': 'less',
304+
'.md': 'markdown',
305+
'.sql': 'sql',
306+
'.graphql': 'graphql',
307+
'.proto': 'protobuf',
308+
};
309+
310+
const language = languageMap[ext] || '';
311+
312+
// 限制预览长度(最多 100 行或 5000 字符)
313+
const MAX_LINES = 100;
314+
const MAX_CHARS = 5000;
315+
316+
let previewContent = content;
317+
let truncated = false;
318+
319+
// 按行数截断
320+
const lines = content.split('\n');
321+
if (lines.length > MAX_LINES) {
322+
previewContent = lines.slice(0, MAX_LINES).join('\n');
323+
truncated = true;
324+
}
325+
326+
// 按字符数截断
327+
if (previewContent.length > MAX_CHARS) {
328+
previewContent = previewContent.substring(0, MAX_CHARS);
329+
truncated = true;
330+
}
331+
332+
// 生成 Markdown 代码块
333+
let preview = '📄 文件内容:\n\n';
334+
preview += '```' + language + '\n';
335+
preview += previewContent;
336+
if (!previewContent.endsWith('\n')) {
337+
preview += '\n';
338+
}
339+
preview += '```';
340+
341+
if (truncated) {
342+
preview += `\n\n⚠️ 内容已截断(完整文件共 ${lines.length} 行,${content.length} 字符)`;
343+
}
344+
345+
return preview;
346+
}
347+
254348
/**
255349
* 格式化文件大小
256350
*/

src/ui/components/BladeInterface.tsx

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -91,10 +91,10 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
9191
const messages = await SessionService.loadSession(otherProps.resume);
9292

9393
const sessionMessages = messages
94-
.filter((msg) => msg.role !== 'tool')
94+
// 不再过滤 tool 消息,让工具输出也能被渲染
9595
.map((msg, index) => ({
9696
id: `restored-${Date.now()}-${index}`,
97-
role: msg.role as 'user' | 'assistant' | 'system',
97+
role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
9898
content:
9999
typeof msg.content === 'string'
100100
? msg.content
@@ -400,18 +400,16 @@ export const BladeInterface: React.FC<BladeInterfaceProps> = ({
400400
try {
401401
const messages = await SessionService.loadSession(sessionId);
402402

403-
// 转换消息格式
404-
const sessionMessages = messages
405-
.filter((msg) => msg.role !== 'tool')
406-
.map((msg, index) => ({
407-
id: `restored-${Date.now()}-${index}`,
408-
role: msg.role as 'user' | 'assistant' | 'system',
409-
content:
410-
typeof msg.content === 'string'
411-
? msg.content
412-
: JSON.stringify(msg.content),
413-
timestamp: Date.now() - (messages.length - index) * 1000,
414-
}));
403+
// 转换消息格式(不再过滤 tool 消息)
404+
const sessionMessages = messages.map((msg, index) => ({
405+
id: `restored-${Date.now()}-${index}`,
406+
role: msg.role as 'user' | 'assistant' | 'system' | 'tool',
407+
content:
408+
typeof msg.content === 'string'
409+
? msg.content
410+
: JSON.stringify(msg.content),
411+
timestamp: Date.now() - (messages.length - index) * 1000,
412+
}));
415413

416414
// 恢复会话
417415
restoreSession(sessionId, sessionMessages);

src/ui/components/MessageRenderer.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ const getRoleStyle = (role: MessageRole) => {
3535
return { color: 'green' as const, prefix: '• ' };
3636
case 'system':
3737
return { color: 'yellow' as const, prefix: '⚙ ' };
38+
case 'tool':
39+
return { color: 'blue' as const, prefix: '🔧 ' };
3840
}
3941
};
4042

src/ui/contexts/SessionContext.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,16 @@
1+
import { nanoid } from 'nanoid';
12
import React, {
23
createContext,
34
ReactNode,
45
useCallback,
56
useContext,
67
useReducer,
78
} from 'react';
8-
import { nanoid } from 'nanoid';
99

1010
/**
1111
* 消息角色类型
1212
*/
13-
export type MessageRole = 'user' | 'assistant' | 'system';
13+
export type MessageRole = 'user' | 'assistant' | 'system' | 'tool';
1414

1515
/**
1616
* 会话消息
@@ -60,6 +60,7 @@ export interface SessionContextType {
6060
dispatch: React.Dispatch<SessionAction>;
6161
addUserMessage: (content: string) => void;
6262
addAssistantMessage: (content: string) => void;
63+
addToolMessage: (content: string) => void;
6364
clearMessages: () => void;
6465
resetSession: () => void;
6566
restoreSession: (sessionId: string, messages: SessionMessage[]) => void;
@@ -151,6 +152,16 @@ export function SessionProvider({ children }: { children: ReactNode }) {
151152
dispatch({ type: 'ADD_MESSAGE', payload: message });
152153
}, []);
153154

155+
const addToolMessage = useCallback((content: string) => {
156+
const message: SessionMessage = {
157+
id: `tool-${Date.now()}-${Math.random()}`,
158+
role: 'tool',
159+
content,
160+
timestamp: Date.now(),
161+
};
162+
dispatch({ type: 'ADD_MESSAGE', payload: message });
163+
}, []);
164+
154165
const clearMessages = useCallback(() => {
155166
dispatch({ type: 'CLEAR_MESSAGES' });
156167
}, []);
@@ -171,6 +182,7 @@ export function SessionProvider({ children }: { children: ReactNode }) {
171182
dispatch,
172183
addUserMessage,
173184
addAssistantMessage,
185+
addToolMessage,
174186
clearMessages,
175187
resetSession,
176188
restoreSession,

0 commit comments

Comments
 (0)