8 Commits

Author SHA1 Message Date
318d3964bd feat(contextButton): 添加上下文按钮组件及相关集成
- 新增上下文按钮组件及其 HTML 内容、样式和脚本代码
- 在输入区域中集成上下文按钮组件,替换原内联代码
- 将上下文按钮样式从输入区域样式中移除并统一管理
- 将上下文按钮事件处理函数移入独立脚本并集成至输入区域脚本
- 保持输入区域功能完整性,简化代码结构,提高可维护性
2025-12-29 11:26:15 +08:00
770da72ce3 feat: 添加模型选择器组件,整合模型选择功能到输入区域 2025-12-29 11:17:10 +08:00
c050f0e167 feat: 添加上下文按钮和自定义下拉框,优化输入区域样式 2025-12-28 19:39:49 +08:00
3daa66ea01 feat:修复多面板任务管理和历史会话加载问题
主要改进:
1. 修复面板ID唯一性问题,为每个面板生成唯一ID
2. 修改任务创建时机,改为首次发送消息时创建
3. 修复面板任务映射,同时存储taskId和projectPath
4. 修复历史会话加载后继续对话的保存问题
5. 移除ensureCurrentTask的自动创建逻辑,避免创建多余任务

技术细节:
- 为面板添加__uniqueId属性,确保多窗口独立性
- 修改panelTaskMap数据结构,存储完整任务信息
- 在selectConversation中更新面板任务映射
- 优化任务创建流程,避免空任务目录
2025-12-28 11:31:28 +08:00
9bdaf34471 feat:实现任务历史加载功能 - 完整还原对话样式
主要改进:
1. 实现selectConversation功能,支持点击任务历史列表加载会话
2. 优化会话存储格式,保存完整的segments信息(包括工具调用)
3. 添加旧格式到新格式的自动转换,兼容历史数据
4. 改进错误处理,自动清理无效的空任务目录
5. 优化路径编码逻辑,确保跨平台一致性
6. 前端支持clearChat、addUserMessage、addAiMessage命令

技术细节:
- 扩展AiMessage数据结构,添加segments字段
- 修改messageHandler保存逻辑,将完整segments保存到一条消息
- 实现loadTaskSession方法,加载指定任务的完整会话
- 添加自动清理机制,删除无效的空任务目录
2025-12-28 10:38:54 +08:00
25a8ea5aa4 feat:记录会话,按顺序记录AI和用户的会话
- 包含工具调用等
2025-12-25 15:13:15 +08:00
ef83016b7f feat:解决强制滚动的问题 2025-12-25 11:00:23 +08:00
2e6812d00d feat:vscode的版本要求从1.8改为1.07 2025-12-25 10:31:35 +08:00
14 changed files with 3838 additions and 81 deletions

View 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. **高性能**:分页加载,避免一次性加载大量数据
系统已经实现了核心的会话管理功能,包括任务创建、消息保存、历史查询等,为用户提供了完整的会话历史管理体验。

View File

