feat:实现任务历史加载功能 - 完整还原对话样式
主要改进: 1. 实现selectConversation功能,支持点击任务历史列表加载会话 2. 优化会话存储格式,保存完整的segments信息(包括工具调用) 3. 添加旧格式到新格式的自动转换,兼容历史数据 4. 改进错误处理,自动清理无效的空任务目录 5. 优化路径编码逻辑,确保跨平台一致性 6. 前端支持clearChat、addUserMessage、addAiMessage命令 技术细节: - 扩展AiMessage数据结构,添加segments字段 - 修改messageHandler保存逻辑,将完整segments保存到一条消息 - 实现loadTaskSession方法,加载指定任务的完整会话 - 添加自动清理机制,删除无效的空任务目录
This commit is contained in:
751
docs/会话存储技术文档.md
Normal file
751
docs/会话存储技术文档.md
Normal file
@ -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<TaskMeta>`
|
||||||
|
|
||||||
|
**流程**:
|
||||||
|
1. 生成唯一的任务 ID
|
||||||
|
2. 创建任务元数据对象
|
||||||
|
3. 创建任务目录
|
||||||
|
4. 保存 `meta.json`
|
||||||
|
5. 初始化空的 `conversation.json`
|
||||||
|
6. 设置为当前任务
|
||||||
|
|
||||||
|
**代码位置**:`chatHistoryManager.ts:114-146`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 消息保存
|
||||||
|
|
||||||
|
系统提供了四种消息保存方法:
|
||||||
|
|
||||||
|
#### 4.2.1 添加用户消息
|
||||||
|
|
||||||
|
**方法**:`addUserMessage(text: string): Promise<void>`
|
||||||
|
|
||||||
|
**代码位置**:`chatHistoryManager.ts:285-299`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4.2.2 添加 AI 消息
|
||||||
|
|
||||||
|
**方法**:`addAiMessage(text: string, toolRequests?: any[]): Promise<void>`
|
||||||
|
|
||||||
|
**代码位置**:`chatHistoryManager.ts:304-319`
|
||||||
|
|
||||||
|
#### 4.2.3 添加系统消息
|
||||||
|
|
||||||
|
**方法**:`addSystemMessage(text: string): Promise<void>`
|
||||||
|
|
||||||
|
**代码位置**:`chatHistoryManager.ts:324-335`
|
||||||
|
|
||||||
|
#### 4.2.4 添加工具执行结果
|
||||||
|
|
||||||
|
**方法**:`addToolExecutionResult(id: string, toolName: string, result: string): Promise<void>`
|
||||||
|
|
||||||
|
**代码位置**:`chatHistoryManager.ts:340-353`
|
||||||
|
|
||||||
|
### 4.3 对话元数据记录
|
||||||
|
|
||||||
|
**方法**:`recordTurnMeta(turnId, usage?, model?, duration?): Promise<void>`
|
||||||
|
|
||||||
|
**功能**:记录每轮对话的元数据,包括 Token 使用量、模型信息、耗时等。
|
||||||
|
|
||||||
|
**代码位置**:`chatHistoryManager.ts:358-378`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 => `
|
||||||
|
<div class="history-item" onclick="selectConversation('${item.id}')">
|
||||||
|
<div class="history-item-title">${item.title || '未命名会话'}</div>
|
||||||
|
<div class="history-item-time">${formatTime(item.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// 如果还有更多数据,添加"加载更多"提示
|
||||||
|
if (hasMoreHistory && currentOffset < MAX_HISTORY_ITEMS) {
|
||||||
|
historyList.innerHTML += `
|
||||||
|
<div class="history-load-more">
|
||||||
|
<span>滚动加载更多...</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 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<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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2 文件读取失败
|
||||||
|
|
||||||
|
读取失败时返回默认值:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
private async loadConversation(): Promise<ChatMessage[]> {
|
||||||
|
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<void> {
|
||||||
|
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. **高性能**:分页加载,避免一次性加载大量数据
|
||||||
|
|
||||||
|
系统已经实现了核心的会话管理功能,包括任务创建、消息保存、历史查询等,为用户提供了完整的会话历史管理体验。
|
||||||
@ -11,6 +11,8 @@ import {
|
|||||||
abortCurrentDialog,
|
abortCurrentDialog,
|
||||||
} from "../utils/messageHandler";
|
} from "../utils/messageHandler";
|
||||||
import { VCDViewerPanel } from "./VCDViewerPanel";
|
import { VCDViewerPanel } from "./VCDViewerPanel";
|
||||||
|
import { ChatHistoryManager } from "../utils/chatHistoryManager";
|
||||||
|
import { MessageType } from "../types/chatHistory";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建并显示 IC 助手面板
|
* 创建并显示 IC 助手面板
|
||||||
@ -96,14 +98,14 @@ export function showICHelperPanel(
|
|||||||
showICHelperPanel(context, panel.viewColumn);
|
showICHelperPanel(context, panel.viewColumn);
|
||||||
break;
|
break;
|
||||||
case "loadConversationHistory":
|
case "loadConversationHistory":
|
||||||
// 加载会话历史(暂未实现)
|
// 加载会话历史(支持分页)
|
||||||
panel.webview.postMessage({
|
loadConversationHistory(panel, message.offset || 0, message.limit || 10);
|
||||||
command: "conversationHistory",
|
|
||||||
history: [],
|
|
||||||
});
|
|
||||||
break;
|
break;
|
||||||
case "selectConversation":
|
case "selectConversation":
|
||||||
// 选择会话(暂未实现)
|
// 选择会话
|
||||||
|
if (message.conversationId) {
|
||||||
|
selectConversation(panel, message.conversationId, context.extensionPath);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
// 新增:处理用户回答
|
// 新增:处理用户回答
|
||||||
case "submitAnswer":
|
case "submitAnswer":
|
||||||
@ -302,3 +304,222 @@ function parseVCDSignals(content: string, maxSignals: number = 3) {
|
|||||||
|
|
||||||
return signals;
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -56,6 +56,7 @@ export interface AiMessage extends BaseMessage {
|
|||||||
text?: string;
|
text?: string;
|
||||||
toolExecutionRequests?: ToolExecutionRequest[];
|
toolExecutionRequests?: ToolExecutionRequest[];
|
||||||
thinking?: string;
|
thinking?: string;
|
||||||
|
segments?: any[]; // 保存完整的 segments 信息用于还原显示
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -8,7 +8,8 @@ import {
|
|||||||
MessageType,
|
MessageType,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
AiMessage,
|
AiMessage,
|
||||||
SystemMessage
|
SystemMessage,
|
||||||
|
ToolExecutionResultMessage
|
||||||
} from '../types/chatHistory';
|
} 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 {
|
private encodeProjectPath(projectPath: string): string {
|
||||||
return projectPath
|
return projectPath
|
||||||
.replace(/:/g, '') // 移除冒号
|
.replace(/\\/g, '--') // 替换反斜杠为 --
|
||||||
.replace(/[/\\]/g, '--'); // 替换斜杠为 --
|
.replace(/\//g, '--') // 替换正斜杠为 --
|
||||||
|
.replace(/:/g, ''); // 移除冒号
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -300,14 +302,15 @@ export class ChatHistoryManager {
|
|||||||
/**
|
/**
|
||||||
* 添加AI消息
|
* 添加AI消息
|
||||||
*/
|
*/
|
||||||
public async addAiMessage(text: string, toolRequests?: any[]): Promise<void> {
|
public async addAiMessage(text: string, toolRequests?: any[], segments?: any[]): Promise<void> {
|
||||||
await this.ensureCurrentTask();
|
await this.ensureCurrentTask();
|
||||||
const messages = await this.loadConversation();
|
const messages = await this.loadConversation();
|
||||||
|
|
||||||
const aiMessage: AiMessage = {
|
const aiMessage: AiMessage = {
|
||||||
type: MessageType.AI,
|
type: MessageType.AI,
|
||||||
text,
|
text,
|
||||||
toolExecutionRequests: toolRequests
|
toolExecutionRequests: toolRequests,
|
||||||
|
segments // 保存完整的 segments 信息
|
||||||
};
|
};
|
||||||
|
|
||||||
messages.push(aiMessage);
|
messages.push(aiMessage);
|
||||||
@ -468,6 +471,20 @@ export class ChatHistoryManager {
|
|||||||
tasks.push(JSON.parse(data));
|
tasks.push(JSON.parse(data));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`加载任务 ${taskId} 失败:`, 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 {
|
public getBaseDir(): string {
|
||||||
return this.baseDir;
|
return this.baseDir;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 加载指定任务的会话内容
|
||||||
|
* @param projectPath 项目路径
|
||||||
|
* @param taskId 任务ID
|
||||||
|
* @returns 任务会话内容,如果任务不存在则返回null
|
||||||
|
*/
|
||||||
|
public async loadTaskSession(projectPath: string, taskId: string): Promise<TaskSession | null> {
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -170,35 +170,16 @@ async function handleUserMessageWithBackend(
|
|||||||
});
|
});
|
||||||
console.log('[MessageHandler] postMessage 返回值:', result);
|
console.log('[MessageHandler] postMessage 返回值:', result);
|
||||||
|
|
||||||
// 按照 segments 顺序保存AI响应到历史记录
|
// 保存完整的 segments 到历史记录
|
||||||
try {
|
try {
|
||||||
// 遍历 segments,按顺序保存每个段落
|
// 将完整的 segments 保存到一条 AI 消息中
|
||||||
for (const segment of segments) {
|
// 这样加载时可以完整还原对话样式
|
||||||
if (segment.type === 'text' && segment.content) {
|
const textContent = segments
|
||||||
// 保存文本消息
|
.filter(s => s.type === 'text' && s.content)
|
||||||
await historyManager.addAiMessage(segment.content);
|
.map(s => s.content)
|
||||||
} else if (segment.type === 'tool') {
|
.join('\n');
|
||||||
// 保存工具调用请求(作为AI消息的一部分)
|
|
||||||
const toolRequest = {
|
|
||||||
id: segment.askId || `tool_${Date.now()}`,
|
|
||||||
name: segment.toolName || '',
|
|
||||||
arguments: ''
|
|
||||||
};
|
|
||||||
await historyManager.addAiMessage('', [toolRequest]);
|
|
||||||
|
|
||||||
// 如果有工具执行结果,保存工具执行结果
|
await historyManager.addAiMessage(textContent, undefined, segments);
|
||||||
if (segment.toolResult) {
|
|
||||||
await historyManager.addToolExecutionResult(
|
|
||||||
toolRequest.id,
|
|
||||||
segment.toolName || '',
|
|
||||||
segment.toolResult
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (segment.type === 'question') {
|
|
||||||
// 保存问题(作为AI消息)
|
|
||||||
await historyManager.addAiMessage(segment.question || '');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("保存AI响应历史失败:", error);
|
console.warn("保存AI响应历史失败:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -111,6 +111,10 @@ export function getConversationHistoryBarStyles(): string {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.2s ease;
|
transition: background 0.2s ease;
|
||||||
border-bottom: 1px solid var(--vscode-panel-border);
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-item:last-child {
|
.history-item:last-child {
|
||||||
@ -124,15 +128,17 @@ export function getConversationHistoryBarStyles(): string {
|
|||||||
.history-item-title {
|
.history-item-title {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-bottom: 4px;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-item-time {
|
.history-item-time {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-empty {
|
.history-empty {
|
||||||
@ -142,6 +148,14 @@ export function getConversationHistoryBarStyles(): string {
|
|||||||
font-size: 14px;
|
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 {
|
.new-conversation-button {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@ -199,6 +213,12 @@ export function getConversationHistoryBarScript(): string {
|
|||||||
// 会话历史相关变量
|
// 会话历史相关变量
|
||||||
let conversationHistory = [];
|
let conversationHistory = [];
|
||||||
let currentConversationId = null;
|
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() {
|
function toggleHistoryDropdown() {
|
||||||
@ -211,33 +231,90 @@ export function getConversationHistoryBarScript(): string {
|
|||||||
} else {
|
} else {
|
||||||
menu.classList.add('active');
|
menu.classList.add('active');
|
||||||
button.classList.add('active');
|
button.classList.add('active');
|
||||||
// 加载会话历史
|
// 重置并加载会话历史
|
||||||
loadConversationHistory();
|
resetAndLoadHistory();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载会话历史
|
// 重置并加载会话历史
|
||||||
function loadConversationHistory() {
|
function resetAndLoadHistory() {
|
||||||
vscode.postMessage({ command: 'loadConversationHistory' });
|
conversationHistory = [];
|
||||||
}
|
currentOffset = 0;
|
||||||
|
totalHistory = 0;
|
||||||
// 渲染会话历史列表
|
hasMoreHistory = false;
|
||||||
function renderConversationHistory(history) {
|
|
||||||
conversationHistory = history;
|
|
||||||
const historyList = document.getElementById('historyList');
|
const historyList = document.getElementById('historyList');
|
||||||
|
if (historyList) {
|
||||||
|
historyList.innerHTML = '<div class="history-empty">加载中...</div>';
|
||||||
|
}
|
||||||
|
loadMoreHistory();
|
||||||
|
}
|
||||||
|
|
||||||
if (!history || history.length === 0) {
|
// 加载更多会话历史
|
||||||
|
function loadMoreHistory() {
|
||||||
|
if (isLoadingHistory || (currentOffset > 0 && !hasMoreHistory)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否已达到最大数量
|
||||||
|
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 = '<div class="history-empty">暂无会话历史</div>';
|
historyList.innerHTML = '<div class="history-empty">暂无会话历史</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
historyList.innerHTML = history.map(item => \`
|
// 渲染所有历史记录
|
||||||
<div class="history-item"
|
historyList.innerHTML = conversationHistory.map(item => \`
|
||||||
onclick="selectConversation('\${item.id}')">
|
<div class="history-item" onclick="selectConversation('\${item.id}')">
|
||||||
<div class="history-item-title">\${item.title || '未命名会话'}</div>
|
<div class="history-item-title">\${item.title || '未命名会话'}</div>
|
||||||
<div class="history-item-time">\${formatTime(item.timestamp)}</div>
|
<div class="history-item-time">\${formatTime(item.timestamp)}</div>
|
||||||
</div>
|
</div>
|
||||||
\`).join('');
|
\`).join('');
|
||||||
|
|
||||||
|
// 如果还有更多数据,添加"加载更多"提示
|
||||||
|
if (hasMoreHistory && currentOffset < MAX_HISTORY_ITEMS) {
|
||||||
|
historyList.innerHTML += \`
|
||||||
|
<div class="history-load-more" id="loadMoreIndicator">
|
||||||
|
<span>滚动加载更多...</span>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
} else if (currentOffset >= MAX_HISTORY_ITEMS && hasMoreHistory) {
|
||||||
|
historyList.innerHTML += \`
|
||||||
|
<div class="history-load-more">
|
||||||
|
<span>已显示最近 \${MAX_HISTORY_ITEMS} 条记录</span>
|
||||||
|
</div>
|
||||||
|
\`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 选择会话
|
// 选择会话
|
||||||
@ -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) => {
|
document.addEventListener('click', (event) => {
|
||||||
const container = document.querySelector('.history-dropdown-container');
|
const container = document.querySelector('.history-dropdown-container');
|
||||||
|
|||||||
@ -525,6 +525,37 @@ export function getWebviewContent(iconUri?: string): string {
|
|||||||
showQuestion(message.askId, message.question, message.options);
|
showQuestion(message.askId, message.question, message.options);
|
||||||
break;
|
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:
|
default:
|
||||||
console.log('[WebView] 未处理的消息类型:', message.command);
|
console.log('[WebView] 未处理的消息类型:', message.command);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user