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
+27 -4
View File
@@ -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} />;