@ -3,8 +3,9 @@
"displayName": "IC Coder plugin", "displayName": "IC Coder plugin",
"description": "Agentic Verilog Coding Platform for Real-World FPGAs", "description": "Agentic Verilog Coding Platform for Real-World FPGAs",
"version": "0.0.2", "version": "0.0.2",
"publisher": "ic-coder-team",
"engines": { "engines": {
"vscode": "^1.107.0" "vscode": "^1.80.0"
}, },
"icon": "media/图案(方底).png", "icon": "media/图案(方底).png",
"categories": [ "categories": [
@ -128,9 +129,10 @@
"devDependencies": { "devDependencies": {
"@types/mocha": "^10.0.10", "@types/mocha": "^10.0.10",
"@types/node": "22.x", "@types/node": "22.x",
"@types/vscode": "^1.107.0", "@types/vscode": "^1.80.0",
"@vscode/test-cli": "^0.0.12", "@vscode/test-cli": "^0.0.12",
"@vscode/test-electron": "^2.5.2", "@vscode/test-electron": "^2.5.2",
"@vscode/vsce": "^3.7.1",
"eslint": "^9.39.1", "eslint": "^9.39.1",
"ts-loader": "^9.5.4", "ts-loader": "^9.5.4",
"typescript": "^5.9.3", "typescript": "^5.9.3",

1944
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -13,16 +13,16 @@ export function activate(context: vscode.ExtensionContext) {
// 注册命令:打开助手面板 // 注册命令:打开助手面板
const openPanelCommand = vscode.commands.registerCommand( const openPanelCommand = vscode.commands.registerCommand(
"ic-coder.openPanel", "ic-coder.openPanel",
() => { async () => {
showICHelperPanel(context); await showICHelperPanel(context);
} }
); );
// 注册命令:打开聊天(用于侧边栏) // 注册命令:打开聊天(用于侧边栏)
const openChatCommand = vscode.commands.registerCommand( const openChatCommand = vscode.commands.registerCommand(
"ic-coder.openChat", "ic-coder.openChat",
() => { async () => {
showICHelperPanel(context); await showICHelperPanel(context);
} }
); );

View File

@ -11,11 +11,13 @@ 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 助手面板
*/ */
export function showICHelperPanel( export async function showICHelperPanel(
context: vscode.ExtensionContext, context: vscode.ExtensionContext,
viewColumn?: vscode.ViewColumn viewColumn?: vscode.ViewColumn
) { ) {
@ -31,6 +33,10 @@ export function showICHelperPanel(
} }
); );
// 为面板生成唯一ID
const panelId = `panel_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
(panel as any).__uniqueId = panelId;
// 设置标签页图标 // 设置标签页图标
panel.iconPath = vscode.Uri.joinPath( panel.iconPath = vscode.Uri.joinPath(
context.extensionUri, context.extensionUri,
@ -48,9 +54,29 @@ export function showICHelperPanel(
// 处理消息 // 处理消息
panel.webview.onDidReceiveMessage( panel.webview.onDidReceiveMessage(
(message) => { async (message) => {
const historyManager = ChatHistoryManager.getInstance();
const panelId = (panel as any).__uniqueId;
switch (message.command) { switch (message.command) {
case "sendMessage": case "sendMessage":
// 仅在用户发送消息时,确保面板有任务上下文
// 如果没有,则创建新任务(仅在首次发送消息时)
if (!historyManager.getPanelTask(panelId)) {
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (workspacePath) {
try {
const taskMeta = await historyManager.createTask(workspacePath, "新对话");
historyManager.setPanelTask(panelId, taskMeta.taskId, workspacePath);
} catch (error) {
console.error("创建任务失败:", error);
}
}
}
// 切换到当前面板的任务上下文
historyManager.switchToPanelTask(panelId);
handleUserMessage(panel, message.text, context.extensionPath); handleUserMessage(panel, message.text, context.extensionPath);
break; break;
case "readFile": case "readFile":
@ -96,14 +122,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":
@ -122,6 +148,17 @@ export function showICHelperPanel(
undefined, undefined,
context.subscriptions context.subscriptions
); );
// 面板关闭时清理任务映射
panel.onDidDispose(
() => {
const historyManager = ChatHistoryManager.getInstance();
const panelId = (panel as any).__uniqueId;
historyManager.removePanelTask(panelId);
},
undefined,
context.subscriptions
);
} }
/** /**
@ -302,3 +339,226 @@ 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;
}
// 更新面板的任务映射,确保后续对话保存到正确的任务中
const panelId = (panel as any).__uniqueId;
historyManager.setPanelTask(panelId, taskId, workspacePath);
// 清空当前聊天界面
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}`);
}
}

View File

@ -56,6 +56,7 @@ export interface AiMessage extends BaseMessage {
text?: string; text?: string;
toolExecutionRequests?: ToolExecutionRequest[]; toolExecutionRequests?: ToolExecutionRequest[];
thinking?: string; thinking?: string;
segments?: any[]; // 保存完整的 segments 信息用于还原显示
} }
/** /**

View File

@ -8,7 +8,8 @@ import {
MessageType, MessageType,
UserMessage, UserMessage,
AiMessage, AiMessage,
SystemMessage SystemMessage,
ToolExecutionResultMessage
} from '../types/chatHistory'; } from '../types/chatHistory';
/** /**
@ -20,6 +21,8 @@ export class ChatHistoryManager {
private baseDir: string; // ~/.iccoder private baseDir: string; // ~/.iccoder
private currentTaskId: string | null = null; private currentTaskId: string | null = null;
private currentProjectPath: string | null = null; private currentProjectPath: string | null = null;
// 存储每个面板的任务信息taskId 和 projectPath
private panelTaskMap: Map<string, { taskId: string; projectPath: string }> = new Map();
private constructor() { private constructor() {
// 设置存储路径: ~/.iccoder // 设置存储路径: ~/.iccoder
@ -33,12 +36,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, ''); // 移除冒号
} }
/** /**
@ -107,6 +111,43 @@ export class ChatHistoryManager {
return ChatHistoryManager.instance; return ChatHistoryManager.instance;
} }
/**
* 为面板设置任务ID
*/
public setPanelTask(panelId: string, taskId: string, projectPath: string): void {
this.panelTaskMap.set(panelId, { taskId, projectPath });
this.currentTaskId = taskId;
this.currentProjectPath = projectPath;
}
/**
* 获取面板的任务ID
*/
public getPanelTask(panelId: string): string | null {
const taskInfo = this.panelTaskMap.get(panelId);
return taskInfo ? taskInfo.taskId : null;
}
/**
* 切换到指定面板的任务上下文
*/
public switchToPanelTask(panelId: string): boolean {
const taskInfo = this.panelTaskMap.get(panelId);
if (taskInfo) {
this.currentTaskId = taskInfo.taskId;
this.currentProjectPath = taskInfo.projectPath;
return true;
}
return false;
}
/**
* 移除面板的任务映射
*/
public removePanelTask(panelId: string): void {
this.panelTaskMap.delete(panelId);
}
/** /**
* 创建新任务 * 创建新任务
*/ */
@ -264,17 +305,11 @@ export class ChatHistoryManager {
} }
/** /**
* 确保有当前任务,如果没有则自动创建 * 确保有当前任务,如果没有则抛出错误
*/ */
private async ensureCurrentTask(): Promise<void> { private async ensureCurrentTask(): Promise<void> {
if (!this.currentTaskId || !this.currentProjectPath) { if (!this.currentTaskId || !this.currentProjectPath) {
// 获取当前工作区路径 throw new Error("没有当前任务上下文,请确保已正确初始化面板任务");
const workspacePath = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
if (workspacePath) {
await this.createTask(workspacePath, "默认任务");
} else {
throw new Error("没有打开的工作区,无法创建任务");
}
} }
} }
@ -300,14 +335,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);
@ -333,6 +369,24 @@ export class ChatHistoryManager {
await this.saveConversation(messages); await this.saveConversation(messages);
} }
/**
* 添加工具执行结果消息
*/
public async addToolExecutionResult(id: string, toolName: string, result: string): Promise<void> {
await this.ensureCurrentTask();
const messages = await this.loadConversation();
const toolResultMessage: ToolExecutionResultMessage = {
type: MessageType.TOOL_EXECUTION_RESULT,
id,
toolName,
text: result
};
messages.push(toolResultMessage);
await this.saveConversation(messages);
}
/** /**
* 记录对话轮次元数据 * 记录对话轮次元数据
*/ */
@ -450,6 +504,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);
}
} }
} }
} }
@ -494,4 +562,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
};
}
} }

