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:
@@ -6,7 +6,12 @@
|
||||
|
||||
import { Hono } from 'hono';
|
||||
import { getSessionManager } from '../session/manager.js';
|
||||
import { CreateSessionInputSchema, type ToolCallInfo, type MergedMessage } from '../types.js';
|
||||
import {
|
||||
CreateSessionInputSchema,
|
||||
type ToolCallInfo,
|
||||
type MergedMessage,
|
||||
type MessagePart,
|
||||
} from '../types.js';
|
||||
|
||||
export const sessionsRouter = new Hono();
|
||||
|
||||
@@ -163,13 +168,40 @@ sessionsRouter.get('/:id/messages', async (c) => {
|
||||
for (const msgInfo of messageInfos) {
|
||||
const parts = await PartStorage.getByIds(msgInfo.id, msgInfo.partIds);
|
||||
|
||||
// 提取文本内容
|
||||
// 转换 Parts 为前端格式(保持顺序)
|
||||
const messageParts: MessagePart[] = parts
|
||||
.filter((p) => p.type === 'text' || p.type === 'tool' || p.type === 'reasoning')
|
||||
.map((p): MessagePart => {
|
||||
if (p.type === 'text') {
|
||||
return { type: 'text', id: p.id, text: p.text ?? '' };
|
||||
}
|
||||
if (p.type === 'reasoning') {
|
||||
return { type: 'reasoning', id: p.id, text: p.text ?? '' };
|
||||
}
|
||||
// tool
|
||||
const state = p.state!;
|
||||
const startTime = state.time?.start;
|
||||
const endTime = state.time?.end;
|
||||
return {
|
||||
type: 'tool',
|
||||
id: p.id,
|
||||
toolCallId: p.toolCallId ?? '',
|
||||
toolName: p.toolName ?? '',
|
||||
status: state.status,
|
||||
arguments: state.input ?? {},
|
||||
result: state.status === 'completed' ? state.output : undefined,
|
||||
error: state.status === 'error' ? state.error : undefined,
|
||||
duration: startTime && endTime ? endTime - startTime : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
// 兼容字段:提取文本内容
|
||||
const textContent = parts
|
||||
.filter((p) => p.type === 'text')
|
||||
.map((p) => p.text ?? '')
|
||||
.join('');
|
||||
|
||||
// 提取工具调用
|
||||
// 兼容字段:提取工具调用
|
||||
const toolCalls: ToolCallInfo[] = parts
|
||||
.filter((p) => p.type === 'tool' && p.state)
|
||||
.map((p) => {
|
||||
@@ -191,8 +223,9 @@ sessionsRouter.get('/:id/messages', async (c) => {
|
||||
id: msgInfo.id,
|
||||
sessionId: msgInfo.sessionId,
|
||||
role: msgInfo.role,
|
||||
content: textContent,
|
||||
timestamp: new Date(msgInfo.createdAt).toISOString(),
|
||||
parts: messageParts,
|
||||
content: textContent || undefined,
|
||||
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -188,7 +188,7 @@ export interface SSEEvent {
|
||||
};
|
||||
}
|
||||
|
||||
// ============ 合并消息相关 (API 层消息合并) ============
|
||||
// ============ 消息 Parts 相关 ============
|
||||
|
||||
/**
|
||||
* 工具调用状态
|
||||
@@ -196,7 +196,45 @@ export interface SSEEvent {
|
||||
export type ToolCallStatus = 'pending' | 'running' | 'completed' | 'error';
|
||||
|
||||
/**
|
||||
* 工具调用信息(合并后)
|
||||
* 文本 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;
|
||||
|
||||
/**
|
||||
* 工具调用信息(兼容字段,合并后)
|
||||
*/
|
||||
export interface ToolCallInfo {
|
||||
id: string;
|
||||
@@ -205,25 +243,27 @@ export interface ToolCallInfo {
|
||||
status: ToolCallStatus;
|
||||
result?: unknown;
|
||||
error?: string;
|
||||
duration?: number; // 执行时长 ms
|
||||
duration?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 消息格式(存储层已经是 2-message 格式,无需 API 层合并)
|
||||
* 消息格式(包含有序 Parts)
|
||||
*
|
||||
* 只有 user 和 assistant 两种角色:
|
||||
* - user: 用户输入
|
||||
* - assistant: AI 回复(包含文本和工具调用)
|
||||
* - assistant: AI 回复(包含文本和工具调用,按原始顺序)
|
||||
*/
|
||||
export interface MergedMessage {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: string;
|
||||
/** 有序的消息内容 Parts */
|
||||
parts: MessagePart[];
|
||||
/** 所有文本拼接(兼容字段) */
|
||||
content?: string;
|
||||
/** 所有工具调用(兼容字段) */
|
||||
toolCalls?: ToolCallInfo[];
|
||||
hasReasoning?: boolean;
|
||||
reasoning?: string;
|
||||
metadata?: {
|
||||
model?: string;
|
||||
stepCount?: number;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取工具状态图标
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user