diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index 741fcf3..09b3d7d 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -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, }); } diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 92023ec..7563bd9 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -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; + 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; diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 34f50e6..de6a729 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -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; + 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; diff --git a/packages/ui/src/components/ChatMessage.tsx b/packages/ui/src/components/ChatMessage.tsx index 388a34b..33a4675 100644 --- a/packages/ui/src/components/ChatMessage.tsx +++ b/packages/ui/src/components/ChatMessage.tsx @@ -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( 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 ( +
+ {message.parts.map((part) => { + switch (part.type) { + case 'text': + if (!part.text) return null; + return isUser ? ( +
+ {part.text} +
+ ) : ( + + ); + case 'tool': + return ; + case 'reasoning': + return ( +
+ {part.text} +
+ ); + default: + return null; + } + })} +
+ ); + } + + // 回退:使用旧的 content + toolCalls 字段 + return ( + <> + {!isUser && message.toolCalls && message.toolCalls.length > 0 && ( + + )} +
+ {isUser ? ( +
{message.content ?? ''}
+ ) : ( + + )} +
+ + ); + }; + return ( ( {copied ? : } - {/* 工具调用显示 */} - {!isUser && message.toolCalls && message.toolCalls.length > 0 && ( - - )} -
- {isUser ? ( - // 用户消息:保持原样显示 -
{message.content}
- ) : ( - // AI 消息:Markdown 渲染 - - )} -
+ {renderContent()}
); @@ -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 ( +
+ {/* 头部:工具名称、状态、时长 */} + + + {/* 展开的详情 */} + + {expanded && hasDetails && ( + +
+ {/* 参数 */} + {Object.keys(part.arguments).length > 0 && ( +
+
Arguments:
+
+                    {JSON.stringify(part.arguments, null, 2)}
+                  
+
+ )} + + {/* 结果 */} + {part.result !== undefined && ( +
+
Result:
+
+                    {typeof part.result === 'string'
+                      ? part.result
+                      : JSON.stringify(part.result, null, 2)}
+                  
+
+ )} + + {/* 错误 */} + {part.error && ( +
+
Error:
+
+                    {part.error}
+                  
+
+ )} +
+
+ )} +
+
+ ); +} + /** * 获取工具状态图标 */ diff --git a/packages/ui/src/hooks/useChat.ts b/packages/ui/src/hooks/useChat.ts index 125cffc..4d9a94b 100644 --- a/packages/ui/src/hooks/useChat.ts +++ b/packages/ui/src/hooks/useChat.ts @@ -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,