diff --git a/packages/core/src/session/storage/message.ts b/packages/core/src/session/storage/message.ts index 1972f71..2588666 100644 --- a/packages/core/src/session/storage/message.ts +++ b/packages/core/src/session/storage/message.ts @@ -97,9 +97,14 @@ export async function listBySession(sessionId: string): Promise { } } - // 消息 ID 是降序的,所以排序后最新的在后面(时间升序) - // 实际上按 ID 字符串排序即可,因为降序 ID 自然会让旧消息在前 - return messages.sort((a, b) => b.id.localeCompare(a.id)).reverse(); + // 按创建时间升序排列(最旧的在前) + // 当时间相同时,按 ID 降序排列(ID 使用降序时间戳,所以 ID 大的更旧) + return messages.sort((a, b) => { + const timeDiff = a.createdAt - b.createdAt; + if (timeDiff !== 0) return timeDiff; + // ID 降序时间戳:数字大 = 时间旧,所以用 b - a 让旧的在前 + return b.id.localeCompare(a.id); + }); } /** diff --git a/packages/core/src/session/storage/part.ts b/packages/core/src/session/storage/part.ts index b14f641..00629bc 100644 --- a/packages/core/src/session/storage/part.ts +++ b/packages/core/src/session/storage/part.ts @@ -150,12 +150,12 @@ export async function createTool( messageId: string, toolCallId: string, toolName: string, - args: Record + args?: Record ): Promise { return create(messageId, 'tool', { toolCallId, toolName, - args, + args: args ?? {}, status: 'pending', }); } diff --git a/packages/server/src/routes/sessions.ts b/packages/server/src/routes/sessions.ts index cd1fbd4..74c6f95 100644 --- a/packages/server/src/routes/sessions.ts +++ b/packages/server/src/routes/sessions.ts @@ -7,6 +7,7 @@ import { Hono } from 'hono'; import { getSessionManager } from '../session/manager.js'; import { CreateSessionInputSchema } from '../types.js'; +import { mergeMessages, type MessageWithParts, type RawPart } from '../utils/message-merger.js'; export const sessionsRouter = new Hono(); @@ -100,7 +101,14 @@ sessionsRouter.delete('/:id', async (c) => { /** * GET /sessions/:id/messages - 获取会话消息 * - * 从 Core 存储读取完整的消息历史(包含 tool-call 和 tool-result) + * 从 Core 存储读取消息,合并为用户视角的对话轮次 + * + * 合并规则: + * - 用户/系统消息:直接返回 + * - 助手消息:将连续的 assistant + tool 消息合并为一条 + * - content: 所有文本内容合并 + * - toolCalls: 工具调用列表(含参数、状态、结果) + * - reasoning: 推理内容(如果有) */ sessionsRouter.get('/:id/messages', async (c) => { const id = c.req.param('id'); @@ -115,44 +123,51 @@ sessionsRouter.get('/:id/messages', async (c) => { ); } - // 从 Core 存储读取消息 - const sessionData = await sessionManager.loadSessionData(id); + try { + // 动态导入 Core 存储 API + const corePath = '@ai-assistant/core'; + const { MessageStorage, PartStorage } = (await import(/* webpackIgnore: true */ corePath)) as { + MessageStorage: { + listBySession(sessionId: string): Promise< + Array<{ id: string; sessionId: string; role: string; createdAt: number; partIds: string[] }> + >; + }; + PartStorage: { + getByIds(messageId: string, partIds: string[]): Promise; + }; + }; - if (!sessionData) { + // 获取消息列表(按创建时间排序) + const messageInfos = await MessageStorage.listBySession(id); + + // 获取每个消息的 Parts + const messagesWithParts: MessageWithParts[] = []; + for (const msgInfo of messageInfos) { + const parts = await PartStorage.getByIds(msgInfo.id, msgInfo.partIds); + messagesWithParts.push({ + info: { + id: msgInfo.id, + sessionId: msgInfo.sessionId, + role: msgInfo.role as 'user' | 'assistant' | 'system' | 'tool', + createdAt: msgInfo.createdAt, + partIds: msgInfo.partIds, + }, + parts, + }); + } + + // 合并消息 + const mergedMessages = mergeMessages(messagesWithParts); + + return c.json({ + success: true, + data: mergedMessages, + }); + } catch (error) { + console.error('[Sessions] Failed to load messages:', error); return c.json({ success: true, data: [], }); } - - // 为消息添加 ID 并转换内容格式(AI SDK 格式 -> 字符串) - const messagesWithId = sessionData.messages.map( - (msg: { role: string; content: unknown }, index: number) => { - // 转换 AI SDK 内容格式为字符串 - let content: string; - if (typeof msg.content === 'string') { - content = msg.content; - } else if (Array.isArray(msg.content)) { - // AI SDK 格式: [{type: "text", text: "..."}, ...] - content = msg.content - .filter((block: { type?: string }) => block.type === 'text') - .map((block: { text?: string }) => block.text || '') - .join(''); - } else { - content = String(msg.content); - } - - return { - id: `${msg.role}-${id}-${index}`, - role: msg.role, - content, - timestamp: new Date().toISOString(), - }; - } - ); - - return c.json({ - success: true, - data: messagesWithId, - }); }); diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 25ef68e..2e54ebd 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -188,6 +188,48 @@ export interface SSEEvent { }; } +// ============ 合并消息相关 (API 层消息合并) ============ + +/** + * 工具调用状态 + */ +export type ToolCallStatus = 'pending' | 'running' | 'completed' | 'error'; + +/** + * 工具调用信息(合并后) + */ +export interface ToolCallInfo { + id: string; + name: string; + arguments: Record; + status: ToolCallStatus; + result?: unknown; + error?: string; + duration?: number; // 执行时长 ms +} + +/** + * 合并后的消息格式 + * + * 将 AI SDK 产生的多条消息(user → assistant → tool → assistant) + * 合并为用户视角的对话轮次 + */ +export interface MergedMessage { + id: string; + sessionId: string; + role: 'user' | 'assistant' | 'system'; + content: string; + timestamp: string; + toolCalls?: ToolCallInfo[]; + hasReasoning?: boolean; + reasoning?: string; + metadata?: { + model?: string; + stepCount?: number; + totalTokens?: number; + }; +} + // ============ API 响应 ============ export interface ApiResponse { diff --git a/packages/server/src/utils/message-merger.ts b/packages/server/src/utils/message-merger.ts new file mode 100644 index 0000000..a96e7e4 --- /dev/null +++ b/packages/server/src/utils/message-merger.ts @@ -0,0 +1,277 @@ +/** + * 消息合并工具 + * + * 将 AI SDK 产生的多条原始消息合并为用户视角的对话轮次 + * + * AI SDK 消息流: + * user → assistant(text+tool) → tool(result) → assistant(final) + * + * 合并后: + * user → assistant (含 content + toolCalls[]) + */ + +import type { MergedMessage, ToolCallInfo, ToolCallStatus } from '../types.js'; + +/** + * 原始消息信息(来自 Core 存储) + */ +export interface RawMessageInfo { + id: string; + sessionId: string; + role: 'user' | 'assistant' | 'system' | 'tool'; + createdAt: number; + partIds: string[]; +} + +/** + * Part 类型(简化版,仅包含合并需要的字段) + */ +export interface RawPart { + id: string; + type: string; + createdAt: number; + // TextPart + text?: string; + // ToolPart + toolCallId?: string; + toolName?: string; + args?: Record; + status?: ToolCallStatus; + result?: unknown; + error?: string; + startedAt?: number; + completedAt?: number; + // ReasoningPart + // (uses text field) +} + +/** + * 带 Parts 的消息 + */ +export interface MessageWithParts { + info: RawMessageInfo; + parts: RawPart[]; +} + +/** + * 工具调用临时收集器 + */ +interface ToolCallCollector { + id: string; + name: string; + arguments: Record; + status: ToolCallStatus; + result?: unknown; + error?: string; + startedAt?: number; + completedAt?: number; +} + +/** + * 合并消息 + * + * @param messagesWithParts 带 Parts 的消息列表(按时间升序) + * @returns 合并后的消息列表 + */ +export function mergeMessages(messagesWithParts: MessageWithParts[]): MergedMessage[] { + const result: MergedMessage[] = []; + let i = 0; + + while (i < messagesWithParts.length) { + const current = messagesWithParts[i]; + + // 用户消息或系统消息:直接输出 + if (current.info.role === 'user' || current.info.role === 'system') { + result.push(createMergedMessage(current)); + i++; + continue; + } + + // Assistant 消息:收集连续的 assistant + tool 消息 + if (current.info.role === 'assistant') { + const merged = mergeAssistantTurn(messagesWithParts, i); + result.push(merged.message); + i = merged.nextIndex; + continue; + } + + // Tool 消息单独出现(理论上不应该发生,但做容错处理) + if (current.info.role === 'tool') { + // 跳过孤立的 tool 消息 + i++; + continue; + } + + i++; + } + + return result; +} + +/** + * 合并一个 Assistant 对话轮次 + * + * 从当前位置开始,收集连续的 assistant 和 tool 消息 + */ +function mergeAssistantTurn( + messages: MessageWithParts[], + startIndex: number +): { message: MergedMessage; nextIndex: number } { + const sessionId = messages[startIndex].info.sessionId; + const firstMessage = messages[startIndex]; + + // 收集所有文本内容 + const textContents: string[] = []; + // 收集所有推理内容 + const reasoningContents: string[] = []; + // 收集所有工具调用(按 toolCallId 去重) + const toolCallMap = new Map(); + // 记录最早的时间戳 + let earliestTimestamp = firstMessage.info.createdAt; + // 记录最早的消息 ID + let earliestMessageId = firstMessage.info.id; + + let i = startIndex; + + // 收集连续的 assistant 和 tool 消息 + while (i < messages.length) { + const msg = messages[i]; + + // 遇到 user 或 system 消息,结束收集 + if (msg.info.role === 'user' || msg.info.role === 'system') { + break; + } + + // 更新最早时间戳 + if (msg.info.createdAt < earliestTimestamp) { + earliestTimestamp = msg.info.createdAt; + earliestMessageId = msg.info.id; + } + + // 处理 Parts + for (const part of msg.parts) { + if (part.type === 'text' && part.text) { + textContents.push(part.text); + } else if (part.type === 'reasoning' && part.text) { + reasoningContents.push(part.text); + } else if (part.type === 'tool' && part.toolCallId) { + // 合并工具调用信息 + const existing = toolCallMap.get(part.toolCallId); + if (existing) { + // 更新已有的工具调用(用更新的状态覆盖) + if (part.status && isMoreRecentStatus(part.status, existing.status)) { + existing.status = part.status; + } + if (part.result !== undefined) { + existing.result = part.result; + } + if (part.error !== undefined) { + existing.error = part.error; + } + if (part.startedAt !== undefined) { + existing.startedAt = part.startedAt; + } + if (part.completedAt !== undefined) { + existing.completedAt = part.completedAt; + } + } else { + // 新的工具调用 + toolCallMap.set(part.toolCallId, { + id: part.toolCallId, + name: part.toolName || 'unknown', + arguments: part.args || {}, + status: part.status || 'pending', + result: part.result, + error: part.error, + startedAt: part.startedAt, + completedAt: part.completedAt, + }); + } + } + } + + i++; + } + + // 构建工具调用列表 + const toolCalls: ToolCallInfo[] = []; + for (const collector of toolCallMap.values()) { + const toolCall: ToolCallInfo = { + id: collector.id, + name: collector.name, + arguments: collector.arguments, + status: collector.status, + }; + + if (collector.result !== undefined) { + toolCall.result = collector.result; + } + if (collector.error !== undefined) { + toolCall.error = collector.error; + } + if (collector.startedAt && collector.completedAt) { + toolCall.duration = collector.completedAt - collector.startedAt; + } + + toolCalls.push(toolCall); + } + + // 构建合并后的消息 + const merged: MergedMessage = { + id: earliestMessageId, + sessionId, + role: 'assistant', + content: textContents.join(''), + timestamp: new Date(earliestTimestamp).toISOString(), + }; + + if (toolCalls.length > 0) { + merged.toolCalls = toolCalls; + } + + if (reasoningContents.length > 0) { + merged.hasReasoning = true; + merged.reasoning = reasoningContents.join('\n'); + } + + return { + message: merged, + nextIndex: i, + }; +} + +/** + * 创建简单的合并消息(用户消息或系统消息) + */ +function createMergedMessage(messageWithParts: MessageWithParts): MergedMessage { + const { info, parts } = messageWithParts; + + // 提取文本内容 + const textContent = parts + .filter((p) => p.type === 'text' && p.text) + .map((p) => p.text!) + .join(''); + + return { + id: info.id, + sessionId: info.sessionId, + role: info.role as 'user' | 'assistant' | 'system', + content: textContent, + timestamp: new Date(info.createdAt).toISOString(), + }; +} + +/** + * 判断状态是否更新 + * + * 状态优先级:pending < running < completed/error + */ +function isMoreRecentStatus(newStatus: ToolCallStatus, oldStatus: ToolCallStatus): boolean { + const priority: Record = { + pending: 0, + running: 1, + completed: 2, + error: 2, + }; + return priority[newStatus] >= priority[oldStatus]; +} diff --git a/packages/ui/src/api/types.ts b/packages/ui/src/api/types.ts index 58904e3..499428c 100644 --- a/packages/ui/src/api/types.ts +++ b/packages/ui/src/api/types.ts @@ -11,11 +11,46 @@ export interface Session { messageCount: number; } +/** + * 工具调用状态 + */ +export type ToolCallStatus = 'pending' | 'running' | 'completed' | 'error'; + +/** + * 工具调用信息 + */ +export interface ToolCallInfo { + id: string; + name: string; + arguments: Record; + status: ToolCallStatus; + result?: unknown; + error?: string; + duration?: number; // 执行时长 ms +} + +/** + * 消息(合并后的格式) + * + * 助手消息可能包含工具调用信息,将多个原始消息合并为一条 + */ export interface Message { id: string; role: 'user' | 'assistant' | 'system'; content: string; timestamp: string; + /** 工具调用列表 */ + toolCalls?: ToolCallInfo[]; + /** 是否包含推理过程 */ + hasReasoning?: boolean; + /** 推理内容 */ + reasoning?: string; + /** 元数据 */ + metadata?: { + model?: string; + stepCount?: number; + totalTokens?: number; + }; } export interface HealthStatus { diff --git a/packages/ui/src/components/ChatMessage.tsx b/packages/ui/src/components/ChatMessage.tsx index dbba720..388a34b 100644 --- a/packages/ui/src/components/ChatMessage.tsx +++ b/packages/ui/src/components/ChatMessage.tsx @@ -2,13 +2,25 @@ * Chat Message Component */ -import { User, Bot, Copy, Check } from 'lucide-react'; -import { motion } from 'framer-motion'; +import { + User, + Bot, + Copy, + Check, + Wrench, + ChevronDown, + ChevronRight, + Clock, + AlertCircle, + CheckCircle2, + Loader2, +} from 'lucide-react'; +import { motion, AnimatePresence } from 'framer-motion'; import { useState, forwardRef } from 'react'; import { cn } from '../utils/cn'; import { fadeInUp, smoothTransition } from '../utils/animations'; import { Markdown } from './Markdown'; -import type { Message } from '../api/client.js'; +import type { Message, ToolCallInfo, ToolCallStatus } from '../api/types.js'; interface ChatMessageProps { message: Message; @@ -59,6 +71,10 @@ export const ChatMessage = forwardRef( {copied ? : } + {/* 工具调用显示 */} + {!isUser && message.toolCalls && message.toolCalls.length > 0 && ( + + )}
{isUser ? ( // 用户消息:保持原样显示 @@ -135,3 +151,137 @@ export function TypingIndicator() { ); } + +// ============ 工具调用显示组件 ============ + +/** + * 获取工具状态图标 + */ +function getStatusIcon(status: ToolCallStatus) { + switch (status) { + case 'pending': + return ; + case 'running': + return ; + case 'completed': + return ; + case 'error': + return ; + } +} + +/** + * 格式化执行时长 + */ +function formatDuration(ms?: number): string { + if (!ms) return ''; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +/** + * 工具调用列表容器 + */ +interface ToolCallsDisplayProps { + toolCalls: ToolCallInfo[]; +} + +function ToolCallsDisplay({ toolCalls }: ToolCallsDisplayProps) { + return ( +
+ {toolCalls.map((toolCall) => ( + + ))} +
+ ); +} + +/** + * 单个工具调用项 + */ +interface ToolCallItemProps { + toolCall: ToolCallInfo; +} + +function ToolCallItem({ toolCall }: ToolCallItemProps) { + const [expanded, setExpanded] = useState(false); + const hasDetails = + Object.keys(toolCall.arguments).length > 0 || + toolCall.result !== undefined || + toolCall.error !== undefined; + + return ( +
+ {/* 头部:工具名称、状态、时长 */} + + + {/* 展开的详情 */} + + {expanded && hasDetails && ( + +
+ {/* 参数 */} + {Object.keys(toolCall.arguments).length > 0 && ( +
+
Arguments:
+
+                    {JSON.stringify(toolCall.arguments, null, 2)}
+                  
+
+ )} + + {/* 结果 */} + {toolCall.result !== undefined && ( +
+
Result:
+
+                    {typeof toolCall.result === 'string'
+                      ? toolCall.result
+                      : JSON.stringify(toolCall.result, null, 2)}
+                  
+
+ )} + + {/* 错误 */} + {toolCall.error && ( +
+
Error:
+
+                    {toolCall.error}
+                  
+
+ )} +
+
+ )} +
+
+ ); +}