feat(ui): 优化流式输出工具调用渲染
- 添加 tool_start/tool_end WebSocket 事件支持 - 流式消息复用 ChatMessage 组件渲染工具调用卡片 - 修复 AI SDK v5 格式兼容问题(input/output 字段) - 修复会话恢复时 tool-result 格式错误 - 放宽 ToolState schema 中 input 字段类型为 unknown
This commit is contained in:
@@ -23,11 +23,35 @@ import { getProviderRegistry, resolveApiKey } from '../provider/index.js';
|
|||||||
import { getHookManager } from '../hooks/index.js';
|
import { getHookManager } from '../hooks/index.js';
|
||||||
import { getGitManager } from '../git/index.js';
|
import { getGitManager } from '../git/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具调用开始事件信息
|
||||||
|
*/
|
||||||
|
export interface ToolStartInfo {
|
||||||
|
id: string;
|
||||||
|
toolName: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具调用结束事件信息
|
||||||
|
*/
|
||||||
|
export interface ToolEndInfo {
|
||||||
|
id: string;
|
||||||
|
status: 'completed' | 'error';
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Agent.chat() 选项
|
* Agent.chat() 选项
|
||||||
*/
|
*/
|
||||||
export interface AgentChatOptions {
|
export interface AgentChatOptions {
|
||||||
onStream?: (text: string) => void;
|
onStream?: (text: string) => void;
|
||||||
|
/** 工具开始执行回调 */
|
||||||
|
onToolStart?: (info: ToolStartInfo) => void;
|
||||||
|
/** 工具执行完成回调 */
|
||||||
|
onToolEnd?: (info: ToolEndInfo) => void;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +348,10 @@ export class Agent {
|
|||||||
async chat(userMessage: string | UserInput, options?: AgentChatOptions | ((text: string) => void)): Promise<ChatResult> {
|
async chat(userMessage: string | UserInput, options?: AgentChatOptions | ((text: string) => void)): Promise<ChatResult> {
|
||||||
// 兼容旧的 onStream 参数
|
// 兼容旧的 onStream 参数
|
||||||
const opts: AgentChatOptions = typeof options === 'function' ? { onStream: options } : (options || {});
|
const opts: AgentChatOptions = typeof options === 'function' ? { onStream: options } : (options || {});
|
||||||
const { onStream, abortSignal } = opts;
|
const { onStream, onToolStart, onToolEnd, abortSignal } = opts;
|
||||||
|
|
||||||
|
// 工具调用时间跟踪
|
||||||
|
const toolStartTimes = new Map<string, number>();
|
||||||
// 处理带图片的消息
|
// 处理带图片的消息
|
||||||
let processedMessage = userMessage;
|
let processedMessage = userMessage;
|
||||||
|
|
||||||
@@ -405,19 +432,55 @@ export class Agent {
|
|||||||
abortSignal, // 支持取消
|
abortSignal, // 支持取消
|
||||||
onChunk: ({ chunk }) => {
|
onChunk: ({ chunk }) => {
|
||||||
if (chunk.type === 'tool-call') {
|
if (chunk.type === 'tool-call') {
|
||||||
onStream(`\n[调用工具: ${chunk.toolName}]\n`);
|
// AI SDK 中工具参数字段名为 input
|
||||||
|
const toolCallChunk = chunk as { toolCallId: string; toolName: string; input: unknown };
|
||||||
|
const toolCallId = toolCallChunk.toolCallId || `tool-${Date.now()}`;
|
||||||
|
|
||||||
|
// 记录开始时间
|
||||||
|
toolStartTimes.set(toolCallId, Date.now());
|
||||||
|
|
||||||
|
// 调用 onToolStart 回调
|
||||||
|
if (onToolStart) {
|
||||||
|
onToolStart({
|
||||||
|
id: toolCallId,
|
||||||
|
toolName: toolCallChunk.toolName,
|
||||||
|
args: (toolCallChunk.input as Record<string, unknown>) || {},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 仅在没有 onToolStart 回调时输出文本(向后兼容 CLI)
|
||||||
|
onStream?.(`\n[调用工具: ${toolCallChunk.toolName}]\n`);
|
||||||
|
}
|
||||||
} else if (chunk.type === 'tool-result') {
|
} else if (chunk.type === 'tool-result') {
|
||||||
const output = (chunk as { output?: ToolResult }).output;
|
const toolResultChunk = chunk as { toolCallId: string; output?: ToolResult };
|
||||||
|
const toolCallId = toolResultChunk.toolCallId || '';
|
||||||
|
const output = toolResultChunk.output;
|
||||||
|
|
||||||
|
// 计算执行时长
|
||||||
|
const startTime = toolStartTimes.get(toolCallId);
|
||||||
|
const duration = startTime ? Date.now() - startTime : undefined;
|
||||||
|
toolStartTimes.delete(toolCallId);
|
||||||
|
|
||||||
if (output && typeof output === 'object') {
|
if (output && typeof output === 'object') {
|
||||||
if (output.success) {
|
// 调用 onToolEnd 回调
|
||||||
// 截断过长的输出
|
if (onToolEnd) {
|
||||||
const displayOutput =
|
onToolEnd({
|
||||||
output.output.length > 500
|
id: toolCallId,
|
||||||
? output.output.substring(0, 500) + '...(截断)'
|
status: output.success ? 'completed' : 'error',
|
||||||
: output.output;
|
result: output.success ? output.output : undefined,
|
||||||
onStream(`[结果: ${displayOutput}]\n`);
|
error: output.success ? undefined : output.error,
|
||||||
|
duration,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
onStream(`[错误: ${output.error}]\n`);
|
// 仅在没有 onToolEnd 回调时输出文本(向后兼容 CLI)
|
||||||
|
if (output.success) {
|
||||||
|
const displayOutput =
|
||||||
|
output.output.length > 500
|
||||||
|
? output.output.substring(0, 500) + '...(截断)'
|
||||||
|
: output.output;
|
||||||
|
onStream?.(`[结果: ${displayOutput}]\n`);
|
||||||
|
} else {
|
||||||
|
onStream?.(`[错误: ${output.error}]\n`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
export { Agent } from './core/agent.js';
|
export { Agent } from './core/agent.js';
|
||||||
export type { AgentChatOptions } from './core/agent.js';
|
export type { AgentChatOptions, ToolStartInfo, ToolEndInfo } from './core/agent.js';
|
||||||
export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js';
|
export { toolRegistry, todoManager, initTaskContext, updateTaskDescription, updateSkillDescription } from './tools/index.js';
|
||||||
export { loadConfig, saveConfig, getConfig, loadVisionConfig, ConfigurationError } from './utils/config.js';
|
export { loadConfig, saveConfig, getConfig, loadVisionConfig, ConfigurationError } from './utils/config.js';
|
||||||
export type { VisionConfig } from './utils/config.js';
|
export type { VisionConfig } from './utils/config.js';
|
||||||
|
|||||||
@@ -45,13 +45,14 @@ function messageToModelMessages(msg: Message): ModelMessage[] {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加工具调用部分(只有 running 或已完成的工具)
|
// 添加工具调用部分(只有 running 或已完成的工具)
|
||||||
|
// AI SDK v5 使用 input 字段(不是 args)
|
||||||
for (const toolPart of toolParts) {
|
for (const toolPart of toolParts) {
|
||||||
if (toolPart.state.status !== 'pending') {
|
if (toolPart.state.status !== 'pending') {
|
||||||
assistantContent.push({
|
assistantContent.push({
|
||||||
type: 'tool-call',
|
type: 'tool-call',
|
||||||
toolCallId: toolPart.toolCallId,
|
toolCallId: toolPart.toolCallId,
|
||||||
toolName: toolPart.toolName,
|
toolName: toolPart.toolName,
|
||||||
args: toolPart.state.input,
|
input: toolPart.state.input,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,12 +88,17 @@ function messageToModelMessages(msg: Message): ModelMessage[] {
|
|||||||
const output = state.status === 'completed'
|
const output = state.status === 'completed'
|
||||||
? (state as { output: unknown }).output
|
? (state as { output: unknown }).output
|
||||||
: (state as { error: string }).error;
|
: (state as { error: string }).error;
|
||||||
|
// 获取 input(AI SDK v5 要求 tool-result 必须包含 input)
|
||||||
|
const input = state.status !== 'pending'
|
||||||
|
? (state as { input: Record<string, unknown> }).input
|
||||||
|
: {};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'tool-result' as const,
|
type: 'tool-result' as const,
|
||||||
toolCallId: toolPart.toolCallId,
|
toolCallId: toolPart.toolCallId,
|
||||||
toolName: toolPart.toolName,
|
toolName: toolPart.toolName,
|
||||||
result: output,
|
input,
|
||||||
|
output,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,12 +140,18 @@ export function toModelMessages(messages: Message[]): ModelMessage[] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取工具调用的输入参数(兼容不同状态)
|
* 获取工具调用的输入参数(兼容不同状态)
|
||||||
|
* 注意:AI SDK 的 input 是 unknown 类型,这里做安全转换
|
||||||
*/
|
*/
|
||||||
export function getToolInput(toolPart: ToolPart): Record<string, unknown> {
|
export function getToolInput(toolPart: ToolPart): Record<string, unknown> {
|
||||||
if (toolPart.state.status === 'pending') {
|
if (toolPart.state.status === 'pending') {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return toolPart.state.input;
|
const input = toolPart.state.input;
|
||||||
|
// 安全转换:如果是对象返回对象,否则返回空对象
|
||||||
|
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
||||||
|
return input as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -224,7 +224,8 @@ export class SessionManager {
|
|||||||
} else if (role === 'assistant') {
|
} else if (role === 'assistant') {
|
||||||
// Assistant 消息:文本 + 工具调用
|
// Assistant 消息:文本 + 工具调用
|
||||||
const content: unknown[] = [];
|
const content: unknown[] = [];
|
||||||
const completedTools: Array<{ toolCallId: string; toolName: string; output: unknown }> = [];
|
// input 使用 unknown 类型以兼容 AI SDK(可能是对象、字符串等)
|
||||||
|
const completedTools: Array<{ toolCallId: string; toolName: string; input: unknown; output: unknown }> = [];
|
||||||
|
|
||||||
for (const part of parts) {
|
for (const part of parts) {
|
||||||
if (part.type === 'text') {
|
if (part.type === 'text') {
|
||||||
@@ -232,11 +233,12 @@ export class SessionManager {
|
|||||||
} else if (part.type === 'tool') {
|
} else if (part.type === 'tool') {
|
||||||
// 只有非 pending 状态的工具调用才添加到 AI SDK 消息
|
// 只有非 pending 状态的工具调用才添加到 AI SDK 消息
|
||||||
if (part.state.status !== 'pending') {
|
if (part.state.status !== 'pending') {
|
||||||
|
// AI SDK v5 使用 input 字段(不是 args)
|
||||||
content.push({
|
content.push({
|
||||||
type: 'tool-call',
|
type: 'tool-call',
|
||||||
toolCallId: part.toolCallId,
|
toolCallId: part.toolCallId,
|
||||||
toolName: part.toolName,
|
toolName: part.toolName,
|
||||||
args: part.state.input,
|
input: part.state.input,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 收集已完成的工具结果
|
// 收集已完成的工具结果
|
||||||
@@ -244,12 +246,14 @@ export class SessionManager {
|
|||||||
completedTools.push({
|
completedTools.push({
|
||||||
toolCallId: part.toolCallId,
|
toolCallId: part.toolCallId,
|
||||||
toolName: part.toolName,
|
toolName: part.toolName,
|
||||||
|
input: part.state.input,
|
||||||
output: part.state.output,
|
output: part.state.output,
|
||||||
});
|
});
|
||||||
} else if (part.state.status === 'error') {
|
} else if (part.state.status === 'error') {
|
||||||
completedTools.push({
|
completedTools.push({
|
||||||
toolCallId: part.toolCallId,
|
toolCallId: part.toolCallId,
|
||||||
toolName: part.toolName,
|
toolName: part.toolName,
|
||||||
|
input: part.state.input,
|
||||||
output: part.state.error,
|
output: part.state.error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -273,6 +277,7 @@ export class SessionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 添加 tool 消息(如果有已完成的工具)
|
// 添加 tool 消息(如果有已完成的工具)
|
||||||
|
// AI SDK v5 要求 tool-result 必须包含 input 和 output 字段
|
||||||
if (completedTools.length > 0) {
|
if (completedTools.length > 0) {
|
||||||
result.push({
|
result.push({
|
||||||
role: 'tool',
|
role: 'tool',
|
||||||
@@ -280,7 +285,8 @@ export class SessionManager {
|
|||||||
type: 'tool-result',
|
type: 'tool-result',
|
||||||
toolCallId: t.toolCallId,
|
toolCallId: t.toolCallId,
|
||||||
toolName: t.toolName,
|
toolName: t.toolName,
|
||||||
result: t.output,
|
input: t.input,
|
||||||
|
output: t.output,
|
||||||
})),
|
})),
|
||||||
} as unknown as ModelMessage);
|
} as unknown as ModelMessage);
|
||||||
}
|
}
|
||||||
@@ -454,7 +460,8 @@ export class SessionManager {
|
|||||||
for (const item of message.content) {
|
for (const item of message.content) {
|
||||||
const itemType = (item as { type: string }).type;
|
const itemType = (item as { type: string }).type;
|
||||||
if (itemType === 'tool-result') {
|
if (itemType === 'tool-result') {
|
||||||
const toolResult = item as unknown as { toolCallId: string; toolName: string; result: unknown };
|
// AI SDK v5 使用 output 字段存储结果(不是 result)
|
||||||
|
const toolResult = item as unknown as { toolCallId: string; toolName: string; output: unknown };
|
||||||
const partId = toolCallPartIds.get(toolResult.toolCallId);
|
const partId = toolCallPartIds.get(toolResult.toolCallId);
|
||||||
if (partId) {
|
if (partId) {
|
||||||
// 更新工具状态为 completed
|
// 更新工具状态为 completed
|
||||||
@@ -463,7 +470,7 @@ export class SessionManager {
|
|||||||
const startTime = part?.type === 'tool' && part.state.status === 'running'
|
const startTime = part?.type === 'tool' && part.state.status === 'running'
|
||||||
? part.state.time.start
|
? part.state.time.start
|
||||||
: Date.now();
|
: Date.now();
|
||||||
await PartStorage.setToolCompleted(currentAssistantMsgId, partId, toolResult.result, startTime);
|
await PartStorage.setToolCompleted(currentAssistantMsgId, partId, toolResult.output, startTime);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,20 +27,22 @@ export type ToolStatePending = z.infer<typeof ToolStatePendingSchema>;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具状态机 - Running(执行中)
|
* 工具状态机 - Running(执行中)
|
||||||
|
* 注意:input 使用 unknown 类型以兼容 AI SDK(可能是对象、字符串等)
|
||||||
*/
|
*/
|
||||||
export const ToolStateRunningSchema = z.object({
|
export const ToolStateRunningSchema = z.object({
|
||||||
status: z.literal('running'),
|
status: z.literal('running'),
|
||||||
input: z.record(z.string(), z.unknown()),
|
input: z.unknown(),
|
||||||
time: z.object({ start: z.number() }),
|
time: z.object({ start: z.number() }),
|
||||||
});
|
});
|
||||||
export type ToolStateRunning = z.infer<typeof ToolStateRunningSchema>;
|
export type ToolStateRunning = z.infer<typeof ToolStateRunningSchema>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具状态机 - Completed(执行完成)
|
* 工具状态机 - Completed(执行完成)
|
||||||
|
* 注意:input 使用 unknown 类型以兼容 AI SDK(可能是对象、字符串等)
|
||||||
*/
|
*/
|
||||||
export const ToolStateCompletedSchema = z.object({
|
export const ToolStateCompletedSchema = z.object({
|
||||||
status: z.literal('completed'),
|
status: z.literal('completed'),
|
||||||
input: z.record(z.string(), z.unknown()),
|
input: z.unknown(),
|
||||||
output: z.unknown(),
|
output: z.unknown(),
|
||||||
time: z.object({ start: z.number(), end: z.number() }),
|
time: z.object({ start: z.number(), end: z.number() }),
|
||||||
});
|
});
|
||||||
@@ -48,10 +50,11 @@ export type ToolStateCompleted = z.infer<typeof ToolStateCompletedSchema>;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 工具状态机 - Error(执行出错)
|
* 工具状态机 - Error(执行出错)
|
||||||
|
* 注意:input 使用 unknown 类型以兼容 AI SDK(可能是对象、字符串等)
|
||||||
*/
|
*/
|
||||||
export const ToolStateErrorSchema = z.object({
|
export const ToolStateErrorSchema = z.object({
|
||||||
status: z.literal('error'),
|
status: z.literal('error'),
|
||||||
input: z.record(z.string(), z.unknown()),
|
input: z.unknown(),
|
||||||
error: z.string(),
|
error: z.string(),
|
||||||
time: z.object({ start: z.number(), end: z.number() }),
|
time: z.object({ start: z.number(), end: z.number() }),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { toast } from 'sonner';
|
|||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
StreamingMessage,
|
|
||||||
TypingIndicator,
|
TypingIndicator,
|
||||||
ChatInput,
|
ChatInput,
|
||||||
} from '@ai-assistant/ui';
|
} from '@ai-assistant/ui';
|
||||||
@@ -46,7 +45,7 @@ export function ChatPage({
|
|||||||
messages,
|
messages,
|
||||||
isConnected,
|
isConnected,
|
||||||
isLoading,
|
isLoading,
|
||||||
streamingContent,
|
streamingMessage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
cancelProcessing,
|
cancelProcessing,
|
||||||
} = useChat({
|
} = useChat({
|
||||||
@@ -73,7 +72,7 @@ export function ChatPage({
|
|||||||
// 自动滚动到底部
|
// 自动滚动到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages, streamingContent]);
|
}, [messages, streamingMessage]);
|
||||||
|
|
||||||
// 空状态组件
|
// 空状态组件
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
@@ -270,9 +269,12 @@ export function ChatPage({
|
|||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{streamingContent && <StreamingMessage content={streamingContent} />}
|
{/* 流式消息 - 复用 ChatMessage 组件 */}
|
||||||
|
{streamingMessage && (
|
||||||
|
<ChatMessage message={streamingMessage} isStreaming />
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && !streamingContent && <TypingIndicator />}
|
{isLoading && !streamingMessage && <TypingIndicator />}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -67,11 +67,33 @@ interface SessionManagerConstructor {
|
|||||||
new (): SessionManagerInstance;
|
new (): SessionManagerInstance;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具开始信息
|
||||||
|
*/
|
||||||
|
interface ToolStartInfo {
|
||||||
|
id: string;
|
||||||
|
toolName: string;
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 工具结束信息
|
||||||
|
*/
|
||||||
|
interface ToolEndInfo {
|
||||||
|
id: string;
|
||||||
|
status: 'completed' | 'error';
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chat 选项接口
|
* Chat 选项接口
|
||||||
*/
|
*/
|
||||||
interface ChatOptions {
|
interface ChatOptions {
|
||||||
onStream?: (chunk: string) => void;
|
onStream?: (chunk: string) => void;
|
||||||
|
onToolStart?: (info: ToolStartInfo) => void;
|
||||||
|
onToolEnd?: (info: ToolEndInfo) => void;
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -397,7 +419,7 @@ export async function processMessage(sessionId: string, content: string): Promis
|
|||||||
payload: { content: chunk },
|
payload: { content: chunk },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 检测工具调用
|
// 检测工具调用(向后兼容 - SSE 日志)
|
||||||
if (chunk.includes('[调用工具:')) {
|
if (chunk.includes('[调用工具:')) {
|
||||||
const match = chunk.match(/\[调用工具: (.+?)\]/);
|
const match = chunk.match(/\[调用工具: (.+?)\]/);
|
||||||
if (match) {
|
if (match) {
|
||||||
@@ -405,6 +427,38 @@ export async function processMessage(sessionId: string, content: string): Promis
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onToolStart: (info) => {
|
||||||
|
// 检查是否已取消
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
|
||||||
|
// 推送工具开始事件
|
||||||
|
broadcastToSession(sessionId, {
|
||||||
|
type: 'tool_start',
|
||||||
|
sessionId,
|
||||||
|
payload: {
|
||||||
|
id: info.id,
|
||||||
|
toolName: info.toolName,
|
||||||
|
arguments: info.args,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onToolEnd: (info) => {
|
||||||
|
// 检查是否已取消
|
||||||
|
if (abortController.signal.aborted) return;
|
||||||
|
|
||||||
|
// 推送工具结束事件
|
||||||
|
broadcastToSession(sessionId, {
|
||||||
|
type: 'tool_end',
|
||||||
|
sessionId,
|
||||||
|
payload: {
|
||||||
|
id: info.id,
|
||||||
|
status: info.status,
|
||||||
|
result: info.result,
|
||||||
|
error: info.error,
|
||||||
|
duration: info.duration,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
abortSignal: abortController.signal,
|
abortSignal: abortController.signal,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,8 @@ export interface ServerMessage {
|
|||||||
| 'chunk'
|
| 'chunk'
|
||||||
| 'tool_call'
|
| 'tool_call'
|
||||||
| 'tool_result'
|
| 'tool_result'
|
||||||
|
| 'tool_start' // 工具开始执行
|
||||||
|
| 'tool_end' // 工具执行完成
|
||||||
| 'done'
|
| 'done'
|
||||||
| 'cancelled'
|
| 'cancelled'
|
||||||
| 'error'
|
| 'error'
|
||||||
@@ -119,6 +121,22 @@ export interface ServerMessage {
|
|||||||
payload?: unknown;
|
payload?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 工具开始事件 Payload
|
||||||
|
export interface ToolStartPayload {
|
||||||
|
id: string;
|
||||||
|
toolName: string;
|
||||||
|
arguments: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 工具结束事件 Payload
|
||||||
|
export interface ToolEndPayload {
|
||||||
|
id: string;
|
||||||
|
status: 'completed' | 'error';
|
||||||
|
result?: unknown;
|
||||||
|
error?: string;
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Permission 相关 ============
|
// ============ Permission 相关 ============
|
||||||
|
|
||||||
export type PermissionType = 'bash' | 'file' | 'git' | 'web';
|
export type PermissionType = 'bash' | 'file' | 'git' | 'web';
|
||||||
|
|||||||
@@ -877,3 +877,29 @@ export interface FileSearchResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ 流式工具调用事件 ============
|
||||||
|
|
||||||
|
/** 工具开始事件 Payload */
|
||||||
|
export interface ToolStartPayload {
|
||||||
|
/** 工具调用唯一 ID */
|
||||||
|
id: string;
|
||||||
|
/** 工具名称 */
|
||||||
|
toolName: string;
|
||||||
|
/** 调用参数 */
|
||||||
|
arguments: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 工具结束事件 Payload */
|
||||||
|
export interface ToolEndPayload {
|
||||||
|
/** 对应 tool_start 的 ID */
|
||||||
|
id: string;
|
||||||
|
/** 执行状态 */
|
||||||
|
status: 'completed' | 'error';
|
||||||
|
/** 执行结果 */
|
||||||
|
result?: unknown;
|
||||||
|
/** 错误信息 */
|
||||||
|
error?: string;
|
||||||
|
/** 执行时长 (ms) */
|
||||||
|
duration?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,12 @@ import type { Message, ToolCallInfo, ToolCallStatus, ToolMessagePart } from '../
|
|||||||
|
|
||||||
interface ChatMessageProps {
|
interface ChatMessageProps {
|
||||||
message: Message;
|
message: Message;
|
||||||
|
/** 是否为流式输出中(显示打字光标) */
|
||||||
|
isStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
||||||
({ message }, ref) => {
|
({ message, isStreaming = false }, ref) => {
|
||||||
const isUser = message.role === 'user';
|
const isUser = message.role === 'user';
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
@@ -42,18 +44,39 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
|||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
// 优先使用 parts 数组(保持原始顺序)
|
// 优先使用 parts 数组(保持原始顺序)
|
||||||
if (message.parts && message.parts.length > 0) {
|
if (message.parts && message.parts.length > 0) {
|
||||||
|
// 查找最后一个文本 part 的索引(用于显示打字光标)
|
||||||
|
let lastTextPartIndex = -1;
|
||||||
|
if (isStreaming) {
|
||||||
|
for (let i = message.parts.length - 1; i >= 0; i--) {
|
||||||
|
if (message.parts[i].type === 'text') {
|
||||||
|
lastTextPartIndex = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="message-content text-fg-secondary space-y-3">
|
<div className="message-content text-fg-secondary space-y-3">
|
||||||
{message.parts.map((part) => {
|
{message.parts.map((part, index) => {
|
||||||
switch (part.type) {
|
switch (part.type) {
|
||||||
case 'text':
|
case 'text':
|
||||||
if (!part.text) return null;
|
if (!part.text && index !== lastTextPartIndex) return null;
|
||||||
return isUser ? (
|
return isUser ? (
|
||||||
<div key={part.id}>
|
<div key={part.id}>
|
||||||
<FileMentionText text={part.text} />
|
<FileMentionText text={part.text} />
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<Markdown key={part.id} content={part.text} />
|
<div key={part.id}>
|
||||||
|
<Markdown content={part.text} />
|
||||||
|
{/* 流式输出时在最后一个文本末尾显示打字光标 */}
|
||||||
|
{isStreaming && index === lastTextPartIndex && (
|
||||||
|
<motion.span
|
||||||
|
animate={{ opacity: [1, 0] }}
|
||||||
|
transition={{ duration: 0.8, repeat: Infinity, repeatType: 'reverse' }}
|
||||||
|
className="inline-block w-2 h-4 bg-primary-400 ml-1 rounded-sm align-middle"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
case 'tool':
|
case 'tool':
|
||||||
return <ToolPartItem key={part.id} part={part} />;
|
return <ToolPartItem key={part.id} part={part} />;
|
||||||
|
|||||||
@@ -7,7 +7,13 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
import { createWebSocket, getMessages, type Message } from '../api/client.js';
|
import { createWebSocket, getMessages, type Message } from '../api/client.js';
|
||||||
import type { PermissionRequest } from '../components/PermissionDialog.js';
|
import type { PermissionRequest } from '../components/PermissionDialog.js';
|
||||||
import type { ConfigErrorPayload } from '../api/types.js';
|
import type {
|
||||||
|
ConfigErrorPayload,
|
||||||
|
ToolStartPayload,
|
||||||
|
ToolEndPayload,
|
||||||
|
MessagePart,
|
||||||
|
ToolMessagePart,
|
||||||
|
} from '../api/types.js';
|
||||||
|
|
||||||
interface UseChatOptions {
|
interface UseChatOptions {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
@@ -22,7 +28,8 @@ interface ChatState {
|
|||||||
messages: Message[];
|
messages: Message[];
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
streamingContent: string;
|
/** 流式消息对象,复用 Message 结构 */
|
||||||
|
streamingMessage: Message | null;
|
||||||
permissionRequest: PermissionRequest | null;
|
permissionRequest: PermissionRequest | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +38,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
messages: [],
|
messages: [],
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
streamingContent: '',
|
streamingMessage: null,
|
||||||
permissionRequest: null,
|
permissionRequest: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -114,27 +121,136 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
const message = JSON.parse(event.data);
|
const message = JSON.parse(event.data);
|
||||||
|
|
||||||
switch (message.type) {
|
switch (message.type) {
|
||||||
case 'chunk':
|
case 'chunk': {
|
||||||
setState((prev) => ({
|
const chunkContent = message.payload?.content || '';
|
||||||
...prev,
|
setState((prev) => {
|
||||||
streamingContent: prev.streamingContent + (message.payload?.content || ''),
|
// 初始化或获取当前流式消息
|
||||||
}));
|
const streaming = prev.streamingMessage || {
|
||||||
|
id: `streaming-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
role: 'assistant' as const,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
parts: [] as MessagePart[],
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 复制 parts 数组以进行修改
|
||||||
|
const parts = [...streaming.parts];
|
||||||
|
const lastPart = parts[parts.length - 1];
|
||||||
|
|
||||||
|
// 如果最后一个 part 是 text,追加内容;否则创建新 text part
|
||||||
|
if (lastPart?.type === 'text') {
|
||||||
|
parts[parts.length - 1] = {
|
||||||
|
...lastPart,
|
||||||
|
text: lastPart.text + chunkContent,
|
||||||
|
};
|
||||||
|
} else if (chunkContent) {
|
||||||
|
parts.push({
|
||||||
|
type: 'text',
|
||||||
|
id: `text-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
text: chunkContent,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
streamingMessage: {
|
||||||
|
...streaming,
|
||||||
|
parts,
|
||||||
|
content: (streaming.content || '') + chunkContent,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
break;
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_start': {
|
||||||
|
const payload = message.payload as ToolStartPayload;
|
||||||
|
setState((prev) => {
|
||||||
|
// 初始化或获取当前流式消息
|
||||||
|
const streaming = prev.streamingMessage || {
|
||||||
|
id: `streaming-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
role: 'assistant' as const,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
parts: [] as MessagePart[],
|
||||||
|
content: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 添加工具调用 part
|
||||||
|
const toolPart: ToolMessagePart = {
|
||||||
|
type: 'tool',
|
||||||
|
id: payload.id,
|
||||||
|
toolCallId: payload.id,
|
||||||
|
toolName: payload.toolName,
|
||||||
|
status: 'running',
|
||||||
|
arguments: payload.arguments,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
streamingMessage: {
|
||||||
|
...streaming,
|
||||||
|
parts: [...streaming.parts, toolPart],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_end': {
|
||||||
|
const payload = message.payload as ToolEndPayload;
|
||||||
|
setState((prev) => {
|
||||||
|
if (!prev.streamingMessage) return prev;
|
||||||
|
|
||||||
|
// 查找并更新对应的工具 part
|
||||||
|
const parts = prev.streamingMessage.parts.map((part) => {
|
||||||
|
if (part.type === 'tool' && part.id === payload.id) {
|
||||||
|
return {
|
||||||
|
...part,
|
||||||
|
status: payload.status,
|
||||||
|
result: payload.result,
|
||||||
|
error: payload.error,
|
||||||
|
duration: payload.duration,
|
||||||
|
} as ToolMessagePart;
|
||||||
|
}
|
||||||
|
return part;
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
streamingMessage: {
|
||||||
|
...prev.streamingMessage,
|
||||||
|
parts,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
case 'done':
|
case 'done':
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
const content = message.payload?.content || prev.streamingContent;
|
// 使用流式消息或创建新消息
|
||||||
const newMessage: Message = {
|
const streaming = prev.streamingMessage;
|
||||||
id: message.payload?.id || `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
const content = message.payload?.content || streaming?.content || '';
|
||||||
role: 'assistant',
|
|
||||||
timestamp: message.payload?.timestamp || new Date().toISOString(),
|
const newMessage: Message = streaming
|
||||||
parts: [{ type: 'text', id: `text-${Date.now()}`, text: content }],
|
? {
|
||||||
content,
|
...streaming,
|
||||||
};
|
id: message.payload?.id || streaming.id,
|
||||||
|
timestamp: message.payload?.timestamp || streaming.timestamp,
|
||||||
|
content,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
id: message.payload?.id || `assistant-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
|
||||||
|
role: 'assistant',
|
||||||
|
timestamp: message.payload?.timestamp || new Date().toISOString(),
|
||||||
|
parts: [{ type: 'text', id: `text-${Date.now()}`, text: content }],
|
||||||
|
content,
|
||||||
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...prev,
|
...prev,
|
||||||
messages: [...prev.messages, newMessage],
|
messages: [...prev.messages, newMessage],
|
||||||
streamingContent: '',
|
streamingMessage: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -165,7 +281,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
} else {
|
} else {
|
||||||
onErrorRef.current?.(new Error(message.payload?.message || 'Unknown error'));
|
onErrorRef.current?.(new Error(message.payload?.message || 'Unknown error'));
|
||||||
}
|
}
|
||||||
setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' }));
|
setState((prev) => ({ ...prev, isLoading: false, streamingMessage: null }));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'session_updated':
|
case 'session_updated':
|
||||||
@@ -225,7 +341,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' }));
|
setState((prev) => ({ ...prev, isLoading: false, streamingMessage: null }));
|
||||||
}, [sessionId]);
|
}, [sessionId]);
|
||||||
|
|
||||||
// 发送权限响应
|
// 发送权限响应
|
||||||
@@ -274,7 +390,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
|
|||||||
messages: [],
|
messages: [],
|
||||||
isConnected: false,
|
isConnected: false,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
streamingContent: '',
|
streamingMessage: null,
|
||||||
permissionRequest: null,
|
permissionRequest: null,
|
||||||
});
|
});
|
||||||
reconnectAttemptsRef.current = 0;
|
reconnectAttemptsRef.current = 0;
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import { toast } from 'sonner';
|
|||||||
import {
|
import {
|
||||||
useChat,
|
useChat,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
StreamingMessage,
|
|
||||||
TypingIndicator,
|
TypingIndicator,
|
||||||
ChatInput,
|
ChatInput,
|
||||||
PermissionDialog,
|
PermissionDialog,
|
||||||
@@ -52,7 +51,7 @@ export function ChatPage({
|
|||||||
messages,
|
messages,
|
||||||
isConnected,
|
isConnected,
|
||||||
isLoading,
|
isLoading,
|
||||||
streamingContent,
|
streamingMessage,
|
||||||
sendMessage,
|
sendMessage,
|
||||||
cancelProcessing,
|
cancelProcessing,
|
||||||
permissionRequest,
|
permissionRequest,
|
||||||
@@ -83,7 +82,7 @@ export function ChatPage({
|
|||||||
// 自动滚动到底部
|
// 自动滚动到底部
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
}, [messages, streamingContent]);
|
}, [messages, streamingMessage]);
|
||||||
|
|
||||||
// 空状态组件
|
// 空状态组件
|
||||||
const EmptyState = () => (
|
const EmptyState = () => (
|
||||||
@@ -290,9 +289,12 @@ export function ChatPage({
|
|||||||
))}
|
))}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
{streamingContent && <StreamingMessage content={streamingContent} />}
|
{/* 流式消息 - 复用 ChatMessage 组件 */}
|
||||||
|
{streamingMessage && (
|
||||||
|
<ChatMessage message={streamingMessage} isStreaming />
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading && !streamingContent && <TypingIndicator />}
|
{isLoading && !streamingMessage && <TypingIndicator />}
|
||||||
|
|
||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user