feat:搭建本地存储会话历史的框架
- 将会话历史存储在C:\Users\admin\.iccoder文件下 - 在里面又会创建多个文件夹进行存储
This commit is contained in:
497
src/utils/chatHistoryManager.ts
Normal file
497
src/utils/chatHistoryManager.ts
Normal file
@ -0,0 +1,497 @@
|
||||
import * as vscode from 'vscode';
|
||||
import * as path from 'path';
|
||||
import {
|
||||
ChatMessage,
|
||||
TaskMeta,
|
||||
TaskSession,
|
||||
ConversationMeta,
|
||||
MessageType,
|
||||
UserMessage,
|
||||
AiMessage,
|
||||
SystemMessage
|
||||
} from '../types/chatHistory';
|
||||
|
||||
/**
|
||||
* 会话历史管理器
|
||||
* 按照设计文档实现:~/.iccoder/projects/{项目路径编码}/{taskId}/
|
||||
*/
|
||||
export class ChatHistoryManager {
|
||||
private static instance: ChatHistoryManager;
|
||||
private baseDir: string; // ~/.iccoder
|
||||
private currentTaskId: string | null = null;
|
||||
private currentProjectPath: string | null = null;
|
||||
|
||||
private constructor() {
|
||||
// 设置存储路径: ~/.iccoder
|
||||
const userHome = process.env.USERPROFILE || process.env.HOME || '';
|
||||
this.baseDir = path.join(userHome, '.iccoder');
|
||||
this.ensureBaseDir();
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目路径编码
|
||||
* 规则:
|
||||
* - 替换 \ 和 / 为 --
|
||||
* - 替换 : 为空
|
||||
* 例如:C:\Users\admin\Documents\Project -> C--Users-admin-Documents-Project
|
||||
*/
|
||||
private encodeProjectPath(projectPath: string): string {
|
||||
return projectPath
|
||||
.replace(/:/g, '') // 移除冒号
|
||||
.replace(/[/\\]/g, '--'); // 替换斜杠为 --
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成任务ID
|
||||
* 格式:task_{date}_{sequence}
|
||||
*/
|
||||
private generateTaskId(): string {
|
||||
const date = new Date().toISOString().split('T')[0].replace(/-/g, '');
|
||||
const sequence = Math.random().toString(36).substr(2, 6);
|
||||
return `task_${date}_${sequence}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取任务目录路径
|
||||
*/
|
||||
private getTaskDir(projectPath: string, taskId: string): string {
|
||||
const encodedPath = this.encodeProjectPath(projectPath);
|
||||
return path.join(this.baseDir, 'projects', encodedPath, taskId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保基础目录存在
|
||||
*/
|
||||
private async ensureBaseDir(): Promise<void> {
|
||||
try {
|
||||
const uri = vscode.Uri.file(this.baseDir);
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
} catch {
|
||||
// 目录不存在,创建它
|
||||
await vscode.workspace.fs.createDirectory(uri);
|
||||
console.log(`创建存储目录: ${this.baseDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("创建存储目录失败:", error);
|
||||
vscode.window.showErrorMessage("创建会话历史存储目录失败");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保任务目录存在
|
||||
*/
|
||||
private async ensureTaskDir(taskDir: string): Promise<void> {
|
||||
try {
|
||||
const uri = vscode.Uri.file(taskDir);
|
||||
try {
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
} catch {
|
||||
// 目录不存在,创建它
|
||||
await vscode.workspace.fs.createDirectory(uri);
|
||||
console.log(`创建任务目录: ${taskDir}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("创建任务目录失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取单例实例
|
||||
*/
|
||||
public static getInstance(): ChatHistoryManager {
|
||||
if (!ChatHistoryManager.instance) {
|
||||
ChatHistoryManager.instance = new ChatHistoryManager();
|
||||
}
|
||||
return ChatHistoryManager.instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建新任务
|
||||
*/
|
||||
public async createTask(projectPath: string, taskName: string): Promise<TaskMeta> {
|
||||
const taskId = this.generateTaskId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const meta: TaskMeta = {
|
||||
taskId,
|
||||
taskName,
|
||||
projectPath,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
stats: {
|
||||
credits: 0,
|
||||
totalTokens: 0,
|
||||
inputTokens: 0,
|
||||
outputTokens: 0
|
||||
}
|
||||
};
|
||||
|
||||
this.currentTaskId = taskId;
|
||||
this.currentProjectPath = projectPath;
|
||||
|
||||
// 创建任务目录
|
||||
const taskDir = this.getTaskDir(projectPath, taskId);
|
||||
await this.ensureTaskDir(taskDir);
|
||||
|
||||
// 保存 meta.json
|
||||
await this.saveTaskMeta(meta);
|
||||
|
||||
// 初始化空的 conversation.json
|
||||
await this.saveConversation([]);
|
||||
|
||||
return meta;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存任务元数据
|
||||
*/
|
||||
private async saveTaskMeta(meta: TaskMeta): Promise<void> {
|
||||
if (!this.currentTaskId || !this.currentProjectPath) {
|
||||
throw new Error("没有当前任务");
|
||||
}
|
||||
|
||||
const taskDir = this.getTaskDir(this.currentProjectPath, this.currentTaskId);
|
||||
const metaPath = path.join(taskDir, 'meta.json');
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(metaPath);
|
||||
const content = Buffer.from(JSON.stringify(meta, null, 2), 'utf-8');
|
||||
await vscode.workspace.fs.writeFile(uri, content);
|
||||
} catch (error) {
|
||||
console.error("保存任务元数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载任务元数据
|
||||
*/
|
||||
private async loadTaskMeta(): Promise<TaskMeta | null> {
|
||||
if (!this.currentTaskId || !this.currentProjectPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const taskDir = this.getTaskDir(this.currentProjectPath, this.currentTaskId);
|
||||
const metaPath = path.join(taskDir, 'meta.json');
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(metaPath);
|
||||
const content = await vscode.workspace.fs.readFile(uri);
|
||||
const data = Buffer.from(content).toString('utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
// 文件不存在或读取失败
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存对话历史(conversation.json)
|
||||
*/
|
||||
private async saveConversation(messages: ChatMessage[]): Promise<void> {
|
||||
if (!this.currentTaskId || !this.currentProjectPath) {
|
||||
throw new Error("没有当前任务");
|
||||
}
|
||||
|
||||
const taskDir = this.getTaskDir(this.currentProjectPath, this.currentTaskId);
|
||||
const conversationPath = path.join(taskDir, 'conversation.json');
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(conversationPath);
|
||||
const content = Buffer.from(JSON.stringify(messages, null, 2), 'utf-8');
|
||||
await vscode.workspace.fs.writeFile(uri, content);
|
||||
} catch (error) {
|
||||
console.error("保存对话历史失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载对话历史
|
||||
*/
|
||||
private async loadConversation(): Promise<ChatMessage[]> {
|
||||
if (!this.currentTaskId || !this.currentProjectPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const taskDir = this.getTaskDir(this.currentProjectPath, this.currentTaskId);
|
||||
const conversationPath = path.join(taskDir, 'conversation.json');
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(conversationPath);
|
||||
const content = await vscode.workspace.fs.readFile(uri);
|
||||
const data = Buffer.from(content).toString('utf-8');
|
||||
return JSON.parse(data);
|
||||
} catch (error) {
|
||||
// 文件不存在或读取失败
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 追加对话元数据(conversation_meta.jsonl)
|
||||
*/
|
||||
private async appendConversationMeta(meta: ConversationMeta): Promise<void> {
|
||||
if (!this.currentTaskId || !this.currentProjectPath) {
|
||||
throw new Error("没有当前任务");
|
||||
}
|
||||
|
||||
const taskDir = this.getTaskDir(this.currentProjectPath, this.currentTaskId);
|
||||
const metaPath = path.join(taskDir, 'conversation_meta.jsonl');
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(metaPath);
|
||||
const line = JSON.stringify(meta) + '\n';
|
||||
|
||||
// 读取现有内容
|
||||
let existingContent = '';
|
||||
try {
|
||||
const content = await vscode.workspace.fs.readFile(uri);
|
||||
existingContent = Buffer.from(content).toString('utf-8');
|
||||
} catch {
|
||||
// 文件不存在,忽略错误
|
||||
}
|
||||
|
||||
// 追加新内容
|
||||
const newContent = existingContent + line;
|
||||
await vscode.workspace.fs.writeFile(uri, Buffer.from(newContent, 'utf-8'));
|
||||
} catch (error) {
|
||||
console.error("追加对话元数据失败:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保有当前任务,如果没有则自动创建
|
||||
*/
|
||||
private async ensureCurrentTask(): Promise<void> {
|
||||
if (!this.currentTaskId || !this.currentProjectPath) {
|
||||
// 获取当前工作区路径
|
||||
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||||
if (workspacePath) {
|
||||
await this.createTask(workspacePath, "默认任务");
|
||||
} else {
|
||||
throw new Error("没有打开的工作区,无法创建任务");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加用户消息
|
||||
*/
|
||||
public async addUserMessage(text: string): Promise<void> {
|
||||
await this.ensureCurrentTask();
|
||||
const messages = await this.loadConversation();
|
||||
|
||||
const userMessage: UserMessage = {
|
||||
type: MessageType.USER,
|
||||
contents: [{ type: "TEXT", text }]
|
||||
};
|
||||
|
||||
messages.push(userMessage);
|
||||
await this.saveConversation(messages);
|
||||
|
||||
// 更新任务元数据
|
||||
await this.updateTaskTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加AI消息
|
||||
*/
|
||||
public async addAiMessage(text: string, toolRequests?: any[]): Promise<void> {
|
||||
await this.ensureCurrentTask();
|
||||
const messages = await this.loadConversation();
|
||||
|
||||
const aiMessage: AiMessage = {
|
||||
type: MessageType.AI,
|
||||
text,
|
||||
toolExecutionRequests: toolRequests
|
||||
};
|
||||
|
||||
messages.push(aiMessage);
|
||||
await this.saveConversation(messages);
|
||||
|
||||
// 更新任务元数据
|
||||
await this.updateTaskTimestamp();
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加系统消息
|
||||
*/
|
||||
public async addSystemMessage(text: string): Promise<void> {
|
||||
await this.ensureCurrentTask();
|
||||
const messages = await this.loadConversation();
|
||||
|
||||
const systemMessage: SystemMessage = {
|
||||
type: MessageType.SYSTEM,
|
||||
text
|
||||
};
|
||||
|
||||
messages.push(systemMessage);
|
||||
await this.saveConversation(messages);
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录对话轮次元数据
|
||||
*/
|
||||
public async recordTurnMeta(
|
||||
turnId: number,
|
||||
usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number },
|
||||
model?: string,
|
||||
duration?: number
|
||||
): Promise<void> {
|
||||
const meta: ConversationMeta = {
|
||||
turnId,
|
||||
timestamp: new Date().toISOString(),
|
||||
usage,
|
||||
model,
|
||||
duration
|
||||
};
|
||||
|
||||
await this.appendConversationMeta(meta);
|
||||
|
||||
// 更新任务统计
|
||||
if (usage) {
|
||||
await this.updateTaskStats(usage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务时间戳
|
||||
*/
|
||||
private async updateTaskTimestamp(): Promise<void> {
|
||||
const meta = await this.loadTaskMeta();
|
||||
if (meta) {
|
||||
meta.updatedAt = new Date().toISOString();
|
||||
await this.saveTaskMeta(meta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新任务统计
|
||||
*/
|
||||
private async updateTaskStats(usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number }): Promise<void> {
|
||||
const meta = await this.loadTaskMeta();
|
||||
if (meta) {
|
||||
meta.stats.inputTokens += usage.inputTokens || 0;
|
||||
meta.stats.outputTokens += usage.outputTokens || 0;
|
||||
meta.stats.totalTokens += usage.totalTokens || 0;
|
||||
meta.updatedAt = new Date().toISOString();
|
||||
await this.saveTaskMeta(meta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前任务会话
|
||||
*/
|
||||
public async getCurrentTaskSession(): Promise<TaskSession | null> {
|
||||
const meta = await this.loadTaskMeta();
|
||||
if (!meta) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const messages = await this.loadConversation();
|
||||
const conversationMeta = await this.loadConversationMeta();
|
||||
|
||||
return {
|
||||
meta,
|
||||
messages,
|
||||
conversationMeta
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载对话元数据
|
||||
*/
|
||||
private async loadConversationMeta(): Promise<ConversationMeta[]> {
|
||||
if (!this.currentTaskId || !this.currentProjectPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const taskDir = this.getTaskDir(this.currentProjectPath, this.currentTaskId);
|
||||
const metaPath = path.join(taskDir, 'conversation_meta.jsonl');
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(metaPath);
|
||||
const content = await vscode.workspace.fs.readFile(uri);
|
||||
const data = Buffer.from(content).toString('utf-8');
|
||||
return data
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map(line => JSON.parse(line));
|
||||
} catch (error) {
|
||||
// 文件不存在或读取失败
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 列出项目的所有任务
|
||||
*/
|
||||
public async listProjectTasks(projectPath: string): Promise<TaskMeta[]> {
|
||||
const encodedPath = this.encodeProjectPath(projectPath);
|
||||
const projectDir = path.join(this.baseDir, 'projects', encodedPath);
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(projectDir);
|
||||
const entries = await vscode.workspace.fs.readDirectory(uri);
|
||||
|
||||
const tasks: TaskMeta[] = [];
|
||||
|
||||
for (const [taskId, type] of entries) {
|
||||
if (type === vscode.FileType.Directory) {
|
||||
const metaPath = path.join(projectDir, taskId, 'meta.json');
|
||||
try {
|
||||
const metaUri = vscode.Uri.file(metaPath);
|
||||
const content = await vscode.workspace.fs.readFile(metaUri);
|
||||
const data = Buffer.from(content).toString('utf-8');
|
||||
tasks.push(JSON.parse(data));
|
||||
} catch (error) {
|
||||
console.error(`加载任务 ${taskId} 失败:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tasks.sort((a, b) =>
|
||||
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
} catch (error) {
|
||||
// 目录不存在
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换到指定任务
|
||||
*/
|
||||
public async switchTask(projectPath: string, taskId: string): Promise<boolean> {
|
||||
const taskDir = this.getTaskDir(projectPath, taskId);
|
||||
const metaPath = path.join(taskDir, 'meta.json');
|
||||
|
||||
try {
|
||||
const uri = vscode.Uri.file(metaPath);
|
||||
await vscode.workspace.fs.stat(uri);
|
||||
this.currentProjectPath = projectPath;
|
||||
this.currentTaskId = taskId;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前任务ID
|
||||
*/
|
||||
public getCurrentTaskId(): string | null {
|
||||
return this.currentTaskId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取基础目录
|
||||
*/
|
||||
public getBaseDir(): string {
|
||||
return this.baseDir;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user