View File

@ -170,6 +170,20 @@ async function handleUserMessageWithBackend(
}); });
console.log('[MessageHandler] postMessage 返回值:', result); console.log('[MessageHandler] postMessage 返回值:', result);
// 保存完整的 segments 到历史记录
try {
// 将完整的 segments 保存到一条 AI 消息中
// 这样加载时可以完整还原对话样式
const textContent = segments
.filter(s => s.type === 'text' && s.content)
.map(s => s.content)
.join('\n');
await historyManager.addAiMessage(textContent, undefined, segments);
} catch (error) {
console.warn("保存AI响应历史失败:", error);
}
resolve(); resolve();
}, },

View File

@ -0,0 +1,71 @@
/**
* 添加上下文按钮组件
*/
/**
* 获取添加上下文按钮的 HTML 内容
*/
export function getContextButtonContent(): string {
return `
<div class="tooltip">
<button class="add-context-button" onclick="handleAddContext()">
<svg t="1766915545722" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4994" width="200" height="200">
<path d="M469.333333 469.333333V170.666667h85.333334v298.666666h298.666666v85.333334h-298.666666v298.666666h-85.333334v-298.666666H170.666667v-85.333334h298.666666z" fill="#8a8a8a" p-id="4995"></path>
</svg>
<span class="add-context-label">添加上下文</span>
</button>
<span class="tooltiptext">添加文件或代码片段作为上下文</span>
</div>
`;
}
/**
* 获取添加上下文按钮的样式
*/
export function getContextButtonStyles(): string {
return `
/* 添加上下文按钮样式 */
.add-context-button {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
background: rgba(128, 128, 128, 0.2);
border: 1px solid var(--vscode-input-border);
border-radius: 6px;
color: var(--vscode-foreground);
cursor: pointer;
transition: all 0.2s ease;
font-size: 13px;
font-weight: 500;
}
.add-context-button:hover {
background: rgba(128, 128, 128, 0.3);
border-color: var(--vscode-focusBorder);
}
.add-context-button svg {
width: 16px;
height: 16px;
color: #409eff;
}
.add-context-label {
white-space: nowrap;
}
`;
}
/**
* 获取添加上下文按钮的脚本
*/
export function getContextButtonScript(): string {
return `
// 添加上下文处理函数
function handleAddContext() {
// 发送添加上下文请求到扩展
vscode.postMessage({ command: 'addContext' });
}
`;
}

