5e32375f0e
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
216 lines
5.5 KiB
TypeScript
216 lines
5.5 KiB
TypeScript
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import type { SessionData, SessionSummary } from './types.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 sessionsDir: string;
|
|
private currentSessionFile: string;
|
|
|
|
constructor(storageDir?: string) {
|
|
this.storageDir = storageDir || getDefaultStorageDir();
|
|
this.sessionsDir = path.join(this.storageDir, 'sessions');
|
|
this.currentSessionFile = path.join(this.storageDir, 'current-session.json');
|
|
}
|
|
|
|
/**
|
|
* 确保存储目录存在
|
|
*/
|
|
async ensureDir(): Promise<void> {
|
|
await fs.mkdir(this.sessionsDir, { recursive: 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 saveCurrentSession(session: SessionData): Promise<void> {
|
|
await this.ensureDir();
|
|
session.updatedAt = new Date().toISOString();
|
|
await fs.writeFile(
|
|
this.currentSessionFile,
|
|
JSON.stringify(session, null, 2),
|
|
'utf-8'
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 加载当前会话
|
|
*/
|
|
async loadCurrentSession(): Promise<SessionData | null> {
|
|
try {
|
|
const content = await fs.readFile(this.currentSessionFile, 'utf-8');
|
|
return JSON.parse(content) as SessionData;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 归档当前会话到历史
|
|
*/
|
|
async archiveCurrentSession(): Promise<void> {
|
|
const current = await this.loadCurrentSession();
|
|
if (!current || current.messages.length === 0) {
|
|
return;
|
|
}
|
|
|
|
await this.ensureDir();
|
|
const archivePath = path.join(this.sessionsDir, `${current.id}.json`);
|
|
await fs.writeFile(archivePath, JSON.stringify(current, null, 2), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* 删除当前会话文件
|
|
*/
|
|
async clearCurrentSession(): Promise<void> {
|
|
try {
|
|
await fs.unlink(this.currentSessionFile);
|
|
} catch {
|
|
// 文件不存在,忽略
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 列出历史会话
|
|
*/
|
|
async listSessions(): Promise<SessionSummary[]> {
|
|
await this.ensureDir();
|
|
const files = await fs.readdir(this.sessionsDir);
|
|
const summaries: SessionSummary[] = [];
|
|
|
|
for (const file of files) {
|
|
if (!file.endsWith('.json')) continue;
|
|
|
|
try {
|
|
const filePath = path.join(this.sessionsDir, file);
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
const session = JSON.parse(content) as SessionData;
|
|
|
|
summaries.push({
|
|
id: session.id,
|
|
title: session.title || this.generateTitle(session),
|
|
workdir: session.workdir,
|
|
messageCount: session.messages.length,
|
|
createdAt: session.createdAt,
|
|
updatedAt: session.updatedAt,
|
|
});
|
|
} catch {
|
|
// 跳过无法解析的文件
|
|
}
|
|
}
|
|
|
|
// 按更新时间降序排列
|
|
return summaries.sort(
|
|
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 加载指定会话
|
|
*/
|
|
async loadSession(sessionId: string): Promise<SessionData | null> {
|
|
try {
|
|
const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
|
|
const content = await fs.readFile(filePath, 'utf-8');
|
|
return JSON.parse(content) as SessionData;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 保存指定会话(用于子会话)
|
|
*/
|
|
async saveSession(session: SessionData): Promise<void> {
|
|
await this.ensureDir();
|
|
session.updatedAt = new Date().toISOString();
|
|
const filePath = path.join(this.sessionsDir, `${session.id}.json`);
|
|
await fs.writeFile(filePath, JSON.stringify(session, null, 2), 'utf-8');
|
|
}
|
|
|
|
/**
|
|
* 删除指定会话
|
|
*/
|
|
async deleteSession(sessionId: string): Promise<boolean> {
|
|
try {
|
|
const filePath = path.join(this.sessionsDir, `${sessionId}.json`);
|
|
await fs.unlink(filePath);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 清理旧会话(保留最近 N 个)
|
|
*/
|
|
async cleanupOldSessions(keepCount: number = 50): Promise<number> {
|
|
const sessions = await this.listSessions();
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 从会话生成标题
|
|
*/
|
|
private generateTitle(session: SessionData): string {
|
|
// 从第一条用户消息生成标题
|
|
const firstUserMessage = session.messages.find((m) => m.role === 'user');
|
|
if (firstUserMessage && typeof firstUserMessage.content === 'string') {
|
|
const content = firstUserMessage.content;
|
|
// 取前 50 个字符
|
|
return content.length > 50 ? content.substring(0, 50) + '...' : content;
|
|
}
|
|
return `会话 ${session.id}`;
|
|
}
|
|
|
|
/**
|
|
* 获取存储目录路径
|
|
*/
|
|
getStorageDir(): string {
|
|
return this.storageDir;
|
|
}
|
|
}
|
|
|
|
// 导出默认实例
|
|
export const sessionStorage = new SessionStorage();
|