feat(ui): 优化流式输出工具调用渲染
- 添加 tool_start/tool_end WebSocket 事件支持 - 流式消息复用 ChatMessage 组件渲染工具调用卡片 - 修复 AI SDK v5 格式兼容问题(input/output 字段) - 修复会话恢复时 tool-result 格式错误 - 放宽 ToolState schema 中 input 字段类型为 unknown
This commit is contained in:
@@ -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