diff --git a/docs/会话存储技术文档.md b/docs/会话存储技术文档.md new file mode 100644 index 0000000..8377543 --- /dev/null +++ b/docs/会话存储技术文档.md @@ -0,0 +1,751 @@ +# IC Coder 会话存储技术文档 + +## 1. 概述 + +IC Coder 的会话存储系统负责持久化保存用户与 AI 的对话历史,支持多项目、多任务的会话管理。系统采用文件系统存储方案,将会话数据按项目和任务组织,便于管理和检索。 + +### 1.1 核心特性 + +- **多项目支持**:不同项目的会话数据独立存储 +- **任务级管理**:每个会话作为独立任务进行管理 +- **分页加载**:支持历史会话的分页查询,提升性能 +- **实时更新**:会话数据实时保存,防止数据丢失 +- **统计信息**:记录 Token 使用量、对话轮次等统计数据 + +### 1.2 技术栈 + +- **存储方式**:文件系统(JSON/JSONL 格式) +- **存储位置**:`~/.iccoder/projects/{项目路径编码}/{taskId}/` +- **数据格式**: + - `meta.json`:任务元数据 + - `conversation.json`:完整对话历史 + - `conversation_meta.jsonl`:对话轮次元数据(JSONL 格式) + +--- + +## 2. 架构设计 + +### 2.1 目录结构 + +``` +~/.iccoder/ +└── projects/ + └── {项目路径编码}/ + └── {taskId}/ + ├── meta.json # 任务元数据 + ├── conversation.json # 对话历史 + └── conversation_meta.jsonl # 对话元数据 +``` + +**项目路径编码规则**: +- 移除冒号 `:` +- 将斜杠 `/` 和反斜杠 `\` 替换为 `--` +- 示例:`C:\Users\admin\Documents\Project` → `C--Users--admin--Documents--Project` + +**任务 ID 格式**: +- 格式:`task_{date}_{sequence}` +- 示例:`task_20231226_a3f9k2` +- `date`:8 位日期(YYYYMMDD) +- `sequence`:6 位随机字符串 + +### 2.2 核心类:ChatHistoryManager + +`ChatHistoryManager` 是会话存储的核心管理类,采用单例模式设计。 + +**主要职责**: +1. 管理会话存储目录 +2. 创建和切换任务 +3. 保存和加载对话历史 +4. 记录统计信息 +5. 提供会话历史查询接口 + +**关键属性**: +```typescript +private static instance: ChatHistoryManager; +private baseDir: string; // ~/.iccoder +private currentTaskId: string | null; // 当前任务 ID +private currentProjectPath: string | null; // 当前项目路径 +``` + +--- + +## 3. 数据模型 + +### 3.1 TaskMeta(任务元数据) + +存储在 `meta.json` 文件中,记录任务的基本信息和统计数据。 + +```typescript +interface TaskMeta { + taskId: string; // 任务 ID + taskName: string; // 任务名称 + projectPath: string; // 项目路径 + createdAt: string; // 创建时间(ISO 8601) + updatedAt: string; // 更新时间(ISO 8601) + stats: { + credits: number; // 消耗的积分 + totalTokens: number; // 总 Token 数 + inputTokens: number; // 输入 Token 数 + outputTokens: number; // 输出 Token 数 + }; +} +``` + +**示例**: +```json +{ + "taskId": "task_20231226_a3f9k2", + "taskName": "实现计数器功能", + "projectPath": "C:\\Users\\admin\\Documents\\Project", + "createdAt": "2023-12-26T10:30:00.000Z", + "updatedAt": "2023-12-26T11:45:00.000Z", + "stats": { + "credits": 0, + "totalTokens": 15420, + "inputTokens": 8200, + "outputTokens": 7220 + } +} +``` + +### 3.2 ChatMessage(对话消息) + +存储在 `conversation.json` 文件中,记录完整的对话历史。 + +**消息类型枚举**: +```typescript +enum MessageType { + USER = "USER", // 用户消息 + AI = "AI", // AI 消息 + SYSTEM = "SYSTEM", // 系统消息 + TOOL_EXECUTION_RESULT = "TOOL_EXECUTION_RESULT" // 工具执行结果 +} +``` + +**用户消息**: +```typescript +interface UserMessage { + type: MessageType.USER; + contents: Array<{ + type: "TEXT"; + text: string; + }>; +} +``` + +**AI 消息**: +```typescript +interface AiMessage { + type: MessageType.AI; + text: string; + toolExecutionRequests?: Array<{ + id: string; + toolName: string; + parameters: any; + }>; +} +``` + +**系统消息**: +```typescript +interface SystemMessage { + type: MessageType.SYSTEM; + text: string; +} +``` + +**工具执行结果消息**: +```typescript +interface ToolExecutionResultMessage { + type: MessageType.TOOL_EXECUTION_RESULT; + id: string; + toolName: string; + text: string; +} +``` + +### 3.3 ConversationMeta(对话轮次元数据) + +存储在 `conversation_meta.jsonl` 文件中,每行一条记录(JSONL 格式)。 + +```typescript +interface ConversationMeta { + turnId: number; // 对话轮次 ID + timestamp: string; // 时间戳(ISO 8601) + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + }; + model?: string; // 使用的模型 + duration?: number; // 耗时(毫秒) +} +``` + +**示例**: +```jsonl +{"turnId":1,"timestamp":"2023-12-26T10:30:15.000Z","usage":{"inputTokens":120,"outputTokens":350,"totalTokens":470},"model":"gpt-4","duration":2500} +{"turnId":2,"timestamp":"2023-12-26T10:32:30.000Z","usage":{"inputTokens":200,"outputTokens":450,"totalTokens":650},"model":"gpt-4","duration":3200} +``` + +--- + +## 4. 核心功能实现 + +### 4.1 任务创建 + +**方法**:`createTask(projectPath: string, taskName: string): Promise` + +**流程**: +1. 生成唯一的任务 ID +2. 创建任务元数据对象 +3. 创建任务目录 +4. 保存 `meta.json` +5. 初始化空的 `conversation.json` +6. 设置为当前任务 + +**代码位置**:`chatHistoryManager.ts:114-146` + +```typescript +public async createTask(projectPath: string, taskName: string): Promise { + 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; +} +``` + +### 4.2 消息保存 + +系统提供了四种消息保存方法: + +#### 4.2.1 添加用户消息 + +**方法**:`addUserMessage(text: string): Promise` + +**代码位置**:`chatHistoryManager.ts:285-299` + +```typescript +public async addUserMessage(text: string): Promise { + 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(); +} +``` + +#### 4.2.2 添加 AI 消息 + +**方法**:`addAiMessage(text: string, toolRequests?: any[]): Promise` + +**代码位置**:`chatHistoryManager.ts:304-319` + +#### 4.2.3 添加系统消息 + +**方法**:`addSystemMessage(text: string): Promise` + +**代码位置**:`chatHistoryManager.ts:324-335` + +#### 4.2.4 添加工具执行结果 + +**方法**:`addToolExecutionResult(id: string, toolName: string, result: string): Promise` + +**代码位置**:`chatHistoryManager.ts:340-353` + +### 4.3 对话元数据记录 + +**方法**:`recordTurnMeta(turnId, usage?, model?, duration?): Promise` + +**功能**:记录每轮对话的元数据,包括 Token 使用量、模型信息、耗时等。 + +**代码位置**:`chatHistoryManager.ts:358-378` + +```typescript +public async recordTurnMeta( + turnId: number, + usage?: { inputTokens?: number; outputTokens?: number; totalTokens?: number }, + model?: string, + duration?: number +): Promise { + const meta: ConversationMeta = { + turnId, + timestamp: new Date().toISOString(), + usage, + model, + duration + }; + + await this.appendConversationMeta(meta); + + // 更新任务统计 + if (usage) { + await this.updateTaskStats(usage); + } +} +``` + +### 4.4 会话历史查询 + +**方法**:`getConversationHistoryList(projectPath, offset, limit): Promise<{items, total, hasMore}>` + +**功能**:分页查询项目的会话历史列表。 + +**参数**: +- `projectPath`:项目路径 +- `offset`:偏移量(从第几条开始,默认 0) +- `limit`:每页数量(默认 10) + +**返回值**: +```typescript +{ + items: Array<{ + id: string; // 任务 ID + title: string; // 会话标题(第一句用户消息) + timestamp: string; // 创建时间 + }>; + total: number; // 总数 + hasMore: boolean; // 是否还有更多 +} +``` + +**代码位置**:`chatHistoryManager.ts:525-590` + +**实现逻辑**: +1. 获取项目的所有任务列表(按更新时间倒序) +2. 根据 offset 和 limit 进行分页 +3. 读取每个任务的 `conversation.json` +4. 提取第一条用户消息作为标题(截取前 50 个字符) +5. 返回分页结果 + +--- + +## 5. 前端集成 + +### 5.1 会话历史栏组件 + +**文件**:`conversationHistoryBar.ts` + +**组件结构**: +- 下拉按钮:显示 "Past Conversations" +- 下拉菜单:显示会话历史列表 +- 新建按钮:创建新会话 + +**关键功能**: + +#### 5.1.1 加载会话历史 + +```javascript +function loadMoreHistory() { + if (isLoadingHistory || (currentOffset > 0 && !hasMoreHistory)) { + return; + } + + // 检查是否已达到最大数量(100 条) + if (currentOffset >= MAX_HISTORY_ITEMS) { + return; + } + + isLoadingHistory = true; + vscode.postMessage({ + command: 'loadConversationHistory', + offset: currentOffset, + limit: HISTORY_PAGE_SIZE + }); +} +``` + +#### 5.1.2 渲染会话列表 + +```javascript +function renderConversationHistory(data) { + isLoadingHistory = false; + + // 追加新数据 + conversationHistory = conversationHistory.concat(data.items); + totalHistory = data.total; + hasMoreHistory = data.hasMore; + currentOffset += data.items.length; + + // 渲染所有历史记录 + historyList.innerHTML = conversationHistory.map(item => ` +
+
${item.title || '未命名会话'}
+
${formatTime(item.timestamp)}
+
+ `).join(''); + + // 如果还有更多数据,添加"加载更多"提示 + if (hasMoreHistory && currentOffset < MAX_HISTORY_ITEMS) { + historyList.innerHTML += ` +
+ 滚动加载更多... +
+ `; + } +} +``` + +#### 5.1.3 滚动加载 + +```javascript +historyDropdownMenu.addEventListener('scroll', () => { + const menu = historyDropdownMenu; + const scrollTop = menu.scrollTop; + const scrollHeight = menu.scrollHeight; + const clientHeight = menu.clientHeight; + + // 当滚动到距离底部 50px 时,加载更多 + if (scrollHeight - scrollTop - clientHeight < 50) { + loadMoreHistory(); + } +}); +``` + +#### 5.1.4 时间格式化 + +```javascript +function formatTime(timestamp) { + const date = new Date(timestamp); + const now = new Date(); + const diff = now - date; + + if (diff < 60000) return '刚刚'; + if (diff < 3600000) return Math.floor(diff / 60000) + '分钟前'; + if (diff < 86400000) return Math.floor(diff / 3600000) + '小时前'; + if (diff < 604800000) return Math.floor(diff / 86400000) + '天前'; + + // 超过7天显示具体日期 + return date.toLocaleDateString('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit' + }); +} +``` + +### 5.2 后端消息处理 + +**文件**:`ICHelperPanel.ts` + +**消息处理流程**: + +```typescript +case "loadConversationHistory": + // 加载会话历史(支持分页) + loadConversationHistory(panel, message.offset || 0, message.limit || 10); + break; + +case "selectConversation": + // 选择会话(暂未实现) + break; + +case "createNewConversation": + // 创建新会话 - 在当前编辑器组中打开新标签页 + showICHelperPanel(context, panel.viewColumn); + break; +``` + +**加载会话历史实现**: + +```typescript +async function loadConversationHistory( + panel: vscode.WebviewPanel, + offset: number = 0, + limit: number = 10 +) { + try { + const historyManager = ChatHistoryManager.getInstance(); + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + if (!workspacePath) { + // 没有打开的工作区,返回空历史 + panel.webview.postMessage({ + command: "conversationHistory", + items: [], + total: 0, + hasMore: false, + }); + return; + } + + // 获取会话历史列表(支持分页) + const result = await historyManager.getConversationHistoryList( + workspacePath, + offset, + limit + ); + + // 发送会话历史到前端 + panel.webview.postMessage({ + command: "conversationHistory", + items: result.items, + total: result.total, + hasMore: result.hasMore, + }); + } catch (error) { + console.error("加载会话历史失败:", error); + // 发生错误时返回空历史 + panel.webview.postMessage({ + command: "conversationHistory", + items: [], + total: 0, + hasMore: false, + }); + } +} +``` + +--- + +## 6. 使用示例 + +### 6.1 创建新任务并保存对话 + +```typescript +const historyManager = ChatHistoryManager.getInstance(); + +// 创建新任务 +const task = await historyManager.createTask( + 'C:\\Users\\admin\\Documents\\Project', + '实现计数器功能' +); + +// 添加用户消息 +await historyManager.addUserMessage('请帮我生成一个4位计数器'); + +// 添加 AI 消息 +await historyManager.addAiMessage( + '好的,我来帮你生成一个4位计数器...', + [{ id: '1', toolName: 'generateCode', parameters: {} }] +); + +// 添加工具执行结果 +await historyManager.addToolExecutionResult( + '1', + 'generateCode', + '代码生成成功' +); + +// 记录对话元数据 +await historyManager.recordTurnMeta( + 1, + { inputTokens: 120, outputTokens: 350, totalTokens: 470 }, + 'gpt-4', + 2500 +); +``` + +### 6.2 查询会话历史 + +```typescript +const historyManager = ChatHistoryManager.getInstance(); + +// 获取第一页(前10条) +const page1 = await historyManager.getConversationHistoryList( + 'C:\\Users\\admin\\Documents\\Project', + 0, + 10 +); + +console.log('总数:', page1.total); +console.log('是否还有更多:', page1.hasMore); +console.log('会话列表:', page1.items); + +// 获取第二页(第11-20条) +const page2 = await historyManager.getConversationHistoryList( + 'C:\\Users\\admin\\Documents\\Project', + 10, + 10 +); +``` + +### 6.3 切换任务 + +```typescript +const historyManager = ChatHistoryManager.getInstance(); + +// 切换到指定任务 +const success = await historyManager.switchTask( + 'C:\\Users\\admin\\Documents\\Project', + 'task_20231226_a3f9k2' +); + +if (success) { + // 获取当前任务会话 + const session = await historyManager.getCurrentTaskSession(); + console.log('任务元数据:', session.meta); + console.log('对话历史:', session.messages); + console.log('对话元数据:', session.conversationMeta); +} +``` + +--- + +## 7. 性能优化 + +### 7.1 分页加载 + +- 前端默认每页加载 10 条记录 +- 最多显示 100 条历史记录 +- 滚动到底部时自动加载下一页 + +### 7.2 懒加载 + +- 只在打开下拉菜单时才加载会话历史 +- 避免不必要的文件读取操作 + +### 7.3 缓存机制 + +- 前端缓存已加载的会话列表 +- 避免重复请求相同数据 + +### 7.4 文件格式优化 + +- 使用 JSONL 格式存储对话元数据,支持追加写入 +- 避免频繁读写整个文件 + +--- + +## 8. 错误处理 + +### 8.1 目录不存在 + +系统会自动创建不存在的目录: + +```typescript +private async ensureTaskDir(taskDir: string): Promise { + 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; + } +} +``` + +### 8.2 文件读取失败 + +读取失败时返回默认值: + +```typescript +private async loadConversation(): Promise { + 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 []; + } +} +``` + +### 8.3 无工作区处理 + +没有打开工作区时,自动创建默认任务: + +```typescript +private async ensureCurrentTask(): Promise { + if (!this.currentTaskId || !this.currentProjectPath) { + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (workspacePath) { + await this.createTask(workspacePath, "默认任务"); + } else { + throw new Error("没有打开的工作区,无法创建任务"); + } + } +} +``` + +--- + +## 9. 未来扩展 + +### 9.1 会话切换功能 + +目前 `selectConversation` 功能暂未实现,未来可以支持: +- 点击历史会话,加载该会话的完整对话历史 +- 在新标签页中打开历史会话 +- 继续历史会话的对话 + +### 9.2 会话搜索 + +- 支持按关键词搜索会话 +- 支持按时间范围筛选 +- 支持按 Token 使用量排序 + +### 9.3 会话导出 + +- 导出为 Markdown 格式 +- 导出为 JSON 格式 +- 导出为 PDF 格式 + +### 9.4 会话统计 + +- 显示总对话轮次 +- 显示总 Token 使用量 +- 显示平均响应时间 + +### 9.5 云端同步 + +- 支持将会话数据同步到云端 +- 支持多设备访问 +- 支持团队协作 + +--- + +## 10. 总结 + +IC Coder 的会话存储系统采用文件系统存储方案,具有以下优势: + +1. **简单可靠**:无需额外的数据库依赖 +2. **易于备份**:直接复制文件即可备份 +3. **跨平台**:支持 Windows、macOS、Linux +4. **可扩展**:易于添加新的数据字段 +5. **高性能**:分页加载,避免一次性加载大量数据 + +系统已经实现了核心的会话管理功能,包括任务创建、消息保存、历史查询等,为用户提供了完整的会话历史管理体验。 diff --git a/src/panels/ICHelperPanel.ts b/src/panels/ICHelperPanel.ts index e5781c3..4da79fe 100644 --- a/src/panels/ICHelperPanel.ts +++ b/src/panels/ICHelperPanel.ts @@ -11,6 +11,8 @@ import { abortCurrentDialog, } from "../utils/messageHandler"; import { VCDViewerPanel } from "./VCDViewerPanel"; +import { ChatHistoryManager } from "../utils/chatHistoryManager"; +import { MessageType } from "../types/chatHistory"; /** * 创建并显示 IC 助手面板 @@ -96,14 +98,14 @@ export function showICHelperPanel( showICHelperPanel(context, panel.viewColumn); break; case "loadConversationHistory": - // 加载会话历史(暂未实现) - panel.webview.postMessage({ - command: "conversationHistory", - history: [], - }); + // 加载会话历史(支持分页) + loadConversationHistory(panel, message.offset || 0, message.limit || 10); break; case "selectConversation": - // 选择会话(暂未实现) + // 选择会话 + if (message.conversationId) { + selectConversation(panel, message.conversationId, context.extensionPath); + } break; // 新增:处理用户回答 case "submitAnswer": @@ -302,3 +304,222 @@ function parseVCDSignals(content: string, maxSignals: number = 3) { return signals; } + +/** + * 加载会话历史(支持分页) + */ +async function loadConversationHistory( + panel: vscode.WebviewPanel, + offset: number = 0, + limit: number = 10 +) { + try { + const historyManager = ChatHistoryManager.getInstance(); + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + if (!workspacePath) { + // 没有打开的工作区,返回空历史 + panel.webview.postMessage({ + command: "conversationHistory", + items: [], + total: 0, + hasMore: false, + }); + return; + } + + // 获取会话历史列表(支持分页) + const result = await historyManager.getConversationHistoryList( + workspacePath, + offset, + limit + ); + + // 发送会话历史到前端 + panel.webview.postMessage({ + command: "conversationHistory", + items: result.items, + total: result.total, + hasMore: result.hasMore, + }); + } catch (error) { + console.error("加载会话历史失败:", error); + // 发生错误时返回空历史 + panel.webview.postMessage({ + command: "conversationHistory", + items: [], + total: 0, + hasMore: false, + }); + } +} + +/** + * 选择并加载指定的会话 + */ +async function selectConversation( + panel: vscode.WebviewPanel, + taskId: string, + extensionPath: string +) { + try { + const historyManager = ChatHistoryManager.getInstance(); + const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + + if (!workspacePath) { + vscode.window.showErrorMessage("没有打开的工作区"); + return; + } + + // 加载任务会话 + const taskSession = await historyManager.loadTaskSession(workspacePath, taskId); + + if (!taskSession) { + vscode.window.showErrorMessage(`加载任务 ${taskId} 失败: 任务不存在或数据损坏`); + return; + } + + // 切换到该任务 + const switched = await historyManager.switchTask(workspacePath, taskId); + if (!switched) { + vscode.window.showErrorMessage(`切换到任务 ${taskId} 失败`); + return; + } + + // 清空当前聊天界面 + panel.webview.postMessage({ + command: "clearChat" + }); + + // 将会话历史消息转换为 segments 格式并发送到前端显示 + const segments: any[] = []; + let i = 0; + + while (i < taskSession.messages.length) { + const message = taskSession.messages[i]; + + if (message.type === MessageType.USER) { + // 用户消息 - 如果有累积的 segments,先发送 + if (segments.length > 0) { + panel.webview.postMessage({ + command: "receiveSegments", + segments: [...segments] + }); + segments.length = 0; + } + + // 发送用户消息 + const textContent = message.contents?.find(c => c.type === 'TEXT'); + if (textContent && 'text' in textContent) { + panel.webview.postMessage({ + command: "addUserMessage", + text: textContent.text + }); + } + i++; + } else if (message.type === MessageType.AI) { + // AI消息 - 如果有 segments,直接使用 + if (message.segments && message.segments.length > 0) { + panel.webview.postMessage({ + command: "receiveSegments", + segments: message.segments + }); + i++; + } else { + // 旧格式:需要转换为 segments + // 收集连续的 AI 消息、工具调用和工具结果 + if (message.text) { + segments.push({ + type: 'text', + content: message.text + }); + } + + // 检查是否有工具调用 + if (message.toolExecutionRequests && message.toolExecutionRequests.length > 0) { + for (const toolReq of message.toolExecutionRequests) { + // 查找对应的工具执行结果 + let toolResult = ''; + if (i + 1 < taskSession.messages.length) { + const nextMsg = taskSession.messages[i + 1]; + if (nextMsg.type === MessageType.TOOL_EXECUTION_RESULT && + nextMsg.id === toolReq.id) { + toolResult = nextMsg.text; + i++; // 跳过工具结果消息 + } + } + + segments.push({ + type: 'tool', + toolName: toolReq.name, + askId: toolReq.id, + toolResult: toolResult + }); + } + } + + i++; + + // 继续收集后续的 AI 消息,直到遇到用户消息或有 segments 的 AI 消息 + while (i < taskSession.messages.length) { + const nextMsg = taskSession.messages[i]; + if (nextMsg.type === MessageType.USER) { + break; + } + if (nextMsg.type === MessageType.AI) { + if (nextMsg.segments && nextMsg.segments.length > 0) { + break; + } + if (nextMsg.text) { + segments.push({ + type: 'text', + content: nextMsg.text + }); + } + if (nextMsg.toolExecutionRequests && nextMsg.toolExecutionRequests.length > 0) { + for (const toolReq of nextMsg.toolExecutionRequests) { + let toolResult = ''; + if (i + 1 < taskSession.messages.length) { + const resultMsg = taskSession.messages[i + 1]; + if (resultMsg.type === MessageType.TOOL_EXECUTION_RESULT && + resultMsg.id === toolReq.id) { + toolResult = resultMsg.text; + i++; // 跳过工具结果消息 + } + } + segments.push({ + type: 'tool', + toolName: toolReq.name, + askId: toolReq.id, + toolResult: toolResult + }); + } + } + i++; + } else if (nextMsg.type === MessageType.TOOL_EXECUTION_RESULT) { + // 独立的工具结果(没有被上面处理的) + i++; + } else { + i++; + } + } + } + } else { + i++; + } + } + + // 发送剩余的 segments + if (segments.length > 0) { + panel.webview.postMessage({ + command: "receiveSegments", + segments: segments + }); + } + + vscode.window.showInformationMessage(`已加载会话: ${taskSession.meta.taskName}`); + } catch (error) { + console.error("选择会话失败:", error); + vscode.window.showErrorMessage(`加载会话失败: ${error}`); + } +} diff --git a/src/types/chatHistory.ts b/src/types/chatHistory.ts index 9025553..ebb33dd 100644 --- a/src/types/chatHistory.ts +++ b/src/types/chatHistory.ts @@ -56,6 +56,7 @@ export interface AiMessage extends BaseMessage { text?: string; toolExecutionRequests?: ToolExecutionRequest[]; thinking?: string; + segments?: any[]; // 保存完整的 segments 信息用于还原显示 } /** diff --git a/src/utils/chatHistoryManager.ts b/src/utils/chatHistoryManager.ts index f6ff2d1..e8a1653 100644 --- a/src/utils/chatHistoryManager.ts +++ b/src/utils/chatHistoryManager.ts @@ -8,7 +8,8 @@ import { MessageType, UserMessage, AiMessage, - SystemMessage + SystemMessage, + ToolExecutionResultMessage } from '../types/chatHistory'; /** @@ -33,12 +34,13 @@ export class ChatHistoryManager { * 规则: * - 替换 \ 和 / 为 -- * - 替换 : 为空 - * 例如:C:\Users\admin\Documents\Project -> C--Users-admin-Documents-Project + * 例如:C:\Users\admin\Documents\Project -> C--Users--admin--Documents--Project */ private encodeProjectPath(projectPath: string): string { return projectPath - .replace(/:/g, '') // 移除冒号 - .replace(/[/\\]/g, '--'); // 替换斜杠为 -- + .replace(/\\/g, '--') // 替换反斜杠为 -- + .replace(/\//g, '--') // 替换正斜杠为 -- + .replace(/:/g, ''); // 移除冒号 } /** @@ -300,14 +302,15 @@ export class ChatHistoryManager { /** * 添加AI消息 */ - public async addAiMessage(text: string, toolRequests?: any[]): Promise { + public async addAiMessage(text: string, toolRequests?: any[], segments?: any[]): Promise { await this.ensureCurrentTask(); const messages = await this.loadConversation(); const aiMessage: AiMessage = { type: MessageType.AI, text, - toolExecutionRequests: toolRequests + toolExecutionRequests: toolRequests, + segments // 保存完整的 segments 信息 }; messages.push(aiMessage); @@ -468,6 +471,20 @@ export class ChatHistoryManager { tasks.push(JSON.parse(data)); } catch (error) { console.error(`加载任务 ${taskId} 失败:`, error); + // 跳过无效的任务目录 + // 尝试清理空目录 + try { + const taskDirUri = vscode.Uri.file(path.join(projectDir, taskId)); + const taskDirEntries = await vscode.workspace.fs.readDirectory(taskDirUri); + if (taskDirEntries.length === 0) { + // 目录为空,删除它 + await vscode.workspace.fs.delete(taskDirUri, { recursive: false }); + console.log(`已清理空任务目录: ${taskId}`); + } + } catch (cleanupError) { + // 清理失败,忽略错误 + console.warn(`清理任务目录 ${taskId} 失败:`, cleanupError); + } } } } @@ -512,4 +529,132 @@ export class ChatHistoryManager { public getBaseDir(): string { return this.baseDir; } + + /** + * 加载指定任务的会话内容 + * @param projectPath 项目路径 + * @param taskId 任务ID + * @returns 任务会话内容,如果任务不存在则返回null + */ + public async loadTaskSession(projectPath: string, taskId: string): Promise { + const taskDir = this.getTaskDir(projectPath, taskId); + const metaPath = path.join(taskDir, 'meta.json'); + + try { + // 检查任务是否存在 + const metaUri = vscode.Uri.file(metaPath); + const metaContent = await vscode.workspace.fs.readFile(metaUri); + const meta: TaskMeta = JSON.parse(Buffer.from(metaContent).toString('utf-8')); + + // 读取会话内容 + const conversationPath = path.join(taskDir, 'conversation.json'); + let messages: ChatMessage[] = []; + try { + const conversationUri = vscode.Uri.file(conversationPath); + const conversationContent = await vscode.workspace.fs.readFile(conversationUri); + messages = JSON.parse(Buffer.from(conversationContent).toString('utf-8')); + } catch { + // 会话文件不存在,使用空数组 + } + + // 读取会话元数据 + const conversationMetaPath = path.join(taskDir, 'conversation_meta.jsonl'); + let conversationMeta: ConversationMeta[] = []; + try { + const metaUri = vscode.Uri.file(conversationMetaPath); + const content = await vscode.workspace.fs.readFile(metaUri); + const data = Buffer.from(content).toString('utf-8'); + conversationMeta = data + .split('\n') + .filter(line => line.trim()) + .map(line => JSON.parse(line)); + } catch { + // 元数据文件不存在,使用空数组 + } + + return { + meta, + messages, + conversationMeta + }; + } catch (error) { + console.error(`加载任务 ${taskId} 的会话失败:`, error); + return null; + } + } + + /** + * 获取会话历史列表(支持分页) + * 返回格式:{ id: taskId, title: 第一句用户消息, timestamp: 创建时间 } + * @param projectPath 项目路径 + * @param offset 偏移量(从第几条开始,默认0) + * @param limit 每页数量(默认10条) + * @returns { items: 历史列表, total: 总数, hasMore: 是否还有更多 } + */ + public async getConversationHistoryList( + projectPath: string, + offset: number = 0, + limit: number = 10 + ): Promise<{ + items: Array<{ id: string; title: string; timestamp: string }>; + total: number; + hasMore: boolean; + }> { + const tasks = await this.listProjectTasks(projectPath); + const total = tasks.length; + const historyList: Array<{ id: string; title: string; timestamp: string }> = []; + + // 计算分页范围 + const start = offset; + const end = Math.min(offset + limit, total); + const limitedTasks = tasks.slice(start, end); + + for (const task of limitedTasks) { + // 读取该任务的 conversation.json 获取第一句用户消息 + const taskDir = this.getTaskDir(task.projectPath, task.taskId); + 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'); + const messages: ChatMessage[] = JSON.parse(data); + + // 找到第一条用户消息 + const firstUserMessage = messages.find(msg => msg.type === MessageType.USER) as UserMessage | undefined; + + let title = '未命名会话'; + if (firstUserMessage && firstUserMessage.contents && firstUserMessage.contents.length > 0) { + const textContent = firstUserMessage.contents.find(c => c.type === 'TEXT'); + if (textContent && 'text' in textContent) { + // 截取前50个字符作为标题 + title = textContent.text.length > 50 + ? textContent.text.substring(0, 50) + '...' + : textContent.text; + } + } + + historyList.push({ + id: task.taskId, + title, + timestamp: task.createdAt + }); + } catch (error) { + console.error(`读取任务 ${task.taskId} 的会话历史失败:`, error); + // 如果读取失败,使用任务名称作为标题 + historyList.push({ + id: task.taskId, + title: task.taskName || '未命名会话', + timestamp: task.createdAt + }); + } + } + + // 返回分页结果 + return { + items: historyList, + total, + hasMore: end < total + }; + } } diff --git a/src/utils/messageHandler.ts b/src/utils/messageHandler.ts index 70599cb..c9fd435 100644 --- a/src/utils/messageHandler.ts +++ b/src/utils/messageHandler.ts @@ -170,35 +170,16 @@ async function handleUserMessageWithBackend( }); console.log('[MessageHandler] postMessage 返回值:', result); - // 按照 segments 顺序保存AI响应到历史记录 + // 保存完整的 segments 到历史记录 try { - // 遍历 segments,按顺序保存每个段落 - for (const segment of segments) { - if (segment.type === 'text' && segment.content) { - // 保存文本消息 - await historyManager.addAiMessage(segment.content); - } else if (segment.type === 'tool') { - // 保存工具调用请求(作为AI消息的一部分) - const toolRequest = { - id: segment.askId || `tool_${Date.now()}`, - name: segment.toolName || '', - arguments: '' - }; - await historyManager.addAiMessage('', [toolRequest]); + // 将完整的 segments 保存到一条 AI 消息中 + // 这样加载时可以完整还原对话样式 + const textContent = segments + .filter(s => s.type === 'text' && s.content) + .map(s => s.content) + .join('\n'); - // 如果有工具执行结果,保存工具执行结果 - if (segment.toolResult) { - await historyManager.addToolExecutionResult( - toolRequest.id, - segment.toolName || '', - segment.toolResult - ); - } - } else if (segment.type === 'question') { - // 保存问题(作为AI消息) - await historyManager.addAiMessage(segment.question || ''); - } - } + await historyManager.addAiMessage(textContent, undefined, segments); } catch (error) { console.warn("保存AI响应历史失败:", error); } diff --git a/src/views/conversationHistoryBar.ts b/src/views/conversationHistoryBar.ts index c4d82b3..b429620 100644 --- a/src/views/conversationHistoryBar.ts +++ b/src/views/conversationHistoryBar.ts @@ -111,6 +111,10 @@ export function getConversationHistoryBarStyles(): string { cursor: pointer; transition: background 0.2s ease; border-bottom: 1px solid var(--vscode-panel-border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; } .history-item:last-child { @@ -124,15 +128,17 @@ export function getConversationHistoryBarStyles(): string { .history-item-title { font-size: 14px; font-weight: 500; - margin-bottom: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + flex: 1; } .history-item-time { font-size: 12px; opacity: 0.7; + white-space: nowrap; + flex-shrink: 0; } .history-empty { @@ -142,6 +148,14 @@ export function getConversationHistoryBarStyles(): string { font-size: 14px; } + .history-load-more { + padding: 12px 16px; + text-align: center; + color: var(--vscode-descriptionForeground); + font-size: 12px; + border-top: 1px solid var(--vscode-panel-border); + } + .new-conversation-button { width: 36px; height: 36px; @@ -199,6 +213,12 @@ export function getConversationHistoryBarScript(): string { // 会话历史相关变量 let conversationHistory = []; let currentConversationId = null; + let currentOffset = 0; + let totalHistory = 0; + let hasMoreHistory = false; + let isLoadingHistory = false; + const HISTORY_PAGE_SIZE = 10; + const MAX_HISTORY_ITEMS = 100; // 切换历史记录下拉菜单 function toggleHistoryDropdown() { @@ -211,33 +231,90 @@ export function getConversationHistoryBarScript(): string { } else { menu.classList.add('active'); button.classList.add('active'); - // 加载会话历史 - loadConversationHistory(); + // 重置并加载会话历史 + resetAndLoadHistory(); } } - // 加载会话历史 - function loadConversationHistory() { - vscode.postMessage({ command: 'loadConversationHistory' }); + // 重置并加载会话历史 + function resetAndLoadHistory() { + conversationHistory = []; + currentOffset = 0; + totalHistory = 0; + hasMoreHistory = false; + const historyList = document.getElementById('historyList'); + if (historyList) { + historyList.innerHTML = '
加载中...
'; + } + loadMoreHistory(); } - // 渲染会话历史列表 - function renderConversationHistory(history) { - conversationHistory = history; - const historyList = document.getElementById('historyList'); + // 加载更多会话历史 + function loadMoreHistory() { + if (isLoadingHistory || (currentOffset > 0 && !hasMoreHistory)) { + return; + } - if (!history || history.length === 0) { + // 检查是否已达到最大数量 + if (currentOffset >= MAX_HISTORY_ITEMS) { + return; + } + + isLoadingHistory = true; + vscode.postMessage({ + command: 'loadConversationHistory', + offset: currentOffset, + limit: HISTORY_PAGE_SIZE + }); + } + + // 渲染会话历史列表(支持追加) + function renderConversationHistory(data) { + isLoadingHistory = false; + + if (!data || !data.items) { + return; + } + + // 追加新数据 + conversationHistory = conversationHistory.concat(data.items); + totalHistory = data.total; + hasMoreHistory = data.hasMore; + currentOffset += data.items.length; + + const historyList = document.getElementById('historyList'); + if (!historyList) { + return; + } + + // 如果没有任何历史记录 + if (conversationHistory.length === 0) { historyList.innerHTML = '
暂无会话历史
'; return; } - historyList.innerHTML = history.map(item => \` -
+ // 渲染所有历史记录 + historyList.innerHTML = conversationHistory.map(item => \` +
\${item.title || '未命名会话'}
\${formatTime(item.timestamp)}
\`).join(''); + + // 如果还有更多数据,添加"加载更多"提示 + if (hasMoreHistory && currentOffset < MAX_HISTORY_ITEMS) { + historyList.innerHTML += \` +
+ 滚动加载更多... +
+ \`; + } else if (currentOffset >= MAX_HISTORY_ITEMS && hasMoreHistory) { + historyList.innerHTML += \` +
+ 已显示最近 \${MAX_HISTORY_ITEMS} 条记录 +
+ \`; + } } // 选择会话 @@ -291,6 +368,22 @@ export function getConversationHistoryBarScript(): string { }); } + // 监听下拉菜单滚动事件 + const historyDropdownMenu = document.getElementById('historyDropdownMenu'); + if (historyDropdownMenu) { + historyDropdownMenu.addEventListener('scroll', () => { + const menu = historyDropdownMenu; + const scrollTop = menu.scrollTop; + const scrollHeight = menu.scrollHeight; + const clientHeight = menu.clientHeight; + + // 当滚动到距离底部 50px 时,加载更多 + if (scrollHeight - scrollTop - clientHeight < 50) { + loadMoreHistory(); + } + }); + } + // 点击外部关闭下拉菜单 document.addEventListener('click', (event) => { const container = document.querySelector('.history-dropdown-container'); diff --git a/src/views/webviewContent.ts b/src/views/webviewContent.ts index c3e321a..f1d2c29 100644 --- a/src/views/webviewContent.ts +++ b/src/views/webviewContent.ts @@ -525,6 +525,37 @@ export function getWebviewContent(iconUri?: string): string { showQuestion(message.askId, message.question, message.options); break; + case 'conversationHistory': + // 渲染会话历史列表(支持分页) + renderConversationHistory({ + items: message.items || [], + total: message.total || 0, + hasMore: message.hasMore || false + }); + break; + + case 'clearChat': + // 清空聊天界面 + const messagesContainer = document.getElementById('messages'); + if (messagesContainer) { + messagesContainer.innerHTML = ''; + } + break; + + case 'addUserMessage': + // 添加用户消息 + if (message.text) { + addMessage(message.text, 'user'); + } + break; + + case 'addAiMessage': + // 添加AI消息 + if (message.text) { + addMessage(message.text, 'bot'); + } + break; + default: console.log('[WebView] 未处理的消息类型:', message.command); }