refactor(storage): 采用 OpenCode 风格三层存储结构

重构消息存储系统,从"每条消息一个文件"改为分层存储:
- Session → Message → Parts 三层结构
- 12 种 Part 类型(TextPart, ToolPart, ReasoningPart 等)
- ToolPart 状态机(pending → running → completed/error)
- 通用 Storage API(read/write/list/remove)

新增文件:
- parts.ts: Part 类型定义(Zod schema)
- message.ts: MessageInfo 类型定义
- id.ts: ID 生成器
- storage/: 分层存储实现

删除旧文件:
- storage.ts, types.ts, migration.ts

存储路径:
~/.local/share/ai-assist/
├── session/{projectId}/{sessionId}.json
├── message/{sessionId}/{messageId}.json
├── part/{messageId}/{partId}.json
└── todo/{sessionId}.json
This commit is contained in:
2025-12-15 11:16:10 +08:00
parent b8fcb65f73
commit 527692ec03
19 changed files with 1867 additions and 943 deletions
+406 -66
View File
@@ -1,76 +1,252 @@
import type { ModelMessage } from 'ai';
import type { SessionData, Todo, SessionSummary, ProjectMetadata } from './types.js';
import { SessionStorage, sessionStorage } from './storage.js';
import * as storage from './storage/index.js';
import { SessionStorage, MessageStorage, PartStorage, TodoStorage } from './storage/index.js';
import type { SessionInfo, Part, TodoItem } from './storage/index.js';
import { generateSessionId } from './id.js';
import { getProjectId, isGitRepository } from './project.js';
/**
* 会话摘要(用于列表展示)
*/
export interface SessionSummary {
id: string;
title: string;
workdir: string;
messageCount: number;
createdAt: string;
updatedAt: string;
}
/**
* 运行时会话数据(兼容旧接口)
*/
export interface SessionData {
id: string;
projectId: string;
parentId?: string;
agentName?: string;
createdAt: string;
updatedAt: string;
workdir: string;
title?: string;
messages: ModelMessage[];
discoveredTools: string[];
todos: TodoItem[];
}
/**
* 项目元数据
*/
export interface ProjectMetadata {
id: string;
workdir: string;
createdAt: string;
isGitRepo: boolean;
}
/**
* 会话管理器
* 提供高级会话操作接口
* 提供高级会话操作接口,使用新的三层存储结构
*/
export class SessionManager {
private storage: SessionStorage;
private currentSession: SessionData | null = null;
private currentProject: ProjectMetadata | null = null;
private autoSaveInterval: ReturnType<typeof setInterval> | null = null;
private lastSyncedCount: number = 0;
private storageDir?: string;
constructor(storage?: SessionStorage) {
this.storage = storage || sessionStorage;
constructor(storageDir?: string) {
this.storageDir = storageDir;
}
/**
* 初始化 - 尝试恢复或创建新会话
*/
async init(workdir: string): Promise<SessionData> {
// 初始化存储
await storage.initStorage(this.storageDir);
// 获取或创建项目
this.currentProject = await this.storage.getOrCreateProject(workdir);
this.currentProject = await this.getOrCreateProject(workdir);
// 尝试加载当前会话
const currentSessionId = await this.storage.getCurrentSessionId();
const currentSessionId = await this.getCurrentSessionId();
if (currentSessionId) {
// 尝试加载会话
const existing = await this.storage.loadSession(this.currentProject.id, currentSessionId);
const existing = await this.loadSession(this.currentProject.id, currentSessionId);
if (existing && existing.workdir === workdir) {
// 同一工作目录,恢复会话
this.currentSession = existing;
this.lastSyncedCount = existing.messages.length;
this.startAutoSave();
return this.currentSession;
}
}
// 创建新会话
this.currentSession = this.createNewSession(workdir);
this.lastSyncedCount = 0;
await this.save();
this.currentSession = await this.createNewSession(workdir);
await this.saveSessionInfo();
await this.setCurrentSessionPointer(this.currentSession.id);
// 启动自动保存
this.startAutoSave();
return this.currentSession;
}
/**
* 获取或创建项目
*/
private async getOrCreateProject(workdir: string): Promise<ProjectMetadata> {
const projectId = await getProjectId(workdir);
try {
const existing = await storage.read<ProjectMetadata>(['project', projectId]);
return existing;
} catch (e) {
if (e instanceof storage.StorageNotFoundError) {
const isGitRepo = await isGitRepository(workdir);
const project: ProjectMetadata = {
id: projectId,
workdir,
createdAt: new Date().toISOString(),
isGitRepo,
};
await storage.write(['project', projectId], project);
return project;
}
throw e;
}
}
/**
* 创建新会话
*/
private createNewSession(workdir: string): SessionData {
private async createNewSession(workdir: string): Promise<SessionData> {
if (!this.currentProject) {
throw new Error('Project not initialized. Call init() first.');
}
const sessionInfo = await SessionStorage.create(this.currentProject.id, workdir);
return {
id: this.storage.generateSessionId(),
projectId: this.currentProject.id,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
workdir,
id: sessionInfo.id,
projectId: sessionInfo.projectId,
createdAt: new Date(sessionInfo.createdAt).toISOString(),
updatedAt: new Date(sessionInfo.updatedAt).toISOString(),
workdir: sessionInfo.workdir,
title: sessionInfo.title,
messages: [],
discoveredTools: [],
discoveredTools: sessionInfo.discoveredTools,
todos: [],
};
}
/**
* 加载会话(从存储重建)
*/
private async loadSession(projectId: string, sessionId: string): Promise<SessionData | null> {
const sessionInfo = await SessionStorage.get(projectId, sessionId);
if (!sessionInfo) return null;
// 加载消息
const messages = await this.loadMessagesFromStorage(sessionId);
// 加载 todos
const todoList = await TodoStorage.get(sessionId);
return {
id: sessionInfo.id,
projectId: sessionInfo.projectId,
parentId: sessionInfo.parentId,
agentName: sessionInfo.agentName,
createdAt: new Date(sessionInfo.createdAt).toISOString(),
updatedAt: new Date(sessionInfo.updatedAt).toISOString(),
workdir: sessionInfo.workdir,
title: sessionInfo.title,
messages,
discoveredTools: sessionInfo.discoveredTools,
todos: todoList?.items || [],
};
}
/**
* 从存储加载消息并转换为 AI SDK 格式
*/
private async loadMessagesFromStorage(sessionId: string): Promise<ModelMessage[]> {
const messageInfos = await MessageStorage.listBySession(sessionId);
const messages: ModelMessage[] = [];
for (const messageInfo of messageInfos) {
const parts = await PartStorage.getByIds(messageInfo.id, messageInfo.partIds);
const modelMessage = this.partsToModelMessage(messageInfo.role, parts);
if (modelMessage) {
messages.push(modelMessage);
}
}
return messages;
}
/**
* 将 Parts 转换为 AI SDK ModelMessage
*/
private partsToModelMessage(role: string, parts: Part[]): ModelMessage | null {
if (parts.length === 0) return null;
// 构建消息内容
const content: unknown[] = [];
for (const part of parts) {
switch (part.type) {
case 'text':
content.push({ type: 'text', text: part.text });
break;
case 'tool':
if (role === 'assistant') {
content.push({
type: 'tool-call',
toolCallId: part.toolCallId,
toolName: part.toolName,
args: part.args,
});
} else if (role === 'tool') {
// Tool result message - AI SDK 的 tool message 格式
return {
role: 'tool',
content: [{
type: 'tool-result',
toolCallId: part.toolCallId,
toolName: part.toolName,
result: part.result,
}],
} as unknown as ModelMessage;
}
break;
case 'file':
content.push({
type: 'image',
image: part.data,
mimeType: part.mimeType,
});
break;
case 'reasoning':
// Reasoning 通常作为文本的一部分
content.push({ type: 'text', text: `[Reasoning] ${part.text}` });
break;
}
}
// 简化:如果只有一个文本内容,直接使用字符串
if (content.length === 1 && (content[0] as { type: string }).type === 'text') {
return {
role: role as 'user' | 'assistant' | 'system',
content: (content[0] as { text: string }).text,
} as ModelMessage;
}
return {
role: role as 'user' | 'assistant' | 'system' | 'tool',
content,
} as ModelMessage;
}
/**
* 获取当前会话
*/
@@ -86,28 +262,104 @@ export class SessionManager {
}
/**
* 保存当前会话(增量保存消息)
* 保存会话信息
*/
async save(): Promise<void> {
private async saveSessionInfo(): Promise<void> {
if (!this.currentSession) return;
// 增量保存消息
await this.storage.saveSession(this.currentSession, this.lastSyncedCount);
const sessionInfo: SessionInfo = {
id: this.currentSession.id,
projectId: this.currentSession.projectId,
parentId: this.currentSession.parentId,
agentName: this.currentSession.agentName,
createdAt: new Date(this.currentSession.createdAt).getTime(),
updatedAt: Date.now(),
workdir: this.currentSession.workdir,
title: this.currentSession.title,
discoveredTools: this.currentSession.discoveredTools,
stats: {
messageCount: this.currentSession.messages.length,
inputTokens: 0,
outputTokens: 0,
},
};
// 更新当前会话指针
await this.storage.setCurrentSession(this.currentSession.id);
// 更新同步计数
this.lastSyncedCount = this.currentSession.messages.length;
await SessionStorage.save(sessionInfo);
}
/**
* 添加消息
* 保存当前会话
*/
async addMessage(message: ModelMessage): Promise<void> {
async save(): Promise<void> {
if (!this.currentSession) return;
this.currentSession.messages.push(message);
await this.save();
await this.saveSessionInfo();
}
/**
* 同步消息到存储(将 AI SDK 消息转换为 Message + Parts
*/
async syncMessages(messages: ModelMessage[]): Promise<void> {
if (!this.currentSession) return;
const sessionId = this.currentSession.id;
// 删除旧消息
await MessageStorage.removeBySession(sessionId);
// 保存新消息
for (const message of messages) {
const messageInfo = await MessageStorage.create(sessionId, message.role as 'user' | 'assistant' | 'system');
// 将消息内容转换为 Parts
const partIds: string[] = [];
if (typeof message.content === 'string') {
// 简单文本
const part = await PartStorage.createText(messageInfo.id, message.content);
partIds.push(part.id);
} else if (Array.isArray(message.content)) {
// 复杂内容(多个 parts
for (const item of message.content) {
const itemType = (item as { type: string }).type;
if (itemType === 'text') {
const part = await PartStorage.createText(messageInfo.id, (item as { text: string }).text);
partIds.push(part.id);
} else if (itemType === 'tool-call') {
const toolCall = item as unknown as { toolCallId: string; toolName: string; args: Record<string, unknown> };
const part = await PartStorage.createTool(
messageInfo.id,
toolCall.toolCallId,
toolCall.toolName,
toolCall.args
);
partIds.push(part.id);
} else if (itemType === 'tool-result') {
const toolResult = item as unknown as { toolCallId: string; toolName: string; result: unknown };
const part = await PartStorage.create(messageInfo.id, 'tool', {
toolCallId: toolResult.toolCallId,
toolName: toolResult.toolName,
args: {},
status: 'completed',
result: toolResult.result,
});
partIds.push(part.id);
} else if (itemType === 'image') {
const img = item as unknown as { image: string; mimeType: string };
const part = await PartStorage.create(messageInfo.id, 'file', {
filename: 'image',
mimeType: img.mimeType,
data: typeof img.image === 'string' ? img.image : '',
});
partIds.push(part.id);
}
}
}
// 更新消息的 partIds
if (partIds.length > 0) {
await MessageStorage.update(sessionId, messageInfo.id, { partIds });
}
}
}
/**
@@ -116,7 +368,17 @@ export class SessionManager {
async setMessages(messages: ModelMessage[]): Promise<void> {
if (!this.currentSession) return;
this.currentSession.messages = messages;
await this.save();
await this.syncMessages(messages);
await this.saveSessionInfo();
}
/**
* 添加消息
*/
async addMessage(message: ModelMessage): Promise<void> {
if (!this.currentSession) return;
this.currentSession.messages.push(message);
await this.setMessages(this.currentSession.messages);
}
/**
@@ -132,7 +394,7 @@ export class SessionManager {
async setDiscoveredTools(tools: string[]): Promise<void> {
if (!this.currentSession) return;
this.currentSession.discoveredTools = tools;
await this.save();
await this.saveSessionInfo();
}
/**
@@ -145,16 +407,16 @@ export class SessionManager {
/**
* 更新待办事项
*/
async setTodos(todos: Todo[]): Promise<void> {
async setTodos(todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed' }>): Promise<void> {
if (!this.currentSession) return;
this.currentSession.todos = todos;
await this.save();
const todoList = await TodoStorage.replace(this.currentSession.id, todos);
this.currentSession.todos = todoList.items;
}
/**
* 获取待办事项
*/
getTodos(): Todo[] {
getTodos(): TodoItem[] {
return this.currentSession?.todos || [];
}
@@ -166,26 +428,22 @@ export class SessionManager {
throw new Error('Project not initialized. Call init() first.');
}
// 创建新会话
const newWorkdir = workdir || this.currentSession?.workdir || process.cwd();
// 如果工作目录变化,需要切换项目
if (workdir && workdir !== this.currentProject.workdir) {
this.currentProject = await this.storage.getOrCreateProject(workdir);
this.currentProject = await this.getOrCreateProject(workdir);
}
this.currentSession = this.createNewSession(newWorkdir);
this.lastSyncedCount = 0;
await this.save();
this.currentSession = await this.createNewSession(newWorkdir);
await this.saveSessionInfo();
await this.setCurrentSessionPointer(this.currentSession.id);
return this.currentSession;
}
/**
* 创建子会话(用于 Task 工具)
* @param parentId 父会话 ID
* @param agentName 关联的 Agent 名称
* @param title 会话标题
*/
createChildSession(parentId: string, agentName: string, title?: string): SessionData {
if (!this.currentProject) {
@@ -193,8 +451,8 @@ export class SessionManager {
}
const workdir = this.currentSession?.workdir || process.cwd();
const childSession: SessionData = {
id: this.storage.generateSessionId(),
return {
id: generateSessionId(),
projectId: this.currentProject.id,
parentId,
agentName,
@@ -206,14 +464,24 @@ export class SessionManager {
discoveredTools: [],
todos: [],
};
return childSession;
}
/**
* 保存子会话
*/
async saveChildSession(session: SessionData): Promise<void> {
await this.storage.saveSession(session, 0);
const sessionInfo: SessionInfo = {
id: session.id,
projectId: session.projectId,
parentId: session.parentId,
agentName: session.agentName,
createdAt: new Date(session.createdAt).getTime(),
updatedAt: Date.now(),
workdir: session.workdir,
title: session.title,
discoveredTools: session.discoveredTools,
};
await SessionStorage.save(sessionInfo);
}
/**
@@ -231,12 +499,11 @@ export class SessionManager {
throw new Error('Project not initialized. Call init() first.');
}
const session = await this.storage.loadSession(this.currentProject.id, sessionId);
const session = await this.loadSession(this.currentProject.id, sessionId);
if (!session) return null;
this.currentSession = session;
this.lastSyncedCount = session.messages.length;
await this.storage.setCurrentSession(sessionId);
await this.setCurrentSessionPointer(sessionId);
return session;
}
@@ -246,26 +513,78 @@ export class SessionManager {
*/
async listSessions(): Promise<SessionSummary[]> {
if (!this.currentProject) {
return this.storage.listAllSessions();
return this.listAllSessions();
}
return this.storage.listSessionsByProject(this.currentProject.id);
const sessions = await SessionStorage.listByProject(this.currentProject.id);
return sessions.map((s) => ({
id: s.id,
title: s.title || `会话 ${s.id}`,
workdir: s.workdir,
messageCount: s.stats?.messageCount || 0,
createdAt: new Date(s.createdAt).toISOString(),
updatedAt: new Date(s.updatedAt).toISOString(),
}));
}
/**
* 列出所有项目的会话
*/
async listAllSessions(): Promise<SessionSummary[]> {
return this.storage.listAllSessions();
const sessions = await SessionStorage.listAll();
return sessions.map((s) => ({
id: s.id,
title: s.title || `会话 ${s.id}`,
workdir: s.workdir,
messageCount: s.stats?.messageCount || 0,
createdAt: new Date(s.createdAt).toISOString(),
updatedAt: new Date(s.updatedAt).toISOString(),
}));
}
/**
* 删除历史会话
*/
async deleteSession(sessionId: string): Promise<boolean> {
if (!this.currentProject) {
if (!this.currentProject) return false;
try {
// 删除会话的消息和 Parts
const messageInfos = await MessageStorage.listBySession(sessionId);
for (const msg of messageInfos) {
await PartStorage.removeByMessage(msg.id);
}
await MessageStorage.removeBySession(sessionId);
// 删除 todos
await TodoStorage.removeBySession(sessionId);
// 删除会话信息
await SessionStorage.remove(this.currentProject.id, sessionId);
return true;
} catch {
return false;
}
return this.storage.deleteSession(this.currentProject.id, sessionId);
}
/**
* 获取当前会话 ID(从存储)
*/
private async getCurrentSessionId(): Promise<string | null> {
try {
const pointer = await storage.read<{ sessionId: string }>(['current-session']);
return pointer.sessionId;
} catch {
return null;
}
}
/**
* 设置当前会话指针
*/
private async setCurrentSessionPointer(sessionId: string): Promise<void> {
await storage.write(['current-session'], { sessionId });
}
/**
@@ -300,8 +619,29 @@ export class SessionManager {
/**
* 清理旧会话
*/
async cleanup(keepCount?: number): Promise<number> {
return this.storage.cleanupOldSessions(keepCount);
async cleanup(keepCount: number = 50): Promise<number> {
const sessions = await this.listAllSessions();
if (sessions.length <= keepCount) {
return 0;
}
const toDelete = sessions.slice(keepCount);
let deletedCount = 0;
for (const session of toDelete) {
if (await this.deleteSession(session.id)) {
deletedCount++;
}
}
return deletedCount;
}
/**
* 获取存储目录
*/
getStorageDir(): string {
return this.storageDir || storage.getDefaultStorageDir();
}
}