feat(ui): 优化流式输出工具调用渲染

- 添加 tool_start/tool_end WebSocket 事件支持
- 流式消息复用 ChatMessage 组件渲染工具调用卡片
- 修复 AI SDK v5 格式兼容问题(input/output 字段)
- 修复会话恢复时 tool-result 格式错误
- 放宽 ToolState schema 中 input 字段类型为 unknown
This commit is contained in:
2025-12-15 17:35:39 +08:00
parent 865e0906b9
commit 3fd8fd98b8
12 changed files with 384 additions and 58 deletions
+136 -20
View File
@@ -7,7 +7,13 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { createWebSocket, getMessages, type Message } from '../api/client.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 {
sessionId: string;
@@ -22,7 +28,8 @@ interface ChatState {
messages: Message[];
isConnected: boolean;
isLoading: boolean;
streamingContent: string;
/** 流式消息对象,复用 Message 结构 */
streamingMessage: Message | null;
permissionRequest: PermissionRequest | null;
}
@@ -31,7 +38,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
messages: [],
isConnected: false,
isLoading: false,
streamingContent: '',
streamingMessage: null,
permissionRequest: null,
});
@@ -114,27 +121,136 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
const message = JSON.parse(event.data);
switch (message.type) {
case 'chunk':
setState((prev) => ({
...prev,
streamingContent: prev.streamingContent + (message.payload?.content || ''),
}));
case 'chunk': {
const chunkContent = message.payload?.content || '';
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: '',
};
// 复制 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;
}
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':
setState((prev) => {
const content = message.payload?.content || prev.streamingContent;
const newMessage: Message = {
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,
};
// 使用流式消息或创建新消息
const streaming = prev.streamingMessage;
const content = message.payload?.content || streaming?.content || '';
const newMessage: Message = streaming
? {
...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 {
...prev,
messages: [...prev.messages, newMessage],
streamingContent: '',
streamingMessage: null,
isLoading: false,
};
});
@@ -165,7 +281,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
} else {
onErrorRef.current?.(new Error(message.payload?.message || 'Unknown error'));
}
setState((prev) => ({ ...prev, isLoading: false, streamingContent: '' }));
setState((prev) => ({ ...prev, isLoading: false, streamingMessage: null }));
break;
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]);
// 发送权限响应
@@ -274,7 +390,7 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
messages: [],
isConnected: false,
isLoading: false,
streamingContent: '',
streamingMessage: null,
permissionRequest: null,
});
reconnectAttemptsRef.current = 0;