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
+37 -4
View File
@@ -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,
});
}
+48 -8
View File
@@ -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;