feat(ui): 实现消息 parts 有序渲染

- Server: API 返回 parts 数组保持原始顺序
- Server: 添加 MessagePart 类型定义 (text/tool/reasoning)
- UI: ChatMessage 按 parts 顺序交叉渲染文本和工具调用
- UI: 新增 ToolPartItem 组件渲染单个工具 part
- UI: useChat 创建消息时添加 parts 字段
This commit is contained in:
2025-12-15 14:46:00 +08:00
parent 2150abde7c
commit cd0dd814ab
5 changed files with 280 additions and 32 deletions
+46 -3
View File
@@ -29,19 +29,62 @@ export interface ToolCallInfo {
duration?: number; // 执行时长 ms
}
// ============ 消息 Parts 相关 ============
/**
* 文本 Part
*/
export interface TextMessagePart {
type: 'text';
id: string;
text: string;
}
/**
* 工具调用 Part
*/
export interface ToolMessagePart {
type: 'tool';
id: string;
toolCallId: string;
toolName: string;
status: ToolCallStatus;
arguments: Record<string, unknown>;
result?: unknown;
error?: string;
duration?: number;
}
/**
* 推理 Part
*/
export interface ReasoningMessagePart {
type: 'reasoning';
id: string;
text: string;
}
/**
* 消息 Part 联合类型
*/
export type MessagePart = TextMessagePart | ToolMessagePart | ReasoningMessagePart;
/**
* 消息格式(存储层已经是 2-message 格式,无需 API 层合并)
*
* 只有 user 和 assistant 两种角色:
* - user: 用户输入
* - assistant: AI 回复(包含文本和工具调用)
* - assistant: AI 回复(包含文本和工具调用,按原始顺序
*/
export interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
/** 工具调用列表 */
/** 有序的消息内容 Parts */
parts: MessagePart[];
/** 所有文本拼接(兼容字段) */
content?: string;
/** 所有工具调用(兼容字段) */
toolCalls?: ToolCallInfo[];
/** 是否包含推理过程 */
hasReasoning?: boolean;
+143 -15
View File
@@ -20,7 +20,7 @@ import { useState, forwardRef } from 'react';
import { cn } from '../utils/cn';
import { fadeInUp, smoothTransition } from '../utils/animations';
import { Markdown } from './Markdown';
import type { Message, ToolCallInfo, ToolCallStatus } from '../api/types.js';
import type { Message, ToolCallInfo, ToolCallStatus, ToolMessagePart } from '../api/types.js';
interface ChatMessageProps {
message: Message;
@@ -32,11 +32,61 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(message.content);
await navigator.clipboard.writeText(message.content ?? '');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// 渲染消息内容 - 使用 parts 保持原始顺序
const renderContent = () => {
// 优先使用 parts 数组(保持原始顺序)
if (message.parts && message.parts.length > 0) {
return (
<div className="message-content text-gray-200 space-y-3">
{message.parts.map((part) => {
switch (part.type) {
case 'text':
if (!part.text) return null;
return isUser ? (
<div key={part.id} className="whitespace-pre-wrap break-words">
{part.text}
</div>
) : (
<Markdown key={part.id} content={part.text} />
);
case 'tool':
return <ToolPartItem key={part.id} part={part} />;
case 'reasoning':
return (
<div key={part.id} className="text-gray-400 italic border-l-2 border-gray-600 pl-3">
{part.text}
</div>
);
default:
return null;
}
})}
</div>
);
}
// 回退:使用旧的 content + toolCalls 字段
return (
<>
{!isUser && message.toolCalls && message.toolCalls.length > 0 && (
<ToolCallsDisplay toolCalls={message.toolCalls} />
)}
<div className="message-content text-gray-200">
{isUser ? (
<div className="whitespace-pre-wrap break-words">{message.content ?? ''}</div>
) : (
<Markdown content={message.content ?? ''} />
)}
</div>
</>
);
};
return (
<motion.div
ref={ref}
@@ -71,19 +121,7 @@ export const ChatMessage = forwardRef<HTMLDivElement, ChatMessageProps>(
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
</button>
</div>
{/* 工具调用显示 */}
{!isUser && message.toolCalls && message.toolCalls.length > 0 && (
<ToolCallsDisplay toolCalls={message.toolCalls} />
)}
<div className="message-content text-gray-200">
{isUser ? (
// 用户消息:保持原样显示
<div className="whitespace-pre-wrap break-words">{message.content}</div>
) : (
// AI 消息:Markdown 渲染
<Markdown content={message.content} />
)}
</div>
{renderContent()}
</div>
</motion.div>
);
@@ -154,6 +192,96 @@ export function TypingIndicator() {
// ============ 工具调用显示组件 ============
/**
* 单个工具 Part 项(用于 parts 数组渲染)
*/
interface ToolPartItemProps {
part: ToolMessagePart;
}
function ToolPartItem({ part }: ToolPartItemProps) {
const [expanded, setExpanded] = useState(false);
const hasDetails =
Object.keys(part.arguments).length > 0 ||
part.result !== undefined ||
part.error !== undefined;
return (
<div className="border border-gray-700 rounded-lg overflow-hidden bg-gray-800/30">
{/* 头部:工具名称、状态、时长 */}
<button
onClick={() => hasDetails && setExpanded(!expanded)}
className={cn(
'w-full flex items-center gap-2 px-3 py-2 text-sm',
hasDetails && 'hover:bg-gray-700/50 cursor-pointer',
!hasDetails && 'cursor-default'
)}
>
<Wrench size={14} className="text-gray-400 flex-shrink-0" />
<span className="font-mono text-gray-200 flex-1 text-left truncate">
{part.toolName}
</span>
{getStatusIcon(part.status)}
{part.duration && (
<span className="text-xs text-gray-500">{formatDuration(part.duration)}</span>
)}
{hasDetails && (
<span className="text-gray-500">
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</span>
)}
</button>
{/* 展开的详情 */}
<AnimatePresence>
{expanded && hasDetails && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
className="border-t border-gray-700 overflow-hidden"
>
<div className="px-3 py-2 space-y-2 text-xs">
{/* 参数 */}
{Object.keys(part.arguments).length > 0 && (
<div>
<div className="text-gray-500 mb-1">Arguments:</div>
<pre className="bg-gray-900 rounded p-2 overflow-x-auto text-gray-300 max-h-48 overflow-y-auto">
{JSON.stringify(part.arguments, null, 2)}
</pre>
</div>
)}
{/* 结果 */}
{part.result !== undefined && (
<div>
<div className="text-gray-500 mb-1">Result:</div>
<pre className="bg-gray-900 rounded p-2 overflow-x-auto text-green-300 max-h-48 overflow-y-auto">
{typeof part.result === 'string'
? part.result
: JSON.stringify(part.result, null, 2)}
</pre>
</div>
)}
{/* 错误 */}
{part.error && (
<div>
<div className="text-red-400 mb-1">Error:</div>
<pre className="bg-gray-900 rounded p-2 overflow-x-auto text-red-300 max-h-48 overflow-y-auto">
{part.error}
</pre>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/**
* 获取工具状态图标
*/
+6 -2
View File
@@ -123,11 +123,13 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
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',
content: message.payload?.content || prev.streamingContent,
timestamp: message.payload?.timestamp || new Date().toISOString(),
parts: [{ type: 'text', id: `text-${Date.now()}`, text: content }],
content,
};
return {
...prev,
@@ -141,11 +143,13 @@ export function useChat({ sessionId, onError, onSessionNotFound, onSessionUpdate
case 'message_received':
// 用户消息已确认 - 构建完整的消息对象
setState((prev) => {
const content = message.payload?.content || '';
const userMessage: Message = {
id: `user-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
role: 'user',
content: message.payload?.content || '',
timestamp: new Date().toISOString(),
parts: [{ type: 'text', id: `text-${Date.now()}`, text: content }],
content,
};
return {
...prev,