refactor(storage): 重构消息存储为 2-message 格式

采用 OpenCode 风格的消息存储架构:
- 只有 user 和 assistant 两种角色,移除 tool/system
- ToolPart 使用状态机模式 (pending → running → completed/error)
- 新增 toModelMessages() 转换函数用于调用 AI SDK
- 删除 message-merger.ts,存储层直接返回正确格式

主要改动:
- parts.ts: ToolState 状态机(pending/running/completed/error)
- message.ts: 移除 system role,添加 parentId 关联
- converter.ts: 新增 toModelMessages() 格式转换
- manager.ts: 重构 syncMessages/partsToModelMessages
- sessions.ts: 简化路由,直接从 Core Storage 读取
This commit is contained in:
2025-12-15 13:35:32 +08:00
parent eda2ccb171
commit 9f456c1029
13 changed files with 635 additions and 513 deletions
+66 -28
View File
@@ -6,8 +6,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';
import { CreateSessionInputSchema, type ToolCallInfo, type MergedMessage } from '../types.js';
export const sessionsRouter = new Hono();
@@ -101,14 +100,11 @@ sessionsRouter.delete('/:id', async (c) => {
/**
* GET /sessions/:id/messages - 获取会话消息
*
* 从 Core 存储读取消息,合并为用户视角的对话轮次
* 从 Core 存储读取消息,直接返回(存储层已经是 2-message 格式)
*
* 合并规则
* - 用户/系统消息:直接返回
* - 助手消息:将连续的 assistant + tool 消息合并为一条
* - content: 所有文本内容合并
* - toolCalls: 工具调用列表(含参数、状态、结果)
* - reasoning: 推理内容(如果有)
* 存储格式
* - user 消息:TextPart(文本内容)
* - assistant 消息:TextPart(文本) + ToolPart(工具调用,含状态机)
*/
sessionsRouter.get('/:id/messages', async (c) => {
const id = c.req.param('id');
@@ -126,42 +122,84 @@ sessionsRouter.get('/:id/messages', async (c) => {
try {
// 动态导入 Core 存储 API
const corePath = '@ai-assistant/core';
type MessageInfo = {
id: string;
sessionId: string;
role: 'user' | 'assistant';
parentId?: string;
createdAt: number;
partIds: string[];
};
type Part = {
id: string;
createdAt: number;
type: string;
text?: string;
toolCallId?: string;
toolName?: string;
state?: {
status: 'pending' | 'running' | 'completed' | 'error';
input?: Record<string, unknown>;
output?: unknown;
error?: string;
time?: { start: number; end?: number };
};
};
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[] }>
>;
listBySession(sessionId: string): Promise<MessageInfo[]>;
};
PartStorage: {
getByIds(messageId: string, partIds: string[]): Promise<RawPart[]>;
getByIds(messageId: string, partIds: string[]): Promise<Part[]>;
};
};
// 获取消息列表(按创建时间排序)
const messageInfos = await MessageStorage.listBySession(id);
// 获取每个消息的 Parts
const messagesWithParts: MessageWithParts[] = [];
// 转换为前端格式
const messages: MergedMessage[] = [];
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 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) => {
const state = p.state!;
const startTime = state.time?.start;
const endTime = state.time?.end;
return {
id: p.toolCallId ?? '',
name: p.toolName ?? '',
arguments: state.input ?? {},
status: state.status,
result: state.status === 'completed' ? state.output : undefined,
error: state.status === 'error' ? state.error : undefined,
duration: startTime && endTime ? endTime - startTime : undefined,
};
});
messages.push({
id: msgInfo.id,
sessionId: msgInfo.sessionId,
role: msgInfo.role,
content: textContent,
timestamp: new Date(msgInfo.createdAt).toISOString(),
toolCalls: toolCalls.length > 0 ? toolCalls : undefined,
});
}
// 合并消息
const mergedMessages = mergeMessages(messagesWithParts);
return c.json({
success: true,
data: mergedMessages,
data: messages,
});
} catch (error) {
console.error('[Sessions] Failed to load messages:', error);
+5 -4
View File
@@ -209,15 +209,16 @@ export interface ToolCallInfo {
}
/**
* 合并后的消息格式
* 消息格式(存储层已经是 2-message 格式,无需 API 层合并)
*
* 将 AI SDK 产生的多条消息(user assistant → tool → assistant
* 合并为用户视角的对话轮次
* 只有 user assistant 两种角色:
* - user: 用户输入
* - assistant: AI 回复(包含文本和工具调用)
*/
export interface MergedMessage {
id: string;
sessionId: string;
role: 'user' | 'assistant' | 'system';
role: 'user' | 'assistant';
content: string;
timestamp: string;
toolCalls?: ToolCallInfo[];
-277
View File
@@ -1,277 +0,0 @@
/**
* 消息合并工具
*
* 将 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<string, unknown>;
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<string, unknown>;
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<string, ToolCallCollector>();
// 记录最早的时间戳
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<ToolCallStatus, number> = {
pending: 0,
running: 1,
completed: 2,
error: 2,
};
return priority[newStatus] >= priority[oldStatus];
}