9ff2934089
- 添加读写锁机制防止并发写入冲突 - 实现数据迁移框架支持版本化升级 - 分层存储结构:项目/会话/消息独立存储 - 使用 Git root commit hash 作为稳定项目 ID - 增量消息同步避免重复写入 - 每条消息独立文件,按序号命名 (0001.json, 0002.json)
453 lines
12 KiB
TypeScript
453 lines
12 KiB
TypeScript
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 {
|
|
// 项目不存在,创建新项目
|
|
const isGitRepo = await isGitRepository(workdir);
|
|
const project: ProjectMetadata = {
|
|
id: projectId,
|
|
workdir,
|
|
createdAt: new Date().toISOString(),
|
|
isGitRepo,
|
|
};
|
|
|
|
using __ = await Lock.write(projectFile);
|
|
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();
|