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:
@@ -14,9 +14,30 @@ export type {
|
||||
CompressionConfig,
|
||||
DetailedCompressionResult,
|
||||
} from './context/index.js';
|
||||
export { SessionStorage } from './session/storage.js';
|
||||
// Session - 新的三层存储结构
|
||||
export { SessionManager } from './session/index.js';
|
||||
export type { SessionData, SessionSummary } from './session/types.js';
|
||||
export type { SessionData, SessionSummary, ProjectMetadata } from './session/index.js';
|
||||
|
||||
// Session Storage API
|
||||
export {
|
||||
SessionStorage,
|
||||
MessageStorage,
|
||||
PartStorage,
|
||||
TodoStorage,
|
||||
initStorage,
|
||||
getStorageDir,
|
||||
StorageNotFoundError,
|
||||
} from './session/index.js';
|
||||
|
||||
export type {
|
||||
SessionInfo,
|
||||
MessageInfo,
|
||||
Part,
|
||||
PartType,
|
||||
ToolStatus,
|
||||
TodoItem,
|
||||
TodoList,
|
||||
} from './session/index.js';
|
||||
|
||||
// Types
|
||||
export type { UserInput, ChatResult } from './types/index.js';
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* ID 生成器模块
|
||||
* 提供各种实体的 ID 生成功能
|
||||
*/
|
||||
|
||||
/**
|
||||
* 生成会话 ID
|
||||
* 格式: {date}_{random} (如 2025-12-15_abc123)
|
||||
*/
|
||||
export function generateSessionId(): string {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `${date}_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成消息 ID(降序,最新的在前)
|
||||
* 格式: msg_{invertedTimestamp}_{random}
|
||||
*/
|
||||
export function generateMessageId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
// 使用最大时间戳减去当前时间戳,实现降序
|
||||
const invertedTimestamp = 9999999999999 - timestamp;
|
||||
return `msg_${invertedTimestamp}_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Part ID(升序,按创建顺序)
|
||||
* 格式: part_{timestamp}_{random}
|
||||
*/
|
||||
export function generatePartId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `part_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Todo ID
|
||||
* 格式: todo_{timestamp}_{random}
|
||||
*/
|
||||
export function generateTodoId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `todo_${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从消息 ID 提取时间戳
|
||||
*/
|
||||
export function getTimestampFromMessageId(messageId: string): number {
|
||||
const match = messageId.match(/^msg_(\d+)_/);
|
||||
if (!match) return 0;
|
||||
const invertedTimestamp = parseInt(match[1], 10);
|
||||
return 9999999999999 - invertedTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 Part ID 提取时间戳
|
||||
*/
|
||||
export function getTimestampFromPartId(partId: string): number {
|
||||
const match = partId.match(/^part_(\d+)_/);
|
||||
if (!match) return 0;
|
||||
return parseInt(match[1], 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话 ID 提取日期
|
||||
*/
|
||||
export function getDateFromSessionId(sessionId: string): string | null {
|
||||
const match = sessionId.match(/^(\d{4}-\d{2}-\d{2})_/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证会话 ID 格式
|
||||
*/
|
||||
export function isValidSessionId(id: string): boolean {
|
||||
return /^\d{4}-\d{2}-\d{2}_[a-z0-9]+$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证消息 ID 格式
|
||||
*/
|
||||
export function isValidMessageId(id: string): boolean {
|
||||
return /^msg_\d+_[a-z0-9]+$/.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证 Part ID 格式
|
||||
*/
|
||||
export function isValidPartId(id: string): boolean {
|
||||
return /^part_\d+_[a-z0-9]+$/.test(id);
|
||||
}
|
||||
@@ -1,16 +1,52 @@
|
||||
export type {
|
||||
SessionData,
|
||||
SessionMetadata,
|
||||
SessionSummary,
|
||||
SessionManagerConfig,
|
||||
Todo,
|
||||
TodoStatus,
|
||||
StoredMessage,
|
||||
CurrentSessionPointer,
|
||||
ProjectMetadata,
|
||||
} from './types.js';
|
||||
// 新的存储 API
|
||||
export * from './storage/index.js';
|
||||
|
||||
export { SessionStorage, sessionStorage } from './storage.js';
|
||||
// 类型定义
|
||||
export type { MessageInfo, Message, MessageRole } from './message.js';
|
||||
export {
|
||||
MessageInfoSchema,
|
||||
MessageRoleSchema,
|
||||
generateMessageId,
|
||||
getTimestampFromMessageId,
|
||||
createMessageInfo,
|
||||
} from './message.js';
|
||||
|
||||
export type { Part, PartType, ToolStatus } from './parts.js';
|
||||
export {
|
||||
PartSchema,
|
||||
TextPartSchema,
|
||||
ToolPartSchema,
|
||||
ReasoningPartSchema,
|
||||
FilePartSchema,
|
||||
StepStartPartSchema,
|
||||
StepFinishPartSchema,
|
||||
SnapshotPartSchema,
|
||||
PatchPartSchema,
|
||||
AgentPartSchema,
|
||||
SubtaskPartSchema,
|
||||
CompactionPartSchema,
|
||||
RetryPartSchema,
|
||||
ToolStatusSchema,
|
||||
createPart,
|
||||
} from './parts.js';
|
||||
|
||||
// ID 生成器
|
||||
export {
|
||||
generateSessionId,
|
||||
generateMessageId as generateMsgId,
|
||||
generatePartId,
|
||||
generateTodoId,
|
||||
getTimestampFromMessageId as getMsgTimestamp,
|
||||
getTimestampFromPartId,
|
||||
getDateFromSessionId,
|
||||
isValidSessionId,
|
||||
isValidMessageId,
|
||||
isValidPartId,
|
||||
} from './id.js';
|
||||
|
||||
// 会话管理器
|
||||
export { SessionManager, sessionManager } from './manager.js';
|
||||
export type { SessionData, SessionSummary, ProjectMetadata } from './manager.js';
|
||||
|
||||
// 项目工具
|
||||
export { getProjectId, isGitRepository } from './project.js';
|
||||
export { runMigrations, getMigrationStatus } from './migration.js';
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 消息角色
|
||||
*/
|
||||
export const MessageRoleSchema = z.enum(['user', 'assistant', 'system']);
|
||||
export type MessageRole = z.infer<typeof MessageRoleSchema>;
|
||||
|
||||
/**
|
||||
* 消息 Info Schema(不含内容,内容在 Parts 中)
|
||||
*/
|
||||
export const MessageInfoSchema = z.object({
|
||||
id: z.string(),
|
||||
sessionId: z.string(),
|
||||
role: MessageRoleSchema,
|
||||
createdAt: z.number(), // Unix timestamp in milliseconds
|
||||
// Part IDs 列表(按顺序)
|
||||
partIds: z.array(z.string()),
|
||||
// 元数据
|
||||
metadata: z
|
||||
.object({
|
||||
// 模型信息
|
||||
model: z.string().optional(),
|
||||
// 步骤索引(如果是多步骤响应)
|
||||
stepIndex: z.number().optional(),
|
||||
// 是否被压缩
|
||||
compacted: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type MessageInfo = z.infer<typeof MessageInfoSchema>;
|
||||
|
||||
/**
|
||||
* 消息(含 Parts 内容,用于运行时)
|
||||
*/
|
||||
export interface Message extends Omit<MessageInfo, 'partIds'> {
|
||||
parts: import('./parts.js').Part[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成消息 ID
|
||||
* 使用降序 ID,最新的消息在前面(方便列表查询)
|
||||
*/
|
||||
export function generateMessageId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
// 使用最大时间戳减去当前时间戳,实现降序
|
||||
const invertedTimestamp = 9999999999999 - timestamp;
|
||||
return `msg_${invertedTimestamp}_${random}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从消息 ID 提取时间戳
|
||||
*/
|
||||
export function getTimestampFromMessageId(messageId: string): number {
|
||||
const match = messageId.match(/^msg_(\d+)_/);
|
||||
if (!match) return 0;
|
||||
const invertedTimestamp = parseInt(match[1], 10);
|
||||
return 9999999999999 - invertedTimestamp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建消息 Info
|
||||
*/
|
||||
export function createMessageInfo(
|
||||
sessionId: string,
|
||||
role: MessageRole,
|
||||
partIds: string[] = [],
|
||||
metadata?: MessageInfo['metadata']
|
||||
): MessageInfo {
|
||||
return {
|
||||
id: generateMessageId(),
|
||||
sessionId,
|
||||
role,
|
||||
createdAt: Date.now(),
|
||||
partIds,
|
||||
metadata,
|
||||
};
|
||||
}
|
||||
@@ -1,197 +0,0 @@
|
||||
/**
|
||||
* 数据迁移框架
|
||||
* 支持版本化的数据结构升级
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { Lock } from '../utils/lock.js';
|
||||
|
||||
type Migration = (storageDir: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* 迁移函数列表
|
||||
* 新迁移添加到数组末尾,索引即为版本号
|
||||
*/
|
||||
const MIGRATIONS: Migration[] = [
|
||||
// 迁移 0: 初始化新目录结构
|
||||
// 将旧的 sessions/ 目录下的会话文件迁移到新的分层结构
|
||||
async (storageDir: string) => {
|
||||
const oldSessionsDir = path.join(storageDir, 'sessions');
|
||||
const projectDir = path.join(storageDir, 'project');
|
||||
const sessionDir = path.join(storageDir, 'session');
|
||||
const messageDir = path.join(storageDir, 'message');
|
||||
|
||||
// 确保新目录存在
|
||||
await fs.mkdir(projectDir, { recursive: true });
|
||||
await fs.mkdir(sessionDir, { recursive: true });
|
||||
await fs.mkdir(messageDir, { recursive: true });
|
||||
|
||||
// 检查是否有旧数据需要迁移
|
||||
try {
|
||||
const files = await fs.readdir(oldSessionsDir);
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
const filePath = path.join(oldSessionsDir, file);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const oldSession = JSON.parse(content);
|
||||
|
||||
// 提取消息
|
||||
const messages = oldSession.messages || [];
|
||||
const sessionId = oldSession.id;
|
||||
|
||||
// 创建消息目录
|
||||
const sessionMessageDir = path.join(messageDir, sessionId);
|
||||
await fs.mkdir(sessionMessageDir, { recursive: true });
|
||||
|
||||
// 写入每条消息
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const messageFile = path.join(sessionMessageDir, `${String(i + 1).padStart(4, '0')}.json`);
|
||||
await fs.writeFile(
|
||||
messageFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
index: i + 1,
|
||||
...messages[i],
|
||||
createdAt: oldSession.createdAt,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// 创建新的会话元数据 (使用默认 projectId,后续会在 storage 中更新)
|
||||
const metadata = {
|
||||
id: sessionId,
|
||||
projectId: 'default',
|
||||
createdAt: oldSession.createdAt,
|
||||
updatedAt: oldSession.updatedAt,
|
||||
workdir: oldSession.workdir,
|
||||
title: oldSession.title,
|
||||
messageCount: messages.length,
|
||||
discoveredTools: oldSession.discoveredTools || [],
|
||||
todos: oldSession.todos || [],
|
||||
parentId: oldSession.parentId,
|
||||
agentName: oldSession.agentName,
|
||||
};
|
||||
|
||||
// 写入会话元数据到默认项目目录
|
||||
const defaultProjectDir = path.join(sessionDir, 'default');
|
||||
await fs.mkdir(defaultProjectDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(defaultProjectDir, `${sessionId}.json`),
|
||||
JSON.stringify(metadata, null, 2),
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// 迁移 current-session.json
|
||||
const currentSessionFile = path.join(storageDir, 'current-session.json');
|
||||
try {
|
||||
const currentContent = await fs.readFile(currentSessionFile, 'utf-8');
|
||||
const currentSession = JSON.parse(currentContent);
|
||||
|
||||
if (currentSession.messages) {
|
||||
const sessionId = currentSession.id;
|
||||
const messages = currentSession.messages;
|
||||
|
||||
// 创建消息目录
|
||||
const sessionMessageDir = path.join(messageDir, sessionId);
|
||||
await fs.mkdir(sessionMessageDir, { recursive: true });
|
||||
|
||||
// 写入每条消息
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const messageFile = path.join(sessionMessageDir, `${String(i + 1).padStart(4, '0')}.json`);
|
||||
await fs.writeFile(
|
||||
messageFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
index: i + 1,
|
||||
...messages[i],
|
||||
createdAt: currentSession.createdAt,
|
||||
},
|
||||
null,
|
||||
2
|
||||
),
|
||||
'utf-8'
|
||||
);
|
||||
}
|
||||
|
||||
// 更新 current-session.json 为只包含 sessionId
|
||||
await fs.writeFile(currentSessionFile, JSON.stringify({ sessionId }, null, 2), 'utf-8');
|
||||
}
|
||||
} catch {
|
||||
// 没有 current-session.json,忽略
|
||||
}
|
||||
} catch {
|
||||
// sessions 目录不存在,跳过迁移
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 获取当前迁移版本
|
||||
*/
|
||||
async function getMigrationVersion(storageDir: string): Promise<number> {
|
||||
const versionFile = path.join(storageDir, 'migration');
|
||||
try {
|
||||
const content = await fs.readFile(versionFile, 'utf-8');
|
||||
return parseInt(content.trim(), 10) || 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存迁移版本
|
||||
*/
|
||||
async function setMigrationVersion(storageDir: string, version: number): Promise<void> {
|
||||
const versionFile = path.join(storageDir, 'migration');
|
||||
await fs.writeFile(versionFile, version.toString(), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行迁移
|
||||
* 在存储初始化时调用
|
||||
*/
|
||||
export async function runMigrations(storageDir: string): Promise<void> {
|
||||
// 使用写锁防止并发迁移
|
||||
using _ = await Lock.write(`migration:${storageDir}`);
|
||||
|
||||
const currentVersion = await getMigrationVersion(storageDir);
|
||||
|
||||
for (let i = currentVersion; i < MIGRATIONS.length; i++) {
|
||||
const migration = MIGRATIONS[i];
|
||||
|
||||
try {
|
||||
console.log(`[migration] Running migration ${i}...`);
|
||||
await migration(storageDir);
|
||||
await setMigrationVersion(storageDir, i + 1);
|
||||
console.log(`[migration] Migration ${i} completed`);
|
||||
} catch (error) {
|
||||
console.error(`[migration] Migration ${i} failed:`, error);
|
||||
// 迁移失败不阻塞启动,但记录错误
|
||||
// 下次启动会重试失败的迁移
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取迁移状态
|
||||
*/
|
||||
export async function getMigrationStatus(storageDir: string): Promise<{
|
||||
currentVersion: number;
|
||||
latestVersion: number;
|
||||
pendingMigrations: number;
|
||||
}> {
|
||||
const currentVersion = await getMigrationVersion(storageDir);
|
||||
return {
|
||||
currentVersion,
|
||||
latestVersion: MIGRATIONS.length,
|
||||
pendingMigrations: Math.max(0, MIGRATIONS.length - currentVersion),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Part 基础 Schema
|
||||
*/
|
||||
const PartBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.number(), // Unix timestamp in milliseconds
|
||||
});
|
||||
|
||||
/**
|
||||
* 文本内容 Part
|
||||
*/
|
||||
export const TextPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('text'),
|
||||
text: z.string(),
|
||||
});
|
||||
export type TextPart = z.infer<typeof TextPartSchema>;
|
||||
|
||||
/**
|
||||
* 工具调用状态
|
||||
*/
|
||||
export const ToolStatusSchema = z.enum(['pending', 'running', 'completed', 'error']);
|
||||
export type ToolStatus = z.infer<typeof ToolStatusSchema>;
|
||||
|
||||
/**
|
||||
* 工具调用 Part
|
||||
*/
|
||||
export const ToolPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('tool'),
|
||||
toolCallId: z.string(),
|
||||
toolName: z.string(),
|
||||
args: z.record(z.string(), z.unknown()),
|
||||
status: ToolStatusSchema,
|
||||
result: z.unknown().optional(),
|
||||
error: z.string().optional(),
|
||||
startedAt: z.number().optional(),
|
||||
completedAt: z.number().optional(),
|
||||
});
|
||||
export type ToolPart = z.infer<typeof ToolPartSchema>;
|
||||
|
||||
/**
|
||||
* 推理/思考 Part
|
||||
*/
|
||||
export const ReasoningPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('reasoning'),
|
||||
text: z.string(),
|
||||
});
|
||||
export type ReasoningPart = z.infer<typeof ReasoningPartSchema>;
|
||||
|
||||
/**
|
||||
* 文件/图片附件 Part
|
||||
*/
|
||||
export const FilePartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('file'),
|
||||
filename: z.string(),
|
||||
mimeType: z.string(),
|
||||
data: z.string(), // base64 encoded
|
||||
size: z.number().optional(),
|
||||
});
|
||||
export type FilePart = z.infer<typeof FilePartSchema>;
|
||||
|
||||
/**
|
||||
* 步骤开始标记 Part
|
||||
*/
|
||||
export const StepStartPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('step-start'),
|
||||
stepIndex: z.number(),
|
||||
});
|
||||
export type StepStartPart = z.infer<typeof StepStartPartSchema>;
|
||||
|
||||
/**
|
||||
* 步骤结束 Part(含 token 统计)
|
||||
*/
|
||||
export const StepFinishPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('step-finish'),
|
||||
stepIndex: z.number(),
|
||||
finishReason: z.string().optional(),
|
||||
usage: z
|
||||
.object({
|
||||
promptTokens: z.number(),
|
||||
completionTokens: z.number(),
|
||||
totalTokens: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type StepFinishPart = z.infer<typeof StepFinishPartSchema>;
|
||||
|
||||
/**
|
||||
* Git 快照引用 Part
|
||||
*/
|
||||
export const SnapshotPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('snapshot'),
|
||||
commitHash: z.string(),
|
||||
message: z.string().optional(),
|
||||
});
|
||||
export type SnapshotPart = z.infer<typeof SnapshotPartSchema>;
|
||||
|
||||
/**
|
||||
* Git 补丁引用 Part
|
||||
*/
|
||||
export const PatchPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('patch'),
|
||||
diff: z.string(),
|
||||
files: z.array(z.string()),
|
||||
});
|
||||
export type PatchPart = z.infer<typeof PatchPartSchema>;
|
||||
|
||||
/**
|
||||
* 子 Agent 调用 Part
|
||||
*/
|
||||
export const AgentPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('agent'),
|
||||
agentName: z.string(),
|
||||
sessionId: z.string().optional(), // 子会话 ID
|
||||
status: z.enum(['pending', 'running', 'completed', 'error']),
|
||||
result: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type AgentPart = z.infer<typeof AgentPartSchema>;
|
||||
|
||||
/**
|
||||
* Task 工具委托 Part
|
||||
*/
|
||||
export const SubtaskPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('subtask'),
|
||||
taskId: z.string(),
|
||||
description: z.string(),
|
||||
status: z.enum(['pending', 'running', 'completed', 'error']),
|
||||
result: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type SubtaskPart = z.infer<typeof SubtaskPartSchema>;
|
||||
|
||||
/**
|
||||
* 上下文压缩标记 Part
|
||||
*/
|
||||
export const CompactionPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('compaction'),
|
||||
summary: z.string(),
|
||||
originalTokens: z.number(),
|
||||
compressedTokens: z.number(),
|
||||
});
|
||||
export type CompactionPart = z.infer<typeof CompactionPartSchema>;
|
||||
|
||||
/**
|
||||
* 重试信息 Part
|
||||
*/
|
||||
export const RetryPartSchema = PartBaseSchema.extend({
|
||||
type: z.literal('retry'),
|
||||
reason: z.string(),
|
||||
attemptNumber: z.number(),
|
||||
});
|
||||
export type RetryPart = z.infer<typeof RetryPartSchema>;
|
||||
|
||||
/**
|
||||
* 所有 Part 类型的联合
|
||||
*/
|
||||
export const PartSchema = z.discriminatedUnion('type', [
|
||||
TextPartSchema,
|
||||
ToolPartSchema,
|
||||
ReasoningPartSchema,
|
||||
FilePartSchema,
|
||||
StepStartPartSchema,
|
||||
StepFinishPartSchema,
|
||||
SnapshotPartSchema,
|
||||
PatchPartSchema,
|
||||
AgentPartSchema,
|
||||
SubtaskPartSchema,
|
||||
CompactionPartSchema,
|
||||
RetryPartSchema,
|
||||
]);
|
||||
export type Part = z.infer<typeof PartSchema>;
|
||||
|
||||
/**
|
||||
* Part 类型字面量
|
||||
*/
|
||||
export type PartType = Part['type'];
|
||||
|
||||
/**
|
||||
* 创建 Part 的工厂函数
|
||||
*/
|
||||
export function createPart<T extends PartType>(
|
||||
type: T,
|
||||
data: Omit<Extract<Part, { type: T }>, 'id' | 'createdAt' | 'type'>
|
||||
): Extract<Part, { type: T }> {
|
||||
return {
|
||||
id: generatePartId(),
|
||||
createdAt: Date.now(),
|
||||
type,
|
||||
...data,
|
||||
} as Extract<Part, { type: T }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成 Part ID
|
||||
*/
|
||||
function generatePartId(): string {
|
||||
const timestamp = Date.now();
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `part_${timestamp}_${random}`;
|
||||
}
|
||||
@@ -1,465 +0,0 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import type { ModelMessage } from 'ai';
|
||||
import type {
|
||||
SessionData,
|
||||
SessionMetadata,
|
||||
SessionSummary,
|
||||
StoredMessage,
|
||||
CurrentSessionPointer,
|
||||
ProjectMetadata,
|
||||
} from './types.js';
|
||||
import { Lock } from '../utils/lock.js';
|
||||
import { getProjectId, isGitRepository } from './project.js';
|
||||
import { runMigrations } from './migration.js';
|
||||
|
||||
/**
|
||||
* 获取默认存储目录
|
||||
* 遵循 XDG 规范:~/.local/share/ai-assist/
|
||||
*/
|
||||
function getDefaultStorageDir(): string {
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME;
|
||||
if (xdgDataHome) {
|
||||
return path.join(xdgDataHome, 'ai-assist');
|
||||
}
|
||||
return path.join(os.homedir(), '.local', 'share', 'ai-assist');
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话存储类
|
||||
* 负责会话数据的读写操作,采用分层存储结构
|
||||
*/
|
||||
export class SessionStorage {
|
||||
private storageDir: string;
|
||||
private projectDir: string;
|
||||
private sessionDir: string;
|
||||
private messageDir: string;
|
||||
private currentSessionFile: string;
|
||||
private initialized = false;
|
||||
|
||||
constructor(storageDir?: string) {
|
||||
this.storageDir = storageDir || getDefaultStorageDir();
|
||||
this.projectDir = path.join(this.storageDir, 'project');
|
||||
this.sessionDir = path.join(this.storageDir, 'session');
|
||||
this.messageDir = path.join(this.storageDir, 'message');
|
||||
this.currentSessionFile = path.join(this.storageDir, 'current-session.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保存储目录存在并执行迁移
|
||||
*/
|
||||
async ensureDir(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
|
||||
await fs.mkdir(this.projectDir, { recursive: true });
|
||||
await fs.mkdir(this.sessionDir, { recursive: true });
|
||||
await fs.mkdir(this.messageDir, { recursive: true });
|
||||
|
||||
// 执行数据迁移
|
||||
await runMigrations(this.storageDir);
|
||||
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成会话 ID
|
||||
*/
|
||||
generateSessionId(): string {
|
||||
const now = new Date();
|
||||
const timestamp = now.toISOString().slice(0, 10); // YYYY-MM-DD
|
||||
const random = Math.random().toString(36).substring(2, 8);
|
||||
return `${timestamp}_${random}`;
|
||||
}
|
||||
|
||||
// ========== 项目管理 ==========
|
||||
|
||||
/**
|
||||
* 获取或创建项目
|
||||
*/
|
||||
async getOrCreateProject(workdir: string): Promise<ProjectMetadata> {
|
||||
await this.ensureDir();
|
||||
|
||||
const projectId = await getProjectId(workdir);
|
||||
const projectFile = path.join(this.projectDir, `${projectId}.json`);
|
||||
|
||||
// 先尝试读取(只需读锁)
|
||||
{
|
||||
using _ = await Lock.read(projectFile);
|
||||
try {
|
||||
const content = await fs.readFile(projectFile, 'utf-8');
|
||||
return JSON.parse(content) as ProjectMetadata;
|
||||
} catch {
|
||||
// 项目不存在,需要创建
|
||||
}
|
||||
}
|
||||
|
||||
// 读锁已释放,获取写锁来创建项目
|
||||
using _ = await Lock.write(projectFile);
|
||||
|
||||
// 双重检查:可能其他进程已经创建了
|
||||
try {
|
||||
const content = await fs.readFile(projectFile, 'utf-8');
|
||||
return JSON.parse(content) as ProjectMetadata;
|
||||
} catch {
|
||||
// 确实不存在,创建新项目
|
||||
}
|
||||
|
||||
const isGitRepo = await isGitRepository(workdir);
|
||||
const project: ProjectMetadata = {
|
||||
id: projectId,
|
||||
workdir,
|
||||
createdAt: new Date().toISOString(),
|
||||
isGitRepo,
|
||||
};
|
||||
|
||||
await fs.writeFile(projectFile, JSON.stringify(project, null, 2), 'utf-8');
|
||||
|
||||
// 确保项目的会话目录存在
|
||||
await fs.mkdir(path.join(this.sessionDir, projectId), { recursive: true });
|
||||
|
||||
return project;
|
||||
}
|
||||
|
||||
// ========== 会话元数据管理 ==========
|
||||
|
||||
/**
|
||||
* 保存会话元数据
|
||||
*/
|
||||
async saveSessionMetadata(session: SessionData): Promise<void> {
|
||||
await this.ensureDir();
|
||||
|
||||
const metadata: SessionMetadata = {
|
||||
id: session.id,
|
||||
projectId: session.projectId,
|
||||
parentId: session.parentId,
|
||||
agentName: session.agentName,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: new Date().toISOString(),
|
||||
workdir: session.workdir,
|
||||
title: session.title,
|
||||
messageCount: session.messages.length,
|
||||
discoveredTools: session.discoveredTools,
|
||||
todos: session.todos,
|
||||
};
|
||||
|
||||
const sessionFile = path.join(this.sessionDir, session.projectId, `${session.id}.json`);
|
||||
|
||||
// 确保项目目录存在
|
||||
await fs.mkdir(path.join(this.sessionDir, session.projectId), { recursive: true });
|
||||
|
||||
using _ = await Lock.write(sessionFile);
|
||||
await fs.writeFile(sessionFile, JSON.stringify(metadata, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载会话元数据
|
||||
*/
|
||||
async loadSessionMetadata(projectId: string, sessionId: string): Promise<SessionMetadata | null> {
|
||||
await this.ensureDir();
|
||||
|
||||
const sessionFile = path.join(this.sessionDir, projectId, `${sessionId}.json`);
|
||||
|
||||
using _ = await Lock.read(sessionFile);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(sessionFile, 'utf-8');
|
||||
return JSON.parse(content) as SessionMetadata;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 消息管理 ==========
|
||||
|
||||
/**
|
||||
* 追加消息
|
||||
*/
|
||||
async appendMessage(sessionId: string, message: ModelMessage, index: number): Promise<void> {
|
||||
await this.ensureDir();
|
||||
|
||||
const sessionMessageDir = path.join(this.messageDir, sessionId);
|
||||
await fs.mkdir(sessionMessageDir, { recursive: true });
|
||||
|
||||
const messageFile = path.join(sessionMessageDir, `${String(index).padStart(4, '0')}.json`);
|
||||
|
||||
const storedMessage: StoredMessage = {
|
||||
index,
|
||||
role: message.role as StoredMessage['role'],
|
||||
content: message.content,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
using _ = await Lock.write(messageFile);
|
||||
await fs.writeFile(messageFile, JSON.stringify(storedMessage, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量追加消息(用于增量同步)
|
||||
*/
|
||||
async syncMessages(sessionId: string, messages: ModelMessage[], startIndex: number): Promise<void> {
|
||||
for (let i = startIndex; i < messages.length; i++) {
|
||||
await this.appendMessage(sessionId, messages[i], i + 1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载会话的所有消息
|
||||
*/
|
||||
async loadMessages(sessionId: string): Promise<ModelMessage[]> {
|
||||
await this.ensureDir();
|
||||
|
||||
const sessionMessageDir = path.join(this.messageDir, sessionId);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(sessionMessageDir);
|
||||
const messageFiles = files.filter((f) => f.endsWith('.json')).sort();
|
||||
|
||||
const messages: ModelMessage[] = [];
|
||||
|
||||
for (const file of messageFiles) {
|
||||
const filePath = path.join(sessionMessageDir, file);
|
||||
using _ = await Lock.read(filePath);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const stored = JSON.parse(content) as StoredMessage;
|
||||
messages.push({
|
||||
role: stored.role,
|
||||
content: stored.content,
|
||||
} as ModelMessage);
|
||||
}
|
||||
|
||||
return messages;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话的所有消息
|
||||
*/
|
||||
async deleteMessages(sessionId: string): Promise<void> {
|
||||
const sessionMessageDir = path.join(this.messageDir, sessionId);
|
||||
|
||||
try {
|
||||
await fs.rm(sessionMessageDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 完整会话操作 ==========
|
||||
|
||||
/**
|
||||
* 保存完整会话(元数据 + 消息)
|
||||
*/
|
||||
async saveSession(session: SessionData, lastSyncedCount: number = 0): Promise<void> {
|
||||
// 增量同步消息
|
||||
await this.syncMessages(session.id, session.messages, lastSyncedCount);
|
||||
// 保存元数据
|
||||
await this.saveSessionMetadata(session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载完整会话
|
||||
*/
|
||||
async loadSession(projectId: string, sessionId: string): Promise<SessionData | null> {
|
||||
const metadata = await this.loadSessionMetadata(projectId, sessionId);
|
||||
if (!metadata) return null;
|
||||
|
||||
const messages = await this.loadMessages(sessionId);
|
||||
|
||||
return {
|
||||
id: metadata.id,
|
||||
projectId: metadata.projectId,
|
||||
parentId: metadata.parentId,
|
||||
agentName: metadata.agentName,
|
||||
createdAt: metadata.createdAt,
|
||||
updatedAt: metadata.updatedAt,
|
||||
workdir: metadata.workdir,
|
||||
title: metadata.title,
|
||||
discoveredTools: metadata.discoveredTools,
|
||||
todos: metadata.todos,
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
async deleteSession(projectId: string, sessionId: string): Promise<boolean> {
|
||||
try {
|
||||
// 删除消息
|
||||
await this.deleteMessages(sessionId);
|
||||
|
||||
// 删除元数据
|
||||
const sessionFile = path.join(this.sessionDir, projectId, `${sessionId}.json`);
|
||||
await fs.unlink(sessionFile);
|
||||
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 当前会话管理 ==========
|
||||
|
||||
/**
|
||||
* 设置当前会话
|
||||
*/
|
||||
async setCurrentSession(sessionId: string): Promise<void> {
|
||||
await this.ensureDir();
|
||||
|
||||
const pointer: CurrentSessionPointer = { sessionId };
|
||||
|
||||
using _ = await Lock.write(this.currentSessionFile);
|
||||
await fs.writeFile(this.currentSessionFile, JSON.stringify(pointer, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前会话 ID
|
||||
*/
|
||||
async getCurrentSessionId(): Promise<string | null> {
|
||||
await this.ensureDir();
|
||||
|
||||
using _ = await Lock.read(this.currentSessionFile);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(this.currentSessionFile, 'utf-8');
|
||||
const pointer = JSON.parse(content) as CurrentSessionPointer;
|
||||
return pointer.sessionId;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除当前会话指针
|
||||
*/
|
||||
async clearCurrentSession(): Promise<void> {
|
||||
try {
|
||||
await fs.unlink(this.currentSessionFile);
|
||||
} catch {
|
||||
// 文件不存在,忽略
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 会话列表 ==========
|
||||
|
||||
/**
|
||||
* 列出项目的所有会话
|
||||
*/
|
||||
async listSessionsByProject(projectId: string): Promise<SessionSummary[]> {
|
||||
await this.ensureDir();
|
||||
|
||||
const projectSessionDir = path.join(this.sessionDir, projectId);
|
||||
|
||||
try {
|
||||
const files = await fs.readdir(projectSessionDir);
|
||||
const summaries: SessionSummary[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith('.json')) continue;
|
||||
|
||||
try {
|
||||
const filePath = path.join(projectSessionDir, file);
|
||||
using _ = await Lock.read(filePath);
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
const metadata = JSON.parse(content) as SessionMetadata;
|
||||
|
||||
summaries.push({
|
||||
id: metadata.id,
|
||||
title: metadata.title || this.generateTitleFromId(metadata.id),
|
||||
workdir: metadata.workdir,
|
||||
messageCount: metadata.messageCount,
|
||||
createdAt: metadata.createdAt,
|
||||
updatedAt: metadata.updatedAt,
|
||||
});
|
||||
} catch {
|
||||
// 跳过无法解析的文件
|
||||
}
|
||||
}
|
||||
|
||||
// 按更新时间降序排列
|
||||
return summaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有会话(跨项目)
|
||||
*/
|
||||
async listAllSessions(): Promise<SessionSummary[]> {
|
||||
await this.ensureDir();
|
||||
|
||||
const allSummaries: SessionSummary[] = [];
|
||||
|
||||
try {
|
||||
const projectDirs = await fs.readdir(this.sessionDir);
|
||||
|
||||
for (const projectId of projectDirs) {
|
||||
const summaries = await this.listSessionsByProject(projectId);
|
||||
allSummaries.push(...summaries);
|
||||
}
|
||||
|
||||
// 按更新时间降序排列
|
||||
return allSummaries.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧会话(保留最近 N 个)
|
||||
*/
|
||||
async cleanupOldSessions(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) {
|
||||
// 需要找到对应的 projectId
|
||||
// 这里通过遍历项目目录来查找
|
||||
try {
|
||||
const projectDirs = await fs.readdir(this.sessionDir);
|
||||
for (const projectId of projectDirs) {
|
||||
const sessionFile = path.join(this.sessionDir, projectId, `${session.id}.json`);
|
||||
try {
|
||||
await fs.access(sessionFile);
|
||||
if (await this.deleteSession(projectId, session.id)) {
|
||||
deletedCount++;
|
||||
}
|
||||
break;
|
||||
} catch {
|
||||
// 不在这个项目目录,继续查找
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
return deletedCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从会话 ID 生成标题
|
||||
*/
|
||||
private generateTitleFromId(sessionId: string): string {
|
||||
return `会话 ${sessionId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取存储目录路径
|
||||
*/
|
||||
getStorageDir(): string {
|
||||
return this.storageDir;
|
||||
}
|
||||
}
|
||||
|
||||
// 导出默认实例
|
||||
export const sessionStorage = new SessionStorage();
|
||||
@@ -0,0 +1,248 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import * as os from 'os';
|
||||
import { Lock } from '../../utils/lock.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 存储错误类型
|
||||
*/
|
||||
export class StorageNotFoundError extends Error {
|
||||
constructor(
|
||||
public readonly key: string[],
|
||||
message?: string
|
||||
) {
|
||||
super(message || `Resource not found: ${key.join('/')}`);
|
||||
this.name = 'StorageNotFoundError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取默认存储目录
|
||||
* 遵循 XDG 规范:~/.local/share/ai-assist/
|
||||
*/
|
||||
export function getDefaultStorageDir(): string {
|
||||
const xdgDataHome = process.env.XDG_DATA_HOME;
|
||||
if (xdgDataHome) {
|
||||
return path.join(xdgDataHome, 'ai-assist');
|
||||
}
|
||||
return path.join(os.homedir(), '.local', 'share', 'ai-assist');
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储状态
|
||||
*/
|
||||
interface StorageState {
|
||||
dir: string;
|
||||
initialized: boolean;
|
||||
}
|
||||
|
||||
let storageState: StorageState | null = null;
|
||||
|
||||
/**
|
||||
* 初始化存储
|
||||
*/
|
||||
export async function initStorage(storageDir?: string): Promise<string> {
|
||||
if (storageState?.initialized) {
|
||||
return storageState.dir;
|
||||
}
|
||||
|
||||
const dir = storageDir || getDefaultStorageDir();
|
||||
|
||||
// 创建必要的目录
|
||||
await fs.mkdir(path.join(dir, 'project'), { recursive: true });
|
||||
await fs.mkdir(path.join(dir, 'session'), { recursive: true });
|
||||
await fs.mkdir(path.join(dir, 'message'), { recursive: true });
|
||||
await fs.mkdir(path.join(dir, 'part'), { recursive: true });
|
||||
await fs.mkdir(path.join(dir, 'todo'), { recursive: true });
|
||||
|
||||
storageState = { dir, initialized: true };
|
||||
return dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取存储目录
|
||||
*/
|
||||
export async function getStorageDir(): Promise<string> {
|
||||
if (!storageState?.initialized) {
|
||||
return initStorage();
|
||||
}
|
||||
return storageState.dir;
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置存储状态(用于测试)
|
||||
*/
|
||||
export function resetStorageState(): void {
|
||||
storageState = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 JSON 文件
|
||||
* @param key 路径键数组,如 ['session', 'projectId', 'sessionId']
|
||||
*/
|
||||
export async function read<T>(key: string[], schema?: z.ZodType<T>): Promise<T> {
|
||||
const dir = await getStorageDir();
|
||||
const target = path.join(dir, ...key) + '.json';
|
||||
|
||||
using _ = await Lock.read(target);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(target, 'utf-8');
|
||||
const data = JSON.parse(content);
|
||||
|
||||
if (schema) {
|
||||
return schema.parse(data);
|
||||
}
|
||||
return data as T;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && (e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new StorageNotFoundError(key);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入 JSON 文件
|
||||
* @param key 路径键数组
|
||||
* @param content 要写入的内容
|
||||
*/
|
||||
export async function write<T>(key: string[], content: T): Promise<void> {
|
||||
const dir = await getStorageDir();
|
||||
const target = path.join(dir, ...key) + '.json';
|
||||
|
||||
// 确保父目录存在
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
|
||||
using _ = await Lock.write(target);
|
||||
await fs.writeFile(target, JSON.stringify(content, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 JSON 文件
|
||||
* @param key 路径键数组
|
||||
* @param fn 更新函数
|
||||
*/
|
||||
export async function update<T>(key: string[], fn: (draft: T) => void): Promise<T> {
|
||||
const dir = await getStorageDir();
|
||||
const target = path.join(dir, ...key) + '.json';
|
||||
|
||||
using _ = await Lock.write(target);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(target, 'utf-8');
|
||||
const data = JSON.parse(content) as T;
|
||||
fn(data);
|
||||
await fs.writeFile(target, JSON.stringify(data, null, 2), 'utf-8');
|
||||
return data;
|
||||
} catch (e) {
|
||||
if (e instanceof Error && 'code' in e && (e as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new StorageNotFoundError(key);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 JSON 文件
|
||||
* @param key 路径键数组
|
||||
*/
|
||||
export async function remove(key: string[]): Promise<void> {
|
||||
const dir = await getStorageDir();
|
||||
const target = path.join(dir, ...key) + '.json';
|
||||
|
||||
try {
|
||||
await fs.unlink(target);
|
||||
} catch {
|
||||
// 忽略不存在的文件
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除目录
|
||||
* @param key 路径键数组
|
||||
*/
|
||||
export async function removeDir(key: string[]): Promise<void> {
|
||||
const dir = await getStorageDir();
|
||||
const target = path.join(dir, ...key);
|
||||
|
||||
try {
|
||||
await fs.rm(target, { recursive: true, force: true });
|
||||
} catch {
|
||||
// 忽略错误
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录下的所有 key
|
||||
* @param prefix 路径前缀
|
||||
* @returns 完整的 key 路径数组
|
||||
*/
|
||||
export async function list(prefix: string[]): Promise<string[][]> {
|
||||
const dir = await getStorageDir();
|
||||
const target = path.join(dir, ...prefix);
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(target, { withFileTypes: true });
|
||||
const results: string[][] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||
// 移除 .json 扩展名
|
||||
const name = entry.name.slice(0, -5);
|
||||
results.push([...prefix, name]);
|
||||
} else if (entry.isDirectory()) {
|
||||
// 递归列出子目录
|
||||
const subResults = await list([...prefix, entry.name]);
|
||||
results.push(...subResults);
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出目录下的直接子项(不递归)
|
||||
* @param prefix 路径前缀
|
||||
* @returns 子项名称数组
|
||||
*/
|
||||
export async function listDirect(prefix: string[]): Promise<string[]> {
|
||||
const dir = await getStorageDir();
|
||||
const target = path.join(dir, ...prefix);
|
||||
|
||||
try {
|
||||
const entries = await fs.readdir(target, { withFileTypes: true });
|
||||
const results: string[] = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (entry.isFile() && entry.name.endsWith('.json')) {
|
||||
results.push(entry.name.slice(0, -5));
|
||||
} else if (entry.isDirectory()) {
|
||||
results.push(entry.name);
|
||||
}
|
||||
}
|
||||
|
||||
return results.sort();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
*/
|
||||
export async function exists(key: string[]): Promise<boolean> {
|
||||
const dir = await getStorageDir();
|
||||
const target = path.join(dir, ...key) + '.json';
|
||||
|
||||
try {
|
||||
await fs.access(target);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// Base storage utilities
|
||||
export {
|
||||
StorageNotFoundError,
|
||||
getDefaultStorageDir,
|
||||
initStorage,
|
||||
getStorageDir,
|
||||
resetStorageState,
|
||||
read,
|
||||
write,
|
||||
update,
|
||||
remove,
|
||||
removeDir,
|
||||
list,
|
||||
listDirect,
|
||||
exists,
|
||||
} from './base.js';
|
||||
|
||||
// Session storage
|
||||
export * as SessionStorage from './session.js';
|
||||
export type { SessionInfo } from './session.js';
|
||||
export { SessionInfoSchema } from './session.js';
|
||||
|
||||
// Message storage
|
||||
export * as MessageStorage from './message.js';
|
||||
export type { MessageInfo } from './message.js';
|
||||
|
||||
// Part storage
|
||||
export * as PartStorage from './part.js';
|
||||
export type { Part, PartType, ToolPart, ToolStatus } from './part.js';
|
||||
|
||||
// Todo storage
|
||||
export * as TodoStorage from './todo.js';
|
||||
export type { TodoItem, TodoList, TodoStatus } from './todo.js';
|
||||
export { TodoItemSchema, TodoListSchema, TodoStatusSchema } from './todo.js';
|
||||
@@ -0,0 +1,118 @@
|
||||
import { z } from 'zod';
|
||||
import * as base from './base.js';
|
||||
import { generateMessageId } from '../id.js';
|
||||
import type { MessageInfo } from '../message.js';
|
||||
import { MessageInfoSchema } from '../message.js';
|
||||
|
||||
// Re-export types
|
||||
export type { MessageInfo } from '../message.js';
|
||||
|
||||
/**
|
||||
* 创建消息
|
||||
*/
|
||||
export async function create(
|
||||
sessionId: string,
|
||||
role: MessageInfo['role'],
|
||||
options?: {
|
||||
partIds?: string[];
|
||||
metadata?: MessageInfo['metadata'];
|
||||
}
|
||||
): Promise<MessageInfo> {
|
||||
const message: MessageInfo = {
|
||||
id: generateMessageId(),
|
||||
sessionId,
|
||||
role,
|
||||
createdAt: Date.now(),
|
||||
partIds: options?.partIds || [],
|
||||
metadata: options?.metadata,
|
||||
};
|
||||
|
||||
await base.write(['message', sessionId, message.id], message);
|
||||
return message;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取消息
|
||||
*/
|
||||
export async function get(sessionId: string, messageId: string): Promise<MessageInfo | null> {
|
||||
try {
|
||||
return await base.read(['message', sessionId, messageId], MessageInfoSchema);
|
||||
} catch (e) {
|
||||
if (e instanceof base.StorageNotFoundError) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存消息
|
||||
*/
|
||||
export async function save(message: MessageInfo): Promise<void> {
|
||||
await base.write(['message', message.sessionId, message.id], message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新消息
|
||||
*/
|
||||
export async function update(
|
||||
sessionId: string,
|
||||
messageId: string,
|
||||
updates: Partial<Omit<MessageInfo, 'id' | 'sessionId' | 'createdAt'>>
|
||||
): Promise<MessageInfo> {
|
||||
return base.update(['message', sessionId, messageId], (message: MessageInfo) => {
|
||||
Object.assign(message, updates);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 Part 到消息
|
||||
*/
|
||||
export async function addPart(sessionId: string, messageId: string, partId: string): Promise<void> {
|
||||
await base.update(['message', sessionId, messageId], (message: MessageInfo) => {
|
||||
message.partIds.push(partId);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息
|
||||
*/
|
||||
export async function remove(sessionId: string, messageId: string): Promise<void> {
|
||||
await base.remove(['message', sessionId, messageId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出会话的所有消息
|
||||
*/
|
||||
export async function listBySession(sessionId: string): Promise<MessageInfo[]> {
|
||||
const keys = await base.list(['message', sessionId]);
|
||||
const messages: MessageInfo[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const message = await base.read<MessageInfo>(key, MessageInfoSchema);
|
||||
messages.push(message);
|
||||
} catch {
|
||||
// 跳过无法解析的文件
|
||||
}
|
||||
}
|
||||
|
||||
// 消息 ID 是降序的,所以排序后最新的在后面(时间升序)
|
||||
// 实际上按 ID 字符串排序即可,因为降序 ID 自然会让旧消息在前
|
||||
return messages.sort((a, b) => b.id.localeCompare(a.id)).reverse();
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话的所有消息
|
||||
*/
|
||||
export async function removeBySession(sessionId: string): Promise<void> {
|
||||
await base.removeDir(['message', sessionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话的消息数量
|
||||
*/
|
||||
export async function countBySession(sessionId: string): Promise<number> {
|
||||
const keys = await base.list(['message', sessionId]);
|
||||
return keys.length;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as base from './base.js';
|
||||
import { generatePartId } from '../id.js';
|
||||
import type { Part, PartType, ToolPart, ToolStatus } from '../parts.js';
|
||||
import { PartSchema } from '../parts.js';
|
||||
|
||||
// Re-export types
|
||||
export type { Part, PartType, ToolPart, ToolStatus } from '../parts.js';
|
||||
|
||||
/**
|
||||
* 创建 Part
|
||||
*/
|
||||
export async function create<T extends Part>(
|
||||
messageId: string,
|
||||
type: T['type'],
|
||||
data: Omit<T, 'id' | 'createdAt' | 'type'>
|
||||
): Promise<T> {
|
||||
const part = {
|
||||
id: generatePartId(),
|
||||
createdAt: Date.now(),
|
||||
type,
|
||||
...data,
|
||||
} as T;
|
||||
|
||||
await base.write(['part', messageId, part.id], part);
|
||||
return part;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Part
|
||||
*/
|
||||
export async function get(messageId: string, partId: string): Promise<Part | null> {
|
||||
try {
|
||||
return await base.read(['part', messageId, partId], PartSchema);
|
||||
} catch (e) {
|
||||
if (e instanceof base.StorageNotFoundError) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Part
|
||||
*/
|
||||
export async function save(messageId: string, part: Part): Promise<void> {
|
||||
await base.write(['part', messageId, part.id], part);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Part
|
||||
*/
|
||||
export async function update<T extends Part>(
|
||||
messageId: string,
|
||||
partId: string,
|
||||
updates: Partial<Omit<T, 'id' | 'createdAt' | 'type'>>
|
||||
): Promise<Part> {
|
||||
return base.update(['part', messageId, partId], (part: Part) => {
|
||||
Object.assign(part, updates);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 ToolPart 状态
|
||||
*/
|
||||
export async function updateToolStatus(
|
||||
messageId: string,
|
||||
partId: string,
|
||||
status: ToolStatus,
|
||||
result?: unknown,
|
||||
error?: string
|
||||
): Promise<ToolPart> {
|
||||
return base.update(['part', messageId, partId], (part: ToolPart) => {
|
||||
part.status = status;
|
||||
if (status === 'running') {
|
||||
part.startedAt = Date.now();
|
||||
}
|
||||
if (status === 'completed' || status === 'error') {
|
||||
part.completedAt = Date.now();
|
||||
if (result !== undefined) {
|
||||
part.result = result;
|
||||
}
|
||||
if (error !== undefined) {
|
||||
part.error = error;
|
||||
}
|
||||
}
|
||||
}) as Promise<ToolPart>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Part
|
||||
*/
|
||||
export async function remove(messageId: string, partId: string): Promise<void> {
|
||||
await base.remove(['part', messageId, partId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出消息的所有 Parts
|
||||
*/
|
||||
export async function listByMessage(messageId: string): Promise<Part[]> {
|
||||
const keys = await base.list(['part', messageId]);
|
||||
const parts: Part[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const part = await base.read<Part>(key, PartSchema);
|
||||
parts.push(part);
|
||||
} catch {
|
||||
// 跳过无法解析的文件
|
||||
}
|
||||
}
|
||||
|
||||
// 按创建时间升序排列(Part ID 是升序的)
|
||||
return parts.sort((a, b) => a.createdAt - b.createdAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 Part IDs 获取 Parts(保持顺序)
|
||||
*/
|
||||
export async function getByIds(messageId: string, partIds: string[]): Promise<Part[]> {
|
||||
const parts: Part[] = [];
|
||||
|
||||
for (const partId of partIds) {
|
||||
const part = await get(messageId, partId);
|
||||
if (part) {
|
||||
parts.push(part);
|
||||
}
|
||||
}
|
||||
|
||||
return parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除消息的所有 Parts
|
||||
*/
|
||||
export async function removeByMessage(messageId: string): Promise<void> {
|
||||
await base.removeDir(['part', messageId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建文本 Part
|
||||
*/
|
||||
export async function createText(messageId: string, text: string): Promise<Part> {
|
||||
return create(messageId, 'text', { text });
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建工具调用 Part
|
||||
*/
|
||||
export async function createTool(
|
||||
messageId: string,
|
||||
toolCallId: string,
|
||||
toolName: string,
|
||||
args: Record<string, unknown>
|
||||
): Promise<ToolPart> {
|
||||
return create<ToolPart>(messageId, 'tool', {
|
||||
toolCallId,
|
||||
toolName,
|
||||
args,
|
||||
status: 'pending',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建推理 Part
|
||||
*/
|
||||
export async function createReasoning(messageId: string, text: string): Promise<Part> {
|
||||
return create(messageId, 'reasoning', { text });
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import { z } from 'zod';
|
||||
import * as base from './base.js';
|
||||
import { generateSessionId } from '../id.js';
|
||||
|
||||
/**
|
||||
* Session Info Schema
|
||||
*/
|
||||
export const SessionInfoSchema = z.object({
|
||||
id: z.string(),
|
||||
projectId: z.string(),
|
||||
parentId: z.string().optional(),
|
||||
agentName: z.string().optional(),
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
workdir: z.string(),
|
||||
title: z.string().optional(),
|
||||
discoveredTools: z.array(z.string()).default([]),
|
||||
// 统计信息
|
||||
stats: z
|
||||
.object({
|
||||
messageCount: z.number(),
|
||||
inputTokens: z.number(),
|
||||
outputTokens: z.number(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
export type SessionInfo = z.infer<typeof SessionInfoSchema>;
|
||||
|
||||
/**
|
||||
* 创建会话
|
||||
*/
|
||||
export async function create(
|
||||
projectId: string,
|
||||
workdir: string,
|
||||
options?: {
|
||||
parentId?: string;
|
||||
agentName?: string;
|
||||
title?: string;
|
||||
}
|
||||
): Promise<SessionInfo> {
|
||||
const session: SessionInfo = {
|
||||
id: generateSessionId(),
|
||||
projectId,
|
||||
workdir,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
parentId: options?.parentId,
|
||||
agentName: options?.agentName,
|
||||
title: options?.title,
|
||||
discoveredTools: [],
|
||||
};
|
||||
|
||||
await base.write(['session', projectId, session.id], session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取会话
|
||||
*/
|
||||
export async function get(projectId: string, sessionId: string): Promise<SessionInfo | null> {
|
||||
try {
|
||||
return await base.read(['session', projectId, sessionId], SessionInfoSchema);
|
||||
} catch (e) {
|
||||
if (e instanceof base.StorageNotFoundError) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话
|
||||
*/
|
||||
export async function update(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
updates: Partial<Omit<SessionInfo, 'id' | 'projectId' | 'createdAt'>>
|
||||
): Promise<SessionInfo> {
|
||||
return base.update(['session', projectId, sessionId], (session: SessionInfo) => {
|
||||
Object.assign(session, updates, { updatedAt: Date.now() });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话
|
||||
*/
|
||||
export async function save(session: SessionInfo): Promise<void> {
|
||||
session.updatedAt = Date.now();
|
||||
await base.write(['session', session.projectId, session.id], session);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话
|
||||
*/
|
||||
export async function remove(projectId: string, sessionId: string): Promise<void> {
|
||||
await base.remove(['session', projectId, sessionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目的所有会话
|
||||
*/
|
||||
export async function listByProject(projectId: string): Promise<SessionInfo[]> {
|
||||
const keys = await base.list(['session', projectId]);
|
||||
const sessions: SessionInfo[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const session = await base.read<SessionInfo>(key, SessionInfoSchema);
|
||||
sessions.push(session);
|
||||
} catch {
|
||||
// 跳过无法解析的文件
|
||||
}
|
||||
}
|
||||
|
||||
// 按更新时间降序排列
|
||||
return sessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出所有会话(跨项目)
|
||||
*/
|
||||
export async function listAll(): Promise<SessionInfo[]> {
|
||||
const projectIds = await base.listDirect(['session']);
|
||||
const allSessions: SessionInfo[] = [];
|
||||
|
||||
for (const projectId of projectIds) {
|
||||
const sessions = await listByProject(projectId);
|
||||
allSessions.push(...sessions);
|
||||
}
|
||||
|
||||
// 按更新时间降序排列
|
||||
return allSessions.sort((a, b) => b.updatedAt - a.updatedAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话标题
|
||||
*/
|
||||
export async function updateTitle(projectId: string, sessionId: string, title: string): Promise<void> {
|
||||
await update(projectId, sessionId, { title });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新会话统计
|
||||
*/
|
||||
export async function updateStats(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
stats: SessionInfo['stats']
|
||||
): Promise<void> {
|
||||
await update(projectId, sessionId, { stats });
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加已发现的工具
|
||||
*/
|
||||
export async function addDiscoveredTool(
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
toolName: string
|
||||
): Promise<void> {
|
||||
await base.update(['session', projectId, sessionId], (session: SessionInfo) => {
|
||||
if (!session.discoveredTools.includes(toolName)) {
|
||||
session.discoveredTools.push(toolName);
|
||||
}
|
||||
session.updatedAt = Date.now();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { z } from 'zod';
|
||||
import * as base from './base.js';
|
||||
import { generateTodoId } from '../id.js';
|
||||
|
||||
/**
|
||||
* Todo 状态
|
||||
*/
|
||||
export const TodoStatusSchema = z.enum(['pending', 'in_progress', 'completed']);
|
||||
export type TodoStatus = z.infer<typeof TodoStatusSchema>;
|
||||
|
||||
/**
|
||||
* Todo Item Schema
|
||||
*/
|
||||
export const TodoItemSchema = z.object({
|
||||
id: z.string(),
|
||||
content: z.string(),
|
||||
status: TodoStatusSchema,
|
||||
createdAt: z.number(),
|
||||
updatedAt: z.number(),
|
||||
});
|
||||
export type TodoItem = z.infer<typeof TodoItemSchema>;
|
||||
|
||||
/**
|
||||
* Todo List Schema (存储在单个文件中)
|
||||
*/
|
||||
export const TodoListSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
items: z.array(TodoItemSchema),
|
||||
updatedAt: z.number(),
|
||||
});
|
||||
export type TodoList = z.infer<typeof TodoListSchema>;
|
||||
|
||||
/**
|
||||
* 获取会话的 Todo 列表
|
||||
*/
|
||||
export async function get(sessionId: string): Promise<TodoList | null> {
|
||||
try {
|
||||
return await base.read(['todo', sessionId], TodoListSchema);
|
||||
} catch (e) {
|
||||
if (e instanceof base.StorageNotFoundError) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取或创建 Todo 列表
|
||||
*/
|
||||
export async function getOrCreate(sessionId: string): Promise<TodoList> {
|
||||
const existing = await get(sessionId);
|
||||
if (existing) return existing;
|
||||
|
||||
const todoList: TodoList = {
|
||||
sessionId,
|
||||
items: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await base.write(['todo', sessionId], todoList);
|
||||
return todoList;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存 Todo 列表
|
||||
*/
|
||||
export async function save(todoList: TodoList): Promise<void> {
|
||||
todoList.updatedAt = Date.now();
|
||||
await base.write(['todo', todoList.sessionId], todoList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 Todo 项
|
||||
*/
|
||||
export async function add(sessionId: string, content: string): Promise<TodoItem> {
|
||||
const todoList = await getOrCreate(sessionId);
|
||||
|
||||
const item: TodoItem = {
|
||||
id: generateTodoId(),
|
||||
content,
|
||||
status: 'pending',
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
todoList.items.push(item);
|
||||
await save(todoList);
|
||||
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量添加 Todo 项
|
||||
*/
|
||||
export async function addMany(sessionId: string, contents: string[]): Promise<TodoItem[]> {
|
||||
const todoList = await getOrCreate(sessionId);
|
||||
const now = Date.now();
|
||||
|
||||
const items: TodoItem[] = contents.map((content) => ({
|
||||
id: generateTodoId(),
|
||||
content,
|
||||
status: 'pending' as TodoStatus,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}));
|
||||
|
||||
todoList.items.push(...items);
|
||||
await save(todoList);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Todo 项状态
|
||||
*/
|
||||
export async function updateStatus(sessionId: string, todoId: string, status: TodoStatus): Promise<TodoItem | null> {
|
||||
const todoList = await get(sessionId);
|
||||
if (!todoList) return null;
|
||||
|
||||
const item = todoList.items.find((t) => t.id === todoId);
|
||||
if (!item) return null;
|
||||
|
||||
item.status = status;
|
||||
item.updatedAt = Date.now();
|
||||
|
||||
await save(todoList);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新 Todo 项内容
|
||||
*/
|
||||
export async function updateContent(sessionId: string, todoId: string, content: string): Promise<TodoItem | null> {
|
||||
const todoList = await get(sessionId);
|
||||
if (!todoList) return null;
|
||||
|
||||
const item = todoList.items.find((t) => t.id === todoId);
|
||||
if (!item) return null;
|
||||
|
||||
item.content = content;
|
||||
item.updatedAt = Date.now();
|
||||
|
||||
await save(todoList);
|
||||
return item;
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除 Todo 项
|
||||
*/
|
||||
export async function remove(sessionId: string, todoId: string): Promise<boolean> {
|
||||
const todoList = await get(sessionId);
|
||||
if (!todoList) return false;
|
||||
|
||||
const index = todoList.items.findIndex((t) => t.id === todoId);
|
||||
if (index === -1) return false;
|
||||
|
||||
todoList.items.splice(index, 1);
|
||||
await save(todoList);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空 Todo 列表
|
||||
*/
|
||||
export async function clear(sessionId: string): Promise<void> {
|
||||
const todoList = await get(sessionId);
|
||||
if (!todoList) return;
|
||||
|
||||
todoList.items = [];
|
||||
await save(todoList);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除会话的 Todo 列表
|
||||
*/
|
||||
export async function removeBySession(sessionId: string): Promise<void> {
|
||||
await base.remove(['todo', sessionId]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换整个 Todo 列表
|
||||
*/
|
||||
export async function replace(sessionId: string, items: Omit<TodoItem, 'id' | 'createdAt' | 'updatedAt'>[]): Promise<TodoList> {
|
||||
const now = Date.now();
|
||||
const todoList: TodoList = {
|
||||
sessionId,
|
||||
items: items.map((item) => ({
|
||||
id: generateTodoId(),
|
||||
content: item.content,
|
||||
status: item.status,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})),
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await base.write(['todo', sessionId], todoList);
|
||||
return todoList;
|
||||
}
|
||||
@@ -1,110 +0,0 @@
|
||||
import type { ModelMessage } from 'ai';
|
||||
|
||||
/**
|
||||
* 待办项状态
|
||||
*/
|
||||
export type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
/**
|
||||
* 待办项
|
||||
*/
|
||||
export interface Todo {
|
||||
id: string;
|
||||
content: string;
|
||||
status: TodoStatus;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话元数据(存储格式,不含消息内容)
|
||||
*/
|
||||
export interface SessionMetadata {
|
||||
/** 会话 ID */
|
||||
id: string;
|
||||
/** 项目 ID */
|
||||
projectId: string;
|
||||
/** 父会话 ID(子会话时存在) */
|
||||
parentId?: string;
|
||||
/** 关联的 Agent 名称(子会话时存在) */
|
||||
agentName?: string;
|
||||
/** 创建时间 */
|
||||
createdAt: string;
|
||||
/** 最后更新时间 */
|
||||
updatedAt: string;
|
||||
/** 工作目录 */
|
||||
workdir: string;
|
||||
/** 会话标题(可选,从第一条消息生成) */
|
||||
title?: string;
|
||||
/** 消息数量 */
|
||||
messageCount: number;
|
||||
/** 已发现的工具 */
|
||||
discoveredTools: string[];
|
||||
/** 待办事项 */
|
||||
todos: Todo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储的消息格式
|
||||
*/
|
||||
export interface StoredMessage {
|
||||
/** 消息序号 (1-based) */
|
||||
index: number;
|
||||
/** 消息角色 */
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
/** 消息内容 */
|
||||
content: ModelMessage['content'];
|
||||
/** 创建时间 */
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话数据(运行时格式,包含消息内容)
|
||||
*/
|
||||
export interface SessionData extends Omit<SessionMetadata, 'messageCount'> {
|
||||
/** 对话历史 */
|
||||
messages: ModelMessage[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话摘要(用于列表展示)
|
||||
*/
|
||||
export interface SessionSummary {
|
||||
id: string;
|
||||
title: string;
|
||||
workdir: string;
|
||||
messageCount: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前会话指针(存储在 current-session.json)
|
||||
*/
|
||||
export interface CurrentSessionPointer {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目元数据
|
||||
*/
|
||||
export interface ProjectMetadata {
|
||||
/** 项目 ID (Git root commit hash 或路径 hash) */
|
||||
id: string;
|
||||
/** 工作目录 */
|
||||
workdir: string;
|
||||
/** 创建时间 */
|
||||
createdAt: string;
|
||||
/** 是否为 Git 仓库 */
|
||||
isGitRepo: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 会话管理器配置
|
||||
*/
|
||||
export interface SessionManagerConfig {
|
||||
/** 存储目录 */
|
||||
storageDir: string;
|
||||
/** 最大历史会话数量 */
|
||||
maxHistorySessions?: number;
|
||||
}
|
||||
@@ -1,6 +1,28 @@
|
||||
import type { Todo, TodoStatus } from '../../session/types.js';
|
||||
import type { TodoItem } from '../../session/storage/todo.js';
|
||||
import type { SessionManager } from '../../session/index.js';
|
||||
|
||||
// 兼容旧接口的 Todo 类型
|
||||
export type Todo = {
|
||||
id: string;
|
||||
content: string;
|
||||
status: 'pending' | 'in_progress' | 'completed';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TodoStatus = 'pending' | 'in_progress' | 'completed';
|
||||
|
||||
// 将 TodoItem 转换为 Todo
|
||||
function toTodo(item: TodoItem): Todo {
|
||||
return {
|
||||
id: item.id,
|
||||
content: item.content,
|
||||
status: item.status,
|
||||
createdAt: new Date(item.createdAt).toISOString(),
|
||||
updatedAt: new Date(item.updatedAt).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo 管理器
|
||||
* 提供对当前会话 todo 列表的操作接口
|
||||
@@ -22,7 +44,7 @@ class TodoManager {
|
||||
if (!this.sessionManager) {
|
||||
return [];
|
||||
}
|
||||
return this.sessionManager.getTodos();
|
||||
return this.sessionManager.getTodos().map(toTodo);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ToolResult } from '../../types/index.js';
|
||||
import type { ToolWithMetadata } from '../types.js';
|
||||
import type { Todo, TodoStatus } from '../../session/types.js';
|
||||
import type { Todo, TodoStatus } from './todo-manager.js';
|
||||
import { todoManager } from './todo-manager.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -115,17 +115,8 @@ sessionsRouter.get('/:id/messages', async (c) => {
|
||||
);
|
||||
}
|
||||
|
||||
// 从 Core Storage 读取消息
|
||||
const storage = sessionManager.getStorage();
|
||||
if (!storage) {
|
||||
return c.json({
|
||||
success: true,
|
||||
data: [],
|
||||
});
|
||||
}
|
||||
|
||||
const projectId = sessionManager.getProjectId(id);
|
||||
const sessionData = await storage.loadSession(projectId, id);
|
||||
// 从 Core 存储读取消息
|
||||
const sessionData = await sessionManager.loadSessionData(id);
|
||||
|
||||
if (!sessionData) {
|
||||
return c.json({
|
||||
|
||||
@@ -41,15 +41,16 @@ interface SessionData {
|
||||
todos: unknown[];
|
||||
}
|
||||
|
||||
interface SessionStorageInterface {
|
||||
ensureDir(): Promise<void>;
|
||||
generateSessionId(): string;
|
||||
getOrCreateProject(workdir: string): Promise<ProjectMetadata>;
|
||||
interface SessionManagerInterface {
|
||||
init(workdir: string): Promise<SessionData>;
|
||||
getSession(): SessionData | null;
|
||||
getProject(): ProjectMetadata | null;
|
||||
listSessions(): Promise<SessionSummary[]>;
|
||||
listAllSessions(): Promise<SessionSummary[]>;
|
||||
listSessionsByProject(projectId: string): Promise<SessionSummary[]>;
|
||||
loadSession(projectId: string, sessionId: string): Promise<SessionData | null>;
|
||||
saveSession(session: SessionData, lastSyncedCount?: number): Promise<void>;
|
||||
deleteSession(projectId: string, sessionId: string): Promise<boolean>;
|
||||
deleteSession(sessionId: string): Promise<boolean>;
|
||||
newSession(workdir?: string): Promise<SessionData>;
|
||||
restoreSession(sessionId: string): Promise<SessionData | null>;
|
||||
getSessionId(): string | undefined;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -59,15 +60,15 @@ interface SessionStorageInterface {
|
||||
export class SessionManager {
|
||||
private sessions: Map<string, Session> = new Map();
|
||||
private sessionProjects: Map<string, string> = new Map(); // sessionId -> projectId
|
||||
private storage: SessionStorageInterface | null = null;
|
||||
private coreManager: SessionManagerInterface | null = null;
|
||||
private currentProject: ProjectMetadata | null = null;
|
||||
private initialized = false;
|
||||
|
||||
/**
|
||||
* 获取 storage 实例(供外部使用)
|
||||
* 获取 Core SessionManager 实例(供外部使用)
|
||||
*/
|
||||
getStorage(): SessionStorageInterface | null {
|
||||
return this.storage;
|
||||
getCoreManager(): SessionManagerInterface | null {
|
||||
return this.coreManager;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -78,7 +79,7 @@ export class SessionManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化:加载 Core 模块的 SessionStorage 并恢复已有 sessions
|
||||
* 初始化:加载 Core 模块的 SessionManager 并恢复已有 sessions
|
||||
*/
|
||||
async init(): Promise<void> {
|
||||
if (this.initialized) return;
|
||||
@@ -87,39 +88,35 @@ export class SessionManager {
|
||||
// 动态导入 Core 模块,避免构建时依赖
|
||||
const corePath = '@ai-assistant/core';
|
||||
const core = (await import(/* webpackIgnore: true */ corePath)) as {
|
||||
SessionStorage: new () => SessionStorageInterface;
|
||||
SessionManager: new (storageDir?: string) => SessionManagerInterface;
|
||||
};
|
||||
|
||||
this.storage = new core.SessionStorage();
|
||||
await this.storage.ensureDir();
|
||||
this.coreManager = new core.SessionManager();
|
||||
await this.coreManager.init(process.cwd());
|
||||
|
||||
// 获取当前工作目录的项目
|
||||
this.currentProject = await this.storage.getOrCreateProject(process.cwd());
|
||||
this.currentProject = this.coreManager.getProject();
|
||||
|
||||
// 加载已持久化的 sessions(所有项目)
|
||||
const summaries = await this.storage.listAllSessions();
|
||||
const summaries = await this.coreManager.listAllSessions();
|
||||
console.log(`[SessionManager] Found ${summaries.length} persisted sessions`);
|
||||
|
||||
for (const summary of summaries) {
|
||||
// 需要找到 session 所属的项目,这里简化处理:加载当前项目的会话
|
||||
const sessionData = await this.storage.loadSession(this.currentProject.id, summary.id);
|
||||
if (!sessionData) continue;
|
||||
|
||||
// 记录 session -> project 映射
|
||||
this.sessionProjects.set(sessionData.id, sessionData.projectId);
|
||||
|
||||
// 转换为 Server Session 格式(只保存元数据,不存储消息)
|
||||
const session: Session = {
|
||||
id: sessionData.id,
|
||||
name: sessionData.title,
|
||||
workdir: sessionData.workdir,
|
||||
createdAt: sessionData.createdAt,
|
||||
updatedAt: sessionData.updatedAt,
|
||||
id: summary.id,
|
||||
name: summary.title,
|
||||
workdir: summary.workdir,
|
||||
createdAt: summary.createdAt,
|
||||
updatedAt: summary.updatedAt,
|
||||
status: 'idle',
|
||||
messageCount: sessionData.messages.length,
|
||||
messageCount: summary.messageCount,
|
||||
};
|
||||
|
||||
this.sessions.set(session.id, session);
|
||||
// 假设所有会话都属于当前项目(简化处理)
|
||||
if (this.currentProject) {
|
||||
this.sessionProjects.set(session.id, this.currentProject.id);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[SessionManager] Loaded ${this.sessions.size} sessions from storage`);
|
||||
@@ -130,36 +127,6 @@ export class SessionManager {
|
||||
this.initialized = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 持久化单个 session 的元数据
|
||||
* 注意:消息存储由 Core Agent 负责,这里只更新会话元数据
|
||||
*/
|
||||
private async persistMetadata(sessionId: string): Promise<void> {
|
||||
if (!this.storage) return;
|
||||
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const projectId = this.sessionProjects.get(sessionId) || this.currentProject?.id || 'default';
|
||||
|
||||
// 先加载现有的 session 数据(保留消息)
|
||||
const existingData = await this.storage.loadSession(projectId, sessionId);
|
||||
|
||||
const sessionData: SessionData = {
|
||||
id: session.id,
|
||||
projectId,
|
||||
createdAt: session.createdAt,
|
||||
updatedAt: session.updatedAt,
|
||||
workdir: session.workdir,
|
||||
title: session.name,
|
||||
messages: existingData?.messages || [],
|
||||
discoveredTools: existingData?.discoveredTools || [],
|
||||
todos: existingData?.todos || [],
|
||||
};
|
||||
|
||||
await this.storage.saveSession(sessionData);
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新会话
|
||||
*/
|
||||
@@ -167,15 +134,20 @@ export class SessionManager {
|
||||
const now = new Date().toISOString();
|
||||
const workdir = input.workdir || process.cwd();
|
||||
|
||||
// 如果指定了不同的工作目录,获取对应项目
|
||||
let sessionId: string;
|
||||
let projectId = this.currentProject?.id || 'default';
|
||||
if (this.storage && workdir !== this.currentProject?.workdir) {
|
||||
const project = await this.storage.getOrCreateProject(workdir);
|
||||
projectId = project.id;
|
||||
|
||||
if (this.coreManager) {
|
||||
// 使用 Core SessionManager 创建会话
|
||||
const sessionData = await this.coreManager.newSession(workdir);
|
||||
sessionId = sessionData.id;
|
||||
projectId = sessionData.projectId;
|
||||
} else {
|
||||
sessionId = uuidv4();
|
||||
}
|
||||
|
||||
const session: Session = {
|
||||
id: this.storage?.generateSessionId() || uuidv4(),
|
||||
id: sessionId,
|
||||
name: input.name,
|
||||
workdir,
|
||||
createdAt: now,
|
||||
@@ -187,9 +159,6 @@ export class SessionManager {
|
||||
this.sessions.set(session.id, session);
|
||||
this.sessionProjects.set(session.id, projectId);
|
||||
|
||||
// 持久化空会话
|
||||
await this.persistMetadata(session.id);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -216,9 +185,8 @@ export class SessionManager {
|
||||
const deleted = this.sessions.delete(id);
|
||||
|
||||
// 从存储中删除
|
||||
if (deleted && this.storage) {
|
||||
const projectId = this.sessionProjects.get(id) || this.currentProject?.id || 'default';
|
||||
await this.storage.deleteSession(projectId, id);
|
||||
if (deleted && this.coreManager) {
|
||||
await this.coreManager.deleteSession(id);
|
||||
this.sessionProjects.delete(id);
|
||||
}
|
||||
|
||||
@@ -259,9 +227,6 @@ export class SessionManager {
|
||||
session.name = name;
|
||||
session.updatedAt = new Date().toISOString();
|
||||
|
||||
// 持久化元数据
|
||||
await this.persistMetadata(sessionId);
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -278,6 +243,17 @@ export class SessionManager {
|
||||
exists(id: string): boolean {
|
||||
return this.sessions.has(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载会话数据(从 Core 存储)
|
||||
*/
|
||||
async loadSessionData(sessionId: string): Promise<SessionData | null> {
|
||||
if (!this.coreManager) return null;
|
||||
|
||||
// 先恢复会话
|
||||
const data = await this.coreManager.restoreSession(sessionId);
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
// 单例
|
||||
|
||||
Reference in New Issue
Block a user