View File

@ -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;
const historyList = document.getElementById('historyList');
if (historyList) {
historyList.innerHTML = '<div class="history-empty">加载中...</div>';
}
loadMoreHistory();
} }
// 渲染会话历史列表 // 加载更多会话历史
function renderConversationHistory(history) { function loadMoreHistory() {
conversationHistory = history; if (isLoadingHistory || (currentOffset > 0 && !hasMoreHistory)) {
const historyList = document.getElementById('historyList'); 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 = '<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');

View File

@ -1,4 +1,14 @@
import { getWaveformPreviewContent } from "./waveformPreviewContent"; import { getWaveformPreviewContent } from "./waveformPreviewContent";
import {
getModelSelectorContent,
getModelSelectorStyles,
getModelSelectorScript
} from "./modelSelector";
import {
getContextButtonContent,
getContextButtonStyles,
getContextButtonScript
} from "./contextButton";
/** /**
* 获取输入区域的 HTML 内容 * 获取输入区域的 HTML 内容
@ -8,8 +18,11 @@ export function getInputAreaContent(): string {
<div class="input-area"> <div class="input-area">
<div class="input-group"> <div class="input-group">
<div class="input-wrapper"> <div class="input-wrapper">
<!-- Plan 开关 --> <!-- 顶部工具栏 -->
<div class="plan-toggle-container"> <div class="input-top-toolbar">
${getContextButtonContent()}
<!-- Plan 开关 -->
<div class="tooltip"> <div class="tooltip">
<label class="plan-toggle"> <label class="plan-toggle">
<input type="checkbox" id="planToggle" onchange="handlePlanToggle()"> <input type="checkbox" id="planToggle" onchange="handlePlanToggle()">
@ -26,14 +39,25 @@ export function getInputAreaContent(): string {
></textarea> ></textarea>
<div class="input-bottom-row"> <div class="input-bottom-row">
<div class="mode-selector"> <div class="mode-selector">
<!-- 模式选择 -->
<div class="tooltip"> <div class="tooltip">
<select id="modeSelect"> <div class="custom-select" id="customSelect">
<option value="agent" selected>Agent</option> <div class="select-trigger" onclick="toggleModeDropdown()">
<option value="ask">Ask</option> <span class="select-value" id="selectValue">Agent</span>
<option value="auto">Auto</option> <svg class="select-arrow" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
</select> <path d="M507.8 727.728a30.016 30.016 0 0 1-21.288-8.824L231.104 463.496a30.088 30.088 0 0 1 0-42.568 30.088 30.088 0 0 1 42.568 0l234.128 234.128 234.16-234.128a30.088 30.088 0 0 1 42.568 0 30.088 30.088 0 0 1 0 42.568L529.08 718.904a30 30 0 0 1-21.28 8.824z" fill="#8a8a8a"/>
<span class="tooltiptext">切换模型</span> </svg>
</div>
<div class="select-dropdown" id="selectDropdown">
<div class="select-option" data-value="agent" onclick="selectMode('agent', 'Agent')">Agent</div>
<div class="select-option" data-value="ask" onclick="selectMode('ask', 'Ask')">Ask</div>
<div class="select-option" data-value="auto" onclick="selectMode('auto', 'Auto')">Auto</div>
</div>
</div>
<span class="tooltiptext">切换模式</span>
</div> </div>
${getModelSelectorContent()}
</div> </div>
<div class="input-actions"> <div class="input-actions">
<!-- 上下文显示 --> <!-- 上下文显示 -->
@ -94,6 +118,8 @@ export function getInputAreaContent(): string {
*/ */
export function getInputAreaStyles(): string { export function getInputAreaStyles(): string {
return ` return `
${getModelSelectorStyles()}
${getContextButtonStyles()}
.input-area { .input-area {
border-top: 1px solid var(--vscode-panel-border); border-top: 1px solid var(--vscode-panel-border);
padding-top: 15px; padding-top: 15px;
@ -123,11 +149,13 @@ export function getInputAreaStyles(): string {
gap: 8px; gap: 8px;
width: 100%; width: 100%;
} }
/* Plan 开关样式 */ /* 顶部工具栏样式 */
.plan-toggle-container { .input-top-toolbar {
display: flex; display: flex;
justify-content: flex-end; justify-content: space-between;
margin-bottom: -4px; align-items: center;
margin-bottom: 8px;
gap: 12px;
} }
.plan-toggle { .plan-toggle {
display: flex; display: flex;
@ -182,26 +210,78 @@ export function getInputAreaStyles(): string {
.mode-selector { .mode-selector {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px;
position: relative; position: relative;
} }
/* 自定义下拉框样式(用于模式选择) */
.custom-select {
position: relative;
user-select: none;
}
.select-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--vscode-input-background);
color: var(--vscode-foreground);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s ease;
}
.select-trigger:hover {
background: var(--vscode-list-hoverBackground);
}
.select-value {
white-space: nowrap;
}
.select-arrow {
width: 12px;
height: 12px;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.custom-select.active .select-arrow {
transform: rotate(180deg);
}
.select-dropdown {
position: absolute;
bottom: calc(100% + 2px);
left: 0;
min-width: 100%;
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1100;
display: none;
overflow: hidden;
}
.custom-select.active .select-dropdown {
display: block;
}
/* 模式选择器的选项样式 */
#selectDropdown .select-option {
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: background 0.2s ease;
white-space: nowrap;
}
#selectDropdown .select-option:hover {
background: rgba(128, 128, 128, 0.3);
}
#selectDropdown .select-option.selected {
background: rgba(128, 128, 128, 0.5);
color: var(--vscode-foreground);
}
.input-actions { .input-actions {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.mode-selector select {
padding: 2px 4px;
background: var(--vscode-input-background);
color: var(--vscode-foreground);
border: none;
cursor: pointer;
font-size: 12px;
outline: none;
border-radius: 4px;
}
.mode-selector select:hover {
background: var(--vscode-list-hoverBackground);
}
/* Tooltip 样式 */ /* Tooltip 样式 */
.tooltip { .tooltip {
position: relative; position: relative;
@ -449,6 +529,9 @@ export function getInputAreaStyles(): string {
*/ */
export function getInputAreaScript(): string { export function getInputAreaScript(): string {
return ` return `
${getModelSelectorScript()}
${getContextButtonScript()}
// 自动调整 textarea 高度 // 自动调整 textarea 高度
function autoResizeTextarea() { function autoResizeTextarea() {
if (messageInput) { if (messageInput) {
@ -468,15 +551,65 @@ export function getInputAreaScript(): string {
messageInput.focus(); messageInput.focus();
} }
// 自定义下拉框相关变量
let currentMode = 'agent';
// 切换模式下拉框显示/隐藏
function toggleModeDropdown() {
const customSelect = document.getElementById('customSelect');
const modelSelect = document.getElementById('modelSelect');
if (customSelect) {
customSelect.classList.toggle('active');
// 关闭模型下拉框
if (modelSelect) {
modelSelect.classList.remove('active');
}
}
}
// 选择模式
function selectMode(value, label) {
currentMode = value;
const selectValue = document.getElementById('selectValue');
if (selectValue) {
selectValue.textContent = label;
}
// 更新选中状态
const options = document.querySelectorAll('#selectDropdown .select-option');
options.forEach(option => {
if (option.getAttribute('data-value') === value) {
option.classList.add('selected');
} else {
option.classList.remove('selected');
}
});
// 关闭下拉框
const customSelect = document.getElementById('customSelect');
if (customSelect) {
customSelect.classList.remove('active');
}
}
// 点击外部关闭下拉框
document.addEventListener('click', (event) => {
const customSelect = document.getElementById('customSelect');
if (customSelect && !customSelect.contains(event.target)) {
customSelect.classList.remove('active');
}
});
function sendMessage() { function sendMessage() {
const text = messageInput.value.trim(); const text = messageInput.value.trim();
if (!text) return; if (!text) return;
const modeSelect = document.getElementById('modeSelect'); const mode = currentMode;
const mode = modeSelect ? modeSelect.value : 'agent'; const model = getCurrentModel(); // 从模型选择器组件获取当前模型
addMessage(text, 'user'); addMessage(text, 'user');
vscode.postMessage({ command: 'sendMessage', text: text, mode: mode }); vscode.postMessage({ command: 'sendMessage', text: text, mode: mode, model: model });
messageInput.value = ''; messageInput.value = '';
autoResizeTextarea(); // 重置输入框高度 autoResizeTextarea(); // 重置输入框高度
messageInput.focus(); messageInput.focus();

View File

@ -556,6 +556,19 @@ export function getMessageAreaScript(): string {
return toolNameMap[toolName] || toolName; return toolNameMap[toolName] || toolName;
} }
// 检查用户是否在底部附近允许50px的误差
function isUserNearBottom() {
const threshold = 50;
return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < threshold;
}
// 智能滚动:只有用户在底部附近时才自动滚动
function smartScrollToBottom() {
if (isUserNearBottom()) {
messagesEl.scrollTop = messagesEl.scrollHeight;
}
}
// 添加消息 // 添加消息
function addMessage(text, sender) { function addMessage(text, sender) {
const div = document.createElement('div'); const div = document.createElement('div');
@ -602,7 +615,7 @@ export function getMessageAreaScript(): string {
} }
messagesEl.appendChild(div); messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight; smartScrollToBottom();
// 添加消息后检查 header 显示状态 // 添加消息后检查 header 显示状态
checkHeaderVisibility(); checkHeaderVisibility();
@ -672,8 +685,8 @@ export function getMessageAreaScript(): string {
} }
} }
// 滚动到底部 // 智能滚动到底部
messagesEl.scrollTop = messagesEl.scrollHeight; smartScrollToBottom();
} }
// 完成流式消息 // 完成流式消息
@ -699,7 +712,7 @@ export function getMessageAreaScript(): string {
currentStreamingMessage = null; currentStreamingMessage = null;
} }
messagesEl.scrollTop = messagesEl.scrollHeight; smartScrollToBottom();
} }
// 显示加载指示器 // 显示加载指示器
@ -715,7 +728,7 @@ export function getMessageAreaScript(): string {
<span class="loading-text">\${text}</span> <span class="loading-text">\${text}</span>
\`; \`;
messagesEl.appendChild(loadingIndicator); messagesEl.appendChild(loadingIndicator);
messagesEl.scrollTop = messagesEl.scrollHeight; smartScrollToBottom();
} }
// 隐藏加载指示器 // 隐藏加载指示器
@ -930,8 +943,8 @@ export function getMessageAreaScript(): string {
currentSegmentedMessage = null; currentSegmentedMessage = null;
} }
// 滚动到底部 // 智能滚动到底部
messagesEl.scrollTop = messagesEl.scrollHeight; smartScrollToBottom();
} }
// 渲染分段消息(兼容旧代码) // 渲染分段消息(兼容旧代码)
@ -1067,7 +1080,7 @@ export function getMessageAreaScript(): string {
container.appendChild(actionsDiv); container.appendChild(actionsDiv);
messagesEl.appendChild(container); messagesEl.appendChild(container);
messagesEl.scrollTop = messagesEl.scrollHeight; smartScrollToBottom();
} }
// 格式化文本(支持 Markdown // 格式化文本(支持 Markdown
@ -1138,7 +1151,7 @@ export function getMessageAreaScript(): string {
\${detail ? \`<div class="tool-detail">\${detail}</div>\` : ''} \${detail ? \`<div class="tool-detail">\${detail}</div>\` : ''}
\`; \`;
messagesEl.appendChild(div); messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight; smartScrollToBottom();
// 添加消息后检查 header 显示状态 // 添加消息后检查 header 显示状态
checkHeaderVisibility(); checkHeaderVisibility();
@ -1198,7 +1211,7 @@ export function getMessageAreaScript(): string {
div.appendChild(customContainer); div.appendChild(customContainer);
messagesEl.appendChild(div); messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight; smartScrollToBottom();
// 添加消息后检查 header 显示状态 // 添加消息后检查 header 显示状态
checkHeaderVisibility(); checkHeaderVisibility();

250
src/views/modelSelector.ts Normal file
View File

@ -0,0 +1,250 @@
/**
* 模型选择器组件
*/
/**
* 获取模型选择器的 HTML 内容
*/
export function getModelSelectorContent(): string {
return `
<!-- 模型选择 -->
<div class="tooltip">
<div class="custom-select" id="modelSelect">
<div class="select-trigger" onclick="toggleModelDropdown()">
<span class="select-value" id="modelValue">Auto</span>
<svg class="select-arrow" viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
<path d="M507.8 727.728a30.016 30.016 0 0 1-21.288-8.824L231.104 463.496a30.088 30.088 0 0 1 0-42.568 30.088 30.088 0 0 1 42.568 0l234.128 234.128 234.16-234.128a30.088 30.088 0 0 1 42.568 0 30.088 30.088 0 0 1 0 42.568L529.08 718.904a30 30 0 0 1-21.28 8.824z" fill="#8a8a8a"/>
</svg>
</div>
<div class="select-dropdown" id="modelDropdown">
<div class="select-option" data-value="lite" data-tooltip="快速响应,适合简单任务" onclick="selectModel('lite', 'Lite')">Lite</div>
<div class="select-option selected" data-value="auto" data-tooltip="自动选择最佳模型" onclick="selectModel('auto', 'Auto')">Auto</div>
<div class="select-option" data-value="syntaxic" data-tooltip="语法分析和代码理解" onclick="selectModel('syntaxic', 'Syntaxic')">Syntaxic</div>
<div class="select-option" data-value="max" data-tooltip="最强性能,复杂任务" onclick="selectModel('max', 'Max')">Max</div>
</div>
<!-- 模型选择器的 tooltip 容器 -->
<div id="modelTooltip" class="model-tooltip"></div>
</div>
<span class="tooltiptext">选择模型</span>
</div>
`;
}
/**
* 获取模型选择器的样式
*/
export function getModelSelectorStyles(): string {
return `
/* 自定义下拉框样式 */
.custom-select {
position: relative;
user-select: none;
}
.select-trigger {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
background: var(--vscode-input-background);
color: var(--vscode-foreground);
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background 0.2s ease;
}
.select-trigger:hover {
background: var(--vscode-list-hoverBackground);
}
.select-value {
white-space: nowrap;
}
.select-arrow {
width: 12px;
height: 12px;
flex-shrink: 0;
transition: transform 0.2s ease;
}
.custom-select.active .select-arrow {
transform: rotate(180deg);
}
.select-dropdown {
position: absolute;
bottom: calc(100% + 2px);
left: 0;
min-width: 100%;
background: var(--vscode-dropdown-background);
border: 1px solid var(--vscode-dropdown-border);
border-radius: 4px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 1100;
display: none;
overflow: visible;
}
.custom-select.active .select-dropdown {
display: block;
}
/* 模型选择器的选项样式 */
#modelDropdown .select-option {
position: relative;
padding: 6px 12px;
font-size: 12px;
cursor: pointer;
transition: background 0.2s ease;
white-space: nowrap;
}
#modelDropdown .select-option:hover {
background: rgba(128, 128, 128, 0.3);
}
#modelDropdown .select-option.selected {
background: rgba(128, 128, 128, 0.5);
color: var(--vscode-foreground);
}
/* 模型选择器的 tooltip 样式 */
.model-tooltip {
position: fixed;
background: #1e1e1e;
color: #ffffff;
padding: 8px 12px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
pointer-events: none;
z-index: 10000;
border: 1px solid rgba(255, 255, 255, 0.2);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6);
opacity: 0;
visibility: hidden;
transition: opacity 0.2s ease, visibility 0.2s ease;
}
.model-tooltip.show {
opacity: 1;
visibility: visible;
}
/* tooltip 箭头 */
.model-tooltip::before {
content: "";
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
border-width: 7px;
border-style: solid;
border-color: transparent rgba(255, 255, 255, 0.2) transparent transparent;
z-index: -1;
}
.model-tooltip::after {
content: "";
position: absolute;
right: 100%;
top: 50%;
transform: translateY(-50%);
border-width: 6px;
border-style: solid;
border-color: transparent #1e1e1e transparent transparent;
margin-right: 1px;
}
`;
}
/**
* 获取模型选择器的脚本
*/
export function getModelSelectorScript(): string {
return `
// 模型选择相关变量
let currentModel = 'auto';
// 切换模型下拉框显示/隐藏
function toggleModelDropdown() {
const modelSelect = document.getElementById('modelSelect');
const customSelect = document.getElementById('customSelect');
if (modelSelect) {
modelSelect.classList.toggle('active');
// 关闭模式下拉框
if (customSelect) {
customSelect.classList.remove('active');
}
}
}
// 选择模型
function selectModel(value, label) {
currentModel = value;
const modelValue = document.getElementById('modelValue');
if (modelValue) {
modelValue.textContent = label;
}
// 更新选中状态
const options = document.querySelectorAll('#modelDropdown .select-option');
options.forEach(option => {
if (option.getAttribute('data-value') === value) {
option.classList.add('selected');
} else {
option.classList.remove('selected');
}
});
// 关闭下拉框
const modelSelect = document.getElementById('modelSelect');
if (modelSelect) {
modelSelect.classList.remove('active');
}
}
// 点击外部关闭模型下拉框
document.addEventListener('click', (event) => {
const modelSelect = document.getElementById('modelSelect');
if (modelSelect && !modelSelect.contains(event.target)) {
modelSelect.classList.remove('active');
}
});
// 获取当前选中的模型
function getCurrentModel() {
return currentModel;
}
// 模型选择器 tooltip 功能
(function initModelTooltip() {
const modelDropdown = document.getElementById('modelDropdown');
const modelTooltip = document.getElementById('modelTooltip');
if (!modelDropdown || !modelTooltip) return;
// 为每个选项添加鼠标事件
const options = modelDropdown.querySelectorAll('.select-option');
options.forEach(option => {
option.addEventListener('mouseenter', function(e) {
const tooltipText = this.getAttribute('data-tooltip');
if (!tooltipText) return;
// 设置 tooltip 内容
modelTooltip.textContent = tooltipText;
// 获取选项的位置
const rect = this.getBoundingClientRect();
// 计算 tooltip 位置(在选项右侧)
const tooltipRect = modelTooltip.getBoundingClientRect();
const left = rect.right + 12;
const top = rect.top + (rect.height / 2) - (tooltipRect.height / 2);
// 设置位置
modelTooltip.style.left = left + 'px';
modelTooltip.style.top = top + 'px';
// 显示 tooltip
modelTooltip.classList.add('show');
});
option.addEventListener('mouseleave', function() {
// 隐藏 tooltip
modelTooltip.classList.remove('show');
});
});
})();
`;
}

View File

@ -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);
} }