feat(ui): 优化流式输出工具调用渲染
- 添加 tool_start/tool_end WebSocket 事件支持 - 流式消息复用 ChatMessage 组件渲染工具调用卡片 - 修复 AI SDK v5 格式兼容问题(input/output 字段) - 修复会话恢复时 tool-result 格式错误 - 放宽 ToolState schema 中 input 字段类型为 unknown
This commit is contained in:
@@ -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 {
|
||||
message: Message;
|
||||
/** 是否为流式输出中(显示打字光标) */
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
||||
({ message }, ref) => {
|
||||
({ message, isStreaming = false }, ref) => {
|
||||
const isUser = message.role === 'user';
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
@@ -42,18 +44,39 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
|
||||
const renderContent = () => {
|
||||
// 优先使用 parts 数组(保持原始顺序)
|
||||
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 (
|
||||
<div className="message-content text-fg-secondary space-y-3">
|
||||
{message.parts.map((part) => {
|
||||
{message.parts.map((part, index) => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
if (!part.text) return null;
|
||||
if (!part.text && index !== lastTextPartIndex) return null;
|
||||
return isUser ? (
|
||||
<div key={part.id}>
|
||||
<FileMentionText text={part.text} />
|
||||
</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':
|
||||
return <ToolPartItem key={part.id} part={part} />;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user