diff --git a/.vscodeignore b/.vscodeignore index 69a648b..58069f3 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -18,10 +18,15 @@ node_modules/** # 文档(避免中文文件名打包问题) docs/** -PUBLISH.md +CLAUDE.md # 只排除 waveform_trace 的 src/dist 目录 -tools/waveform_trace/src/dist/** +tools/waveform_trace/src/** +tools/iverilog/examples/** +tools/iverilog/INSTALL.md +tools/iverilog/README.md +tools/iverilog/DOWNLOAD_INSTRUCTIONS.md + # Git 相关 .git/** diff --git a/docs/EDA联动功能需求文档.md b/docs/EDA联动功能需求文档.md new file mode 100644 index 0000000..e7cfd69 --- /dev/null +++ b/docs/EDA联动功能需求文档.md @@ -0,0 +1,392 @@ +# Vivado 联动功能需求文档 + +## 1. 项目背景 + +### 1.1 当前状态 + +IC Coder Plugin 目前支持: + +- iverilog 仿真(内置 Windows 版本) +- VCD 波形查看 +- Verilog 代码生成和文件操作 + +### 1.2 需求来源 + +用户需要在 VS Code 中直接调用本地 Vivado 工具,并将产出文件自动导入到项目中,完成从仿真到 FPGA 部署的完整流程。 + +### 1.3 Vivado 是什么? + +**Vivado** 是 Xilinx(现 AMD)的 FPGA 开发工具,用于将 Verilog 代码部署到 FPGA 硬件: + +- **综合(Synthesis)**:将 RTL 代码转换为门级网表 +- **实现(Implementation)**:布局布线,映射到具体 FPGA 芯片 +- **生成比特流(Bitstream)**:生成 .bit 配置文件用于烧录 + +**与 iverilog 的区别**: + +- iverilog:只做**仿真验证**(软件层面验证逻辑) +- Vivado:做**综合+实现+生成配置文件**(真正部署到硬件) + +**典型开发流程**: + +``` +编写 Verilog → iverilog 仿真验证 → Vivado 综合 → Vivado 实现 → 生成 .bit 文件 → 烧录到 FPGA +``` + +## 2. 功能目标 + +### 2.1 核心目标 + +- **前端工具封装**:在插件前端实现 Vivado 调用的完整逻辑 +- **后端简化调用**:后端只需调用一个工具接口 +- **文件自动导入**:Vivado 执行完成后,自动将产出文件导入到项目 +- **流程可视化**:执行进度、日志实时显示 + +### 2.2 非功能目标 + +- 配置简单,用户友好 +- 执行过程可视化(进度、日志) +- 错误处理完善,提示清晰 + +## 3. 功能详细需求 + +### 3.1 Vivado 支持的操作 + +#### 3.1.1 综合(Synthesis) + +- **输入**:Verilog/VHDL 源文件、约束文件(.xdc) +- **输出**:设计检查点(.dcp)、综合报告(.rpt) +- **用途**:将 RTL 代码转换为门级网表,检查资源使用情况 + +#### 3.1.2 实现(Implementation) + +- **输入**:综合后的 .dcp 文件 +- **输出**:实现后的 .dcp 文件、时序报告、布局布线报告 +- **用途**:完成布局布线,检查时序是否满足要求 + +#### 3.1.3 生成比特流(Generate Bitstream) + +- **输入**:实现后的 .dcp 文件 +- **输出**:比特流文件(.bit) +- **用途**:生成可烧录到 FPGA 的配置文件 + +### 3.2 配置管理 + +#### 3.2.1 配置项 + +```json +{ + "vivado": { + "enabled": true, + "executablePath": "C:/Xilinx/Vivado/2023.1/bin/vivado.bat", + "workingDir": "${workspaceFolder}/vivado_project", + "part": "xc7a35tcpg236-1", // FPGA 型号 + "commands": { + "synthesis": "vivado -mode batch -source synth.tcl", + "implementation": "vivado -mode batch -source impl.tcl", + "bitstream": "vivado -mode batch -source bitstream.tcl" + }, + "outputFiles": { + "synthesis": ["*.dcp", "*_synth.rpt"], + "implementation": ["*.dcp", "*_timing.rpt", "*_utilization.rpt"], + "bitstream": ["*.bit"] + } + } +} +``` + +#### 3.2.2 存储位置 + +- 全局配置:VS Code Settings(`settings.json`) +- 项目配置:`.vscode/ic-coder-vivado.json`(优先级更高) + +### 3.3 工具调用接口 + +#### 3.3.1 接口定义 + +```typescript +interface VivadoToolRequest { + command: string; // 命令类型:synthesis | implementation | bitstream + parameters?: { + topModule?: string; // 顶层模块名 + files?: string[]; // 输入文件列表 + part?: string; // FPGA 型号(可选,使用配置中的默认值) + constraints?: string; // 约束文件路径(.xdc) + outputDir?: string; // 输出目录 + }; + importOutput?: { + enabled: boolean; // 是否自动导入 + targetDir: string; // 目标目录 + }; +} + +interface VivadoToolResponse { + success: boolean; + command: string; + executionTime: number; // 执行时间(毫秒) + output: string; // 标准输出 + error?: string; // 错误信息 + importedFiles?: string[]; // 已导入的文件列表 + reports?: { + // 报告摘要 + resources?: string; // 资源使用情况 + timing?: string; // 时序信息 + }; +} +``` + +### 3.4 执行流程 + +#### 3.4.1 参数验证 + +- 检查 Vivado 是否已配置 +- 检查可执行文件是否存在 +- 检查输入文件是否存在 +- 检查工作目录是否存在 + +#### 3.4.2 TCL 脚本生成 + +根据命令类型自动生成 TCL 脚本: + +**综合脚本示例(synth.tcl)**: + +```tcl +# 读取源文件 +read_verilog counter.v +read_xdc constraints.xdc + +# 设置顶层模块 +set_property top counter [current_fileset] + +# 综合 +synth_design -part xc7a35tcpg236-1 -top counter + +# 生成报告 +report_utilization -file utilization_synth.rpt +report_timing -file timing_synth.rpt + +# 保存检查点 +write_checkpoint -force counter_synth.dcp +``` + +#### 3.4.3 命令执行 + +- 启动子进程执行 Vivado 命令 +- 实时捕获标准输出和错误输出 +- 向前端推送进度信息(解析日志中的进度标记) + +#### 3.4.4 结果处理 + +- 检查执行结果(退出码) +- 解析报告文件,提取关键信息(资源使用、时序) +- 查找产出文件 + +#### 3.4.5 文件导入 + +- 根据配置的文件模式查找产出文件 +- 复制文件到目标目录 +- 通知用户导入结果 + +### 3.5 UI 交互 + +#### 3.5.1 配置界面 + +- 在设置页面添加 "Vivado 配置" 选项 +- 支持配置 Vivado 路径、FPGA 型号 +- 支持测试 Vivado 可用性(点击按钮测试) + +#### 3.5.2 调用界面 + +- 在聊天面板中,AI 可以建议使用 Vivado +- 用户确认后,显示执行进度对话框 +- 实时显示日志输出(可折叠) +- 显示执行状态:准备中 → 执行中 → 完成/失败 + +#### 3.5.3 结果展示 + +- 执行成功:显示执行时间、资源使用、时序信息 +- 执行失败:显示错误信息、建议解决方案 +- 导入文件:高亮显示已导入的文件,支持点击打开报告 + +### 3.6 后端集成 + +#### 3.6.1 工具定义 + +后端在工具列表中添加 Vivado 工具: + +```json +{ + "name": "runVivado", + "description": "调用 Vivado 执行综合、实现或生成比特流。使用前必须先询问用户芯片型号等必要参数。", + "parameters": { + "command": "命令类型(synthesis/implementation/bitstream)", + "topModule": "顶层模块名", + "files": "输入文件列表", + "part": "FPGA 芯片型号(必须从用户获取)", + "constraints": "约束文件路径(可选)" + } +} +``` + +#### 3.6.2 后端交互流程 + +**关键点**:后端必须先收集必要参数,再调用工具 + +1. **用户发起请求**:"打开 Vivado" 或 "用 Vivado 综合" +2. **后端识别意图**:需要调用 runVivado 工具 +3. **后端询问参数**: + - FPGA 芯片型号(必须) + - 约束文件(可选) + - 确认顶层模块名 +4. **用户提供参数** +5. **后端调用工具**:传递完整参数给前端 +6. **前端执行**:VivadoRunner 执行命令 +7. **返回结果**:后端接收结果并展示给用户 + +#### 3.6.3 调用示例(完整交互) + +``` +用户:帮我用 Vivado 综合一下 counter.v + +AI:好的,我将使用 Vivado 进行综合。请提供以下信息: + 1. FPGA 芯片型号(例如:xc7a35tcpg236-1) + 2. 是否有约束文件(.xdc)? + +用户:xc7a35tcpg236-1,没有约束文件 + +AI:收到,开始综合... + [调用工具] runVivado + 参数: + - command: synthesis + - topModule: counter + - files: ["counter.v"] + - part: "xc7a35tcpg236-1" + + [执行中...] + Vivado 综合完成! + - 芯片型号:xc7a35tcpg236-1 + - 执行时间:45 秒 + - 资源使用:LUT: 32/20800 (0.15%), FF: 8/41600 (0.02%) + - 产出文件:counter_synth.dcp, utilization_synth.rpt + - 已自动导入到:vivado_output/ +``` + +## 4. 用户场景 + +### 4.1 场景一:单步综合 + +1. 用户编写完 Verilog 代码 +2. 在聊天中输入:"用 Vivado 综合一下 counter.v" +3. AI 调用 `runVivado` 工具 +4. 插件执行 Vivado 综合 +5. 综合完成后,显示资源使用情况,自动导入报告文件 + +### 4.2 场景二:完整流程 + +1. 用户输入:"用 Vivado 跑完整个流程" +2. AI 依次调用: + - 综合(Synthesis) + - 实现(Implementation) + - 生成比特流(Bitstream) +3. 每个步骤完成后显示结果 +4. 最终生成 .bit 文件,用户可以烧录到 FPGA + +### 4.3 场景三:查看报告 + +1. Vivado 执行完成后 +2. 用户点击导入的报告文件 +3. 在编辑器中查看资源使用、时序分析等信息 + +## 5. 技术约束 + +### 5.1 平台兼容性 + +- Windows:支持 `.bat` 可执行文件 +- Linux:支持 shell 脚本 +- 路径分隔符自动适配 + +### 5.2 性能要求 + +- 命令执行不阻塞 UI +- 综合时间可能较长(分钟级),需要进度提示 +- 日志输出实时更新,限制缓冲区大小 + +### 5.3 安全性 + +- 工作目录限制在项目范围内 +- 许可证路径不记录到日志 + +## 6. 验收标准 + +### 6.1 功能验收 + +- [ ] 用户可以配置 Vivado 路径和 FPGA 型号 +- [ ] AI 可以通过工具调用成功执行 Vivado 综合 +- [ ] 产出文件自动导入到指定目录 +- [ ] 执行过程有清晰的进度提示 +- [ ] 报告文件可以正常打开查看 + +### 6.2 性能验收 + +- [ ] 小型项目综合时间 < 1 分钟 +- [ ] UI 响应流畅,不卡顿 +- [ ] 日志输出实时更新(延迟 < 500ms) + +### 6.3 用户体验验收 + +- [ ] 配置界面直观易用 +- [ ] 首次使用有引导提示 +- [ ] 错误提示清晰,有解决建议 +- [ ] 导入的文件可以直接打开查看 + +## 7. 风险和依赖 + +### 7.1 风险 + +- **Vivado 版本差异**:不同版本的命令行参数可能不同 +- **许可证问题**:Vivado 需要许可证才能运行 +- **路径问题**:Windows 路径中的空格和特殊字符 +- **执行时间长**:大型项目可能需要数十分钟 + +### 7.2 依赖 + +- 用户需要自行安装 Vivado +- 用户需要配置正确的 Vivado 路径 +- 需要设置环境变量(如 `XILINX_VIVADO`) +- 需要有效的 Vivado 许可证 + +## 8. 后续扩展 + +### 8.1 短期扩展 + +- 支持自定义 TCL 脚本模板 +- 支持批量处理多个设计 +- 支持时序约束编辑器 + +### 8.2 长期扩展 + +- 支持其他 FPGA 工具(Quartus) +- 云端 Vivado 服务集成 +- 结果对比和版本管理 +- 性能分析和优化建议 + +--- + +## 附录 + +### A. Vivado 命令行参考 + +- 官方文档:https://docs.xilinx.com/ +- TCL 命令参考:UG835 +- 设计流程参考:UG892 + +### B. 术语表 + +- **RTL**:Register Transfer Level,寄存器传输级 +- **综合**:Synthesis,将 RTL 代码转换为门级网表 +- **实现**:Implementation,布局布线 +- **比特流**:Bitstream,FPGA 配置文件 +- **DCP**:Design Checkpoint,Vivado 设计检查点文件 +- **XDC**:Xilinx Design Constraints,约束文件 +- **LUT**:Look-Up Table,查找表(FPGA 基本逻辑单元) +- **FF**:Flip-Flop,触发器 diff --git a/IVERILOG_INTEGRATION.md b/docs/IVERILOG_INTEGRATION.md similarity index 100% rename from IVERILOG_INTEGRATION.md rename to docs/IVERILOG_INTEGRATION.md diff --git a/PUBLISH.md b/docs/PUBLISH.md similarity index 100% rename from PUBLISH.md rename to docs/PUBLISH.md diff --git a/docs/Vivado联动前后端对接文档.md b/docs/Vivado联动前后端对接文档.md new file mode 100644 index 0000000..3281be4 --- /dev/null +++ b/docs/Vivado联动前后端对接文档.md @@ -0,0 +1,637 @@ +# Vivado 联动前后端对接文档 + +## 1. 概述 + +本文档描述后端 AI 服务如何调用前端的 Vivado 工具,以及前端如何响应和返回结果。 + +### 1.1 调用流程 + +``` +后端 AI 服务 + ↓ (1) 发送工具调用请求 +前端 Extension (MessageHandler) + ↓ (2) 解析请求,调用 VivadoRunner +VivadoRunner + ↓ (3) 执行 Vivado,实时推送进度 +前端 Webview + ↓ (4) 显示进度和结果 +前端 Extension + ↓ (5) 返回执行结果给后端 +后端 AI 服务 +``` + +## 2. 工具定义(后端) + +### 2.1 工具注册 + +后端需要在工具列表中注册 `runVivado` 工具: + +```json +{ + "name": "runVivado", + "description": "调用本地 Vivado 工具执行 FPGA 综合、实现或生成比特流。用于将 Verilog 代码部署到 FPGA 硬件。使用前必须先询问用户必要的参数(如芯片型号、执行模式)。", + "inputSchema": { + "type": "object", + "properties": { + "command": { + "type": "string", + "enum": ["synthesis", "implementation", "bitstream"], + "description": "要执行的命令类型:synthesis(综合)、implementation(实现)、bitstream(生成比特流)" + }, + "topModule": { + "type": "string", + "description": "顶层模块名称" + }, + "files": { + "type": "array", + "items": { "type": "string" }, + "description": "输入的 Verilog 文件路径列表" + }, + "constraints": { + "type": "string", + "description": "约束文件路径(.xdc 文件),可选" + }, + "part": { + "type": "string", + "description": "FPGA 芯片型号(如 xc7a35tcpg236-1),必须从用户处获取" + }, + "mode": { + "type": "string", + "enum": ["batch", "gui"], + "description": "执行模式:batch(后台批处理)、gui(打开图形界面),必须询问用户" + } + }, + "required": ["command", "topModule", "files", "part", "mode"] + } +} +``` + +### 2.2 后端调用前的准备工作 + +**重要**:后端在调用 `runVivado` 工具前,必须先向用户询问必要参数: + +1. **芯片型号(part)**:必须询问,例如 "xc7a35tcpg236-1" +2. **执行模式(mode)**:必须询问用户选择 + - `batch`:后台批处理执行,自动完成 + - `gui`:打开 Vivado 图形界面,用户手动操作 +3. **顶层模块名**:可从文件名推断,但建议确认 +4. **约束文件**:询问是否有时序约束文件(.xdc) + +**询问示例**: +``` +AI: 我将使用 Vivado 进行综合。请提供以下信息: +1. FPGA 芯片型号(例如:xc7a35tcpg236-1) +2. 执行模式: + - 批处理模式:后台自动执行,完成后返回结果 + - 图形界面:打开 Vivado GUI,您可以手动操作 +3. 是否有约束文件(.xdc)? + +用户: xc7a35tcpg236-1,批处理模式,没有约束文件 + +AI: 好的,开始后台综合... +[调用 runVivado 工具] +``` + +### 2.3 调用示例 + +#### 示例 1:综合单个文件(批处理模式) + +```json +{ + "tool": "runVivado", + "parameters": { + "command": "synthesis", + "topModule": "counter", + "files": ["counter.v"], + "part": "xc7a35tcpg236-1", + "mode": "batch" + } +} +``` + +#### 示例 2:综合带约束文件(图形界面模式) + +```json +{ + "tool": "runVivado", + "parameters": { + "command": "synthesis", + "topModule": "uart_top", + "files": ["uart_tx.v", "uart_rx.v", "uart_top.v"], + "constraints": "constraints.xdc", + "part": "xc7k325tffg900-2", + "mode": "gui" + } +} +``` + +#### 示例 3:实现(批处理模式) + +```json +{ + "tool": "runVivado", + "parameters": { + "command": "implementation", + "topModule": "counter", + "part": "xc7a35tcpg236-1", + "mode": "batch" + } +} +``` + +#### 示例 4:生成比特流(图形界面) + +```json +{ + "tool": "runVivado", + "parameters": { + "command": "bitstream", + "topModule": "counter", + "part": "xc7a35tcpg236-1", + "mode": "gui" + } +} +``` + +## 3. 前端接收和处理 + +### 3.1 后端如何控制前端 + +**核心机制**:后端通过调用 `runVivado` 工具来控制前端执行 Vivado 命令。 + +**控制流程**: +1. 后端识别用户意图(如"打开 Vivado"、"开始仿真") +2. 后端向用户询问必要参数(芯片型号等) +3. 后端调用 `runVivado` 工具,传递参数 +4. 前端接收工具调用,执行相应操作 +5. 前端返回执行结果给后端 +6. 后端将结果展示给用户 + +**示例场景**: +``` +用户输入:"打开 Vivado 进行综合" + +后端处理: +1. 识别意图 → 需要调用 runVivado 工具 +2. 检查参数 → 缺少芯片型号 +3. 询问用户 → "请提供 FPGA 芯片型号" +4. 用户回复 → "xc7a35tcpg236-1" +5. 调用工具 → runVivado({ command: "synthesis", part: "xc7a35tcpg236-1", ... }) +6. 前端执行 → VivadoRunner 启动 Vivado +7. 返回结果 → { success: true, ... } +8. 展示结果 → "综合完成,耗时 45 秒" +``` + +### 3.2 MessageHandler 处理逻辑 + +前端在 `messageHandler.ts` 中添加工具处理: + +```typescript +// src/utils/messageHandler.ts + +export async function handleToolExecution( + panel: vscode.WebviewPanel, + toolName: string, + parameters: any +): Promise { + + if (toolName === 'runVivado') { + return await handleVivadoTool(panel, parameters); + } + + // 其他工具处理... +} + +async function handleVivadoTool( + panel: vscode.WebviewPanel, + parameters: any +): Promise { + + const { command, topModule, files, constraints, part, mode } = parameters; + + // 验证必需参数 + if (!part) { + return { + success: false, + command, + executionTime: 0, + output: '', + error: '缺少必需参数:芯片型号(part)。后端应该先询问用户。' + }; + } + + if (!mode) { + return { + success: false, + command, + executionTime: 0, + output: '', + error: '缺少必需参数:执行模式(mode)。后端应该询问用户选择 batch 或 gui。' + }; + } + + // 构建请求 + const request: VivadoToolRequest = { + command, + parameters: { + topModule, + files, + constraints, + part, + mode + }, + importOutput: { + enabled: mode === 'batch', // 只有批处理模式才自动导入 + targetDir: path.join( + vscode.workspace.workspaceFolders![0].uri.fsPath, + 'vivado_output' + ) + } + }; + + // 向前端发送开始消息 + panel.webview.postMessage({ + type: 'vivado-start', + command + }); + + // 执行 Vivado + const response = await runVivado(request, (progress) => { + // 实时推送进度到前端 + panel.webview.postMessage({ + type: 'vivado-progress', + progress + }); + }); + + // 向前端发送完成消息 + panel.webview.postMessage({ + type: 'vivado-complete', + response + }); + + // 返回结果给后端 + return response; +} +``` + +## 4. 响应格式 + +### 4.1 成功响应 + +```json +{ + "success": true, + "command": "synthesis", + "executionTime": 45230, + "output": "Vivado 执行日志...", + "importedFiles": [ + "/path/to/vivado_output/counter_synth.dcp", + "/path/to/vivado_output/counter_utilization_synth.rpt" + ], + "reports": { + "resources": "LUT: 32/20800 (0.15%)\nFF: 8/41600 (0.02%)", + "timing": "WNS: 5.234ns, TNS: 0.000ns" + } +} +``` + +### 4.2 失败响应 + +```json +{ + "success": false, + "command": "synthesis", + "executionTime": 1250, + "output": "部分执行日志...", + "error": "ERROR: [Synth 8-439] module 'counter' not found" +} +``` + +## 5. 后端使用指南 + +### 5.1 AI 对话流程(完整版) + +``` +用户:帮我用 Vivado 综合一下 counter.v + +AI 分析: +1. 用户想要综合 Verilog 文件 +2. 需要调用 runVivado 工具 +3. 命令类型是 synthesis +4. 顶层模块名从文件名推断为 counter +5. 输入文件是 counter.v +6. ⚠️ 缺少必要参数:芯片型号 + +AI 回复用户: +"好的,我将使用 Vivado 进行综合。请提供以下信息: +1. FPGA 芯片型号(例如:xc7a35tcpg236-1、xc7k325tffg900-2) +2. 是否有约束文件(.xdc)?" + +用户:xc7a35tcpg236-1,没有约束文件 + +AI 调用工具: +{ + "tool": "runVivado", + "parameters": { + "command": "synthesis", + "topModule": "counter", + "files": ["counter.v"], + "part": "xc7a35tcpg236-1" + } +} + +前端执行并返回结果 + +AI 回复用户: +"Vivado 综合完成! +- 执行时间:45.2 秒 +- 芯片型号:xc7a35tcpg236-1 +- 资源使用:LUT: 32/20800 (0.15%), FF: 8/41600 (0.02%) +- 产出文件已导入到 vivado_output 目录" +``` + +### 5.2 完整流程示例 + +``` +用户:用 Vivado 跑完整个流程 + +AI:好的,我将依次执行综合、实现和生成比特流。请提供: +1. FPGA 芯片型号 +2. 顶层模块名 +3. 是否有约束文件 + +用户:xc7a35tcpg236-1,顶层模块是 counter,没有约束文件 + +AI:收到,开始执行... + +步骤 1:综合 +[调用] runVivado { command: "synthesis", topModule: "counter", files: ["counter.v"], part: "xc7a35tcpg236-1" } +[结果] 综合成功,耗时 45s + +步骤 2:实现 +[调用] runVivado { command: "implementation", topModule: "counter", part: "xc7a35tcpg236-1" } +[结果] 实现成功,耗时 120s,时序满足要求 + +步骤 3:生成比特流 +[调用] runVivado { command: "bitstream", topModule: "counter", part: "xc7a35tcpg236-1" } +[结果] 比特流生成成功,文件:counter.bit + +完成!所有文件已导入到 vivado_output 目录。 +``` + +## 6. 错误处理 + +### 6.1 常见错误 + +#### 错误 1:Vivado 未配置 + +```json +{ + "success": false, + "error": "Vivado 未配置,请在设置中配置 Vivado 路径" +} +``` + +**AI 应该回复**: +"Vivado 尚未配置,请先在插件设置中配置 Vivado 的安装路径。" + +#### 错误 2:文件不存在 + +```json +{ + "success": false, + "error": "输入文件不存在: counter.v" +} +``` + +**AI 应该回复**: +"找不到文件 counter.v,请确认文件路径是否正确。" + +#### 错误 3:综合失败 + +```json +{ + "success": false, + "error": "ERROR: [Synth 8-439] module 'counter' not found", + "output": "详细日志..." +} +``` + +**AI 应该回复**: +"综合失败,错误信息:找不到模块 'counter'。请检查: +1. 模块名是否正确 +2. 文件中是否定义了该模块 +3. 是否有语法错误" + +### 6.2 错误处理建议 + +后端收到 `success: false` 时: +1. 提取 `error` 字段中的错误信息 +2. 分析错误类型(配置问题、文件问题、语法问题等) +3. 给用户提供具体的解决建议 +4. 必要时可以查看 `output` 字段获取详细日志 + +## 7. 进度推送(可选) + +前端会实时推送进度信息到 Webview,后端无需处理,但可以了解进度格式: + +```json +{ + "type": "vivado-progress", + "progress": { + "stage": "synthesis", + "percentage": 45, + "message": "正在综合模块 counter..." + } +} +``` + +## 8. 测试建议 + +### 8.1 后端测试用例 + +```javascript +// 测试用例 1:基本综合 +test('综合单个文件', async () => { + const result = await callTool('runVivado', { + command: 'synthesis', + topModule: 'counter', + files: ['counter.v'] + }); + + expect(result.success).toBe(true); + expect(result.importedFiles.length).toBeGreaterThan(0); +}); + +// 测试用例 2:错误处理 +test('文件不存在', async () => { + const result = await callTool('runVivado', { + command: 'synthesis', + topModule: 'test', + files: ['not_exist.v'] + }); + + expect(result.success).toBe(false); + expect(result.error).toContain('不存在'); +}); +``` + +## 9. 注意事项 + +### 9.1 后端必须询问的参数 + +**关键要求**:后端在调用 `runVivado` 前,必须先向用户收集以下参数: + +| 参数 | 是否必须 | 询问方式 | 示例 | +|------|---------|---------|------| +| part(芯片型号) | ✅ 必须 | 直接询问 | "请提供 FPGA 芯片型号(如 xc7a35tcpg236-1)" | +| mode(执行模式) | ✅ 必须 | 提供选项 | "选择执行模式:1.批处理 2.图形界面" | +| topModule | ✅ 必须 | 可推断或询问 | 从文件名推断,或询问"顶层模块名是什么?" | +| files | ✅ 必须(综合时) | 可推断或询问 | 从当前文件或工作区推断 | +| constraints | ❌ 可选 | 询问 | "是否有约束文件(.xdc)?" | + +### 9.2 常见用户输入场景 + +#### 场景 1:用户说"打开 Vivado" +``` +用户:打开 Vivado + +后端分析:用户想使用 Vivado,但没有指定具体操作 + +后端回复: +"好的,请问您想执行什么操作? +1. 综合(Synthesis)- 将 Verilog 转换为门级网表 +2. 实现(Implementation)- 布局布线 +3. 生成比特流(Bitstream)- 生成 .bit 文件 + +另外,请提供: +- FPGA 芯片型号(例如:xc7a35tcpg236-1) +- 执行模式:批处理(后台自动)或 图形界面(手动操作)" +``` + +#### 场景 2:用户说"开始仿真" +``` +用户:开始仿真 + +后端分析:用户可能混淆了仿真和综合 + +后端回复: +"您是想: +1. 使用 iverilog 进行功能仿真(验证逻辑) +2. 使用 Vivado 进行综合(部署到 FPGA) + +请选择您需要的操作。" +``` + +#### 场景 3:用户提供完整信息 +``` +用户:用 Vivado 综合 counter.v,芯片是 xc7a35tcpg236-1,批处理模式 + +后端分析:信息完整,可以直接调用 + +后端操作: +[调用] runVivado({ + command: "synthesis", + topModule: "counter", + files: ["counter.v"], + part: "xc7a35tcpg236-1", + mode: "batch" +}) +``` + +#### 场景 4:用户选择图形界面 +``` +用户:打开 Vivado 图形界面做综合 + +后端分析:用户明确要求 GUI 模式 + +后端询问: +"好的,请提供: +1. FPGA 芯片型号 +2. 顶层模块名" + +用户:xc7a35tcpg236-1, counter + +后端操作: +[调用] runVivado({ + command: "synthesis", + topModule: "counter", + files: ["counter.v"], + part: "xc7a35tcpg236-1", + mode: "gui" +}) + +前端执行: +- 生成 TCL 脚本和项目文件 +- 执行: vivado counter_project.xpr (打开图形界面) +- 返回: { success: true, message: "Vivado GUI 已启动" } + +后端回复: +"Vivado 图形界面已打开,您可以在界面中手动操作。" +``` + +### 9.3 执行时间 +- 综合:小型设计 30s-2min,大型设计 5-30min +- 实现:通常是综合时间的 2-3 倍 +- 生成比特流:通常 10-30s + +后端应该设置合理的超时时间(建议 10 分钟)。 + +### 9.4 依赖关系 +- `implementation` 需要先执行 `synthesis` +- `bitstream` 需要先执行 `implementation` + +后端 AI 应该理解这个依赖关系,按顺序调用。 + +### 9.5 文件路径 +- 所有文件路径都是相对于工作区根目录 +- 前端会自动解析为绝对路径 +- 支持相对路径和绝对路径 + +## 10. 参数传递详细说明 + +### 10.1 必需参数 + +| 参数 | 类型 | 说明 | 获取方式 | +|------|------|------|----------| +| command | string | 命令类型 | 从用户意图推断 | +| topModule | string | 顶层模块名 | 从文件名推断或询问用户 | +| files | string[] | 源文件列表 | 从工作区查找或用户指定 | +| part | string | 芯片型号 | **必须询问用户** | + +### 10.2 可选参数 + +| 参数 | 类型 | 说明 | 默认值 | +|------|------|------|--------| +| constraints | string | 约束文件路径 | 无 | + +### 10.3 参数验证规则 + +后端在调用前应验证: +- `part` 格式正确(如 xc7a35tcpg236-1) +- `files` 数组不为空 +- `topModule` 不为空 +- `command` 在枚举值内 + +## 11. 快速集成清单 + +后端开发者需要做的事情: + +- [ ] 在工具列表中注册 `runVivado` 工具 +- [ ] **实现参数询问逻辑(芯片型号等)** +- [ ] 实现工具调用逻辑(发送请求到前端) +- [ ] 处理返回结果(success/error) +- [ ] 实现错误处理和用户提示 +- [ ] 理解三个命令的依赖关系 +- [ ] 设置合理的超时时间(建议 10 分钟) +- [ ] 编写测试用例 + +前端开发者需要做的事情: + +- [ ] 实现 `handleVivadoTool` 函数 +- [ ] 集成 VivadoRunner +- [ ] 实现进度推送 +- [ ] 实现结果展示 +- [ ] 处理各种错误情况 +- [ ] 验证传入的参数完整性 diff --git a/docs/Vivado联动功能技术设计文档.md b/docs/Vivado联动功能技术设计文档.md new file mode 100644 index 0000000..093ce99 --- /dev/null +++ b/docs/Vivado联动功能技术设计文档.md @@ -0,0 +1,923 @@ +# Vivado 联动功能技术设计文档 + +## 1. 架构设计 + +### 1.1 整体架构 + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 后端 AI 服务 │ +│ (调用 runVivado 工具) │ +└────────────────────────────┬────────────────────────────────┘ + │ 工具调用请求 + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ VS Code Extension │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ ICHelperPanel (Webview) │ │ +│ │ - 接收后端工具调用 │ │ +│ │ - 显示执行进度和日志 │ │ +│ │ - 展示执行结果 │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ VivadoRunner (utils/vivadoRunner.ts) │ │ +│ │ - 配置管理 │ │ +│ │ - TCL 脚本生成 │ │ +│ │ - 命令执行 │ │ +│ │ - 进度监控 │ │ +│ │ - 结果解析 │ │ +│ └──────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌──────────────────────────────────────────────────────┐ │ +│ │ FileImporter (utils/fileImporter.ts) │ │ +│ │ - 查找产出文件 │ │ +│ │ - 复制文件到目标目录 │ │ +│ │ - 通知文件变更 │ │ +│ └──────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────┐ +│ 本地 Vivado 工具 │ +│ (通过子进程执行 TCL 脚本) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 1.2 模块职责 + +#### 1.2.1 VivadoRunner +- 读取和验证 Vivado 配置 +- 根据命令类型生成 TCL 脚本 +- 启动子进程执行 Vivado +- 实时捕获输出并解析进度 +- 返回执行结果 + +#### 1.2.2 FileImporter +- 根据文件模式查找产出文件 +- 复制文件到指定目录 +- 返回已导入的文件列表 + +#### 1.2.3 MessageHandler +- 接收后端的 `runVivado` 工具调用 +- 调用 VivadoRunner 执行 +- 向 Webview 推送进度和结果 + +## 2. 数据结构设计 + +### 2.1 配置结构 + +```typescript +/** + * Vivado 配置 + */ +interface VivadoConfig { + enabled: boolean; + executablePath: string; + workingDir: string; + part: string; + commands: { + synthesis: string; + implementation: string; + bitstream: string; + }; + outputFiles: { + synthesis: string[]; + implementation: string[]; + bitstream: string[]; + }; +} +``` + +### 2.2 请求和响应结构 + +```typescript +/** + * Vivado 工具请求 + */ +interface VivadoToolRequest { + command: 'synthesis' | 'implementation' | 'bitstream'; + parameters?: { + topModule?: string; + files?: string[]; + part?: string; + constraints?: string; + outputDir?: string; + }; + importOutput?: { + enabled: boolean; + targetDir: string; + }; +} + +/** + * Vivado 工具响应 + */ +interface VivadoToolResponse { + success: boolean; + command: string; + executionTime: number; + output: string; + error?: string; + importedFiles?: string[]; + reports?: { + resources?: string; + timing?: string; + }; +} + +/** + * 执行进度 + */ +interface VivadoProgress { + stage: string; + percentage: number; + message: string; +} +``` + +## 3. 核心模块实现 + +### 3.1 配置管理 + +#### 3.1.1 配置读取 +```typescript +// src/utils/vivadoConfig.ts + +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; + +export function getVivadoConfig(): VivadoConfig | null { + // 优先读取项目配置 + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + const projectConfigPath = path.join( + workspaceFolder.uri.fsPath, + '.vscode', + 'ic-coder-vivado.json' + ); + if (fs.existsSync(projectConfigPath)) { + const content = fs.readFileSync(projectConfigPath, 'utf-8'); + return JSON.parse(content).vivado; + } + } + + // 读取全局配置 + const config = vscode.workspace.getConfiguration('ic-coder'); + return config.get('vivado') || null; +} + +export function validateConfig(config: VivadoConfig): string | null { + if (!config.enabled) { + return 'Vivado 未启用'; + } + if (!fs.existsSync(config.executablePath)) { + return `Vivado 可执行文件不存在: ${config.executablePath}`; + } + return null; +} +``` + +### 3.2 TCL 脚本生成 + +#### 3.2.1 脚本生成器 +```typescript +// src/utils/tclGenerator.ts + +export function generateSynthesisTcl( + topModule: string, + files: string[], + part: string, + constraints?: string, + outputDir?: string +): string { + const output = outputDir || '.'; + let tcl = `# Vivado 综合脚本\n\n`; + + // 读取源文件 + files.forEach(file => { + tcl += `read_verilog ${file}\n`; + }); + + // 读取约束文件 + if (constraints) { + tcl += `read_xdc ${constraints}\n`; + } + + tcl += `\n# 综合\n`; + tcl += `synth_design -part ${part} -top ${topModule}\n\n`; + + // 生成报告 + tcl += `# 生成报告\n`; + tcl += `report_utilization -file ${output}/${topModule}_utilization_synth.rpt\n`; + tcl += `report_timing -file ${output}/${topModule}_timing_synth.rpt\n\n`; + + // 保存检查点 + tcl += `# 保存检查点\n`; + tcl += `write_checkpoint -force ${output}/${topModule}_synth.dcp\n`; + + return tcl; +} + +export function generateImplementationTcl( + dcpFile: string, + outputDir?: string +): string { + const output = outputDir || '.'; + const baseName = path.basename(dcpFile, '.dcp').replace('_synth', ''); + + let tcl = `# Vivado 实现脚本\n\n`; + tcl += `open_checkpoint ${dcpFile}\n\n`; + + tcl += `# 优化\n`; + tcl += `opt_design\n`; + tcl += `place_design\n`; + tcl += `route_design\n\n`; + + tcl += `# 生成报告\n`; + tcl += `report_utilization -file ${output}/${baseName}_utilization_impl.rpt\n`; + tcl += `report_timing_summary -file ${output}/${baseName}_timing_impl.rpt\n\n`; + + tcl += `# 保存检查点\n`; + tcl += `write_checkpoint -force ${output}/${baseName}_impl.dcp\n`; + + return tcl; +} + +export function generateBitstreamTcl( + dcpFile: string, + outputDir?: string +): string { + const output = outputDir || '.'; + const baseName = path.basename(dcpFile, '.dcp').replace('_impl', ''); + + let tcl = `# Vivado 比特流生成脚本\n\n`; + tcl += `open_checkpoint ${dcpFile}\n\n`; + + tcl += `# 生成比特流\n`; + tcl += `write_bitstream -force ${output}/${baseName}.bit\n`; + + return tcl; +} +``` + +### 3.3 VivadoRunner 实现 + +```typescript +// src/utils/vivadoRunner.ts + +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import { spawn } from 'child_process'; +import { getVivadoConfig, validateConfig } from './vivadoConfig'; +import { generateSynthesisTcl, generateImplementationTcl, generateBitstreamTcl } from './tclGenerator'; + +export async function runVivado( + request: VivadoToolRequest, + progressCallback?: (progress: VivadoProgress) => void +): Promise { + const startTime = Date.now(); + + // 读取配置 + const config = getVivadoConfig(); + if (!config) { + return { + success: false, + command: request.command, + executionTime: 0, + output: '', + error: 'Vivado 未配置' + }; + } + + // 验证配置 + const configError = validateConfig(config); + if (configError) { + return { + success: false, + command: request.command, + executionTime: 0, + output: '', + error: configError + }; + } + + // 准备工作目录 + const workingDir = resolveWorkingDir(config.workingDir); + if (!fs.existsSync(workingDir)) { + fs.mkdirSync(workingDir, { recursive: true }); + } + + // 生成 TCL 脚本 + const tclScript = generateTclScript(request, config, workingDir); + const tclPath = path.join(workingDir, `${request.command}.tcl`); + fs.writeFileSync(tclPath, tclScript); + + // 执行 Vivado + const result = await executeVivado( + config.executablePath, + tclPath, + workingDir, + progressCallback + ); + + const executionTime = Date.now() - startTime; + + // 解析报告 + const reports = parseReports(request.command, workingDir, request.parameters?.topModule); + + // 导入文件 + let importedFiles: string[] = []; + if (request.importOutput?.enabled && result.success) { + importedFiles = await importOutputFiles( + request.command, + config, + workingDir, + request.importOutput.targetDir + ); + } + + return { + success: result.success, + command: request.command, + executionTime, + output: result.output, + error: result.error, + importedFiles, + reports + }; +} + +function generateTclScript( + request: VivadoToolRequest, + config: VivadoConfig, + workingDir: string +): string { + const { command, parameters } = request; + const part = parameters?.part || config.part; + + switch (command) { + case 'synthesis': + return generateSynthesisTcl( + parameters?.topModule || 'top', + parameters?.files || [], + part, + parameters?.constraints, + parameters?.outputDir + ); + case 'implementation': + const synthDcp = path.join(workingDir, `${parameters?.topModule}_synth.dcp`); + return generateImplementationTcl(synthDcp, parameters?.outputDir); + case 'bitstream': + const implDcp = path.join(workingDir, `${parameters?.topModule}_impl.dcp`); + return generateBitstreamTcl(implDcp, parameters?.outputDir); + default: + throw new Error(`未知命令: ${command}`); + } +} + +async function executeVivado( + executablePath: string, + tclPath: string, + workingDir: string, + progressCallback?: (progress: VivadoProgress) => void +): Promise<{ success: boolean; output: string; error?: string }> { + return new Promise((resolve) => { + let output = ''; + let errorOutput = ''; + + const process = spawn(executablePath, ['-mode', 'batch', '-source', tclPath], { + cwd: workingDir, + shell: true + }); + + process.stdout.on('data', (data) => { + const text = data.toString(); + output += text; + + // 解析进度 + if (progressCallback) { + const progress = parseProgress(text); + if (progress) { + progressCallback(progress); + } + } + }); + + process.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + process.on('close', (code) => { + if (code === 0) { + resolve({ success: true, output }); + } else { + resolve({ success: false, output, error: errorOutput || '执行失败' }); + } + }); + }); +} + +function parseProgress(logText: string): VivadoProgress | null { + // 解析 Vivado 日志中的进度信息 + if (logText.includes('Starting synthesis')) { + return { stage: 'synthesis', percentage: 10, message: '开始综合' }; + } + if (logText.includes('Finished synthesis')) { + return { stage: 'synthesis', percentage: 100, message: '综合完成' }; + } + // 更多进度解析... + return null; +} + +function parseReports( + command: string, + workingDir: string, + topModule?: string +): { resources?: string; timing?: string } { + const reports: { resources?: string; timing?: string } = {}; + + if (command === 'synthesis' || command === 'implementation') { + const utilizationFile = path.join( + workingDir, + `${topModule}_utilization_${command === 'synthesis' ? 'synth' : 'impl'}.rpt` + ); + if (fs.existsSync(utilizationFile)) { + const content = fs.readFileSync(utilizationFile, 'utf-8'); + reports.resources = extractResourceSummary(content); + } + + const timingFile = path.join( + workingDir, + `${topModule}_timing_${command === 'synthesis' ? 'synth' : 'impl'}.rpt` + ); + if (fs.existsSync(timingFile)) { + const content = fs.readFileSync(timingFile, 'utf-8'); + reports.timing = extractTimingSummary(content); + } + } + + return reports; +} + +function extractResourceSummary(reportContent: string): string { + // 提取资源使用摘要 + const lines = reportContent.split('\n'); + const summary: string[] = []; + + for (const line of lines) { + if (line.includes('LUT') || line.includes('FF') || line.includes('BRAM')) { + summary.push(line.trim()); + } + } + + return summary.join('\n'); +} + +function extractTimingSummary(reportContent: string): string { + // 提取时序摘要 + const lines = reportContent.split('\n'); + for (const line of lines) { + if (line.includes('WNS') || line.includes('TNS')) { + return line.trim(); + } + } + return ''; +} + +function resolveWorkingDir(workingDir: string): string { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (workspaceFolder) { + return workingDir.replace('${workspaceFolder}', workspaceFolder.uri.fsPath); + } + return workingDir; +} +``` + +### 3.4 文件导入实现 + +```typescript +// src/utils/fileImporter.ts + +import * as vscode from 'vscode'; +import * as path from 'path'; +import * as fs from 'fs'; +import * as glob from 'glob'; + +export async function importOutputFiles( + command: string, + config: VivadoConfig, + sourceDir: string, + targetDir: string +): Promise { + const patterns = config.outputFiles[command] || []; + const importedFiles: string[] = []; + + for (const pattern of patterns) { + const files = glob.sync(pattern, { cwd: sourceDir }); + + for (const file of files) { + const sourcePath = path.join(sourceDir, file); + const targetPath = path.join(targetDir, file); + + // 确保目标目录存在 + const targetDirPath = path.dirname(targetPath); + if (!fs.existsSync(targetDirPath)) { + fs.mkdirSync(targetDirPath, { recursive: true }); + } + + // 复制文件 + fs.copyFileSync(sourcePath, targetPath); + importedFiles.push(targetPath); + } + } + + return importedFiles; +} +``` + +### 3.5 MessageHandler 集成 + +```typescript +// src/utils/messageHandler.ts (新增部分) + +import { runVivado } from './vivadoRunner'; + +// 在 handleUserMessage 中添加 Vivado 工具处理 +export async function handleVivadoTool( + panel: vscode.WebviewPanel, + toolCall: any +): Promise { + const { command, topModule, files, constraints, part } = toolCall.parameters; + + // 验证必需参数 + if (!part) { + return { + success: false, + command, + executionTime: 0, + output: '', + error: '缺少必需参数:芯片型号(part)' + }; + } + + // 构建请求 + const request: VivadoToolRequest = { + command, + parameters: { + topModule, + files, + constraints, + part + }, + importOutput: { + enabled: true, + targetDir: path.join(vscode.workspace.workspaceFolders![0].uri.fsPath, 'vivado_output') + } + }; + + // 向前端发送开始消息 + panel.webview.postMessage({ + type: 'vivado-start', + command + }); + + // 执行 Vivado + const response = await runVivado(request, (progress) => { + // 推送进度 + panel.webview.postMessage({ + type: 'vivado-progress', + progress + }); + }); + + // 向前端发送结果 + panel.webview.postMessage({ + type: 'vivado-complete', + response + }); + + // 返回结果给后端 + return response; +} +``` + +### 3.6 参数验证和处理 + +```typescript +// src/utils/vivadoValidator.ts + +export interface ValidationResult { + valid: boolean; + error?: string; +} + +export function validateVivadoRequest(request: VivadoToolRequest): ValidationResult { + const { command, parameters } = request; + + // 验证命令类型 + if (!['synthesis', 'implementation', 'bitstream'].includes(command)) { + return { valid: false, error: `无效的命令类型: ${command}` }; + } + + // 验证必需参数 + if (!parameters?.topModule) { + return { valid: false, error: '缺少顶层模块名(topModule)' }; + } + + if (!parameters?.part) { + return { valid: false, error: '缺少芯片型号(part)' }; + } + + // 验证芯片型号格式 + const partPattern = /^xc[0-9a-z]+$/i; + if (!partPattern.test(parameters.part)) { + return { valid: false, error: `芯片型号格式不正确: ${parameters.part}` }; + } + + // 综合命令需要文件列表 + if (command === 'synthesis') { + if (!parameters?.files || parameters.files.length === 0) { + return { valid: false, error: '综合命令需要提供源文件列表' }; + } + } + + return { valid: true }; +} +``` + +## 4. 前端 UI 实现 + +### 4.1 进度显示组件 + +```typescript +// src/views/vivadoProgress.ts + +export function renderVivadoProgress(progress: VivadoProgress): string { + return ` +
+
+ ${progress.stage} + ${progress.percentage}% +
+
+
+
+
${progress.message}
+
+ `; +} +``` + +### 4.2 结果展示组件 + +```typescript +// src/views/vivadoResult.ts + +export function renderVivadoResult(response: VivadoToolResponse): string { + if (!response.success) { + return ` +
+

❌ 执行失败

+
${response.error}
+
+ `; + } + + return ` +
+

✅ 执行成功

+
+

命令: ${response.command}

+

执行时间: ${(response.executionTime / 1000).toFixed(2)}s

+
+ + ${response.reports?.resources ? ` +
+
资源使用
+
${response.reports.resources}
+
+ ` : ''} + + ${response.reports?.timing ? ` +
+
时序信息
+
${response.reports.timing}
+
+ ` : ''} + + ${response.importedFiles && response.importedFiles.length > 0 ? ` +
+
已导入文件
+
    + ${response.importedFiles.map(f => `
  • ${f}
  • `).join('')} +
+
+ ` : ''} +
+ `; +} +``` + +## 5. 配置界面实现 + +### 5.1 设置页面扩展 + +```typescript +// src/views/vivadoSettings.ts + +export function renderVivadoSettings(config: VivadoConfig | null): string { + return ` +
+

Vivado 配置

+ +
+ + +
+ +
+ + + +
+ +
+ + +
+ +
+ + +
+ + +
+ `; +} +``` + +## 6. 测试方案 + +### 6.1 单元测试 + +```typescript +// src/test/vivadoRunner.test.ts + +import * as assert from 'assert'; +import { generateSynthesisTcl } from '../utils/tclGenerator'; + +suite('Vivado TCL Generator', () => { + test('生成综合脚本', () => { + const tcl = generateSynthesisTcl( + 'counter', + ['counter.v'], + 'xc7a35tcpg236-1' + ); + + assert.ok(tcl.includes('read_verilog counter.v')); + assert.ok(tcl.includes('synth_design')); + assert.ok(tcl.includes('write_checkpoint')); + }); +}); +``` + +### 6.2 集成测试 + +```typescript +// src/test/vivadoIntegration.test.ts + +suite('Vivado Integration', () => { + test('完整综合流程', async () => { + const request: VivadoToolRequest = { + command: 'synthesis', + parameters: { + topModule: 'counter', + files: ['test/fixtures/counter.v'] + } + }; + + const response = await runVivado(request); + assert.ok(response.success); + assert.ok(response.executionTime > 0); + }); +}); +``` + +## 7. 部署和发布 + +### 7.1 文件清单 + +新增文件: +- `src/utils/vivadoConfig.ts` - 配置管理 +- `src/utils/tclGenerator.ts` - TCL 脚本生成 +- `src/utils/vivadoRunner.ts` - Vivado 执行器 +- `src/utils/fileImporter.ts` - 文件导入 +- `src/views/vivadoProgress.ts` - 进度显示 +- `src/views/vivadoResult.ts` - 结果展示 +- `src/views/vivadoSettings.ts` - 设置界面 + +修改文件: +- `src/utils/messageHandler.ts` - 添加 Vivado 工具处理 +- `src/views/settingsComponent.ts` - 添加 Vivado 设置页面 + +### 7.2 配置文件更新 + +```json +// package.json (新增配置项) +{ + "contributes": { + "configuration": { + "properties": { + "ic-coder.vivado.enabled": { + "type": "boolean", + "default": false, + "description": "启用 Vivado 集成" + }, + "ic-coder.vivado.executablePath": { + "type": "string", + "default": "", + "description": "Vivado 可执行文件路径" + }, + "ic-coder.vivado.workingDir": { + "type": "string", + "default": "${workspaceFolder}/vivado_project", + "description": "Vivado 工作目录" + }, + "ic-coder.vivado.part": { + "type": "string", + "default": "xc7a35tcpg236-1", + "description": "默认 FPGA 型号" + } + } + } + } +} +``` + +## 8. 常见问题和解决方案 + +### 8.1 Vivado 许可证问题 + +**问题**:执行时提示许可证错误 + +**解决方案**: +1. 检查环境变量 `XILINX_VIVADO` 是否设置 +2. 确认许可证服务器可访问 +3. 在配置中添加许可证路径 + +### 8.2 路径问题 + +**问题**:Windows 路径包含空格导致执行失败 + +**解决方案**: +```typescript +function escapeWindowsPath(p: string): string { + return p.includes(' ') ? `"${p}"` : p; +} +``` + +### 8.3 执行超时 + +**问题**:大型项目综合时间过长 + +**解决方案**: +- 增加超时时间配置 +- 添加取消执行功能 +- 显示详细进度信息 + +## 9. 性能优化 + +### 9.1 日志缓冲 + +限制日志输出大小,避免内存溢出: + +```typescript +const MAX_LOG_SIZE = 1024 * 1024; // 1MB +let logBuffer = ''; + +process.stdout.on('data', (data) => { + logBuffer += data.toString(); + if (logBuffer.length > MAX_LOG_SIZE) { + logBuffer = logBuffer.slice(-MAX_LOG_SIZE / 2); + } +}); +``` + +### 9.2 增量构建 + +支持增量综合,只重新综合修改的模块。 + +## 10. 后续优化方向 + +1. **并行执行**:支持多个设计同时综合 +2. **缓存机制**:缓存未修改模块的综合结果 +3. **云端集成**:支持云端 Vivado 服务 +4. **可视化报告**:图形化展示资源使用和时序 +5. **自动约束生成**:根据设计自动生成 XDC 约束文件 + diff --git a/src/constants/toolIcons.ts b/src/constants/toolIcons.ts index 93fda53..a11cc2e 100644 --- a/src/constants/toolIcons.ts +++ b/src/constants/toolIcons.ts @@ -201,6 +201,11 @@ export const setting = ``; +/** + * 任务完成的图标svg + */ +export const taskCompleteIconSvg = ``; + /** * 个人规则的图标svg */ diff --git a/src/extension.ts b/src/extension.ts index 2f436d3..171268f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -31,7 +31,11 @@ export async function activate(context: vscode.ExtensionContext) { if (!editor) return; if (!editor.selection.isEmpty) { - const range = new vscode.Range(editor.selection.end, editor.selection.end); + // 找到选区末尾所在的行,并将提示放在该行的末尾 + const { anchor, active } = editor.selection; + const endPos = anchor.isAfter(active) ? anchor : active; + const lineEndPos = editor.document.lineAt(endPos.line).range.end; + const range = new vscode.Range(lineEndPos, lineEndPos); const decoration = { range }; editor.setDecorations(decorationType, [decoration]); } else { diff --git a/src/panels/ICHelperPanel.ts b/src/panels/ICHelperPanel.ts index dc9c4be..bf31d94 100644 --- a/src/panels/ICHelperPanel.ts +++ b/src/panels/ICHelperPanel.ts @@ -1,139 +1,90 @@ +/** + * IC 助手面板主组件 + * 功能:创建和管理 IC Coder 聊天面板 + * 依赖:vscode, webviewContent, helpers + * 使用场景:用户打开 IC Coder 聊天界面 + */ import * as vscode from "vscode"; import { getWebviewContent } from "../views/webviewContent"; -import { - handleUserMessage, - insertCodeToEditor, - handleReadFile, - handleUpdateFile, - handleRenameFile, - handleReplaceInFile, - handleUserAnswer, - abortCurrentDialog, - handleOptimizePrompt, - handlePlanAction, - getCurrentTaskId, - setLastTaskId, - handleAcceptChange, - handleRejectChange, - startChangeSession, - handleOpenFileDiff, -} from "../utils/messageHandler"; -import { compactDialog } from "../services/apiClient"; -import { VCDViewerPanel } from "./VCDViewerPanel"; import { ChatHistoryManager } from "../utils/chatHistoryManager"; -import { MessageType } from "../types/chatHistory"; -import { getCachedUserInfo } from "../services/userService"; -import { isTokenExpired } from "../utils/jwtUtils"; -import { setBalanceUpdateCallback } from "../services/creditsService"; -import { savePersonalRule, updatePersonalRule, deletePersonalRule, loadPersonalRules } from "../utils/personalRulesManager"; +import { checkAuthAndPromptLogin } from "./helpers/authHelper"; +import { + sendUserInfoToWebview, + setupBalanceUpdateCallback, +} from "./helpers/userInfoHelper"; +import { handleWebviewMessage } from "./helpers/messageRouter"; -/** - * 获取会员等级图标 URI - */ -function getTierIconUri( +function getIconUris( webview: vscode.Webview, context: vscode.ExtensionContext, - tierCode?: string, -): string | undefined { - if (!tierCode) { - return undefined; - } - - const tierIconMap: Record = { - BASIC: "free.png", - TRIAL: "PRO-Try.png", - ADVANCED: "PRO.png", - PROFESSIONAL: "PRO+.png", - }; - - const iconFile = tierIconMap[tierCode]; - if (!iconFile) { - return undefined; - } - - const iconUri = webview.asWebviewUri( - vscode.Uri.joinPath( - context.extensionUri, - "dist", - "assets", - "titleIcon", - iconFile, +) { + return { + icon: webview.asWebviewUri( + vscode.Uri.joinPath(context.extensionUri, "media", "icon.png"), ), - ); - - return iconUri.toString(); + auto: webview.asWebviewUri( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "assets", + "model", + "Auto.png", + ), + ), + lite: webview.asWebviewUri( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "assets", + "model", + "lite.png", + ), + ), + sy: webview.asWebviewUri( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "assets", + "model", + "Sy.png", + ), + ), + max: webview.asWebviewUri( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "assets", + "model", + "Max.png", + ), + ), + qrCode: webview.asWebviewUri( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "assets", + "QRCode", + "wx.png", + ), + ), + logo: webview.asWebviewUri( + vscode.Uri.joinPath(context.extensionUri, "media", "homepage-logo.png"), + ), + }; } -/** - * 创建并显示 IC 助手面板 - */ export async function showICHelperPanel( context: vscode.ExtensionContext, viewColumn?: vscode.ViewColumn, ) { - // 检查 token 是否过期 - let token: string | undefined; - try { - const session = await vscode.authentication.getSession("iccoder", [], { - createIfNone: false, - }); - token = session?.accessToken; - } catch (error) { - console.warn("[ICHelperPanel] 获取 session 失败:", error); - } - - if (token && isTokenExpired(token)) { - // 清除过期的 session - await context.globalState.update("icCoderSessions", []); - await context.globalState.update("icCoderUserInfo", undefined); - - const action = await vscode.window.showWarningMessage( - "登录已过期,请重新登录", - "立即登录", - ); - if (action === "立即登录") { - vscode.commands.executeCommand("ic-coder.login", { - forceReauth: true, - }); - } + if (!(await checkAuthAndPromptLogin(context))) { return; } - // 检查用户是否已登录 - try { - const session = await vscode.authentication.getSession("iccoder", [], { - createIfNone: false, - }); - if (!session) { - vscode.window - .showWarningMessage("请先登录后再使用 IC Coder", "立即登录") - .then((selection) => { - if (selection === "立即登录") { - vscode.commands.executeCommand("ic-coder.login", { - forceReauth: true, - }); - } - }); - return; - } - } catch (error) { - vscode.window - .showWarningMessage("请先登录后再使用 IC Coder", "立即登录") - .then((selection) => { - if (selection === "立即登录") { - vscode.commands.executeCommand("ic-coder.login", { - forceReauth: true, - }); - } - }); - return; - } - - // 创建WebView面板 const panel = vscode.window.createWebviewPanel( - "icCoder", // 面板ID - "IC Coder", // 面板标题 - viewColumn || vscode.ViewColumn.Beside, // 默认显示在旁边,但可以指定 + "icCoder", + "IC Coder", + viewColumn || vscode.ViewColumn.Beside, { enableScripts: true, retainContextWhenHidden: true, @@ -144,177 +95,37 @@ export async function showICHelperPanel( }, ); - // 保存 panel 引用到全局 (global as any).currentICHelperPanel = panel; - // 为面板生成唯一ID const panelId = `panel_${Date.now()}_${Math.random() .toString(36) .substr(2, 9)}`; (panel as any).__uniqueId = panelId; (panel as any).__context = context; - // 设置标签页图标 panel.iconPath = vscode.Uri.joinPath( context.extensionUri, "media", "icon.png", ); - // 获取页面内图标URI - const iconUri = panel.webview.asWebviewUri( - vscode.Uri.joinPath(context.extensionUri, "media", "icon.png"), - ); - - // 获取模型图标URI - const autoIconUri = panel.webview.asWebviewUri( - vscode.Uri.joinPath( - context.extensionUri, - "dist", - "assets", - "model", - "Auto.png", - ), - ); - const liteIconUri = panel.webview.asWebviewUri( - vscode.Uri.joinPath( - context.extensionUri, - "dist", - "assets", - "model", - "lite.png", - ), - ); - const syIconUri = panel.webview.asWebviewUri( - vscode.Uri.joinPath( - context.extensionUri, - "dist", - "assets", - "model", - "Sy.png", - ), - ); - const maxIconUri = panel.webview.asWebviewUri( - vscode.Uri.joinPath( - context.extensionUri, - "dist", - "assets", - "model", - "Max.png", - ), - ); - - // 获取二维码图片URI - const qrCodeUri = panel.webview.asWebviewUri( - vscode.Uri.joinPath( - context.extensionUri, - "dist", - "assets", - "QRCode", - "wx.png", - ), - ); - - // 获取Logo URI - const logoUri = panel.webview.asWebviewUri( - vscode.Uri.joinPath(context.extensionUri, "media", "homepage-logo.png"), - ); - - // 设置HTML内容 + const icons = getIconUris(panel.webview, context); panel.webview.html = getWebviewContent( - iconUri.toString(), - autoIconUri.toString(), - liteIconUri.toString(), - syIconUri.toString(), - maxIconUri.toString(), - qrCodeUri.toString(), - logoUri.toString(), + icons.icon.toString(), + icons.auto.toString(), + icons.lite.toString(), + icons.sy.toString(), + icons.max.toString(), + icons.qrCode.toString(), + icons.logo.toString(), ); - // 获取并发送用户信息到 webview - try { - // 优先使用缓存的用户信息 - let userInfo = getCachedUserInfo(); + await sendUserInfoToWebview(panel, context); + setupBalanceUpdateCallback(panel, context); - if (userInfo) { - // 使用缓存的用户信息 - console.log("[ICHelperPanel] 使用缓存的用户信息:", userInfo); - console.log("[ICHelperPanel] Credits 余额:", userInfo.credits); - const tierIconUrl = getTierIconUri( - panel.webview, - context, - userInfo.membership?.tierCode, - ); - const messageData = { - command: "updateUserInfo", - userInfo: { - userId: userInfo.userId, - nickname: userInfo.nickname, - username: userInfo.username, - credits: userInfo.credits, - membership: userInfo.membership, - }, - tierIconUrl: tierIconUrl, - }; - console.log("[ICHelperPanel] 发送用户信息到前端:", messageData); - panel.webview.postMessage(messageData); - } else { - // 如果没有缓存,从 session 中获取 - const session = await vscode.authentication.getSession("iccoder", [], { - createIfNone: false, - }); - if (session) { - console.log( - "[ICHelperPanel] 从 session 获取用户信息, account:", - session.account, - ); - panel.webview.postMessage({ - command: "updateUserInfo", - userInfo: { - userId: session.account.id, - nickname: session.account.label, - username: session.account.label, - }, - }); - } - } - } catch (error) { - console.error("[ICHelperPanel] 获取用户信息失败:", error); - } - - // 设置余额更新回调 - setBalanceUpdateCallback((balance: number) => { - const userInfo = getCachedUserInfo(); - if (userInfo) { - userInfo.credits = balance; - const tierIconUrl = getTierIconUri( - panel.webview, - context, - userInfo.membership?.tierCode, - ); - panel.webview.postMessage({ - command: "updateUserInfo", - userInfo: { - userId: userInfo.userId, - nickname: userInfo.nickname, - username: userInfo.username, - credits: balance, - membership: userInfo.membership, - }, - tierIconUrl: tierIconUrl, - }); - } - }); - - // 检查是否有待发送的消息 const pendingMessage = context.globalState.get("pendingMessage") as any; if (pendingMessage) { - console.log("[ICHelperPanel] 检测到待发送消息,准备自动发送"); - - // 清除待发送消息 await context.globalState.update("pendingMessage", undefined); - - // 延迟发送,确保面板已完全初始化 setTimeout(() => { panel.webview.postMessage({ command: "autoSendMessage", @@ -325,640 +136,14 @@ export async function showICHelperPanel( }, 500); } - // 处理消息 panel.webview.onDidReceiveMessage( async (message) => { - const historyManager = ChatHistoryManager.getInstance(); - const panelId = (panel as any).__uniqueId; - - switch (message.command) { - 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); - - // 启动变更追踪会话 - const sessionId = `session_${panelId}_${Date.now()}`; - startChangeSession(sessionId); - - // 显示进度条 - panel.webview.postMessage({ type: "showProgress" }); - - handleUserMessage( - panel, - message.text, - context.extensionPath, - message.mode, - message.model, // 传递服务等级 - message.contextItems, // 传递上下文项 - ); - break; - case "readFile": - handleReadFile(panel, message.filePath); - break; - case "updateFile": - handleUpdateFile(panel, message.filePath, message.content); - break; - case "renameFile": - handleRenameFile(panel, message.oldPath, message.newPath); - break; - case "replaceInFile": - handleReplaceInFile( - panel, - message.filePath, - message.searchText, - message.replaceText, - ); - break; - case "insertCode": - insertCodeToEditor(message.code); - break; - case "showInfo": - vscode.window.showInformationMessage(message.text); - break; - case "openWaveformViewer": - // 在新列中打开波形查看器 - if (message.vcdFilePath) { - vscode.commands.executeCommand( - "ic-coder.openVCDViewer", - message.vcdFilePath, - ); - } - break; - case "getVCDInfo": - // 获取 VCD 文件信息 - if (message.vcdFilePath && message.containerId) { - getVCDFileInfo(panel, message.vcdFilePath, message.containerId); - } - break; - case "createNewConversation": - // 创建新会话 - 在当前编辑器组中打开新标签页 - showICHelperPanel(context, panel.viewColumn); - break; - case "loadConversationHistory": - // 加载会话历史(支持分页) - loadConversationHistory( - panel, - message.offset || 0, - message.limit || 10, - ); - break; - case "selectConversation": - // 选择会话 - if (message.conversationId) { - selectConversation( - panel, - message.conversationId, - context.extensionPath, - ); - } - break; - // 新增:处理用户回答 - case "submitAnswer": - void handleUserAnswer( - message.askId, - message.selected, - message.customInput, - message.answers - ); - break; - // 新增:中止对话 - case "abortDialog": - void abortCurrentDialog(); - break; - // 新增:压缩会话 - case "compressConversation": - { - const taskId = getCurrentTaskId(); - if (taskId) { - compactDialog(taskId) - .then((result) => { - if (result.success) { - panel.webview.postMessage({ - command: "receiveMessage", - text: "✅ 会话压缩完成", - }); - } else { - panel.webview.postMessage({ - command: "receiveMessage", - text: `❌ 压缩失败: ${result.error || "未知错误"}`, - }); - } - }) - .catch((err) => { - panel.webview.postMessage({ - command: "receiveMessage", - text: `❌ 压缩失败: ${err.message || "网络错误"}`, - }); - }); - } else { - panel.webview.postMessage({ - command: "receiveMessage", - text: "❌ 没有活跃的会话", - }); - } - } - break; - case "optimizePrompt": - if (typeof message.prompt === "string") { - void handleOptimizePrompt(panel, message.prompt); - } else { - panel.webview.postMessage({ - command: "optimizeResult", - success: false, - error: "提示词为空或格式错误", - }); - } - break; - case "logout": - // 退出登录 - vscode.commands.executeCommand("ic-coder.logout"); - break; - case "savePersonalRule": - // 保存个人规则 - if (message.name && message.content && message.enabled !== undefined) { - const success = await savePersonalRule(message.name, message.content, message.enabled); - if (success) { - const rulesData = loadPersonalRules(); - panel.webview.postMessage({ - command: "personalRulesLoaded", - data: rulesData - }); - } - } - break; - case "updatePersonalRule": - // 更新个人规则 - if (message.filename && message.name && message.content && message.enabled !== undefined) { - const success = await updatePersonalRule(message.filename, message.name, message.content, message.enabled); - if (success) { - const rulesData = loadPersonalRules(); - panel.webview.postMessage({ - command: "personalRulesLoaded", - data: rulesData - }); - } - } - break; - case "deletePersonalRule": - // 删除个人规则 - if (message.filename) { - const success = await deletePersonalRule(message.filename); - if (success) { - const rulesData = loadPersonalRules(); - panel.webview.postMessage({ - command: "personalRulesLoaded", - data: rulesData - }); - } - } - break; - case "loadPersonalRules": - // 加载个人规则 - const rulesData = loadPersonalRules(); - panel.webview.postMessage({ - command: "personalRulesLoaded", - data: rulesData - }); - break; - case "openFile": - // 打开文件 - if (message.filePath) { - const path = require('path'); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - const fullPath = path.isAbsolute(message.filePath) || !workspaceFolder - ? message.filePath - : vscode.Uri.joinPath(workspaceFolder.uri, message.filePath).fsPath; - vscode.workspace.openTextDocument(fullPath).then(doc => { - vscode.window.showTextDocument(doc); - }); - } - break; - case "openFileWithSelection": - // 打开文件并选中代码 - if (message.filePath) { - const path = require('path'); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - const fullPath = path.isAbsolute(message.filePath) || !workspaceFolder - ? message.filePath - : vscode.Uri.joinPath(workspaceFolder.uri, message.filePath).fsPath; - vscode.workspace.openTextDocument(fullPath).then(doc => { - vscode.window.showTextDocument(doc).then(editor => { - const start = new vscode.Position(message.startLine - 1, 0); - const end = new vscode.Position(message.endLine - 1, doc.lineAt(message.endLine - 1).text.length); - editor.selection = new vscode.Selection(start, end); - editor.revealRange(new vscode.Range(start, end)); - }); - }); - } - break; - case "openFilePathTag": - // 打开文件路径标签(智能查找) - if (message.filePath) { - const path = require('path'); - const fs = require('fs'); - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - - let fullPath = message.filePath; - - // 如果是相对路径且工作区存在 - if (!path.isAbsolute(message.filePath) && workspaceFolder) { - const candidatePath = vscode.Uri.joinPath(workspaceFolder.uri, message.filePath).fsPath; - // 检查文件是否存在 - if (fs.existsSync(candidatePath)) { - fullPath = candidatePath; - } else { - // 尝试在工作区中搜索该文件 - const fileName = path.basename(message.filePath); - const files = await vscode.workspace.findFiles(`**/${fileName}`, '**/node_modules/**', 1); - if (files.length > 0) { - fullPath = files[0].fsPath; - } - } - } - - if (message.startLine && message.endLine) { - vscode.workspace.openTextDocument(fullPath).then(doc => { - vscode.window.showTextDocument(doc).then(editor => { - const start = new vscode.Position(message.startLine - 1, 0); - const end = new vscode.Position(message.endLine - 1, doc.lineAt(message.endLine - 1).text.length); - editor.selection = new vscode.Selection(start, end); - editor.revealRange(new vscode.Range(start, end)); - }); - }); - } else { - vscode.workspace.openTextDocument(fullPath).then(doc => { - vscode.window.showTextDocument(doc); - }); - } - } - break; - case "acceptChange": - // 采纳变更 - if (message.changeId) { - await handleAcceptChange(panel, message.changeId); - } - break; - case "rejectChange": - // 拒绝变更 - if (message.changeId) { - await handleRejectChange(panel, message.changeId); - } - break; - case "openFileDiff": - // 打开文件 diff - if (message.changeId) { - await handleOpenFileDiff(panel, message.changeId); - } - break; - case "checkInvitationCode": - // 检查邀请码验证状态 - { - // 先检查是否是试用用户 - const { getCachedUserInfo } = require("../services/userService"); - const userInfo = getCachedUserInfo(); - - if (userInfo?.isPluginTrial === true) { - // 试用用户,跳过邀请码验证,直接返回已验证 - console.log("[ICHelperPanel] 试用用户,跳过邀请码验证"); - panel.webview.postMessage({ - command: "invitationCodeStatus", - verified: true, - }); - } else { - // 正式用户,检查邀请码 - const { - InvitationService, - } = require("../services/invitationService"); - const isVerified = await InvitationService.isVerified(context); - panel.webview.postMessage({ - command: "invitationCodeStatus", - verified: isVerified, - }); - } - } - break; - case "checkWelcomeModal": - // 检查是否需要显示欢迎弹窗 - { - console.log("[ICHelperPanel] 收到 checkWelcomeModal 消息"); - const userInfo = getCachedUserInfo(); - - console.log("[ICHelperPanel] 用户信息:", userInfo); - console.log("[ICHelperPanel] isPluginTrial:", userInfo?.isPluginTrial); - console.log("[ICHelperPanel] pluginTrialExpiresAt:", userInfo?.pluginTrialExpiresAt); - - if (userInfo?.isPluginTrial === true) { - // undefined 表示无效,不显示 - if (userInfo.pluginTrialExpiresAt === undefined) { - console.log("[ICHelperPanel] pluginTrialExpiresAt 未设置,不显示欢迎弹窗"); - break; - } - - // null 表示长期有效,显示弹窗 - // 有值则检查是否过期 - if (userInfo.pluginTrialExpiresAt !== null) { - const now = Date.now(); - const isExpired = now >= userInfo.pluginTrialExpiresAt; - console.log("[ICHelperPanel] 是否过期:", isExpired); - - if (isExpired) { - console.log("[ICHelperPanel] 试用已过期,不显示欢迎弹窗"); - break; - } - } - - // 未过期或长期有效(null),显示欢迎弹窗 - console.log("[ICHelperPanel] ✅ 发送 showWelcomeModal 命令到前端"); - panel.webview.postMessage({ - command: "showWelcomeModal", - }); - } else { - console.log("[ICHelperPanel] 非试用用户"); - } - } - break; - case "checkTrialExpiration": - // 检查试用期是否过期 - { - console.log("[ICHelperPanel] 收到 checkTrialExpiration 消息"); - const { - TrialExpirationService, - } = require("../services/trialExpirationService"); - const trialService = new TrialExpirationService(context, panel); - const isExpired = await trialService.checkExpiration(); - console.log("[ICHelperPanel] 试用期过期状态:", isExpired); - } - break; - case "verifyInvitationCode": - // 验证邀请码 - { - const { - InvitationService, - } = require("../services/invitationService"); - const result = await InvitationService.verifyCode(message.code); - - if (result.success) { - // 验证成功,保存状态 - await InvitationService.saveVerificationStatus( - context, - message.code, - ); - panel.webview.postMessage({ - command: "invitationCodeVerified", - success: true, - }); - // 延迟显示欢迎弹窗,确保邀请码弹窗已关闭 - setTimeout(() => { - panel.webview.postMessage({ - command: "showNdtWelcomeModal", - }); - }, 300); - } else { - // 验证失败,返回错误信息 - panel.webview.postMessage({ - command: "invitationCodeVerified", - success: false, - message: result.message, - }); - } - } - break; - case "openICCoder": - // 跳转到 IC Coder 官网 - vscode.env.openExternal(vscode.Uri.parse("https://www.iccoder.com")); - break; - case "openTutorial": - // 打开使用教程 - vscode.env.openExternal( - vscode.Uri.parse( - "https://www.iccoder.com/guides/quick-start/first-task-plugin", - ), - ); - break; - case "openUserManual": - // 打开用户手册 - vscode.env.openExternal(vscode.Uri.parse("https://www.iccoder.com")); - break; - case "openUserFeedback": - // 打开用户反馈二维码弹窗 - panel.webview.postMessage({ - command: "showFeedbackQRCode", - }); - break; - // 处理计划操作(只做模式切换,响应已通过 submitAnswer 发送) - case "planAction": - if (message.action === "confirm") { - // 确认执行:切换到 Agent 模式(UI 切换) - panel.webview.postMessage({ - command: "switchMode", - mode: "agent", - }); - // 注意:不再设置待执行计划;后端 LLM 会在同一对话中自动执行计划 - } else if ( - message.action === "modify" || - message.action === "cancel" - ) { - void handlePlanAction( - panel, - message.action, - message.planTitle || "", - context.extensionPath, - message.model, - ); - } - break; - // 添加文件上下文 - 显示工作区文件列表 - case "addContextFile": - { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showWarningMessage("请先打开一个工作区"); - break; - } - - // 获取工作区所有文件 - const files = await vscode.workspace.findFiles( - "**/*", - "**/node_modules/**", - ); - - panel.webview.postMessage({ - command: "showWorkspaceFileList", - files: files.map((uri) => ({ - path: uri.fsPath, - relativePath: vscode.workspace.asRelativePath(uri), - })), - }); - } - break; - // 添加文件夹上下文 - 显示工作区文件夹列表 - case "addContextFolder": - { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (!workspaceFolder) { - vscode.window.showWarningMessage("请先打开一个工作区"); - break; - } - - // 获取工作区所有文件夹 - const fs = require("fs"); - const path = require("path"); - const folders: Array<{ path: string; relativePath: string }> = []; - - function scanFolders(dir: string, baseDir: string) { - try { - const items = fs.readdirSync(dir, { withFileTypes: true }); - for (const item of items) { - if ( - item.isDirectory() && - item.name !== "node_modules" && - !item.name.startsWith(".") - ) { - const fullPath = path.join(dir, item.name); - const relativePath = path.relative(baseDir, fullPath); - folders.push({ path: fullPath, relativePath }); - scanFolders(fullPath, baseDir); - } - } - } catch (error) { - console.error("扫描文件夹失败:", error); - } - } - - scanFolders(workspaceFolder.uri.fsPath, workspaceFolder.uri.fsPath); - - panel.webview.postMessage({ - command: "showWorkspaceFolderList", - folders: folders, - }); - } - break; - // 添加图片上下文 - case "addContextImage": - { - const imageUris = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: true, - openLabel: "选择图片", - filters: { - 图片文件: ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"], - }, - }); - if (imageUris && imageUris.length > 0) { - panel.webview.postMessage({ - command: "contextImagesSelected", - images: imageUris.map((uri) => uri.fsPath), - }); - } - } - break; - // 添加文档库上下文 - case "addContextDocument": - { - const docUris = await vscode.window.showOpenDialog({ - canSelectFiles: true, - canSelectFolders: false, - canSelectMany: true, - openLabel: "选择文档", - filters: { - 文档文件: ["pdf", "doc", "docx", "txt", "md"], - 所有文件: ["*"], - }, - }); - if (docUris && docUris.length > 0) { - panel.webview.postMessage({ - command: "contextDocumentsSelected", - documents: docUris.map((uri) => uri.fsPath), - }); - } - } - break; - // 打开文件 - case "openFile": - { - let filePath = message.filePath; - if (filePath) { - // 如果是相对路径,转换为绝对路径 - if (!require("path").isAbsolute(filePath)) { - const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; - if (workspaceFolder) { - filePath = require("path").join(workspaceFolder.uri.fsPath, filePath); - } - } - const uri = vscode.Uri.file(filePath); - vscode.window.showTextDocument(uri); - } - } - break; - // 新增:检查工作区状态 - case "checkWorkspace": - const hasWorkspace = !!( - vscode.workspace.workspaceFolders && - vscode.workspace.workspaceFolders.length > 0 - ); - if (!hasWorkspace) { - // 弹窗提示用户需要打开工作区 - vscode.window - .showWarningMessage( - "请先打开一个文件夹作为工作区,这样我就能更好地为您服务了 😊", - "打开文件夹", - ) - .then((selection) => { - if (selection === "打开文件夹") { - vscode.commands.executeCommand("vscode.openFolder"); - } - }); - } - // 返回工作区状态给前端 - panel.webview.postMessage({ - command: "workspaceStatus", - hasWorkspace: hasWorkspace, - }); - break; - case "openExternalUrl": - // 打开外部链接 - if (message.url) { - vscode.env.openExternal(vscode.Uri.parse(message.url)); - } - break; - case "openICCoder": - // 打开 IC Coder 官网 - vscode.env.openExternal(vscode.Uri.parse("https://www.iccoder.com")); - break; - case "logout": - // 退出登录(前端已有确认对话框) - vscode.commands.executeCommand("ic-coder.logout"); - break; - } + await handleWebviewMessage(message, panel, context); }, undefined, context.subscriptions, ); - // 面板关闭时清理任务映射 panel.onDidDispose( () => { const historyManager = ChatHistoryManager.getInstance(); @@ -969,425 +154,3 @@ export async function showICHelperPanel( context.subscriptions, ); } - -/** - * 获取 VCD 文件信息 - */ -async function getVCDFileInfo( - panel: vscode.WebviewPanel, - vcdFilePath: string, - containerId: string, -) { - try { - const fs = require("fs"); - const path = require("path"); - - // 检查文件是否存在 - if (!fs.existsSync(vcdFilePath)) { - panel.webview.postMessage({ - command: "vcdInfo", - containerId: containerId, - vcdInfo: { - signalCount: "N/A", - timeRange: "N/A", - fileSize: "N/A", - error: "文件不存在", - }, - }); - return; - } - - // 获取文件大小 - const stats = fs.statSync(vcdFilePath); - const fileSizeKB = stats.size / 1024; - const fileSize = - fileSizeKB < 1024 - ? `${fileSizeKB.toFixed(2)} KB` - : `${(fileSizeKB / 1024).toFixed(2)} MB`; - - // 读取 VCD 文件内容 - const content = fs.readFileSync(vcdFilePath, "utf-8"); - - // 解析信号数量 - const varMatches = content.match(/\$var/g); - const signalCount = varMatches ? varMatches.length : 0; - - // 解析时间范围 - let timeRange = "N/A"; - const timeMatch = content.match(/#(\d+)/g); - if (timeMatch && timeMatch.length > 0) { - const times = timeMatch.map((t: string) => parseInt(t.substring(1))); - const minTime = Math.min(...times); - const maxTime = Math.max(...times); - timeRange = `${minTime} - ${maxTime}`; - } - - // 解析前几个信号的真实数据 - const signals = parseVCDSignals(content, 3); // 只解析前3个信号 - - // 发送信息回前端 - panel.webview.postMessage({ - command: "vcdInfo", - containerId: containerId, - vcdInfo: { - signalCount: signalCount.toString(), - timeRange: timeRange, - fileSize: fileSize, - signals: signals, // 添加真实信号数据 - }, - }); - } catch (error) { - console.error("获取 VCD 文件信息失败:", error); - panel.webview.postMessage({ - command: "vcdInfo", - containerId: containerId, - vcdInfo: { - signalCount: "N/A", - timeRange: "N/A", - fileSize: "N/A", - error: error instanceof Error ? error.message : "未知错误", - }, - }); - } -} - -/** - * 解析 VCD 文件中的信号数据 - */ -function parseVCDSignals(content: string, maxSignals: number = 3) { - const signals: Array<{ - name: string; - identifier: string; - width: number; - values: Array<{ time: number; value: string }>; - }> = []; - - try { - // 1. 解析信号定义部分 - const varRegex = /\$var\s+(\w+)\s+(\d+)\s+(\S+)\s+([^\$]+?)\s+\$end/g; - let match; - const signalDefs: Array<{ - name: string; - identifier: string; - width: number; - }> = []; - - while ( - (match = varRegex.exec(content)) !== null && - signalDefs.length < maxSignals - ) { - const width = parseInt(match[2]); - const identifier = match[3]; - const name = match[4].trim(); - - signalDefs.push({ name, identifier, width }); - } - - // 2. 找到数据变化部分的起始位置 - const dumpvarsIndex = content.indexOf("$dumpvars"); - if (dumpvarsIndex === -1) { - return signals; - } - - const dataSection = content.substring(dumpvarsIndex); - - // 3. 解析每个信号的值变化 - for (const signalDef of signalDefs) { - const values: Array<{ time: number; value: string }> = []; - let currentTime = 0; - - // 分行处理数据 - const lines = dataSection.split("\n"); - - for (const line of lines) { - const trimmedLine = line.trim(); - - // 解析时间戳 - if (trimmedLine.startsWith("#")) { - currentTime = parseInt(trimmedLine.substring(1)); - continue; - } - - // 解析信号值变化 - // 格式1: 单比特信号 "0!" 或 "1!" - // 格式2: 多比特信号 "b1010 !" - if (signalDef.width === 1) { - // 单比特信号 - const singleBitMatch = trimmedLine.match( - new RegExp(`^([01xz])${signalDef.identifier}$`), - ); - if (singleBitMatch) { - values.push({ time: currentTime, value: singleBitMatch[1] }); - } - } else { - // 多比特信号 - const multiBitMatch = trimmedLine.match( - new RegExp(`^b([01xz]+)\\s+${signalDef.identifier}$`), - ); - if (multiBitMatch) { - values.push({ time: currentTime, value: multiBitMatch[1] }); - } - } - - // 限制采样点数量,避免数据过多 - if (values.length >= 50) { - break; - } - } - - signals.push({ - name: signalDef.name, - identifier: signalDef.identifier, - width: signalDef.width, - values: values, - }); - } - } catch (error) { - console.error("解析 VCD 信号数据失败:", error); - } - - 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; - } - - // 设置 lastTaskId,用于压缩等操作 - setLastTaskId(taskId); - - // 更新面板的任务映射,确保后续对话保存到正确的任务中 - 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}`); - } -} diff --git a/src/panels/helpers/authHelper.ts b/src/panels/helpers/authHelper.ts new file mode 100644 index 0000000..c5ba440 --- /dev/null +++ b/src/panels/helpers/authHelper.ts @@ -0,0 +1,66 @@ +/** + * 认证辅助模块 + * 功能:处理用户登录状态检查和 token 验证 + * 依赖:vscode, jwtUtils + * 使用场景:面板初始化时验证用户登录状态 + */ +import * as vscode from "vscode"; +import { isTokenExpired } from "../../utils/jwtUtils"; + +export async function checkAuthAndPromptLogin( + context: vscode.ExtensionContext, +): Promise { + let token: string | undefined; + try { + const session = await vscode.authentication.getSession("iccoder", [], { + createIfNone: false, + }); + token = session?.accessToken; + } catch (error) { + console.warn("[AuthHelper] 获取 session 失败:", error); + } + + if (token && isTokenExpired(token)) { + await context.globalState.update("icCoderSessions", []); + await context.globalState.update("icCoderUserInfo", undefined); + const action = await vscode.window.showWarningMessage( + "登录已过期,请重新登录", + "立即登录", + ); + if (action === "立即登录") { + vscode.commands.executeCommand("ic-coder.login", { forceReauth: true }); + } + return false; + } + + try { + const session = await vscode.authentication.getSession("iccoder", [], { + createIfNone: false, + }); + if (!session) { + vscode.window + .showWarningMessage("请先登录后再使用 IC Coder", "立即登录") + .then((selection) => { + if (selection === "立即登录") { + vscode.commands.executeCommand("ic-coder.login", { + forceReauth: true, + }); + } + }); + return false; + } + } catch (error) { + vscode.window + .showWarningMessage("请先登录后再使用 IC Coder", "立即登录") + .then((selection) => { + if (selection === "立即登录") { + vscode.commands.executeCommand("ic-coder.login", { + forceReauth: true, + }); + } + }); + return false; + } + + return true; +} diff --git a/src/panels/helpers/contextHelper.ts b/src/panels/helpers/contextHelper.ts new file mode 100644 index 0000000..91e6ca2 --- /dev/null +++ b/src/panels/helpers/contextHelper.ts @@ -0,0 +1,104 @@ +/** + * 上下文管理模块 + * 功能:处理文件、文件夹、图片、文档上下文添加 + * 依赖:vscode, fs, path + * 使用场景:用户添加上下文项时 + */ +import * as vscode from "vscode"; + +export async function handleAddContextFile(panel: vscode.WebviewPanel) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showWarningMessage("请先打开一个工作区"); + return; + } + + const files = await vscode.workspace.findFiles( + "**/*", + "**/node_modules/**", + ); + + panel.webview.postMessage({ + command: "showWorkspaceFileList", + files: files.map((uri) => ({ + path: uri.fsPath, + relativePath: vscode.workspace.asRelativePath(uri), + })), + }); +} + +export async function handleAddContextFolder(panel: vscode.WebviewPanel) { + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + if (!workspaceFolder) { + vscode.window.showWarningMessage("请先打开一个工作区"); + return; + } + + const fs = require("fs"); + const path = require("path"); + const folders: Array<{ path: string; relativePath: string }> = []; + + function scanFolders(dir: string, baseDir: string) { + try { + const items = fs.readdirSync(dir, { withFileTypes: true }); + for (const item of items) { + if ( + item.isDirectory() && + item.name !== "node_modules" && + !item.name.startsWith(".") + ) { + const fullPath = path.join(dir, item.name); + const relativePath = path.relative(baseDir, fullPath); + folders.push({ path: fullPath, relativePath }); + scanFolders(fullPath, baseDir); + } + } + } catch (error) { + console.error("扫描文件夹失败:", error); + } + } + + scanFolders(workspaceFolder.uri.fsPath, workspaceFolder.uri.fsPath); + + panel.webview.postMessage({ + command: "showWorkspaceFolderList", + folders: folders, + }); +} + +export async function handleAddContextImage(panel: vscode.WebviewPanel) { + const imageUris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + openLabel: "选择图片", + filters: { + 图片文件: ["png", "jpg", "jpeg", "gif", "bmp", "svg", "webp"], + }, + }); + if (imageUris && imageUris.length > 0) { + panel.webview.postMessage({ + command: "contextImagesSelected", + images: imageUris.map((uri) => uri.fsPath), + }); + } +} + +export async function handleAddContextDocument(panel: vscode.WebviewPanel) { + const docUris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + openLabel: "选择文档", + filters: { + 文档文件: ["pdf", "doc", "docx", "txt", "md"], + 所有文件: ["*"], + }, + }); + if (docUris && docUris.length > 0) { + panel.webview.postMessage({ + command: "contextDocumentsSelected", + documents: docUris.map((uri) => uri.fsPath), + }); + } +} diff --git a/src/panels/helpers/conversationHelper.ts b/src/panels/helpers/conversationHelper.ts new file mode 100644 index 0000000..2550f2f --- /dev/null +++ b/src/panels/helpers/conversationHelper.ts @@ -0,0 +1,224 @@ +/** + * 会话历史管理模块 + * 功能:加载和选择会话历史 + * 依赖:vscode, chatHistoryManager, messageHandler + * 使用场景:会话历史列表和切换 + */ +import * as vscode from "vscode"; +import { ChatHistoryManager } from "../../utils/chatHistoryManager"; +import { MessageType } from "../../types/chatHistory"; +import { setLastTaskId } from "../../utils/messageHandler"; + +export 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, + }); + } +} + +export 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; + } + + setLastTaskId(taskId); + + const panelId = (panel as any).__uniqueId; + historyManager.setPanelTask(panelId, taskId, workspacePath); + + panel.webview.postMessage({ command: "clearChat" }); + + const segments: any[] = []; + let i = 0; + + while (i < taskSession.messages.length) { + const message = taskSession.messages[i]; + + if (message.type === MessageType.USER) { + 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) { + if (message.segments && message.segments.length > 0) { + panel.webview.postMessage({ + command: "receiveSegments", + segments: message.segments, + }); + i++; + } else { + 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++; + + 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++; + } + } + + if (segments.length > 0) { + panel.webview.postMessage({ + command: "receiveSegments", + segments: segments, + }); + } + + // 发送任务完成消息(历史记录) + panel.webview.postMessage({ + command: "taskCompleteHistory", + }); + + vscode.window.showInformationMessage( + `已加载会话: ${taskSession.meta.taskName}`, + ); + } catch (error) { + console.error("选择会话失败:", error); + vscode.window.showErrorMessage(`加载会话失败: ${error}`); + } +} diff --git a/src/panels/helpers/fileHelper.ts b/src/panels/helpers/fileHelper.ts new file mode 100644 index 0000000..4c046ae --- /dev/null +++ b/src/panels/helpers/fileHelper.ts @@ -0,0 +1,78 @@ +/** + * 文件操作辅助模块 + * 功能:处理文件打开、选择等操作 + * 依赖:vscode, fs, path + * 使用场景:打开文件、跳转到代码位置 + */ +import * as vscode from "vscode"; + +export async function openFile(filePath: string) { + const path = require("path"); + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const fullPath = + path.isAbsolute(filePath) || !workspaceFolder + ? filePath + : vscode.Uri.joinPath(workspaceFolder.uri, filePath).fsPath; + const doc = await vscode.workspace.openTextDocument(fullPath); + await vscode.window.showTextDocument(doc); +} + +export async function openFileWithSelection( + filePath: string, + startLine: number, + endLine: number, +) { + const path = require("path"); + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + const fullPath = + path.isAbsolute(filePath) || !workspaceFolder + ? filePath + : vscode.Uri.joinPath(workspaceFolder.uri, filePath).fsPath; + const doc = await vscode.workspace.openTextDocument(fullPath); + const editor = await vscode.window.showTextDocument(doc); + const start = new vscode.Position(startLine - 1, 0); + const end = new vscode.Position( + endLine - 1, + doc.lineAt(endLine - 1).text.length, + ); + editor.selection = new vscode.Selection(start, end); + editor.revealRange(new vscode.Range(start, end)); +} + +export async function openFilePathTag( + filePath: string, + startLine?: number, + endLine?: number, +) { + const path = require("path"); + const fs = require("fs"); + const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; + + let fullPath = filePath; + + if (!path.isAbsolute(filePath) && workspaceFolder) { + const candidatePath = vscode.Uri.joinPath( + workspaceFolder.uri, + filePath, + ).fsPath; + if (fs.existsSync(candidatePath)) { + fullPath = candidatePath; + } else { + const fileName = path.basename(filePath); + const files = await vscode.workspace.findFiles( + `**/${fileName}`, + "**/node_modules/**", + 1, + ); + if (files.length > 0) { + fullPath = files[0].fsPath; + } + } + } + + if (startLine && endLine) { + await openFileWithSelection(fullPath, startLine, endLine); + } else { + await openFile(fullPath); + } +} diff --git a/src/panels/helpers/messageRouter.ts b/src/panels/helpers/messageRouter.ts new file mode 100644 index 0000000..2f78904 --- /dev/null +++ b/src/panels/helpers/messageRouter.ts @@ -0,0 +1,396 @@ +/** + * 消息路由处理模块 + * 功能:处理 webview 消息的路由分发 + * 依赖:各个 helper 模块和 messageHandler + * 使用场景:webview 消息接收时 + */ +import * as vscode from "vscode"; +import { + handleUserMessage, + insertCodeToEditor, + handleReadFile, + handleUpdateFile, + handleRenameFile, + handleReplaceInFile, + handleUserAnswer, + abortCurrentDialog, + handleOptimizePrompt, + handlePlanAction, + getCurrentTaskId, + handleAcceptChange, + handleRejectChange, + handleOpenFileDiff, + startChangeSession, +} from "../../utils/messageHandler"; +import { compactDialog } from "../../services/apiClient"; +import { ChatHistoryManager } from "../../utils/chatHistoryManager"; +import { getCachedUserInfo } from "../../services/userService"; +import { loadConversationHistory, selectConversation } from "./conversationHelper"; +import { getVCDFileInfo } from "./vcdHelper"; +import { + handleAddContextFile, + handleAddContextFolder, + handleAddContextImage, + handleAddContextDocument, +} from "./contextHelper"; +import { openFile, openFileWithSelection, openFilePathTag } from "./fileHelper"; + +export async function handleWebviewMessage( + message: any, + panel: vscode.WebviewPanel, + context: vscode.ExtensionContext, +) { + const historyManager = ChatHistoryManager.getInstance(); + const panelId = (panel as any).__uniqueId; + + switch (message.command) { + 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); + const sessionId = `session_${panelId}_${Date.now()}`; + startChangeSession(sessionId); + panel.webview.postMessage({ type: "showProgress" }); + + handleUserMessage( + panel, + message.text, + context.extensionPath, + message.mode, + message.model, + message.contextItems, + ); + break; + + case "readFile": + handleReadFile(panel, message.filePath); + break; + + case "updateFile": + handleUpdateFile(panel, message.filePath, message.content); + break; + + case "renameFile": + handleRenameFile(panel, message.oldPath, message.newPath); + break; + + case "replaceInFile": + handleReplaceInFile( + panel, + message.filePath, + message.searchText, + message.replaceText, + ); + break; + + case "insertCode": + insertCodeToEditor(message.code); + break; + + case "showInfo": + vscode.window.showInformationMessage(message.text); + break; + + case "openWaveformViewer": + if (message.vcdFilePath) { + vscode.commands.executeCommand( + "ic-coder.openVCDViewer", + message.vcdFilePath, + ); + } + break; + + case "getVCDInfo": + if (message.vcdFilePath && message.containerId) { + getVCDFileInfo(panel, message.vcdFilePath, message.containerId); + } + break; + + case "createNewConversation": + const { showICHelperPanel } = require("../ICHelperPanel"); + showICHelperPanel(context, panel.viewColumn); + break; + + case "loadConversationHistory": + loadConversationHistory( + panel, + message.offset || 0, + message.limit || 10, + ); + break; + + case "selectConversation": + if (message.conversationId) { + selectConversation(panel, message.conversationId, context.extensionPath); + } + break; + + case "submitAnswer": + void handleUserAnswer( + message.askId, + message.selected, + message.customInput, + message.answers, + ); + break; + + case "abortDialog": + void abortCurrentDialog(); + break; + + case "compressConversation": + { + const taskId = getCurrentTaskId(); + if (taskId) { + compactDialog(taskId) + .then((result) => { + panel.webview.postMessage({ + command: "receiveMessage", + text: result.success + ? "✅ 会话压缩完成" + : `❌ 压缩失败: ${result.error || "未知错误"}`, + }); + }) + .catch((err) => { + panel.webview.postMessage({ + command: "receiveMessage", + text: `❌ 压缩失败: ${err.message || "网络错误"}`, + }); + }); + } else { + panel.webview.postMessage({ + command: "receiveMessage", + text: "❌ 没有活跃的会话", + }); + } + } + break; + + case "optimizePrompt": + if (typeof message.prompt === "string") { + void handleOptimizePrompt(panel, message.prompt); + } else { + panel.webview.postMessage({ + command: "optimizeResult", + success: false, + error: "提示词为空或格式错误", + }); + } + break; + + case "logout": + vscode.commands.executeCommand("ic-coder.logout"); + break; + + case "openFile": + if (message.filePath) { + await openFile(message.filePath); + } + break; + + case "openFileWithSelection": + if (message.filePath) { + await openFileWithSelection( + message.filePath, + message.startLine, + message.endLine, + ); + } + break; + + case "openFilePathTag": + if (message.filePath) { + await openFilePathTag( + message.filePath, + message.startLine, + message.endLine, + ); + } + break; + + case "acceptChange": + if (message.changeId) { + await handleAcceptChange(panel, message.changeId); + } + break; + + case "rejectChange": + if (message.changeId) { + await handleRejectChange(panel, message.changeId); + } + break; + + case "openFileDiff": + if (message.changeId) { + await handleOpenFileDiff(panel, message.changeId); + } + break; + + case "checkInvitationCode": + { + const userInfo = getCachedUserInfo(); + if (userInfo?.isPluginTrial === true) { + panel.webview.postMessage({ + command: "invitationCodeStatus", + verified: true, + }); + } else { + const { InvitationService } = require("../../services/invitationService"); + const isVerified = await InvitationService.isVerified(context); + panel.webview.postMessage({ + command: "invitationCodeStatus", + verified: isVerified, + }); + } + } + break; + + case "checkWelcomeModal": + { + const userInfo = getCachedUserInfo(); + if (userInfo?.isPluginTrial === true) { + if (userInfo.pluginTrialExpiresAt === undefined) { + break; + } + if (userInfo.pluginTrialExpiresAt !== null) { + const now = Date.now(); + const isExpired = now >= userInfo.pluginTrialExpiresAt; + if (isExpired) { + break; + } + } + panel.webview.postMessage({ command: "showWelcomeModal" }); + } + } + break; + + case "checkTrialExpiration": + { + const { TrialExpirationService } = require("../../services/trialExpirationService"); + const trialService = new TrialExpirationService(context, panel); + await trialService.checkExpiration(); + } + break; + + case "verifyInvitationCode": + { + const { InvitationService } = require("../../services/invitationService"); + const result = await InvitationService.verifyCode(message.code); + + if (result.success) { + await InvitationService.saveVerificationStatus(context, message.code); + panel.webview.postMessage({ + command: "invitationCodeVerified", + success: true, + }); + setTimeout(() => { + panel.webview.postMessage({ command: "showNdtWelcomeModal" }); + }, 300); + } else { + panel.webview.postMessage({ + command: "invitationCodeVerified", + success: false, + message: result.message, + }); + } + } + break; + + case "openICCoder": + vscode.env.openExternal(vscode.Uri.parse("https://www.iccoder.com")); + break; + + case "openTutorial": + vscode.env.openExternal( + vscode.Uri.parse( + "https://www.iccoder.com/guides/quick-start/first-task-plugin", + ), + ); + break; + + case "openUserManual": + vscode.env.openExternal(vscode.Uri.parse("https://www.iccoder.com")); + break; + + case "openUserFeedback": + panel.webview.postMessage({ command: "showFeedbackQRCode" }); + break; + + case "planAction": + if (message.action === "confirm") { + panel.webview.postMessage({ command: "switchMode", mode: "agent" }); + } else if (message.action === "modify" || message.action === "cancel") { + void handlePlanAction( + panel, + message.action, + message.planTitle || "", + context.extensionPath, + message.model, + ); + } + break; + + case "addContextFile": + await handleAddContextFile(panel); + break; + + case "addContextFolder": + await handleAddContextFolder(panel); + break; + + case "addContextImage": + await handleAddContextImage(panel); + break; + + case "addContextDocument": + await handleAddContextDocument(panel); + break; + + case "checkWorkspace": + const hasWorkspace = !!( + vscode.workspace.workspaceFolders && + vscode.workspace.workspaceFolders.length > 0 + ); + if (!hasWorkspace) { + vscode.window + .showWarningMessage( + "请先打开一个文件夹作为工作区,这样我就能更好地为您服务了 😊", + "打开文件夹", + ) + .then((selection) => { + if (selection === "打开文件夹") { + vscode.commands.executeCommand("vscode.openFolder"); + } + }); + } + panel.webview.postMessage({ + command: "workspaceStatus", + hasWorkspace: hasWorkspace, + }); + break; + + case "openExternalUrl": + if (message.url) { + vscode.env.openExternal(vscode.Uri.parse(message.url)); + } + break; + } +} diff --git a/src/panels/helpers/userInfoHelper.ts b/src/panels/helpers/userInfoHelper.ts new file mode 100644 index 0000000..becf1d8 --- /dev/null +++ b/src/panels/helpers/userInfoHelper.ts @@ -0,0 +1,116 @@ +/** + * 用户信息辅助模块 + * 功能:管理用户信息的获取、更新和发送 + * 依赖:vscode, userService, creditsService + * 使用场景:面板初始化和余额更新时 + */ +import * as vscode from "vscode"; +import { getCachedUserInfo } from "../../services/userService"; +import { setBalanceUpdateCallback } from "../../services/creditsService"; + +export function getTierIconUri( + webview: vscode.Webview, + context: vscode.ExtensionContext, + tierCode?: string, +): string | undefined { + if (!tierCode) { + return undefined; + } + + const tierIconMap: Record = { + BASIC: "free.png", + TRIAL: "PRO-Try.png", + ADVANCED: "PRO.png", + PROFESSIONAL: "PRO+.png", + }; + + const iconFile = tierIconMap[tierCode]; + if (!iconFile) { + return undefined; + } + + const iconUri = webview.asWebviewUri( + vscode.Uri.joinPath( + context.extensionUri, + "dist", + "assets", + "titleIcon", + iconFile, + ), + ); + + return iconUri.toString(); +} + +export async function sendUserInfoToWebview( + panel: vscode.WebviewPanel, + context: vscode.ExtensionContext, +) { + try { + let userInfo = getCachedUserInfo(); + + if (userInfo) { + console.log("[UserInfoHelper] 使用缓存的用户信息:", userInfo); + const tierIconUrl = getTierIconUri( + panel.webview, + context, + userInfo.membership?.tierCode, + ); + panel.webview.postMessage({ + command: "updateUserInfo", + userInfo: { + userId: userInfo.userId, + nickname: userInfo.nickname, + username: userInfo.username, + credits: userInfo.credits, + membership: userInfo.membership, + }, + tierIconUrl: tierIconUrl, + }); + } else { + const session = await vscode.authentication.getSession("iccoder", [], { + createIfNone: false, + }); + if (session) { + panel.webview.postMessage({ + command: "updateUserInfo", + userInfo: { + userId: session.account.id, + nickname: session.account.label, + username: session.account.label, + }, + }); + } + } + } catch (error) { + console.error("[UserInfoHelper] 获取用户信息失败:", error); + } +} + +export function setupBalanceUpdateCallback( + panel: vscode.WebviewPanel, + context: vscode.ExtensionContext, +) { + setBalanceUpdateCallback((balance: number) => { + const userInfo = getCachedUserInfo(); + if (userInfo) { + userInfo.credits = balance; + const tierIconUrl = getTierIconUri( + panel.webview, + context, + userInfo.membership?.tierCode, + ); + panel.webview.postMessage({ + command: "updateUserInfo", + userInfo: { + userId: userInfo.userId, + nickname: userInfo.nickname, + username: userInfo.username, + credits: balance, + membership: userInfo.membership, + }, + tierIconUrl: tierIconUrl, + }); + } + }); +} diff --git a/src/panels/helpers/vcdHelper.ts b/src/panels/helpers/vcdHelper.ts new file mode 100644 index 0000000..6480bd4 --- /dev/null +++ b/src/panels/helpers/vcdHelper.ts @@ -0,0 +1,158 @@ +/** + * VCD 文件处理模块 + * 功能:VCD 文件信息获取和信号解析 + * 依赖:vscode, fs + * 使用场景:波形查看器相关功能 + */ +import * as vscode from "vscode"; + +export async function getVCDFileInfo( + panel: vscode.WebviewPanel, + vcdFilePath: string, + containerId: string, +) { + try { + const fs = require("fs"); + + if (!fs.existsSync(vcdFilePath)) { + panel.webview.postMessage({ + command: "vcdInfo", + containerId: containerId, + vcdInfo: { + signalCount: "N/A", + timeRange: "N/A", + fileSize: "N/A", + error: "文件不存在", + }, + }); + return; + } + + const stats = fs.statSync(vcdFilePath); + const fileSizeKB = stats.size / 1024; + const fileSize = + fileSizeKB < 1024 + ? `${fileSizeKB.toFixed(2)} KB` + : `${(fileSizeKB / 1024).toFixed(2)} MB`; + + const content = fs.readFileSync(vcdFilePath, "utf-8"); + const varMatches = content.match(/\$var/g); + const signalCount = varMatches ? varMatches.length : 0; + + let timeRange = "N/A"; + const timeMatch = content.match(/#(\d+)/g); + if (timeMatch && timeMatch.length > 0) { + const times = timeMatch.map((t: string) => parseInt(t.substring(1))); + const minTime = Math.min(...times); + const maxTime = Math.max(...times); + timeRange = `${minTime} - ${maxTime}`; + } + + const signals = parseVCDSignals(content, 3); + + panel.webview.postMessage({ + command: "vcdInfo", + containerId: containerId, + vcdInfo: { + signalCount: signalCount.toString(), + timeRange: timeRange, + fileSize: fileSize, + signals: signals, + }, + }); + } catch (error) { + console.error("获取 VCD 文件信息失败:", error); + panel.webview.postMessage({ + command: "vcdInfo", + containerId: containerId, + vcdInfo: { + signalCount: "N/A", + timeRange: "N/A", + fileSize: "N/A", + error: error instanceof Error ? error.message : "未知错误", + }, + }); + } +} + +function parseVCDSignals(content: string, maxSignals: number = 3) { + const signals: Array<{ + name: string; + identifier: string; + width: number; + values: Array<{ time: number; value: string }>; + }> = []; + + try { + const varRegex = /\$var\s+(\w+)\s+(\d+)\s+(\S+)\s+([^\$]+?)\s+\$end/g; + let match; + const signalDefs: Array<{ + name: string; + identifier: string; + width: number; + }> = []; + + while ( + (match = varRegex.exec(content)) !== null && + signalDefs.length < maxSignals + ) { + const width = parseInt(match[2]); + const identifier = match[3]; + const name = match[4].trim(); + signalDefs.push({ name, identifier, width }); + } + + const dumpvarsIndex = content.indexOf("$dumpvars"); + if (dumpvarsIndex === -1) { + return signals; + } + + const dataSection = content.substring(dumpvarsIndex); + + for (const signalDef of signalDefs) { + const values: Array<{ time: number; value: string }> = []; + let currentTime = 0; + const lines = dataSection.split("\n"); + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine.startsWith("#")) { + currentTime = parseInt(trimmedLine.substring(1)); + continue; + } + + if (signalDef.width === 1) { + const singleBitMatch = trimmedLine.match( + new RegExp(`^([01xz])${signalDef.identifier}$`), + ); + if (singleBitMatch) { + values.push({ time: currentTime, value: singleBitMatch[1] }); + } + } else { + const multiBitMatch = trimmedLine.match( + new RegExp(`^b([01xz]+)\\s+${signalDef.identifier}$`), + ); + if (multiBitMatch) { + values.push({ time: currentTime, value: multiBitMatch[1] }); + } + } + + if (values.length >= 50) { + break; + } + } + + signals.push({ + name: signalDef.name, + identifier: signalDef.identifier, + width: signalDef.width, + values: values, + }); + } + } catch (error) { + console.error("解析 VCD 信号数据失败:", error); + } + + return signals; +} diff --git a/src/services/toolExecutor.ts b/src/services/toolExecutor.ts index 930e5e6..c61c54e 100644 --- a/src/services/toolExecutor.ts +++ b/src/services/toolExecutor.ts @@ -291,7 +291,7 @@ async function executeSyntaxCheck( // 检查 iverilog 是否可用 const iverilogCheck = await checkIverilogAvailable(context.extensionPath); if (!iverilogCheck.available) { - throw new Error(`iverilog 不可用: ${iverilogCheck.message}`); + throw new Error(`IC Coder编译器不可用: ${iverilogCheck.message}`); } // 创建临时文件 @@ -372,7 +372,7 @@ async function executeIverilog( // 检查 iverilog 是否可用 const iverilogCheck = await checkIverilogAvailable(context.extensionPath); if (!iverilogCheck.available) { - throw new Error(`iverilog 不可用: ${iverilogCheck.message}`); + throw new Error(`IC Coder编译器不可用: ${iverilogCheck.message}`); } // 获取工作目录 diff --git a/src/utils/iverilogRunner.ts b/src/utils/iverilogRunner.ts index 0c45298..ac0af58 100644 --- a/src/utils/iverilogRunner.ts +++ b/src/utils/iverilogRunner.ts @@ -7,7 +7,7 @@ import { promisify } from "util"; function execCommand( command: string, args: string[], - options: { cwd: string; env?: any } + options: { cwd: string; env?: any }, ): Promise<{ stdout: string; stderr: string }> { return new Promise((resolve, reject) => { // 在 Windows 上,如果路径包含空格,不使用 shell,直接用 spawn @@ -23,25 +23,25 @@ function execCommand( let stderr = ""; // 在 Windows 上使用 GBK 编码解码输出 - const encoding = process.platform === 'win32' ? 'gbk' : 'utf8'; + const encoding = process.platform === "win32" ? "gbk" : "utf8"; child.stdout.on("data", (data) => { try { // 尝试使用 iconv-lite 解码(如果可用) - const iconv = require('iconv-lite'); + const iconv = require("iconv-lite"); stdout += iconv.decode(data, encoding); } catch { // 如果 iconv-lite 不可用,使用默认解码 - stdout += data.toString('utf8'); + stdout += data.toString("utf8"); } }); child.stderr.on("data", (data) => { try { - const iconv = require('iconv-lite'); + const iconv = require("iconv-lite"); stderr += iconv.decode(data, encoding); } catch { - stderr += data.toString('utf8'); + stderr += data.toString("utf8"); } }); @@ -93,7 +93,7 @@ export interface VCDGenerationResult { * 检查项目中的 Verilog 文件完整性 */ export async function checkVerilogProject( - projectPath: string + projectPath: string, ): Promise { const result: VerilogProjectCheck = { isComplete: false, @@ -164,7 +164,7 @@ export async function checkVerilogProject( return result; } catch (error) { result.errors.push( - `检查项目时出错: ${error instanceof Error ? error.message : "未知错误"}` + `检查项目时出错: ${error instanceof Error ? error.message : "未知错误"}`, ); return result; } @@ -209,12 +209,30 @@ async function getIverilogPath(extensionPath: string): Promise { let iverilogBin = ""; if (platform === "win32") { - iverilogBin = path.join(extensionPath, "tools", "iverilog", "bin", "iverilog.exe"); + iverilogBin = path.join( + extensionPath, + "tools", + "iverilog", + "bin", + "iverilog.exe", + ); } else if (platform === "darwin") { - iverilogBin = path.join(extensionPath, "tools", "iverilog", "bin", "iverilog"); + iverilogBin = path.join( + extensionPath, + "tools", + "iverilog", + "bin", + "iverilog", + ); } else { // Linux - iverilogBin = path.join(extensionPath, "tools", "iverilog", "bin", "iverilog"); + iverilogBin = path.join( + extensionPath, + "tools", + "iverilog", + "bin", + "iverilog", + ); } // 如果插件包中没有,尝试使用系统安装的 iverilog @@ -258,7 +276,7 @@ async function getVvpPath(extensionPath: string): Promise { */ export async function generateVCD( projectPath: string, - extensionPath: string + extensionPath: string, ): Promise { try { // 1. 检查项目完整性 @@ -302,12 +320,27 @@ export async function generateVCD( } catch (error: any) { return { success: false, - message: `iverilog 编译失败:\n${error.message}`, + message: `IC Coder编译器编译失败:\n${error.message}`, stderr: error.stderr, stdout: error.stdout, }; } + // 6.5. 删除 shebang 行(修复 Windows 上的 vvp 解析错误) + try { + const fs = require("fs"); + const vvpContent = fs.readFileSync(outputFile, "utf8"); + const lines = vvpContent.split("\n"); + + if (lines.length > 0 && lines[0].startsWith("#!")) { + const cleanedContent = lines.slice(1).join("\n"); + fs.writeFileSync(outputFile, cleanedContent, "utf8"); + console.log("已删除 .vvp 文件的 shebang 行"); + } + } catch (error) { + console.warn("删除 shebang 失败,继续执行:", error); + } + // 7. 执行仿真生成 VCD const simArgs = [outputFile]; console.log("执行仿真命令:", vvpPath, simArgs.join(" ")); @@ -331,13 +364,17 @@ export async function generateVCD( const projectUri = vscode.Uri.file(projectPath); const entries = await vscode.workspace.fs.readDirectory(projectUri); const vcdFiles = entries - .filter(([fileName, fileType]) => fileType === vscode.FileType.File && fileName.endsWith('.vcd')) + .filter( + ([fileName, fileType]) => + fileType === vscode.FileType.File && fileName.endsWith(".vcd"), + ) .map(([fileName]) => fileName); if (vcdFiles.length === 0) { return { success: false, - message: "VCD 文件未生成。请确保 testbench 中包含 $dumpfile 和 $dumpvars 语句。", + message: + "VCD 文件未生成。请确保 testbench 中包含 $dumpfile 和 $dumpvars 语句。", stdout: simResult.stdout, }; } @@ -373,7 +410,7 @@ export async function generateVCD( * 检查 iverilog 是否可用 */ export async function checkIverilogAvailable( - extensionPath: string + extensionPath: string, ): Promise<{ available: boolean; version?: string; message: string }> { try { const iverilogPath = await getIverilogPath(extensionPath); @@ -385,7 +422,7 @@ export async function checkIverilogAvailable( } catch (error) { return { available: false, - message: `iverilog 不可用。未找到文件: ${iverilogPath}`, + message: `IC Coder编译器不可用。未找到文件: ${iverilogPath}`, }; } @@ -404,12 +441,12 @@ export async function checkIverilogAvailable( return { available: true, version: version, - message: `iverilog 可用: ${version}`, + message: `IC Coder编译器可用: ${version}`, }; } catch (error: any) { return { available: false, - message: `iverilog 执行失败: ${error.message}\n${error.stderr || ""}`, + message: `IC Coder编译器执行失败: ${error.message}\n${error.stderr || ""}`, }; } } @@ -418,8 +455,8 @@ export async function checkIverilogAvailable( * 要 dump 的模块定义 */ export interface DumpModule { - name: string; // 模块名(用于 VCD 文件名和宏名) - path: string; // 实例路径(如 dut.u_tx) + name: string; // 模块名(用于 VCD 文件名和宏名) + path: string; // 实例路径(如 dut.u_tx) } /** @@ -444,10 +481,11 @@ export interface MultiVCDResult { function injectConditionalDump( tbContent: string, dumpModules: DumpModule[], - vcdDir: string + vcdDir: string, ): string { // 匹配 $dumpfile 和 $dumpvars 语句(可能跨多行) - const dumpPattern = /(\$dumpfile\s*\([^)]+\)\s*;[\s\S]*?\$dumpvars\s*\([^)]+\)\s*;)/g; + const dumpPattern = + /(\$dumpfile\s*\([^)]+\)\s*;[\s\S]*?\$dumpvars\s*\([^)]+\)\s*;)/g; // 生成条件编译代码 const conditionalCode = generateConditionalDumpCode(dumpModules, vcdDir); @@ -469,7 +507,7 @@ function injectConditionalDump( */ function generateConditionalDumpCode( dumpModules: DumpModule[], - vcdDir: string + vcdDir: string, ): string { if (dumpModules.length === 0) { return '$dumpfile("output.vcd");\n $dumpvars(0, dut);'; @@ -480,7 +518,7 @@ function generateConditionalDumpCode( dumpModules.forEach((module, index) => { const macroName = `DUMP_${module.name.toUpperCase()}`; const vcdPath = `${vcdDir}/${module.name}.vcd`; - const directive = index === 0 ? '`ifdef' : '`elsif'; + const directive = index === 0 ? "`ifdef" : "`elsif"; lines.push(`${directive} ${macroName}`); lines.push(` $dumpfile("${vcdPath}");`); @@ -488,12 +526,12 @@ function generateConditionalDumpCode( }); // 添加默认分支(使用第一个模块) - lines.push('`else'); + lines.push("`else"); lines.push(` $dumpfile("${vcdDir}/${dumpModules[0].name}.vcd");`); lines.push(` $dumpvars(1, ${dumpModules[0].path});`); - lines.push('`endif'); + lines.push("`endif"); - return lines.join('\n'); + return lines.join("\n"); } /** @@ -504,10 +542,10 @@ export async function generateMultiVCD( extensionPath: string, tbPath: string, dumpModules: DumpModule[], - vcdDir: string = 'vcd' + vcdDir: string = "vcd", ): Promise { - const results: MultiVCDResult['vcdFiles'] = []; - let allStdout = ''; + const results: MultiVCDResult["vcdFiles"] = []; + let allStdout = ""; try { // 1. 创建 vcd 目录 @@ -520,16 +558,21 @@ export async function generateMultiVCD( } // 2. 读取原始 testbench - const tbFullPath = path.isAbsolute(tbPath) ? tbPath : path.join(projectPath, tbPath); + const tbFullPath = path.isAbsolute(tbPath) + ? tbPath + : path.join(projectPath, tbPath); const tbUri = vscode.Uri.file(tbFullPath); const tbBytes = await vscode.workspace.fs.readFile(tbUri); - const originalTb = Buffer.from(tbBytes).toString('utf-8'); + const originalTb = Buffer.from(tbBytes).toString("utf-8"); // 3. 注入条件编译代码 const modifiedTb = injectConditionalDump(originalTb, dumpModules, vcdDir); - await vscode.workspace.fs.writeFile(tbUri, Buffer.from(modifiedTb, 'utf-8')); + await vscode.workspace.fs.writeFile( + tbUri, + Buffer.from(modifiedTb, "utf-8"), + ); - console.log('[generateMultiVCD] Testbench 已修改,开始多次仿真...'); + console.log("[generateMultiVCD] Testbench 已修改,开始多次仿真..."); // 4. 获取工具路径 const iverilogPath = await getIverilogPath(extensionPath); @@ -554,27 +597,34 @@ export async function generateMultiVCD( // 编译(带宏定义) const compileArgs = [ `-D${macroName}`, - "-o", outputFile, - ...projectCheck.allVerilogFiles + "-o", + outputFile, + ...projectCheck.allVerilogFiles, ]; await execCommand(iverilogPath, compileArgs, { cwd: projectPath, env }); // 仿真 - const simResult = await execCommand(vvpPath, [outputFile], { cwd: projectPath, env }); + const simResult = await execCommand(vvpPath, [outputFile], { + cwd: projectPath, + env, + }); allStdout += `\n[${module.name}] ${simResult.stdout}`; results.push({ moduleName: module.name, vcdPath: vcdPath, - success: true + success: true, }); } catch (error: any) { - console.error(`[generateMultiVCD] 模块 ${module.name} 仿真失败:`, error.message); + console.error( + `[generateMultiVCD] 模块 ${module.name} 仿真失败:`, + error.message, + ); results.push({ moduleName: module.name, vcdPath: vcdPath, success: false, - error: error.message + error: error.message, }); // 继续执行其他模块 } @@ -587,19 +637,18 @@ export async function generateMultiVCD( // 忽略 } - const successCount = results.filter(r => r.success).length; + const successCount = results.filter((r) => r.success).length; return { success: successCount > 0, vcdFiles: results, message: `生成完成:${successCount}/${dumpModules.length} 个 VCD 文件成功`, - stdout: allStdout + stdout: allStdout, }; - } catch (error) { return { success: false, vcdFiles: results, - message: `生成多 VCD 文件失败: ${error instanceof Error ? error.message : '未知错误'}` + message: `生成多 VCD 文件失败: ${error instanceof Error ? error.message : "未知错误"}`, }; } } diff --git a/src/utils/messageHandler.ts b/src/utils/messageHandler.ts index 13ef613..fb0cff1 100644 --- a/src/utils/messageHandler.ts +++ b/src/utils/messageHandler.ts @@ -41,7 +41,11 @@ let currentSession: DialogSession | null = null; /** 最后一个活跃的 taskId(用于压缩等操作) */ let lastTaskId: string | null = null; -async function trackFileChange(filePath: string, oldContent: string, newContent: string): Promise { +async function trackFileChange( + filePath: string, + oldContent: string, + newContent: string, +): Promise { try { changeTracker.trackChange(filePath, oldContent, newContent); } catch (error) { @@ -58,7 +62,7 @@ export async function handleUserMessage( extensionPath?: string, mode?: RunMode, serviceTier?: ServiceTier, // 服务等级参数 - contextItems?: Array<{ id: number; type: string; path: string }> // 上下文项参数 + contextItems?: Array<{ id: number; type: string; path: string }>, // 上下文项参数 ) { console.log("收到用户消息:", text); @@ -68,7 +72,9 @@ export async function handleUserMessage( // 从 session 中获取 token let token: string | undefined; try { - const session = await vscode.authentication.getSession("iccoder", [], { createIfNone: false }); + const session = await vscode.authentication.getSession("iccoder", [], { + createIfNone: false, + }); token = session?.accessToken; } catch (error) { console.warn("[MessageHandler] 获取 session 失败:", error); @@ -78,20 +84,20 @@ export async function handleUserMessage( console.warn("[MessageHandler] 未登录,阻止发送"); // 保存待发送的消息 - await context.globalState.update('pendingMessage', { + await context.globalState.update("pendingMessage", { text, mode, serviceTier, - timestamp: Date.now() + timestamp: Date.now(), }); // 显示弹窗提示 const action = await vscode.window.showWarningMessage( - '请先登录后再发送消息', - '立即登录' + "请先登录后再发送消息", + "立即登录", ); - if (action === '立即登录') { + if (action === "立即登录") { vscode.commands.executeCommand("ic-coder.login", { forceReauth: true, }); @@ -110,24 +116,24 @@ export async function handleUserMessage( console.warn("[MessageHandler] Token 已过期,阻止发送"); // 保存待发送的消息 - await context.globalState.update('pendingMessage', { + await context.globalState.update("pendingMessage", { text, mode, serviceTier, - timestamp: Date.now() + timestamp: Date.now(), }); // 清除过期的 session - await context.globalState.update('icCoderSessions', []); - await context.globalState.update('icCoderUserInfo', undefined); + await context.globalState.update("icCoderSessions", []); + await context.globalState.update("icCoderUserInfo", undefined); // 显示弹窗提示 const action = await vscode.window.showWarningMessage( - '登录已过期,请重新登录', - '立即登录' + "登录已过期,请重新登录", + "立即登录", ); - if (action === '立即登录') { + if (action === "立即登录") { vscode.commands.executeCommand("ic-coder.login", { forceReauth: true, }); @@ -190,11 +196,11 @@ export async function handleUserMessage( // 显示错误提示 const selection = await vscode.window.showWarningMessage( balanceCheck.message || "资源点余额不足", - "去充值" + "去充值", ); if (selection === "去充值") { vscode.env.openExternal( - vscode.Uri.parse("https://iccoder.com/memberCenter") + vscode.Uri.parse("https://iccoder.com/memberCenter"), ); } // 恢复输入状态 @@ -216,14 +222,14 @@ export async function handleUserMessage( mode, undefined, serviceTier, - contextItems + contextItems, ); return; } catch (error) { - console.error("后端服务不可用:", error); + console.error("当前访问人数过多,请稍后重试:", error); panel.webview.postMessage({ command: "updateStatus", - text: "后端服务不可用", + text: "当前访问人数过多,请稍后重试", type: "error", }); // 恢复输入状态 @@ -254,7 +260,7 @@ async function handleUserMessageWithBackend( mode?: RunMode, reuseTaskId?: string, // 可选,复用现有 taskId(用于 Plan 模式确认后继续执行) serviceTier?: ServiceTier, // 服务等级参数 - contextItems?: Array<{ id: number; type: string; path: string }> // 上下文项参数 + contextItems?: Array<{ id: number; type: string; path: string }>, // 上下文项参数 ): Promise { const historyManager = ChatHistoryManager.getInstance(); @@ -262,7 +268,7 @@ async function handleUserMessageWithBackend( let enhancedText = text; if (contextItems && contextItems.length > 0) { console.log("[MessageHandler] 处理上下文项:", contextItems.length); - const paths = contextItems.map(item => item.path).join('\n'); + const paths = contextItems.map((item) => item.path).join("\n"); enhancedText = `${paths}\n\n${text}`; } @@ -273,7 +279,7 @@ async function handleUserMessageWithBackend( // 创建会话(dialogManager 会自动处理旧会话的中止) currentSession = dialogManager.createSession( extensionPath, - taskIdToUse || undefined + taskIdToUse || undefined, ); // 保存 taskId 用于后续操作(如压缩) lastTaskId = currentSession.getTaskId(); @@ -281,7 +287,7 @@ async function handleUserMessageWithBackend( "[MessageHandler] 创建会话: taskId=", lastTaskId, "来源=", - taskIdToUse ? "historyManager" : "新生成" + taskIdToUse ? "historyManager" : "新生成", ); // 显示状态栏 @@ -300,10 +306,18 @@ async function handleUserMessageWithBackend( }, onSegmentUpdate: (segments) => { + // 过滤掉包含 [调用工具:xxx] 的段落 + const filteredSegments = segments.filter((seg) => { + if (seg.type === "text" && typeof seg.content === "string") { + return !/\[调用工具:.+?\]/.test(seg.content); + } + return true; + }); + // 实时发送段落更新,按后端返回顺序展示 panel.webview.postMessage({ command: "updateSegments", - segments: segments, + segments: filteredSegments, }); }, @@ -324,7 +338,10 @@ async function handleUserMessageWithBackend( // 工具错误,不需要单独处理,通过 onSegmentUpdate 统一更新 }, - onQuestion: (askId: string, questions: import("../types/api").QuestionItem[]) => { + onQuestion: ( + askId: string, + questions: import("../types/api").QuestionItem[], + ) => { // 只更新状态栏,问题显示由 onSegmentUpdate 统一处理 panel.webview.postMessage({ command: "updateStatus", @@ -376,21 +393,29 @@ async function handleUserMessageWithBackend( isComplete: true, }); + // 发送任务完成消息 + panel.webview.postMessage({ + command: "taskComplete", + }); + // 发送系统通知 - AI 响应完成 const notificationService = NotificationService.getInstance(); notificationService.success( - 'IC Coder - AI 响应完成', - '您的问题已得到回复,点击查看详情', + "IC Coder - AI 响应完成", + "您的问题已得到回复,点击查看详情", () => { // 点击通知时聚焦到面板 panel.reveal(); - } + }, ); // 发送代码变更到前端 sendChangesToWebview(panel); } catch (error) { - console.warn("[MessageHandler] 更新面板失败(面板可能已关闭):", error); + console.warn( + "[MessageHandler] 更新面板失败(面板可能已关闭):", + error, + ); } resolve(); @@ -402,7 +427,7 @@ async function handleUserMessageWithBackend( }); panel.webview.postMessage({ command: "receiveMessage", - text: `❌ 错误: ${message}`, + text: `❌ 服务繁忙,请稍后重试`, }); // 恢复输入状态 panel.webview.postMessage({ @@ -458,7 +483,7 @@ async function handleUserMessageWithBackend( }, }, mode, - serviceTier // 传递服务等级 + serviceTier, // 传递服务等级 ); }); } @@ -470,7 +495,7 @@ export async function handleUserAnswer( askId: string, selected?: string[], customInput?: string, - answers?: { [questionIndex: string]: string[] } + answers?: { [questionIndex: string]: string[] }, ): Promise { if (currentSession) { await currentSession.submitAnswer(askId, selected, customInput, answers); @@ -540,7 +565,7 @@ export async function handlePlanAction( action: string, planTitle: string, extensionPath: string, - serviceTier?: ServiceTier + serviceTier?: ServiceTier, ): Promise { console.log( "[handlePlanAction] action:", @@ -548,7 +573,7 @@ export async function handlePlanAction( "planTitle:", planTitle, "serviceTier:", - serviceTier + serviceTier, ); switch (action) { @@ -564,7 +589,7 @@ export async function handlePlanAction( `请按照刚才的计划执行:${planTitle}`, extensionPath, "agent", - serviceTier + serviceTier, ); break; @@ -581,7 +606,7 @@ export async function handlePlanAction( `请根据以下建议修改计划:${modification}`, extensionPath, "plan", - serviceTier + serviceTier, ); } break; @@ -636,7 +661,7 @@ function parseFileOperation(text: string): { // 匹配重命名文件:将 xxx.ts 重命名为 yyy.ts 或 把 xxx.ts 改名为 yyy.ts(优先匹配,避免被修改匹配) const renameMatch = lowerText.match( - /(?:将|把)\s*(.+?\.\w+)\s*(?:重命名|改名)\s*(?:为|成)\s*(.+?\.\w+)/ + /(?:将|把)\s*(.+?\.\w+)\s*(?:重命名|改名)\s*(?:为|成)\s*(.+?\.\w+)/, ); if (renameMatch) { const oldPath = renameMatch[1].trim(); @@ -653,7 +678,7 @@ function parseFileOperation(text: string): { // 格式2: 将 xxx.ts 文件 "aaa" 替换为 "bbb" // 格式3: 将 xxx.ts 文件 'aaa' 替换为 'bbb' const replaceMatch1 = lowerText.match( - /在\s*(.+?\.\w+)\s*(?:文件)?中?\s*(?:将|把)\s*["'](.+?)["']\s*替换\s*(?:为|成)\s*["'](.+?)["']/ + /在\s*(.+?\.\w+)\s*(?:文件)?中?\s*(?:将|把)\s*["'](.+?)["']\s*替换\s*(?:为|成)\s*["'](.+?)["']/, ); if (replaceMatch1) { const filePath = replaceMatch1[1].trim(); @@ -669,7 +694,7 @@ function parseFileOperation(text: string): { // 格式2: 将 xxx.ts 文件 "aaa" 替换为 "bbb" const replaceMatch2 = lowerText.match( - /(?:将|把)\s*(.+?\.\w+)\s*(?:文件)?\s*["'](.+?)["']\s*替换\s*(?:为|成)\s*["'](.+?)["']/ + /(?:将|把)\s*(.+?\.\w+)\s*(?:文件)?\s*["'](.+?)["']\s*替换\s*(?:为|成)\s*["'](.+?)["']/, ); if (replaceMatch2) { const filePath = replaceMatch2[1].trim(); @@ -718,7 +743,7 @@ async function handleFileOperation( newPath?: string; searchText?: string; replaceText?: string; - } + }, ) { const historyManager = ChatHistoryManager.getInstance(); @@ -734,7 +759,7 @@ async function handleFileOperation( text: responseText, }); vscode.window.showInformationMessage( - `文件创建成功: ${operation.filePath}` + `文件创建成功: ${operation.filePath}`, ); await historyManager.addAiMessage(responseText); break; @@ -747,7 +772,7 @@ async function handleFileOperation( text: responseText, }); vscode.window.showInformationMessage( - `文件删除成功: ${operation.filePath}` + `文件删除成功: ${operation.filePath}`, ); await historyManager.addAiMessage(responseText); break; @@ -783,7 +808,7 @@ async function handleFileOperation( text: responseText, }); vscode.window.showInformationMessage( - `文件重命名成功: ${operation.filePath} → ${operation.newPath}` + `文件重命名成功: ${operation.filePath} → ${operation.newPath}`, ); await historyManager.addAiMessage(responseText); break; @@ -792,21 +817,29 @@ async function handleFileOperation( if (!operation.searchText || !operation.replaceText) { throw new Error("缺少替换内容"); } - const oldContentBeforeReplace = await readFileContent(operation.filePath); + const oldContentBeforeReplace = await readFileContent( + operation.filePath, + ); await replaceFile( operation.filePath, operation.searchText, - operation.replaceText + operation.replaceText, + ); + const newContentAfterReplace = await readFileContent( + operation.filePath, + ); + await trackFileChange( + operation.filePath, + oldContentBeforeReplace, + newContentAfterReplace, ); - const newContentAfterReplace = await readFileContent(operation.filePath); - await trackFileChange(operation.filePath, oldContentBeforeReplace, newContentAfterReplace); responseText = `✅ 文件内容替换成功: ${operation.filePath}`; panel.webview.postMessage({ command: "receiveMessage", text: responseText, }); vscode.window.showInformationMessage( - `文件内容替换成功: ${operation.filePath}` + `文件内容替换成功: ${operation.filePath}`, ); await historyManager.addAiMessage(responseText); break; @@ -815,10 +848,10 @@ async function handleFileOperation( const errorMsg = error instanceof Error ? error.message : "操作失败"; panel.webview.postMessage({ command: "receiveMessage", - text: `❌ ${errorMsg}`, + text: `❌ 服务繁忙,请稍后重试`, }); vscode.window.showErrorMessage(errorMsg); - await historyManager.addAiMessage(`❌ ${errorMsg}`); + await historyManager.addAiMessage(`❌ 服务繁忙,请稍后重试`); } } @@ -866,7 +899,7 @@ function getDefaultContent(filePath: string): string { */ export async function handleReadFile( panel: vscode.WebviewPanel, - filePath: string + filePath: string, ) { try { const content = await readFileContent(filePath); @@ -890,7 +923,7 @@ export async function handleCreateFile( panel: vscode.WebviewPanel, filePath: string, content: string, - overwrite: boolean = false //是否覆盖 + overwrite: boolean = false, //是否覆盖 ) { try { if (overwrite) { @@ -909,11 +942,14 @@ export async function handleCreateFile( // 发送系统通知 const notificationService = NotificationService.getInstance(); notificationService.success( - 'IC Coder - 文件创建', + "IC Coder - 文件创建", `文件已创建: ${path.basename(filePath)}`, () => { - vscode.commands.executeCommand('vscode.open', vscode.Uri.file(filePath)); - } + vscode.commands.executeCommand( + "vscode.open", + vscode.Uri.file(filePath), + ); + }, ); } catch (error) { panel.webview.postMessage({ @@ -921,7 +957,7 @@ export async function handleCreateFile( error: error instanceof Error ? error.message : "创建文件失败", }); vscode.window.showErrorMessage( - `创建文件失败: ${error instanceof Error ? error.message : "未知错误"}` + `创建文件失败: ${error instanceof Error ? error.message : "未知错误"}`, ); } } @@ -932,7 +968,7 @@ export async function handleCreateFile( export async function handleUpdateFile( panel: vscode.WebviewPanel, filePath: string, - content: string + content: string, ) { try { const oldContent = await readFileContent(filePath); @@ -948,8 +984,8 @@ export async function handleUpdateFile( // 发送系统通知 const notificationService = NotificationService.getInstance(); notificationService.info( - 'IC Coder - 文件更新', - `文件已更新: ${path.basename(filePath)}` + "IC Coder - 文件更新", + `文件已更新: ${path.basename(filePath)}`, ); } catch (error) { panel.webview.postMessage({ @@ -957,7 +993,7 @@ export async function handleUpdateFile( error: error instanceof Error ? error.message : "更新文件失败", }); vscode.window.showErrorMessage( - `更新文件失败: ${error instanceof Error ? error.message : "未知错误"}` + `更新文件失败: ${error instanceof Error ? error.message : "未知错误"}`, ); } } @@ -968,7 +1004,7 @@ export async function handleUpdateFile( export async function handleRenameFile( panel: vscode.WebviewPanel, oldPath: string, - newPath: string + newPath: string, ) { try { await renameFile(oldPath, newPath); @@ -979,7 +1015,7 @@ export async function handleRenameFile( message: "文件重命名成功", }); vscode.window.showInformationMessage( - `文件重命名成功: ${oldPath} → ${newPath}` + `文件重命名成功: ${oldPath} → ${newPath}`, ); } catch (error) { panel.webview.postMessage({ @@ -987,7 +1023,7 @@ export async function handleRenameFile( error: error instanceof Error ? error.message : "重命名文件失败", }); vscode.window.showErrorMessage( - `重命名文件失败: ${error instanceof Error ? error.message : "未知错误"}` + `重命名文件失败: ${error instanceof Error ? error.message : "未知错误"}`, ); } } @@ -999,7 +1035,7 @@ export async function handleReplaceInFile( panel: vscode.WebviewPanel, filePath: string, searchText: string, - replaceText: string + replaceText: string, ) { try { const oldContent = await readFileContent(filePath); @@ -1018,7 +1054,7 @@ export async function handleReplaceInFile( error: error instanceof Error ? error.message : "替换文件内容失败", }); vscode.window.showErrorMessage( - `替换文件内容失败: ${error instanceof Error ? error.message : "未知错误"}` + `替换文件内容失败: ${error instanceof Error ? error.message : "未知错误"}`, ); } } @@ -1063,7 +1099,7 @@ function isVCDGenerationCommand(text: string): boolean { */ async function handleVCDGeneration( panel: vscode.WebviewPanel, - extensionPath: string + extensionPath: string, ) { try { // 获取当前工作区路径 @@ -1090,7 +1126,7 @@ async function handleVCDGeneration( if (!iverilogCheck.available) { panel.webview.postMessage({ command: "receiveMessage", - text: `❌ ${iverilogCheck.message}\n\n请参考插件文档安装 iverilog 工具。`, + text: `❌ ${iverilogCheck.message}。`, }); vscode.window.showErrorMessage(iverilogCheck.message); return; @@ -1165,12 +1201,15 @@ async function handleVCDGeneration( // 发送系统通知 const notificationService = NotificationService.getInstance(); notificationService.success( - 'IC Coder - 仿真完成', + "IC Coder - 仿真完成", `VCD 文件已生成: ${fileName}`, () => { // 点击通知时打开 VCD 查看器 - vscode.commands.executeCommand('ic-coder.openVCDViewer', result.vcdFilePath); - } + vscode.commands.executeCommand( + "ic-coder.openVCDViewer", + result.vcdFilePath, + ); + }, ); } else { panel.webview.postMessage({ @@ -1199,12 +1238,12 @@ async function handleVCDGeneration( // 发送系统通知 const notificationService = NotificationService.getInstance(); notificationService.error( - 'IC Coder - 仿真失败', - 'VCD 文件生成失败,请查看错误信息', + "IC Coder - 仿真失败", + "VCD 文件生成失败,请查看错误信息", () => { // 点击通知时聚焦到面板 panel.reveal(); - } + }, ); } } catch (error) { @@ -1222,11 +1261,11 @@ async function handleVCDGeneration( // 发送系统通知 const notificationService = NotificationService.getInstance(); notificationService.error( - 'IC Coder - 仿真错误', - error instanceof Error ? error.message : '生成 VCD 文件时出错', + "IC Coder - 仿真错误", + error instanceof Error ? error.message : "生成 VCD 文件时出错", () => { panel.reveal(); - } + }, ); } } @@ -1236,7 +1275,7 @@ async function handleVCDGeneration( */ export async function handleOptimizePrompt( panel: vscode.WebviewPanel, - prompt: string + prompt: string, ): Promise { console.log("[MessageHandler] ========== 收到提示词优化请求 =========="); console.log("[MessageHandler] prompt:", prompt); @@ -1268,7 +1307,7 @@ export async function handleOptimizePrompt( */ export async function handleAcceptChange( panel: vscode.WebviewPanel, - changeId: string + changeId: string, ) { try { const success = await changeTracker.acceptChange(changeId); @@ -1276,14 +1315,14 @@ export async function handleAcceptChange( panel.webview.postMessage({ command: "changeAccepted", changeId: changeId, - success: true + success: true, }); } else { panel.webview.postMessage({ command: "changeAccepted", changeId: changeId, success: false, - error: "采纳变更失败" + error: "采纳变更失败", }); } } catch (error) { @@ -1292,7 +1331,7 @@ export async function handleAcceptChange( command: "changeAccepted", changeId: changeId, success: false, - error: String(error) + error: String(error), }); } } @@ -1302,7 +1341,7 @@ export async function handleAcceptChange( */ export async function handleRejectChange( panel: vscode.WebviewPanel, - changeId: string + changeId: string, ) { try { const success = await changeTracker.rejectChange(changeId); @@ -1310,14 +1349,14 @@ export async function handleRejectChange( panel.webview.postMessage({ command: "changeRejected", changeId: changeId, - success: true + success: true, }); } else { panel.webview.postMessage({ command: "changeRejected", changeId: changeId, success: false, - error: "拒绝变更失败" + error: "拒绝变更失败", }); } } catch (error) { @@ -1326,7 +1365,7 @@ export async function handleRejectChange( command: "changeRejected", changeId: changeId, success: false, - error: String(error) + error: String(error), }); } } @@ -1337,18 +1376,18 @@ export async function handleRejectChange( export function sendChangesToWebview(panel: vscode.WebviewPanel) { const session = changeTracker.endSession(); if (session && session.changes.length > 0) { - const changesWithDiff = session.changes.map(change => { + const changesWithDiff = session.changes.map((change) => { const diffLines = generateDiff(change.oldContent, change.newContent); const diffHtml = renderDiffHtml(diffLines); return { ...change, - diffHtml + diffHtml, }; }); panel.webview.postMessage({ command: "showChanges", - changes: changesWithDiff + changes: changesWithDiff, }); } } @@ -1365,62 +1404,67 @@ export function startChangeSession(sessionId: string) { */ export async function handleOpenFileDiff( panel: vscode.WebviewPanel, - changeId: string + changeId: string, ) { try { const session = changeTracker.getCurrentSession(); if (!session) { - vscode.window.showErrorMessage('没有找到变更会话'); + vscode.window.showErrorMessage("没有找到变更会话"); return; } - const change = session.changes.find(c => c.changeId === changeId); + const change = session.changes.find((c) => c.changeId === changeId); if (!change) { - vscode.window.showErrorMessage('没有找到该变更'); + vscode.window.showErrorMessage("没有找到该变更"); return; } const workspaceFolder = vscode.workspace.workspaceFolders?.[0]; if (!workspaceFolder) { - vscode.window.showErrorMessage('没有打开的工作区'); + vscode.window.showErrorMessage("没有打开的工作区"); return; } // 创建临时文件用于对比 const filePath = change.filePath; const absolutePath = vscode.Uri.file( - path.join(workspaceFolder.uri.fsPath, filePath) + path.join(workspaceFolder.uri.fsPath, filePath), ); // 创建虚拟文档显示旧内容 const oldUri = vscode.Uri.parse( - `ic-coder-diff:${filePath}.old?${changeId}` - ).with({ scheme: 'ic-coder-diff' }); + `ic-coder-diff:${filePath}.old?${changeId}`, + ).with({ scheme: "ic-coder-diff" }); // 注册文档内容提供者(如果还没注册) if (!(global as any).__diffProviderRegistered) { - const provider = new (class implements vscode.TextDocumentContentProvider { + const provider = new (class + implements vscode.TextDocumentContentProvider + { provideTextDocumentContent(uri: vscode.Uri): string { const changeId = uri.query; const session = changeTracker.getCurrentSession(); - const change = session?.changes.find(c => c.changeId === changeId); - return change?.oldContent || ''; + const change = session?.changes.find((c) => c.changeId === changeId); + return change?.oldContent || ""; } })(); - vscode.workspace.registerTextDocumentContentProvider('ic-coder-diff', provider); + vscode.workspace.registerTextDocumentContentProvider( + "ic-coder-diff", + provider, + ); (global as any).__diffProviderRegistered = true; } // 打开 diff 编辑器 await vscode.commands.executeCommand( - 'vscode.diff', + "vscode.diff", oldUri, absolutePath, - `${filePath} (变更对比)` + `${filePath} (变更对比)`, ); } catch (error) { - console.error('[MessageHandler] 打开 diff 失败:', error); + console.error("[MessageHandler] 打开 diff 失败:", error); vscode.window.showErrorMessage(`打开 diff 失败: ${error}`); } } diff --git a/src/views/exampleShowcase.ts b/src/views/exampleShowcase.ts index b457e11..95041ee 100644 --- a/src/views/exampleShowcase.ts +++ b/src/views/exampleShowcase.ts @@ -4,7 +4,14 @@ export function getExampleShowcaseContent(): string { return `
-
示例
+
+
示例
+ +
@@ -62,12 +69,44 @@ export function getExampleShowcaseStyles(): string { display: none; } + .showcase-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 12px; + } + .showcase-title { font-size: 14px; font-weight: 600; color: var(--vscode-foreground); - margin-bottom: 12px; - text-align: left; + } + + .refresh-button { + background: transparent; + border: none; + color: var(--vscode-foreground); + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s ease; + opacity: 0.6; + } + + .refresh-button:hover { + opacity: 1; + background: var(--vscode-input-background); + } + + .refresh-button svg { + transition: transform 0.3s ease; + } + + .refresh-button:active svg { + transform: rotate(180deg); } .example-cards { @@ -220,15 +259,74 @@ export function getExampleShowcaseStyles(): string { */ export function getExampleShowcaseScript(): string { return ` - // 示例文本数组 - const exampleTexts = [ - '生成一个SPI控制器', - '生成一个GMII接口的以太网UDP通信模块' + // 所有可用的示例 + const allExamples = [ + '设计一个算术逻辑单元,完成常见运算', + '实现一个优先编码器,多个输入同时有效时,只输出优先级最高的那个编号', + '实现一个译码器,把二进制编号转换成 one-hot 输出', + '实现一个移位寄存器,完成串行/并行数据移位与装载', + '实现一个按键消抖模块,解决机械按键抖动问题', + '实现一个跑马灯控制器,控制 LED 形成不同流动效果', + '实现一个序列检测器,检测串行输入中是否出现指定比特序列', + '实现一个LFSR 伪随机数发生器', + '实现一个自动售货机,模拟一个简单售货逻辑', + '实现一个交通灯控制器,控制两方向交通灯的切换', + '实现一个先进先出的数据缓冲区', + '单端口 RAM 读写控制器', + '实现一个移位加法乘法器,不用 * 运算符' ]; + // 当前显示的示例文本 + let exampleTexts = ['生成一个SPI控制器', '生成一个GMII接口的以太网UDP通信模块']; + // 存储待发送的示例索引 let pendingExampleIndex = -1; + // 节流控制 + let refreshing = false; + + // 刷新示例 + function refreshExamples() { + if (refreshing) return; + refreshing = true; + + const used = new Set(); + const newExamples = []; + while (newExamples.length < 2) { + const idx = Math.floor(Math.random() * allExamples.length); + if (!used.has(idx)) { + used.add(idx); + newExamples.push(allExamples[idx]); + } + } + exampleTexts = newExamples; + updateExampleCards(); + + setTimeout(() => { refreshing = false; }, 500); + } + + // 更新示例卡片显示 + function updateExampleCards() { + const container = document.querySelector('.example-cards'); + if (!container) return; + container.innerHTML = exampleTexts.map((text, i) => \` +
+
+ + + + + + + +
+
+
\${text}
+
+
+ \`).join(''); + } + // 直接发送示例消息 function sendExample(index) { // 先检查邀请码验证状态 diff --git a/src/views/messageArea.ts b/src/views/messageArea.ts index f6e2afd..49cf669 100644 --- a/src/views/messageArea.ts +++ b/src/views/messageArea.ts @@ -1,13 +1,8 @@ /** * 消息区域模块 - * - * 功能说明: - * - 负责聊天消息的显示和渲染 - * - 支持用户消息和 AI 消息的不同样式 - * - 提供消息操作功能(复制、点赞、点踩) - * - 支持流式消息实时更新 - * - 支持分段消息渲染(文本、工具调用、用户问题) - * - 显示工具执行状态和加载指示器 + * 功能:消息区域入口,整合样式和脚本 + * 依赖:messageStyles, toolHelpers, textFormatter, questionHandler, messageRenderer + * 使用场景:webview 内容生成 */ import { @@ -17,7 +12,6 @@ import { fileDeleteIconSvg, syntaxCheckIconSvg, SearchCode, - agentIconSvg, saveKnowledgeIconSvg, simulationIconSvg, waveformIconSvg, @@ -27,1737 +21,165 @@ import { updateStageIconSvg, successIconSvg, } from "../constants/toolIcons"; -import { - getWaveformPreviewContent, - getWaveformPreviewScript, -} from "./waveformPreviewContent"; -import { getAgentCardStyles, getAgentCardScript } from "./agentCard"; -import { getPlanCardStyles, getPlanCardScript } from "./planCard"; -import { - getCodeHighlightStyles, - getCodeHighlightScript, -} from "../components/codeHighlight"; +import { getMessageAreaStyles } from "./messageStyles"; +import { getQuestionHandlerScript } from "./questionHandler"; +import { getMessageRendererScript } from "./messageRenderer"; +import { getSegmentRendererScript } from "./segmentRenderer"; -/** - * 获取消息区域的 HTML 内容 - */ export function getMessageAreaContent(): string { return `
`; } -/** - * 获取消息区域的样式 - */ -export function getMessageAreaStyles(): string { - return ` - .messages { - flex: 1; - overflow-y: auto; - margin-bottom: 15px; - min-height: 0; - } - .message { - margin-bottom: 12px; - } - .user-message { - padding: 10px 15px; - border-radius: 8px; - background: var(--vscode-button-secondaryBackground); - border: 1px solid var(--vscode-input-border); - margin-left: auto; - width: fit-content; - max-width: 80%; - } - .bot-message { - padding: 0; - text-align: left; - color: var(--vscode-foreground); - max-width: 100%; - position: relative; - } - .message-actions { - display: flex; - gap: 8px; - margin-top: 12px; - margin-left: 10px; - opacity: 0.85; - transition: opacity 0.2s ease; - } - .message-actions:hover { - opacity: 1; - } - .action-btn { - background: transparent; - border: none; - cursor: pointer; - padding: 4px; - display: flex; - align-items: center; - justify-content: center; - color: var(--vscode-foreground); - opacity: 0.9; - transition: opacity 0.2s ease; - position: relative; - } - .action-btn:hover { - opacity: 1; - } - .action-btn svg { - width: 14px; - height: 14px; - } - .action-btn.active { - color: var(--vscode-button-background); - opacity: 1; - } - .action-btn .action-tooltip { - visibility: hidden; - width: auto; - background: #1e1e1e; - color: #ffffff; - text-align: center; - border-radius: 4px; - border: 1px solid rgba(255, 255, 255, 0.2); - padding: 4px 8px; - position: absolute; - z-index: 1000; - bottom: 125%; - left: 50%; - transform: translateX(-50%) translateY(5px); - opacity: 0; - transition: all 0.2s ease; - font-size: 12px;white-space: nowrap;pointer-events: none; - } - .action-btn .action-tooltip::after { - content: ""; - position: absolute; - top: 100%; - left: 50%; - margin-left: -5px; - border-width: 5px; - border-style: solid; - border-color: #1e1e1e transparent transparent transparent; - } - .action-btn .action-tooltip::before { - content: ""; - position: absolute; - top: 100%; - left: 50%; - margin-left: -6px; - border-width: 6px; - border-style: solid; - border-color: rgba(255, 255, 255, 0.2) transparent transparent transparent; - z-index: -1; - } - .action-btn:hover .action-tooltip { - visibility: visible; - opacity: 1; - transform: translateX(-50%) translateY(0); - } +export { getMessageAreaStyles }; - /* 流式消息样式 */ - .streaming .message-content { - border-right: 2px solid var(--vscode-focusBorder); - animation: blink 1s infinite; - } - @keyframes blink { - 0%, 50% { border-color: var(--vscode-focusBorder); } - 51%, 100% { border-color: transparent; } - } - - /* 加载指示器样式 */ - .loading-message { - display: flex; - align-items: center; - gap: 10px; - padding: 12px 16px; - color: var(--vscode-descriptionForeground); - } - .loading-dots { - display: flex; - gap: 4px;} - .loading-dots span { - width: 6px; - height: 6px; - border-radius: 50%; - background: var(--vscode-focusBorder); - animation: loadingDot 1.4s infinite ease-in-out; - } - .loading-dots span:nth-child(1) { animation-delay: 0s; } - .loading-dots span:nth-child(2) { animation-delay: 0.2s; } - .loading-dots span:nth-child(3) { animation-delay: 0.4s; } - @keyframes loadingDot { - 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } - 40% { transform: scale(1); opacity: 1; } - } - .loading-text { - font-size: 13px; - } - - /* 工具状态样式 */ - .tool-status { - display: flex; - align-items: center; - gap: 8px; - padding: 8px 12px; - margin: 4px 0; - font-size: 12px; - border-radius: 6px; - background: var(--vscode-textBlockQuote-background); - } - .tool-status.tool-start { - border-left: 3px solid var(--vscode-charts-blue); - } - .tool-status.tool-complete { - border-left: 3px solid var(--vscode-charts-green); - } - .tool-status.tool-error { - border-left: 3px solid var(--vscode-charts-red); - } - .tool-icon { - font-size: 14px; - } - .tool-name { - font-weight: 500; - color: var(--vscode-foreground); - } - .tool-status-text { - color: var(--vscode-descriptionForeground); - } - .tool-detail { - margin-top: 4px; - font-size: 11px; - color: var(--vscode-descriptionForeground); - white-space: pre-wrap; - max-height: 100px; - overflow-y: auto; - } - - /* 用户问题样式 */ - .question-message { - padding: 16px; - } - .question-text { - margin-bottom: 12px; - font-weight: 500; - } - .question-options { - display: flex; - flex-wrap: wrap; - gap: 8px; - } - .question-option { - padding: 8px 16px; - background: #007ACC; - color: #ffffff; - border: 1px solid #007ACC; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - } - .question-option:hover { - background: #005a9e; - border-color: #005a9e; - } - .question-option.selected { - background: #007ACC; - color: #ffffff; - border-color: #007ACC; - } - .question-message.answered .question-option:not(.selected) { - opacity: 0.5; - pointer-events: none; - } - .custom-input-container { - display: flex; - gap: 8px; - width: 100%; - margin-top: 8px; - } - .custom-input { - flex: 1; - padding: 8px 12px; - background: var(--vscode-input-background); - color: var(--vscode-input-foreground); - border: 1px solid var(--vscode-input-border); - border-radius: 6px; - font-size: 13px; - } - .custom-submit { - padding: 8px 16px; - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: none; - border-radius: 6px; - cursor: pointer; - } - .custom-submit:hover { - background: var(--vscode-button-hoverBackground); - } - .question-message.answered .custom-input-container { - display: none; - } - - /* 分段消息样式 */ - .segmented-message { - padding: 0; - } - .message-segment { - padding: 10px 0; - } - .segment-text { - line-height: 1.6; - } - - /* Markdown 样式 */ - .segment-text h1, - .segment-text h2, - .segment-text h3, - .question-text h1, - .question-text h2, - .question-text h3 { - margin: 0px 0 -10px 0; - font-weight: 600; - line-height: 1.3; - } - .segment-text h1, - .question-text h1 { - font-size: 1.5em; - border-bottom: 1px solid var(--vscode-panel-border); - padding-bottom: 8px; - } - .segment-text h2, - .question-text h2 { - font-size: 1.3em; - } - .segment-text h3, - .question-text h3 { - font-size: 1.1em; - } - .segment-text ul, - .segment-text ol, - .question-text ul, - .question-text ol { - margin: 8px 0; - padding-left: 24px; - } - .segment-text li, - .question-text li { - line-height: 1; - } - .segment-text strong, - .question-text strong { - font-weight: 600; - color: var(--vscode-foreground); - } - .segment-text em, - .question-text em { - font-style: italic; - } - .segment-text a, - .question-text a { - color: var(--vscode-textLink-foreground); - text-decoration: none; - } - .segment-text a:hover, - .question-text a:hover { - text-decoration: underline; - } - .segment-text p, - .question-text p { - margin: 8px 0; - } - .segment-text code, - .question-text code { - background: var(--vscode-textCodeBlock-background); - padding: 2px 6px; - border-radius: 3px; - font-family: var(--vscode-editor-font-family); - font-size: 0.9em; - } - - .segment-tool { - margin: 4px 0; - padding: 4px 0; - } - /* 低调显示的工具调用 - 移除边距和背景 */ - .segment-tool.low-profile { - margin: 5px 0px; - padding: 0; - background: none; - } - .tool-segment-header { - display: flex; - align-items: center; - gap: 6px; - font-size: 12px; - color: var(--vscode-descriptionForeground); - cursor: pointer; - } - .tool-segment-icon { - font-size: 12px; - } - .tool-segment-name { - font-weight: normal; - } - .tool-segment-result { - display: inline; - font-size: 12px; - color: var(--vscode-descriptionForeground); - margin-left: 6px; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - max-width: 500px; - } - .tool-collapse-icon { - width: 12px; - height: 12px; - flex-shrink: 0; - transition: transform 0.2s ease; - cursor: pointer; - } - .tool-collapse-icon svg { - width: 100%; - height: 100%; - display: block; - } - .icon-expanded svg path { - fill: #007ACC !important; - } - .tool-segment-header.collapsed .tool-collapse-icon { - transform: rotate(-90deg); - } - .tool-segment-header:not(.collapsed) .tool-collapse-icon { - transform: rotate(0deg); - } - .tool-file-write-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-file-write-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-file-read-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-file-read-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-file-delete-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-file-delete-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-syntax-check-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-syntax-check-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-search-code-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-search-code-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-save-knowledge-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-save-knowledge-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-simulation-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-simulation-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-waveform-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-waveform-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-knowledge-load-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-knowledge-load-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-state-transition-icon { - width: 16px; - height: 16px; - flex-shrink: 0; - margin-right: 6px; - } - .tool-state-transition-icon svg { - width: 100%; - height: 100%; - display: block; - } - .tool-segment-content { - overflow: hidden; - transition: max-height 0.3s ease; - } - .tool-segment-content.collapsed { - max-height: 0; - } - .tool-segment-description { - margin: 6px 0 0 0px; - font-size: 0.9rem; - color: var(--vscode-descriptionForeground); - line-height: 1.4; - } - /* 低调显示的工具调用样式 */ - .segment-tool.low-profile .tool-segment-header { - opacity: 0.65; - font-size: 12px; - } - .segment-tool.low-profile .tool-segment-icon { - opacity: 0.55; - font-size: 11px; - } - .segment-tool.low-profile .tool-segment-name { - font-weight: 300; - opacity: 0.8; - } - .segment-tool.low-profile .tool-segment-result { - opacity: 0.7; - font-size: 12px; - } - .segment-question { - background: var(--vscode-textBlockQuote-background); - border-radius: 6px; - margin: 8px 0; - padding: 12px 35px; - border-left: 3px solid var(--vscode-charts-orange); - } - .segment-question .question-text { - margin-bottom: 12px; - font-weight: 500; - } - .segment-question .question-options { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-bottom: 8px; - } - .segment-question .question-option { - padding: 8px 16px; - background: #007ACC; - color: #ffffff; - border: 1px solid #007ACC; - border-radius: 6px; - cursor: pointer; - transition: all 0.2s; - font-size: 13px; - } - .segment-question .question-option:hover { - background: #005a9e; - border-color: #005a9e; - } - .segment-question .question-option.selected { - background: #007ACC; - color: #ffffff; - border-color: #007ACC; - } - .segment-question.answered .question-option:not(.selected) { - opacity: 0.5; - pointer-events: none; - } - .segment-question .custom-input-container { - display: flex; - gap: 8px; - width: 100%; - margin-top: 8px; - } - .segment-question .custom-input { - flex: 1; - padding: 8px 12px; - background: var(--vscode-input-background); - color: var(--vscode-input-foreground); - border: 1px solid var(--vscode-input-border); - border-radius: 6px; - font-size: 13px; - margin-left: -20px; - } - .segment-question .custom-submit { - padding: 8px 16px; - background: var(--vscode-button-background); - color: var(--vscode-button-foreground); - border: none; - border-radius: 6px; - cursor: pointer; - } - .segment-question .custom-submit:hover { - background: var(--vscode-button-hoverBackground); - } - .segment-question.answered .custom-input-container { - display: none; - } - .question-segment .question-text { - margin-bottom: 8px; - font-weight: 500; - } - .question-segment .question-options { - display: flex; - flex-wrap: wrap; - gap: 6px; - } - .question-opt { - padding: 4px 10px; - background: var(--vscode-button-secondaryBackground); - color: var(--vscode-button-secondaryForeground); - border-radius: 4px; - font-size: 12px;} - - ${getAgentCardStyles()} - - ${getPlanCardStyles()} - - ${getCodeHighlightStyles()} - - ${getWaveformPreviewContent()} - `; -} - -/** - * 获取消息区域的脚本 - */ export function getMessageAreaScript(): string { return ` - // 工具图标定义 - const collapseIconSvg = \`${collapseIconSvg}\`; - const fileWriteIconSvg = \`${fileWriteIconSvg}\`; - const fileReadIconSvg = \`${fileReadIconSvg}\`; - const fileDeleteIconSvg = \`${fileDeleteIconSvg}\`; - const syntaxCheckIconSvg = \`${syntaxCheckIconSvg}\`; - const searchCodeIconSvg = \`${SearchCode}\`; - const saveKnowledgeIconSvg = \`${saveKnowledgeIconSvg}\`; - const simulationIconSvg = \`${simulationIconSvg}\`; - const waveformIconSvg = \`${waveformIconSvg}\`; - const knowledgeLoadIconSvg = \`${knowledgeLoadIconSvg}\`; - const stateTransitionIconSvg = \`${stateTransitionIconSvg}\`; - const userQuestionIconSvg = \`${userQuestionIconSvg}\`; - const updateStageIconSvg = \`${updateStageIconSvg}\`; - const successIconSvg = \`${successIconSvg}\`; + const collapseIconSvg = \`${collapseIconSvg}\`; + const fileWriteIconSvg = \`${fileWriteIconSvg}\`; + const fileReadIconSvg = \`${fileReadIconSvg}\`; + const fileDeleteIconSvg = \`${fileDeleteIconSvg}\`; + const syntaxCheckIconSvg = \`${syntaxCheckIconSvg}\`; + const searchCodeIconSvg = \`${SearchCode}\`; + const saveKnowledgeIconSvg = \`${saveKnowledgeIconSvg}\`; + const simulationIconSvg = \`${simulationIconSvg}\`; + const waveformIconSvg = \`${waveformIconSvg}\`; + const knowledgeLoadIconSvg = \`${knowledgeLoadIconSvg}\`; + const stateTransitionIconSvg = \`${stateTransitionIconSvg}\`; + const userQuestionIconSvg = \`${userQuestionIconSvg}\`; + const updateStageIconSvg = \`${updateStageIconSvg}\`; + const successIconSvg = \`${successIconSvg}\`; - ${getAgentCardScript()} + function getToolIcon(toolName) { + const iconMap = { + 'file_read': fileReadIconSvg, + 'file_write': fileWriteIconSvg, + 'file_delete': fileDeleteIconSvg, + 'file_list': searchCodeIconSvg, + 'syntax_check': syntaxCheckIconSvg, + 'simulation': simulationIconSvg, + 'waveform_summary': waveformIconSvg, + 'knowledge_save': saveKnowledgeIconSvg, + 'knowledge_load': knowledgeLoadIconSvg, + 'queryKnowledgeSummary': knowledgeLoadIconSvg, + 'queryRules': knowledgeLoadIconSvg, + 'setModule': fileWriteIconSvg, + 'addSignal': fileWriteIconSvg, + 'addSignalExample': fileWriteIconSvg, + 'validateKnowledgeGraph': syntaxCheckIconSvg, + 'querySignals': searchCodeIconSvg, + 'addPlan': fileWriteIconSvg, + 'addEdge': fileWriteIconSvg, + 'showPlan': searchCodeIconSvg, + 'addRule': fileWriteIconSvg, + 'updateNode': fileWriteIconSvg, + 'addStateTransition': stateTransitionIconSvg, + 'askUser': userQuestionIconSvg, + 'updatePhase': updateStageIconSvg, + 'iverilog': successIconSvg, + }; + return iconMap[toolName] || ''; + } - ${getPlanCardScript()} + function getToolDisplayName(toolName) { + const toolNameMap = { + 'file_read': '已完成文件读取', + 'file_write': '已完成文件写入', + 'file_delete': '已完成文件删除', + 'file_list': '已检索代码文件', + 'syntax_check': '已完成语法检查', + 'simulation': '已完成仿真', + 'waveform_summary': '已完成波形分析', + 'knowledge_save': '已保存知识库', + 'knowledge_load': '已加载知识库', + 'queryKnowledgeSummary': '已查询知识摘要', + 'queryRules': '已查询规则', + 'setModule': '已设置模块', + 'addSignal': '信号分析完成', + 'addSignalExample': '信号示例处理完成', + 'validateKnowledgeGraph': '已验证知识图谱', + 'querySignals': '已查询信号', + 'addPlan': '已添加计划', + 'addEdge': '已添加边', + 'showPlan': '已显示计划', + 'addRule': '已添加规则', + 'updateNode': '已更新节点', + 'addStateTransition': '已添加状态转换', + 'spawnExplorer': '代码探索', + 'spawnDebugger': '波形调试', + 'askUser': '用户提问', + 'updatePhase': '已更新阶段', + 'iverilog': '已完成编译', + }; + return toolNameMap[toolName] || toolName; + } - // 解析多 VCD 文件路径 - function parseMultiVcdPaths(toolResult) { - if (!toolResult) return []; - const result = String(toolResult); - - // 匹配 "- moduleName: path" 格式 - const vcdListMatch = result.match(/VCD 文件列表:[\\s\\S]*?(?=\\n\\n|$)/); - if (!vcdListMatch) return []; - - const paths = []; - const lineRegex = /- (\\w+): ([^\\n]+)/g; - let match; - while ((match = lineRegex.exec(vcdListMatch[0])) !== null) { - const name = match[1]; - const pathOrError = match[2].trim(); - // 跳过失败的条目 - if (!pathOrError.startsWith('失败')) { - paths.push({ name: name + '.vcd', path: pathOrError }); - } - } - return paths; - } - - // 获取工具图标 - function getToolIcon(toolName) { - const iconMap = { - 'file_read': fileReadIconSvg, - 'file_write': fileWriteIconSvg, - 'file_delete': fileDeleteIconSvg, - 'file_list': searchCodeIconSvg, - 'syntax_check': syntaxCheckIconSvg, - 'simulation': simulationIconSvg, - 'waveform_summary': waveformIconSvg, - 'knowledge_save': saveKnowledgeIconSvg, - 'knowledge_load': knowledgeLoadIconSvg, - 'queryKnowledgeSummary': knowledgeLoadIconSvg, - 'queryRules': knowledgeLoadIconSvg, - 'setModule': fileWriteIconSvg, - 'addSignal': fileWriteIconSvg, - 'addSignalExample': fileWriteIconSvg, - 'validateKnowledgeGraph': syntaxCheckIconSvg, - 'querySignals': searchCodeIconSvg, - 'addPlan': fileWriteIconSvg, - 'addEdge': fileWriteIconSvg, - 'showPlan': searchCodeIconSvg, - 'addRule': fileWriteIconSvg, - 'updateNode': fileWriteIconSvg, - 'addStateTransition': stateTransitionIconSvg, - 'askUser': userQuestionIconSvg, - 'updatePhase': updateStageIconSvg, - 'iverilog': successIconSvg, - }; - return iconMap[toolName] || ''; - } - - // 工具名称映射 - function getToolDisplayName(toolName) { - const toolNameMap = { - 'file_read': '已完成文件读取', - 'file_write': '已完成文件写入', - 'file_delete': '已完成文件删除', - 'file_list': '已检索代码文件', - 'syntax_check': '已完成语法检查', - 'simulation': '已完成仿真', - 'waveform_summary': '已完成波形分析', - 'knowledge_save': '已保存知识库', - 'knowledge_load': '已加载知识库', - 'queryKnowledgeSummary': '已查询知识摘要', - 'queryRules': '已查询规则', - 'setModule': '已设置模块', - 'addSignal': '信号分析完成', - 'addSignalExample': '信号示例处理完成', - 'validateKnowledgeGraph': '已验证知识图谱', - 'querySignals': '已查询信号', - 'addPlan': '已添加计划', - 'addEdge': '已添加边', - 'showPlan': '已显示计划', - 'addRule': '已添加规则', - 'updateNode': '已更新节点', - 'addStateTransition': '已添加状态转换', - 'spawnExplorer': '代码探索', - 'spawnDebugger': '波形调试', - 'askUser': '用户提问', - 'updatePhase': '已更新阶段', - 'iverilog': '已完成编译', - }; - return toolNameMap[toolName] || toolName; - } - - // 自动滚动控制标志 - let shouldAutoScroll = true; - let lastScrollHeight = 0; - - // 检查用户是否在底部附近(允许50px的误差) - function isUserNearBottom() { - const threshold = 50; - return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < threshold; - } - - // 监听用户滚动行为 - messagesEl.addEventListener('scroll', () => { - const isAtBottom = isUserNearBottom(); - - // 如果用户滚动到底部,恢复自动滚动 - if (isAtBottom) { - shouldAutoScroll = true; - } else { - // 只有当内容高度没有变化时,才认为是用户主动滚动 - // 如果内容高度变化了,说明是因为新内容导致的位置变化,不应该停止自动滚动 - if (messagesEl.scrollHeight === lastScrollHeight) { - shouldAutoScroll = false; - } - } - - lastScrollHeight = messagesEl.scrollHeight; - }); - - // 智能滚动:只有在允许自动滚动时才滚动到底部 - function smartScrollToBottom() { - if (shouldAutoScroll) { - messagesEl.scrollTop = messagesEl.scrollHeight; - lastScrollHeight = messagesEl.scrollHeight; + function parseMultiVcdPaths(toolResult) { + if (!toolResult) return []; + const result = String(toolResult); + const vcdListMatch = result.match(/VCD 文件列表:[\\s\\S]*?(?=\\n\\n|$)/); + if (!vcdListMatch) return []; + const paths = []; + const lineRegex = /- (\\w+): ([^\\n]+)/g; + let match; + while ((match = lineRegex.exec(vcdListMatch[0])) !== null) { + const name = match[1]; + const pathOrError = match[2].trim(); + if (!pathOrError.startsWith('失败')) { + paths.push({ name: name + '.vcd', path: pathOrError }); } } - - // 添加消息 - function addMessage(text, sender) { - const div = document.createElement('div'); - div.className = \`message \${sender}-message\`; - - if (sender === 'bot') { - // 创建消息内容 - const messageContent = document.createElement('div'); - messageContent.textContent = text; - div.appendChild(messageContent); - - // 创建操作按钮容器 - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'message-actions'; - - // 复制按钮 - const copyBtn = document.createElement('button'); - copyBtn.className = 'action-btn'; - copyBtn.innerHTML = \`复制\`; - copyBtn.onclick = () => copyMessage(text, copyBtn); - - // 点赞按钮 - const likeBtn = document.createElement('button'); - likeBtn.className = 'action-btn'; - likeBtn.innerHTML = \`点赞\`; - likeBtn.onclick = () => toggleLike(likeBtn); - - // 点踩按钮 - const dislikeBtn = document.createElement('button'); - dislikeBtn.className = 'action-btn'; - dislikeBtn.innerHTML = \`点踩\`; - dislikeBtn.onclick = () => toggleDislike(dislikeBtn); - - actionsDiv.appendChild(copyBtn); - actionsDiv.appendChild(likeBtn); - actionsDiv.appendChild(dislikeBtn); - - div.appendChild(actionsDiv); - } else { - // 用户消息:解析文件路径并转换为标签 - const parts = text.split(' '); - const filePaths = []; - const textParts = []; - - parts.forEach(part => { - // 判断是否为文件路径或代码片段:包含路径分隔符、文件扩展名或代码片段格式(文件名:行号-行号) - if (part.includes('/') || part.includes('\\\\') || /\\.[a-zA-Z0-9]+$/.test(part) || /:[0-9]+-[0-9]+$/.test(part)) { - filePaths.push(part); - } else { - textParts.push(part); - } - }); - - if (filePaths.length > 0) { - div.innerHTML = filePaths.map(fp => window.createFilePathTag ? window.createFilePathTag(fp) : fp).join('') + ' ' + textParts.join(' '); - } else { - div.textContent = text; - } - - // 当添加用户消息时,隐藏 header - hideHeaderIfNeeded(); - } - - messagesEl.appendChild(div); - smartScrollToBottom(); - - // 添加消息后检查 header 显示状态 - checkHeaderVisibility(); - } - - // 检查是否需要隐藏 header - function hideHeaderIfNeeded() { - checkHeaderVisibility(); - } - - // 复制消息 - function copyMessage(text, button) { - navigator.clipboard.writeText(text).then(() => { - const originalHTML = button.innerHTML; - button.innerHTML = \`\`; - setTimeout(() => { - button.innerHTML = originalHTML; - }, 2000); - }); - } - - // 点赞 - function toggleLike(button) { - const isActive = button.classList.contains('active'); - // 移除所有同级按钮的 active 状态 - const parent = button.parentElement; - parent.querySelectorAll('.action-btn').forEach(btn => btn.classList.remove('active')); - - if (!isActive) { - button.classList.add('active'); - } - } - - // 点踩 - function toggleDislike(button) { - const isActive = button.classList.contains('active'); - // 移除所有同级按钮的 active 状态 - const parent = button.parentElement; - parent.querySelectorAll('.action-btn').forEach(btn => btn.classList.remove('active')); - - if (!isActive) { - button.classList.add('active'); - } - } - - // 更新或创建流式消息 - function updateOrCreateStreamingMessage(text) { - hideLoadingIndicator(); - - if (!currentStreamingMessage) { - // 创建新的流式消息元素 - const div = document.createElement('div'); - div.className = 'message bot-message streaming'; - - const messageContent = document.createElement('div'); - messageContent.className = 'message-content'; - messageContent.textContent = text; - div.appendChild(messageContent); - - messagesEl.appendChild(div); - currentStreamingMessage = div; - } else { - // 更新现有消息内容 - const messageContent = currentStreamingMessage.querySelector('.message-content'); - if (messageContent) { - messageContent.textContent = text; - } - } - - // 智能滚动到底部 - smartScrollToBottom(); - } - - // 完成流式消息 - function finalizeStreamingMessage(finalText) { - if (currentStreamingMessage) { - const messageContent = currentStreamingMessage.querySelector('.message-content'); - if (messageContent) { - messageContent.textContent = finalText; - } - currentStreamingMessage.classList.remove('streaming'); - - // 添加操作按钮 - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'message-actions'; - - const copyBtn = document.createElement('button'); - copyBtn.className = 'action-btn'; - copyBtn.innerHTML = ''; - copyBtn.onclick = () => copyMessage(finalText, copyBtn); - actionsDiv.appendChild(copyBtn); - - currentStreamingMessage.appendChild(actionsDiv); - currentStreamingMessage = null; - } - - smartScrollToBottom(); - } - - // 显示加载指示器 - function showLoadingIndicator(text) { - hideLoadingIndicator(); - - loadingIndicator = document.createElement('div'); - loadingIndicator.className = 'message bot-message loading-message'; - loadingIndicator.innerHTML = \` -
- -
- \${text} - \`; - messagesEl.appendChild(loadingIndicator); - smartScrollToBottom(); - } - - // 隐藏加载指示器 - function hideLoadingIndicator() { - if (loadingIndicator) { - loadingIndicator.remove(); - loadingIndicator = null; - } - } - - // 存储已回答问题的状态 - const answeredQuestions = new Map(); // askId -> answer - - // 存储工具展开/折叠状态 - const toolCollapseStates = new Map(); // index -> isCollapsed - - // 实时更新分段消息(按后端返回顺序) - function updateSegmentsRealtime(segments, isComplete) { - // 如果对话完成且没有新段落,只重置容器 - if (isComplete && (!segments || segments.length === 0)) { - currentSegmentedMessage = null; - return; - } - - if (!segments || segments.length === 0) { - return; - } - - // 如果没有当前分段消息容器,创建一个 - if (!currentSegmentedMessage) { - // 移除流式消息(如果有) - if (currentStreamingMessage) { - currentStreamingMessage.remove(); - currentStreamingMessage = null; - } - - // 移除所有工具状态消息(因为会在分段中显示) - const toolStatuses = messagesEl.querySelectorAll('.tool-status'); - toolStatuses.forEach(el => { - el.remove(); - }); - - // 检查最后一个容器是否是未完成的对话(没有操作按钮) - const lastSegmented = messagesEl.querySelector('.segmented-message:last-child'); - if (lastSegmented && !lastSegmented.querySelector('.message-actions')) { - // 复用未完成的容器 - currentSegmentedMessage = lastSegmented; - } else { - // 创建新容器 - currentSegmentedMessage = document.createElement('div'); - currentSegmentedMessage.className = 'message bot-message segmented-message'; - messagesEl.appendChild(currentSegmentedMessage); - } - renderedSegmentCount = 0; - } - - // 保存当前所有工具的展开/折叠状态 - if (currentSegmentedMessage) { - const toolHeaders = currentSegmentedMessage.querySelectorAll('.tool-segment-header[data-collapsible="true"]'); - toolHeaders.forEach((header, idx) => { - const isCollapsed = header.classList.contains('collapsed'); - toolCollapseStates.set(idx, isCollapsed); - }); - } - - // 清空容器并重新渲染所有段落 - currentSegmentedMessage.innerHTML = ''; - - // 合并连续相同的工具调用 - const mergedSegments = []; - let i = 0; - while (i < segments.length) { - const segment = segments[i]; - if (segment.type === 'tool') { - // 统计连续相同的工具调用 - let count = 1; - while (i + count < segments.length && - segments[i + count].type === 'tool' && - segments[i + count].toolName === segment.toolName) { - count++; - } - // 添加合并后的段落(带计数) - mergedSegments.push({ ...segment, toolCount: count }); - i += count; - } else { - mergedSegments.push(segment); - i++; - } - } - - let toolIndex = 0; // 用于跟踪工具段落的索引 - mergedSegments.forEach((segment, index) => { - const segmentDiv = document.createElement('div'); - segmentDiv.className = 'message-segment segment-' + segment.type; - - if (segment.type === 'text' && segment.content) { - segmentDiv.className += ' segment-text'; - segmentDiv.innerHTML = formatText(segment.content); - } else if (segment.type === 'tool') { - // 过滤掉不需要显示的工具 - if (segment.toolName === 'spawnExplorer') { - return; - } - - // 所有工具调用都使用低调样式 - segmentDiv.className += ' low-profile'; - - const statusIcon = segment.toolStatus === 'error' ? '❌' : '🔧'; - const toolResult = segment.toolResult || ''; - const toolCount = segment.toolCount || 1; - const countSuffix = toolCount > 1 ? \` x\${toolCount}\` : ''; - const toolDescription = segment.toolDescription || ''; - - // 检查工具结果是否过长(超过一行显示不下) - const shouldCollapse = toolResult && toolResult.length > 60; - - // 恢复之前保存的展开/折叠状态 - const savedState = toolCollapseStates.get(toolIndex); - const isCollapsed = savedState !== undefined ? savedState : shouldCollapse; - const currentToolIndex = toolIndex; - toolIndex++; // 递增工具索引 - - segmentDiv.innerHTML = \` -
- \${shouldCollapse ? \`\${collapseIconSvg}\` : getToolIcon(segment.toolName)} - \${getToolDisplayName(segment.toolName) || '工具'}\${countSuffix} - \${toolResult && !shouldCollapse ? \`\${toolResult}\` : ''} -
- \${shouldCollapse ? \`
\${toolResult}
\` : ''} - \${toolDescription ? \`

\${toolDescription}

\` : ''} - \`; - - // 如果是仿真工具且成功完成,尝试添加波形预览 - if (segment.toolName === 'simulation' && segment.toolStatus === 'success') { - // 尝试解析多个 VCD 文件(多 VCD 模式) - const vcdPaths = parseMultiVcdPaths(segment.toolResult); - - if (vcdPaths.length > 0) { - // 多 VCD 模式:为每个文件创建预览 - vcdPaths.forEach(vcdInfo => { - const waveformPreview = createWaveformPreview(vcdInfo.path, vcdInfo.name); - segmentDiv.appendChild(waveformPreview); - }); - } else { - // 单 VCD 模式(兼容旧逻辑) - let vcdPath = segment.vcdFilePath; - if (!vcdPath && segment.toolResult) { - const match = String(segment.toolResult).match(/路径\s*:\s*(.+)/); - if (match && match[1]) { - vcdPath = match[1].trim(); - } - } - - if (vcdPath) { - const fileName = segment.fileName || vcdPath.split(/[\\\\\/]/).pop() || 'waveform.vcd'; - const waveformPreview = createWaveformPreview(vcdPath, fileName); - segmentDiv.appendChild(waveformPreview); - } - } - } - - // 添加折叠/展开事件监听 - if (shouldCollapse) { - setTimeout(() => { - const header = segmentDiv.querySelector('.tool-segment-header'); - const content = segmentDiv.querySelector('.tool-segment-content'); - - if (header && content) { - header.addEventListener('click', function() { - const isCollapsed = header.classList.contains('collapsed'); - const toolIdx = parseInt(header.getAttribute('data-tool-index') || '0'); - - if (isCollapsed) { - // 展开 - header.classList.remove('collapsed'); - content.classList.remove('collapsed'); - content.style.maxHeight = content.scrollHeight + 'px'; - toolCollapseStates.set(toolIdx, false); - } else { - // 折叠 - header.classList.add('collapsed'); - content.classList.add('collapsed'); - content.style.maxHeight = '0'; - toolCollapseStates.set(toolIdx, true); - } - }); - } - }, 0); - } - } else if (segment.type === 'question') { - segmentDiv.className += ' segment-question'; - - // 兼容旧格式:如果有 segment.question,转换为 questions 数组 - const questions = segment.questions || (segment.question ? [{ - question: segment.question, - options: segment.options || [], - multiSelect: false - }] : []); - - // 检查是否已回答 - const isAnswered = answeredQuestions.has(segment.askId); - const savedAnswers = answeredQuestions.get(segment.askId) || {}; - - if (isAnswered) { - segmentDiv.classList.add('answered'); - } - - // 渲染多个问题 - const questionsHtml = questions.map((q, qIndex) => { - const inputType = q.multiSelect ? 'checkbox' : 'radio'; - const inputName = \`q\${qIndex}\`; - const selectedAnswers = savedAnswers[qIndex] || []; - - const optionsHtml = q.options.map(opt => { - const isSelected = selectedAnswers.includes(opt); - return \`\`; - }).join(''); - - return \` -
-
\${formatText(q.question)}
-
\${optionsHtml}
-
- \`; - }).join(''); - - segmentDiv.innerHTML = \` - \${questionsHtml} - - \`; - - // 只在未回答时添加事件监听 - if (!isAnswered) { - setTimeout(() => { - const submitBtn = segmentDiv.querySelector('.custom-submit'); - if (submitBtn) { - submitBtn.addEventListener('click', function() { - const answers = {}; - questions.forEach((q, qIndex) => { - const inputs = segmentDiv.querySelectorAll(\`input[name="q\${qIndex}"]:checked\`); - answers[qIndex] = Array.from(inputs).map(input => input.value); - }); - handleMultiQuestionAnswer(segment.askId, answers, segmentDiv); - }); - } - }, 0); - } - } else if (segment.type === 'agent') { - // 智能体卡片渲染 - renderAgentCard(segment, segmentDiv); - } else if (segment.type === 'plan') { - // 计划卡片渲染(使用独立组件) - renderPlanCardInSegment(segment, segmentDiv, answeredQuestions); - } - - currentSegmentedMessage.appendChild(segmentDiv); - }); - - // 如果对话完成,添加操作按钮并重置容器 - if (isComplete) { - console.log('[WebView] 对话完成,添加操作按钮'); - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'message-actions'; - - // 复制按钮 - const copyBtn = document.createElement('button'); - copyBtn.className = 'action-btn'; - copyBtn.innerHTML = \`复制\`; - copyBtn.onclick = () => { - const textContent = segments - .filter(s => s.type === 'text' && s.content) - .map(s => s.content) - .join('\\n'); - copyMessage(textContent, copyBtn); - }; - - // 点赞按钮 - const likeBtn = document.createElement('button'); - likeBtn.className = 'action-btn'; - likeBtn.innerHTML = \`点赞\`; - likeBtn.onclick = () => toggleLike(likeBtn); - - // 点踩按钮 - const dislikeBtn = document.createElement('button'); - dislikeBtn.className = 'action-btn'; - dislikeBtn.innerHTML = \`点踩\`; - dislikeBtn.onclick = () => toggleDislike(dislikeBtn); - - actionsDiv.appendChild(copyBtn); - actionsDiv.appendChild(likeBtn); - actionsDiv.appendChild(dislikeBtn); - currentSegmentedMessage.appendChild(actionsDiv); - - // 重置当前分段消息容器(继续对话时创建新容器) - currentSegmentedMessage = null; - } - - // 智能滚动到底部 - smartScrollToBottom(); - } - - // 渲染分段消息(兼容旧代码) - function renderSegments(segments) { - console.log('[WebView] renderSegments 被调用, segments:', segments); - if (!segments || segments.length === 0) { - console.log('[WebView] segments 为空,跳过渲染'); - return; - } - - // 移除流式消息(如果有) - if (currentStreamingMessage) { - console.log('[WebView] 移除流式消息'); - currentStreamingMessage.remove(); - currentStreamingMessage = null; - } - - // 移除所有工具状态消息(因为会在分段中显示) - const toolStatuses = messagesEl.querySelectorAll('.tool-status'); - console.log('[WebView] 找到工具状态消息数量:', toolStatuses.length); - toolStatuses.forEach(el => { - console.log('[WebView] 移除工具状态消息:', el.className); - el.remove(); - }); - - // 创建消息容器 - const container = document.createElement('div'); - container.className = 'message bot-message segmented-message'; - - // 合并连续相同的工具调用 - const mergedSegments = []; - let i = 0; - while (i < segments.length) { - const segment = segments[i]; - if (segment.type === 'tool') { - // 统计连续相同的工具调用 - let count = 1; - while (i + count < segments.length && - segments[i + count].type === 'tool' && - segments[i + count].toolName === segment.toolName) { - count++; - } - // 添加合并后的段落(带计数) - mergedSegments.push({ ...segment, toolCount: count }); - i += count; - } else { - mergedSegments.push(segment); - i++; - } - } - - mergedSegments.forEach((segment, index) => { - const segmentDiv = document.createElement('div'); - segmentDiv.className = 'message-segment segment-' + segment.type; - - if (segment.type === 'text' && segment.content) { - segmentDiv.className += ' segment-text'; - segmentDiv.innerHTML = formatText(segment.content); - } else if (segment.type === 'tool') { - // 过滤掉不需要显示的工具 - if (segment.toolName === 'spawnExplorer') { - return; - } - - // 所有工具调用都使用低调样式 - segmentDiv.className += ' low-profile'; - - const statusIcon = segment.toolStatus === 'error' ? '❌' : '🔧'; - const toolResult = segment.toolResult || ''; - const toolCount = segment.toolCount || 1; - const countSuffix = toolCount > 1 ? \` x\${toolCount}\` : ''; - const toolDescription = segment.toolDescription || ''; - - // 检查工具结果是否过长(超过一行显示不下) - const shouldCollapse = toolResult && toolResult.length > 60; - - segmentDiv.innerHTML = \` -
- \${shouldCollapse ? \`\${collapseIconSvg}\` : getToolIcon(segment.toolName)} - \${getToolDisplayName(segment.toolName) || '工具'}\${countSuffix} - \${toolResult && !shouldCollapse ? \`\${toolResult}\` : ''} -
- \${shouldCollapse ? \`\` : ''} - \${toolDescription ? \`

\${toolDescription}

\` : ''} - \`; - - // 如果是仿真工具且成功完成,尝试添加波形预览 - if (segment.toolName === 'simulation' && segment.toolStatus === 'success') { - // 尝试解析多个 VCD 文件(多 VCD 模式) - const vcdPaths = parseMultiVcdPaths(segment.toolResult); - - if (vcdPaths.length > 0) { - // 多 VCD 模式:为每个文件创建预览 - vcdPaths.forEach(vcdInfo => { - const waveformPreview = createWaveformPreview(vcdInfo.path, vcdInfo.name); - segmentDiv.appendChild(waveformPreview); - }); - } else { - // 单 VCD 模式(兼容旧逻辑) - let vcdPath = segment.vcdFilePath; - if (!vcdPath && segment.toolResult) { - const match = String(segment.toolResult).match(/路径\s*:\s*(.+)/); - if (match && match[1]) { - vcdPath = match[1].trim(); - } - } - - if (vcdPath) { - const fileName = segment.fileName || vcdPath.split(/[\\\\\/]/).pop() || 'waveform.vcd'; - const waveformPreview = createWaveformPreview(vcdPath, fileName); - segmentDiv.appendChild(waveformPreview); - } - } - } - - // 添加折叠/展开事件监听 - if (shouldCollapse) { - setTimeout(() => { - const header = segmentDiv.querySelector('.tool-segment-header'); - const content = segmentDiv.querySelector('.tool-segment-content'); - - if (header && content) { - header.addEventListener('click', function() { - const isCollapsed = header.classList.contains('collapsed'); - const toolIdx = parseInt(header.getAttribute('data-tool-index') || '0'); - - if (isCollapsed) { - // 展开 - header.classList.remove('collapsed'); - content.classList.remove('collapsed'); - content.style.maxHeight = content.scrollHeight + 'px'; - toolCollapseStates.set(toolIdx, false); - } else { - // 折叠 - header.classList.add('collapsed'); - content.classList.add('collapsed'); - content.style.maxHeight = '0'; - toolCollapseStates.set(toolIdx, true); - } - }); - } - }, 0); - } - } else if (segment.type === 'question') { - segmentDiv.innerHTML = \` -
-
\${formatText(segment.question || '')}
-
- \${(segment.options || []).map(opt => \`\${opt}\`).join('')} -
-
- \`; - } else if (segment.type === 'agent') { - // 智能体卡片渲染 - renderAgentCard(segment, segmentDiv); - } else if (segment.type === 'plan') { - // 计划卡片渲染(使用独立组件) - renderPlanCardStatic(segment, segmentDiv); - } - - container.appendChild(segmentDiv); - }); - - // 添加操作按钮 - const actionsDiv = document.createElement('div'); - actionsDiv.className = 'message-actions'; - - // 复制按钮 - const copyBtn = document.createElement('button'); - copyBtn.className = 'action-btn'; - copyBtn.innerHTML = \`复制\`; - copyBtn.onclick = () => { - const textContent = segments - .filter(s => s.type === 'text' && s.content) - .map(s => s.content) - .join('\\n'); - copyMessage(textContent, copyBtn); - }; - - // 点赞按钮 - const likeBtn = document.createElement('button'); - likeBtn.className = 'action-btn'; - likeBtn.innerHTML = \`点赞\`; - likeBtn.onclick = () => toggleLike(likeBtn); - - // 点踩按钮 - const dislikeBtn = document.createElement('button'); - dislikeBtn.className = 'action-btn'; - dislikeBtn.innerHTML = \`点踩\`; - dislikeBtn.onclick = () => toggleDislike(dislikeBtn); - - actionsDiv.appendChild(copyBtn); - actionsDiv.appendChild(likeBtn); - actionsDiv.appendChild(dislikeBtn); - container.appendChild(actionsDiv); - - messagesEl.appendChild(container); - smartScrollToBottom(); - } - - // 格式化文本(支持 Markdown) - function formatText(text) { - if (!text) return ''; - - let html = text; - - // 先提取并处理代码块(避免被转义) - const codeBlocks = []; - html = html.replace(/\`\`\`(\\w+)?\\n([\\s\\S]*?)\`\`\`/g, function(match, lang, code) { - const language = lang || 'plaintext'; - // 转义代码内容 - const escapedCode = code.trim() - .replace(/&/g, '&') - .replace(//g, '>'); - // 不再手动高亮,让 highlight.js 处理 - const placeholder = \`___CODE_BLOCK_\${codeBlocks.length}___\`; - codeBlocks.push('
' + escapedCode + '
'); - return placeholder; - }); - - // 提取行内代码(避免被转义) - const inlineCodes = []; - html = html.replace(/\`([^\`]+)\`/g, function(match, code) { - const escapedCode = code - .replace(/&/g, '&') - .replace(//g, '>'); - const placeholder = \`___INLINE_CODE_\${inlineCodes.length}___\`; - inlineCodes.push('' + escapedCode + ''); - return placeholder; - }); - - // 转义其他 HTML 特殊字符 - html = html + return paths; + } + + function formatText(text) { + if (!text) return ''; + let html = text; + const codeBlocks = []; + html = html.replace(/\`\`\`(\\w+)?\\n([\\s\\S]*?)\`\`\`/g, function(match, lang, code) { + const language = lang || 'plaintext'; + const escapedCode = code.trim() .replace(/&/g, '&') .replace(//g, '>'); - - // 处理标题 ### Title - html = html.replace(/^### (.+)$/gm, '

$1

'); - html = html.replace(/^## (.+)$/gm, '

$1

'); - html = html.replace(/^# (.+)$/gm, '

$1

'); - - // 处理粗体 **text** - html = html.replace(/\\*\\*(.+?)\\*\\*/g, '$1'); - - // 处理斜体 *text* - html = html.replace(/\\*(.+?)\\*/g, '$1'); - - // 处理无序列表 - item 或 * item - html = html.replace(/^[\\-\\*] (.+)$/gm, '
  • $1
  • '); - html = html.replace(/(
  • .*<\\/li>\\n?)+/g, '
      $&
    '); - - // 处理有序列表 1. item - html = html.replace(/^\\d+\\. (.+)$/gm, '
  • $1
  • '); - - // 处理链接 [text](url) - html = html.replace(/\\[([^\\]]+)\\]\\(([^\\)]+)\\)/g, '$1'); - - // 处理换行(在恢复代码块之前) - html = html.replace(/\\n/g, '
    '); - - // 恢复代码块(在最后恢复,避免被其他处理影响) - codeBlocks.forEach((block, index) => { - html = html.replace(\`___CODE_BLOCK_\${index}___\`, block); - }); - - // 恢复行内代码 - inlineCodes.forEach((code, index) => { - html = html.replace(\`___INLINE_CODE_\${index}___\`, code); - }); - - return html; - } - - // 添加工具状态消息 - function addToolStatus(toolName, status, detail) { - const statusIcons = { - start: '🔧', - complete: '✅', - error: '❌' - }; - const statusTexts = { - start: '正在执行', - complete: '执行完成', - error: '执行失败' - }; - - const div = document.createElement('div'); - div.className = \`message tool-status tool-\${status}\`; - div.innerHTML = \` - \${statusIcons[status]} - \${getToolDisplayName(toolName)} - \${statusTexts[status]} - \${detail ? \`
    \${detail}
    \` : ''} - \`; - messagesEl.appendChild(div); - smartScrollToBottom(); - - // 添加消息后检查 header 显示状态 - checkHeaderVisibility(); + const placeholder = \`___CODE_BLOCK_\${codeBlocks.length}___\`; + codeBlocks.push('
    ' + escapedCode + '
    '); + return placeholder; + }); + const inlineCodes = []; + html = html.replace(/\`([^\`]+)\`/g, function(match, code) { + const escapedCode = code + .replace(/&/g, '&') + .replace(//g, '>'); + const placeholder = \`___INLINE_CODE_\${inlineCodes.length}___\`; + inlineCodes.push('' + escapedCode + ''); + return placeholder; + }); + html = html + .replace(/&/g, '&') + .replace(//g, '>'); + html = html.replace(/^### (.+)$/gm, '

    $1

    '); + html = html.replace(/^## (.+)$/gm, '

    $1

    '); + html = html.replace(/^# (.+)$/gm, '

    $1

    '); + html = html.replace(/\\*\\*(.+?)\\*\\*/g, '$1'); + html = html.replace(/\\*(.+?)\\*/g, '$1'); + html = html.replace(/^[\\-\\*] (.+)$/gm, '
  • $1
  • '); + html = html.replace(/(
  • .*<\\/li>\\n?)+/g, '
      $&
    '); + html = html.replace(/^\\d+\\. (.+)$/gm, '
  • $1
  • '); + html = html.replace(/\\[([^\\]]+)\\]\\(([^\\)]+)\\)/g, '$1'); + html = html.replace(/\\n/g, '
    '); + codeBlocks.forEach((block, index) => { + html = html.replace(\`___CODE_BLOCK_\${index}___\`, block); + }); + inlineCodes.forEach((code, index) => { + html = html.replace(\`___INLINE_CODE_\${index}___\`, code); + }); + return html; } - // 显示用户问题 - function showQuestion(askId, question, options) { - console.log('[WebView] showQuestion 被调用:', askId, question, options); - - // 创建问题消息容器 - const div = document.createElement('div'); - div.className = 'message bot-message question-message'; - div.setAttribute('data-ask-id', askId); - - // 问题文本 - const questionText = document.createElement('div'); - questionText.className = 'question-text'; - questionText.textContent = question; - div.appendChild(questionText); - - // 选项容器 - const optionsContainer = document.createElement('div'); - optionsContainer.className = 'question-options'; - - // 添加选项按钮 - options.forEach((option, index) => { - const optionBtn = document.createElement('button'); - optionBtn.className = 'question-option'; - optionBtn.textContent = option; - optionBtn.onclick = () => handleQuestionAnswer(askId, option, div); - optionsContainer.appendChild(optionBtn); - }); - - div.appendChild(optionsContainer); - - // 添加自定义输入("其他"选项) - const customContainer = document.createElement('div'); - customContainer.className = 'custom-input-container'; - - const customInput = document.createElement('input'); - customInput.type = 'text'; - customInput.className = 'custom-input'; - customInput.placeholder = '输入其他答案...'; - - const customSubmit = document.createElement('button'); - customSubmit.className = 'custom-submit'; - customSubmit.textContent = '提交'; - customSubmit.onclick = () => { - const customValue = customInput.value.trim(); - if (customValue) { - handleQuestionAnswer(askId, customValue, div); - } - }; - - customContainer.appendChild(customInput); - customContainer.appendChild(customSubmit); - div.appendChild(customContainer); - - messagesEl.appendChild(div); - smartScrollToBottom(); - - // 添加消息后检查 header 显示状态 - checkHeaderVisibility(); - } - - // 处理问题回答 - function handleQuestionAnswer(askId, answer, questionDiv) { - console.log('[WebView] 用户选择答案:', askId, answer); - - // 标记问题已回答 - questionDiv.classList.add('answered'); - - // 高亮选中的选项 - const options = questionDiv.querySelectorAll('.question-option'); - options.forEach(opt => { - if (opt.textContent === answer) { - opt.classList.add('selected'); - } - }); - - // 发送答案到后端 - vscode.postMessage({ - command: 'submitAnswer', - askId: askId, - selected: [answer], - customInput: answer - }); - } - - // 处理段落中的问题回答 - function handleQuestionAnswerInSegment(askId, answer, segmentDiv) { - console.log('[WebView] 段落中用户选择答案:', askId, answer); - - // 保存答案到 Map 中 - answeredQuestions.set(askId, answer); - - // 标记问题已回答 - segmentDiv.classList.add('answered'); - - // 高亮选中的选项 - const options = segmentDiv.querySelectorAll('.question-option'); - options.forEach(opt => { - if (opt.getAttribute('data-option') === answer) { - opt.classList.add('selected'); - } - }); - - // 隐藏自定义输入 - const customContainer = segmentDiv.querySelector('.custom-input-container'); - if (customContainer) { - customContainer.style.display = 'none'; - } - - // 发送答案到后端 - vscode.postMessage({ - command: 'submitAnswer', - askId: askId, - selected: [answer], - customInput: answer - }); - } - - // 处理多问题答案提交 - function handleMultiQuestionAnswer(askId, answers, segmentDiv) { - console.log('[WebView] 多问题答案提交:', askId, answers); - - // 保存答案到 Map 中 - answeredQuestions.set(askId, answers); - - // 标记问题已回答 - segmentDiv.classList.add('answered'); - - // 禁用所有输入并保持选中状态的高亮 - const inputs = segmentDiv.querySelectorAll('input'); - inputs.forEach(input => { - input.disabled = true; - // 确保选中的选项保持高亮 - if (input.checked) { - const label = input.closest('.question-option'); - if (label) { - label.classList.add('selected'); - } - } - }); - - // 隐藏提交按钮 - const submitBtn = segmentDiv.querySelector('.custom-submit'); - if (submitBtn) { - submitBtn.style.display = 'none'; - } - - // 发送答案到后端 - vscode.postMessage({ - command: 'submitAnswer', - askId: askId, - answers: answers - }); - } - - ${getWaveformPreviewScript()} - - ${getCodeHighlightScript()} + ${getQuestionHandlerScript()} + ${getMessageRendererScript()} + ${getSegmentRendererScript()} `; } diff --git a/src/views/messageRenderer.ts b/src/views/messageRenderer.ts new file mode 100644 index 0000000..662b3f7 --- /dev/null +++ b/src/views/messageRenderer.ts @@ -0,0 +1,220 @@ +/** + * 消息渲染脚本模块 + * 功能:消息渲染、滚动控制、工具状态显示 + * 依赖:toolHelpers, textFormatter, waveformPreviewContent, agentCard, planCard, codeHighlight + * 使用场景:webview 中的消息显示逻辑 + */ + +import { collapseIconSvg } from "../constants/toolIcons"; +import { getWaveformPreviewScript } from "./waveformPreviewContent"; +import { getAgentCardScript } from "./agentCard"; +import { getPlanCardScript } from "./planCard"; +import { getCodeHighlightScript } from "../components/codeHighlight"; + +export function getMessageRendererScript(): string { + return ` + ${getAgentCardScript()} + ${getPlanCardScript()} + + const toolCollapseStates = new Map(); + let shouldAutoScroll = true; + let lastScrollHeight = 0; + + function isUserNearBottom() { + const threshold = 50; + return messagesEl.scrollHeight - messagesEl.scrollTop - messagesEl.clientHeight < threshold; + } + + messagesEl.addEventListener('scroll', () => { + const isAtBottom = isUserNearBottom(); + if (isAtBottom) { + shouldAutoScroll = true; + } else { + if (messagesEl.scrollHeight === lastScrollHeight) { + shouldAutoScroll = false; + } + } + lastScrollHeight = messagesEl.scrollHeight; + }); + + function smartScrollToBottom() { + if (shouldAutoScroll) { + messagesEl.scrollTop = messagesEl.scrollHeight; + lastScrollHeight = messagesEl.scrollHeight; + } + } + + function addMessage(text, sender) { + const div = document.createElement('div'); + div.className = \`message \${sender}-message\`; + if (sender === 'bot') { + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'message-actions'; + const messageContent = document.createElement('span'); + messageContent.textContent = text; + const copyBtn = document.createElement('button'); + copyBtn.className = 'action-btn'; + copyBtn.innerHTML = \`复制\`; + copyBtn.onclick = () => copyMessage(text, copyBtn); + const likeBtn = document.createElement('button'); + likeBtn.className = 'action-btn'; + likeBtn.innerHTML = \`点赞\`; + likeBtn.onclick = () => toggleLike(likeBtn); + const dislikeBtn = document.createElement('button'); + dislikeBtn.className = 'action-btn'; + dislikeBtn.innerHTML = \`点踩\`; + dislikeBtn.onclick = () => toggleDislike(dislikeBtn); + actionsDiv.appendChild(messageContent); + actionsDiv.appendChild(copyBtn); + actionsDiv.appendChild(likeBtn); + actionsDiv.appendChild(dislikeBtn); + div.appendChild(actionsDiv); + } else { + const parts = text.split(' '); + const filePaths = []; + const textParts = []; + parts.forEach(part => { + if (part.includes('/') || part.includes('\\\\') || /\\.[a-zA-Z0-9]+$/.test(part) || /:[0-9]+-[0-9]+$/.test(part)) { + filePaths.push(part); + } else { + textParts.push(part); + } + }); + if (filePaths.length > 0) { + div.innerHTML = filePaths.map(fp => window.createFilePathTag ? window.createFilePathTag(fp) : fp).join('') + ' ' + textParts.join(' '); + } else { + div.textContent = text; + } + hideHeaderIfNeeded(); + } + messagesEl.appendChild(div); + smartScrollToBottom(); + checkHeaderVisibility(); + } + + function hideHeaderIfNeeded() { + checkHeaderVisibility(); + } + + function copyMessage(text, button) { + // 从按钮的父消息元素中获取实际文本内容 + const messageDiv = button.closest('.message'); + const messageContent = messageDiv?.querySelector('.message-content, div:not(.message-actions)'); + const textToCopy = messageContent ? messageContent.textContent : text; + + navigator.clipboard.writeText(textToCopy).then(() => { + const originalHTML = button.innerHTML; + button.innerHTML = \`\`; + setTimeout(() => { + button.innerHTML = originalHTML; + }, 2000); + }); + } + + function toggleLike(button) { + const isActive = button.classList.contains('active'); + const parent = button.parentElement; + parent.querySelectorAll('.action-btn').forEach(btn => btn.classList.remove('active')); + if (!isActive) { + button.classList.add('active'); + } + } + + function toggleDislike(button) { + const isActive = button.classList.contains('active'); + const parent = button.parentElement; + parent.querySelectorAll('.action-btn').forEach(btn => btn.classList.remove('active')); + if (!isActive) { + button.classList.add('active'); + } + } + + function updateOrCreateStreamingMessage(text) { + hideLoadingIndicator(); + if (!currentStreamingMessage) { + const div = document.createElement('div'); + div.className = 'message bot-message streaming'; + const messageContent = document.createElement('div'); + messageContent.className = 'message-content'; + messageContent.textContent = text; + div.appendChild(messageContent); + messagesEl.appendChild(div); + currentStreamingMessage = div; + } else { + const messageContent = currentStreamingMessage.querySelector('.message-content'); + if (messageContent) { + messageContent.textContent = text; + } + } + smartScrollToBottom(); + } + + function finalizeStreamingMessage(finalText) { + if (currentStreamingMessage) { + const messageContent = currentStreamingMessage.querySelector('.message-content'); + if (messageContent) { + messageContent.textContent = finalText; + } + currentStreamingMessage.classList.remove('streaming'); + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'message-actions'; + const copyBtn = document.createElement('button'); + copyBtn.className = 'action-btn'; + copyBtn.innerHTML = ''; + copyBtn.onclick = () => copyMessage(finalText, copyBtn); + actionsDiv.appendChild(copyBtn); + currentStreamingMessage.appendChild(actionsDiv); + currentStreamingMessage = null; + } + smartScrollToBottom(); + } + + function showLoadingIndicator(text) { + hideLoadingIndicator(); + loadingIndicator = document.createElement('div'); + loadingIndicator.className = 'message bot-message loading-message'; + loadingIndicator.innerHTML = \` +
    + +
    + \${text} + \`; + messagesEl.appendChild(loadingIndicator); + smartScrollToBottom(); + } + + function hideLoadingIndicator() { + if (loadingIndicator) { + loadingIndicator.remove(); + loadingIndicator = null; + } + } + + function addToolStatus(toolName, status, detail) { + const statusIcons = { + start: '🔧', + complete: '✅', + error: '❌' + }; + const statusTexts = { + start: '正在执行', + complete: '执行完成', + error: '执行失败' + }; + const div = document.createElement('div'); + div.className = \`message tool-status tool-\${status}\`; + div.innerHTML = \` + \${statusIcons[status]} + \${getToolDisplayName(toolName)} + \${statusTexts[status]} + \${detail ? \`
    \${detail}
    \` : ''} + \`; + messagesEl.appendChild(div); + smartScrollToBottom(); + checkHeaderVisibility(); + } + + ${getWaveformPreviewScript()} + ${getCodeHighlightScript()} + `; +} diff --git a/src/views/messageStyles.ts b/src/views/messageStyles.ts new file mode 100644 index 0000000..19b5e6c --- /dev/null +++ b/src/views/messageStyles.ts @@ -0,0 +1,632 @@ +/** + * 消息样式模块 + * 功能:提供消息区域的所有 CSS 样式 + * 依赖:agentCard, planCard, codeHighlight, waveformPreviewContent + * 使用场景:webview 样式注入 + */ + +import { getAgentCardStyles } from "./agentCard"; +import { getPlanCardStyles } from "./planCard"; +import { getCodeHighlightStyles } from "../components/codeHighlight"; +import { getWaveformPreviewContent } from "./waveformPreviewContent"; + +export function getMessageAreaStyles(): string { + return ` + .messages { + flex: 1; + overflow-y: auto; + margin-bottom: 15px; + min-height: 0; + } + .message { + margin-bottom: 12px; + } + .user-message { + padding: 10px 15px; + border-radius: 8px; + background: var(--vscode-button-secondaryBackground); + border: 1px solid var(--vscode-input-border); + margin-left: auto; + width: fit-content; + max-width: 80%; + } + .bot-message { + padding: 0; + text-align: left; + color: var(--vscode-foreground); + max-width: 100%; + position: relative; + } + .message-actions { + display: inline-flex; + gap: 8px; + margin-left: 12px; + opacity: 0.85; + transition: opacity 0.2s ease; + vertical-align: middle; + align-items: center; + } + .message-actions > span { + display: inline-flex; + align-items: center; + gap: 4px; + } + .message-actions:hover { + opacity: 1; + } + .action-btn { + background: transparent; + border: none; + cursor: pointer; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + color: var(--vscode-foreground); + opacity: 0.9; + transition: opacity 0.2s ease; + position: relative; + margin-top: 1px; + } + .action-btn:hover { + opacity: 1; + } + .action-btn svg { + width: 14px; + height: 14px; + } + .action-btn.active { + color: var(--vscode-button-background); + opacity: 1; + } + .action-btn .action-tooltip { + visibility: hidden; + width: auto; + background: #1e1e1e; + color: #ffffff; + text-align: center; + border-radius: 4px; + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 4px 8px; + position: absolute; + z-index: 1000; + bottom: 125%; + left: 50%; + transform: translateX(-50%) translateY(5px); + opacity: 0; + transition: all 0.2s ease; + font-size: 12px;white-space: nowrap;pointer-events: none; + } + .action-btn .action-tooltip::after { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -5px; + border-width: 5px; + border-style: solid; + border-color: #1e1e1e transparent transparent transparent; + } + .action-btn .action-tooltip::before { + content: ""; + position: absolute; + top: 100%; + left: 50%; + margin-left: -6px; + border-width: 6px; + border-style: solid; + border-color: rgba(255, 255, 255, 0.2) transparent transparent transparent; + z-index: -1; + } + .action-btn:hover .action-tooltip { + visibility: visible; + opacity: 1; + transform: translateX(-50%) translateY(0); + } + + .streaming .message-content { + border-right: 2px solid var(--vscode-focusBorder); + animation: blink 1s infinite; + } + @keyframes blink { + 0%, 50% { border-color: var(--vscode-focusBorder); } + 51%, 100% { border-color: transparent; } + } + + .loading-message { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 16px; + color: var(--vscode-descriptionForeground); + } + .loading-dots { + display: flex; + gap: 4px;} + .loading-dots span { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--vscode-focusBorder); + animation: loadingDot 1.4s infinite ease-in-out; + } + .loading-dots span:nth-child(1) { animation-delay: 0s; } + .loading-dots span:nth-child(2) { animation-delay: 0.2s; } + .loading-dots span:nth-child(3) { animation-delay: 0.4s; } + @keyframes loadingDot { + 0%, 80%, 100% { transform: scale(0.6); opacity: 0.5; } + 40% { transform: scale(1); opacity: 1; } + } + .loading-text { + font-size: 13px; + } + + .tool-status { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin: 4px 0; + font-size: 12px; + border-radius: 6px; + background: var(--vscode-textBlockQuote-background); + } + .tool-status.tool-start { + border-left: 3px solid var(--vscode-charts-blue); + } + .tool-status.tool-complete { + border-left: 3px solid var(--vscode-charts-green); + } + .tool-status.tool-error { + border-left: 3px solid var(--vscode-charts-red); + } + .tool-icon { + font-size: 14px; + } + .tool-name { + font-weight: 500; + color: var(--vscode-foreground); + } + .tool-status-text { + color: var(--vscode-descriptionForeground); + } + .tool-detail { + margin-top: 4px; + font-size: 11px; + color: var(--vscode-descriptionForeground); + white-space: pre-wrap; + max-height: 100px; + overflow-y: auto; + } + + .question-message { + padding: 16px; + } + .question-text { + margin-bottom: 12px; + font-weight: 500; + } + .question-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .question-option { + padding: 8px 16px; + background: #007ACC; + color: #ffffff; + border: 1px solid #007ACC; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + } + .question-option:hover { + background: #005a9e; + border-color: #005a9e; + } + .question-option.selected { + background: #007ACC; + color: #ffffff; + border-color: #007ACC; + } + .question-message.answered .question-option:not(.selected) { + opacity: 0.5; + pointer-events: none; + } + .custom-input-container { + display: flex; + gap: 8px; + width: 100%; + margin-top: 8px; + } + .custom-input { + flex: 1; + padding: 8px 12px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 6px; + font-size: 13px; + } + .custom-submit { + padding: 8px 16px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: 6px; + cursor: pointer; + } + .custom-submit:hover { + background: var(--vscode-button-hoverBackground); + } + .question-message.answered .custom-input-container { + display: none; + } + + .segmented-message { + padding: 0; + } + .message-segment { + padding: 10px 0; + } + .segment-text { + line-height: 1.6; + } + + .segment-text h1, + .segment-text h2, + .segment-text h3, + .question-text h1, + .question-text h2, + .question-text h3 { + margin: 0px 0 -10px 0; + font-weight: 600; + line-height: 1.3; + } + .segment-text h1, + .question-text h1 { + font-size: 1.5em; + border-bottom: 1px solid var(--vscode-panel-border); + padding-bottom: 8px; + } + .segment-text h2, + .question-text h2 { + font-size: 1.3em; + } + .segment-text h3, + .question-text h3 { + font-size: 1.1em; + } + .segment-text ul, + .segment-text ol, + .question-text ul, + .question-text ol { + margin: 8px 0; + padding-left: 24px; + } + .segment-text li, + .question-text li { + line-height: 1; + } + .segment-text strong, + .question-text strong { + font-weight: 600; + color: var(--vscode-foreground); + } + .segment-text em, + .question-text em { + font-style: italic; + } + .segment-text a, + .question-text a { + color: var(--vscode-textLink-foreground); + text-decoration: none; + } + .segment-text a:hover, + .question-text a:hover { + text-decoration: underline; + } + .segment-text p, + .question-text p { + margin: 8px 0; + } + .segment-text code, + .question-text code { + background: var(--vscode-textCodeBlock-background); + padding: 2px 6px; + border-radius: 3px; + font-family: var(--vscode-editor-font-family); + font-size: 0.9em; + } + + .segment-tool { + margin: 4px 0; + padding: 4px 0; + } + .segment-tool.low-profile { + margin: 25px 0px; + padding: 0; + background: none; + } + .tool-segment-header { + display: flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--vscode-descriptionForeground); + cursor: pointer; + } + .tool-segment-icon { + font-size: 12px; + } + .tool-segment-name { + font-weight: normal; + } + .tool-segment-result { + display: inline; + font-size: 12px; + color: var(--vscode-descriptionForeground); + margin-left: 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 500px; + } + .tool-collapse-icon { + width: 12px; + height: 12px; + flex-shrink: 0; + transition: transform 0.2s ease; + cursor: pointer; + } + .tool-collapse-icon svg { + width: 100%; + height: 100%; + display: block; + } + .icon-expanded svg path { + fill: #007ACC !important; + } + .tool-segment-header.collapsed .tool-collapse-icon { + transform: rotate(-90deg); + } + .tool-segment-header:not(.collapsed) .tool-collapse-icon { + transform: rotate(0deg); + } + .tool-file-write-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-file-write-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-file-read-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-file-read-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-file-delete-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-file-delete-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-syntax-check-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-syntax-check-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-search-code-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-search-code-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-save-knowledge-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-save-knowledge-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-simulation-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-simulation-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-waveform-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-waveform-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-knowledge-load-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-knowledge-load-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-state-transition-icon { + width: 16px; + height: 16px; + flex-shrink: 0; + margin-right: 6px; + } + .tool-state-transition-icon svg { + width: 100%; + height: 100%; + display: block; + } + .tool-segment-content { + overflow: hidden; + transition: max-height 0.3s ease; + } + .tool-segment-content.collapsed { + max-height: 0; + } + .tool-segment-description { + margin: 25px 0 0 0px; + font-size: 0.9rem; + color: var(--vscode-foreground); + line-height: 1.4; + letter-spacing: 0.5px; + } + .segment-tool.low-profile .tool-segment-header { + opacity: 0.65; + font-size: 12px; + } + .segment-tool.low-profile .tool-segment-icon { + opacity: 0.55; + font-size: 11px; + } + .segment-tool.low-profile .tool-segment-name { + font-weight: 300; + opacity: 0.8; + } + .segment-tool.low-profile .tool-segment-result { + opacity: 0.7; + font-size: 12px; + letter-spacing: 0.5px; + } + .segment-question { + background: var(--vscode-textBlockQuote-background); + border-radius: 6px; + margin: 8px 0; + padding: 12px 35px; + border-left: 3px solid var(--vscode-charts-orange); + } + .segment-question .question-text { + margin-bottom: 12px; + font-weight: 500; + } + .segment-question .question-options { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; + } + .segment-question .question-option { + padding: 8px 16px; + background: #3d3f41; + color: var(--vscode-foreground); + border: 1px solid #474747; + border-radius: 6px; + cursor: pointer; + transition: all 0.2s; + font-size: 13px; + letter-spacing: 0.5px; + } + .segment-question .question-option:hover { + background: #005a9e; + border-color: #005a9e; + } + .segment-question .question-option.selected { + background: #007ACC; + color: #ffffff; + border-color: #007ACC; + } + .segment-question.answered .question-option:not(.selected) { + opacity: 0.5; + pointer-events: none; + } + .segment-question .custom-input-container { + display: flex; + gap: 8px; + width: 100%; + margin-top: 8px; + } + .segment-question .custom-input { + flex: 1; + padding: 8px 12px; + background: var(--vscode-input-background); + color: var(--vscode-input-foreground); + border: 1px solid var(--vscode-input-border); + border-radius: 6px; + font-size: 13px; + margin-left: -20px; + } + .segment-question .custom-submit { + padding: 8px 16px; + background: var(--vscode-button-background); + color: var(--vscode-button-foreground); + border: none; + border-radius: 6px; + cursor: pointer; + } + .segment-question .custom-submit:hover { + background: var(--vscode-button-hoverBackground); + } + .segment-question.answered .custom-input-container { + display: none; + } + .question-segment .question-text { + margin-bottom: 8px; + font-weight: 500; + } + .question-segment .question-options { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .question-opt { + padding: 4px 10px; + background: var(--vscode-button-secondaryBackground); + color: var(--vscode-button-secondaryForeground); + border-radius: 4px; + font-size: 12px;} + + ${getAgentCardStyles()} + + ${getPlanCardStyles()} + + ${getCodeHighlightStyles()} + + ${getWaveformPreviewContent()} + `; +} diff --git a/src/views/planCard.ts b/src/views/planCard.ts index 7820b82..38c9e60 100644 --- a/src/views/planCard.ts +++ b/src/views/planCard.ts @@ -59,7 +59,7 @@ export function getPlanCardStyles(): string { .plan-summary h2 { font-size: 16px; } .plan-summary h3 { font-size: 14px; } .plan-summary h4 { font-size: 13px; } - .plan-summary p { margin: 8px 0; } + .plan-summary p { margin: 8px 0; letter-spacing: 0.5px; } .plan-summary ul, .plan-summary ol { margin: 8px 0; padding-left: 0; diff --git a/src/views/progressBar.ts b/src/views/progressBar.ts index c0321e8..5772ba0 100644 --- a/src/views/progressBar.ts +++ b/src/views/progressBar.ts @@ -25,7 +25,7 @@ export function getProgressBarContent(): string { 1
    -
    Spec
    +
    Specification
    diff --git a/src/views/questionHandler.ts b/src/views/questionHandler.ts new file mode 100644 index 0000000..7945904 --- /dev/null +++ b/src/views/questionHandler.ts @@ -0,0 +1,118 @@ +/** + * 问题处理脚本模块 + * 功能:用户问题交互逻辑 + * 依赖:textFormatter + * 使用场景:webview 中的问题回答处理 + */ + +export function getQuestionHandlerScript(): string { + return ` + const answeredQuestions = new Map(); + + function handleQuestionAnswer(askId, answer, questionDiv) { + console.log('[WebView] 用户选择答案:', askId, answer); + questionDiv.classList.add('answered'); + const options = questionDiv.querySelectorAll('.question-option'); + options.forEach(opt => { + if (opt.textContent === answer) { + opt.classList.add('selected'); + } + }); + vscode.postMessage({ + command: 'submitAnswer', + askId: askId, + selected: [answer], + customInput: answer + }); + } + + function handleQuestionAnswerInSegment(askId, answer, segmentDiv) { + console.log('[WebView] 段落中用户选择答案:', askId, answer); + answeredQuestions.set(askId, answer); + segmentDiv.classList.add('answered'); + const options = segmentDiv.querySelectorAll('.question-option'); + options.forEach(opt => { + if (opt.getAttribute('data-option') === answer) { + opt.classList.add('selected'); + } + }); + const customContainer = segmentDiv.querySelector('.custom-input-container'); + if (customContainer) { + customContainer.style.display = 'none'; + } + vscode.postMessage({ + command: 'submitAnswer', + askId: askId, + selected: [answer], + customInput: answer + }); + } + + function handleMultiQuestionAnswer(askId, answers, segmentDiv) { + console.log('[WebView] 多问题答案提交:', askId, answers); + answeredQuestions.set(askId, answers); + segmentDiv.classList.add('answered'); + const inputs = segmentDiv.querySelectorAll('input'); + inputs.forEach(input => { + input.disabled = true; + if (input.checked) { + const label = input.closest('.question-option'); + if (label) { + label.classList.add('selected'); + } + } + }); + const submitBtn = segmentDiv.querySelector('.custom-submit'); + if (submitBtn) { + submitBtn.style.display = 'none'; + } + vscode.postMessage({ + command: 'submitAnswer', + askId: askId, + answers: answers + }); + } + + function showQuestion(askId, question, options) { + console.log('[WebView] showQuestion 被调用:', askId, question, options); + const div = document.createElement('div'); + div.className = 'message bot-message question-message'; + div.setAttribute('data-ask-id', askId); + const questionText = document.createElement('div'); + questionText.className = 'question-text'; + questionText.textContent = question; + div.appendChild(questionText); + const optionsContainer = document.createElement('div'); + optionsContainer.className = 'question-options'; + options.forEach((option, index) => { + const optionBtn = document.createElement('button'); + optionBtn.className = 'question-option'; + optionBtn.textContent = option; + optionBtn.onclick = () => handleQuestionAnswer(askId, option, div); + optionsContainer.appendChild(optionBtn); + }); + div.appendChild(optionsContainer); + const customContainer = document.createElement('div'); + customContainer.className = 'custom-input-container'; + const customInput = document.createElement('input'); + customInput.type = 'text'; + customInput.className = 'custom-input'; + customInput.placeholder = '输入其他答案...'; + const customSubmit = document.createElement('button'); + customSubmit.className = 'custom-submit'; + customSubmit.textContent = '提交'; + customSubmit.onclick = () => { + const customValue = customInput.value.trim(); + if (customValue) { + handleQuestionAnswer(askId, customValue, div); + } + }; + customContainer.appendChild(customInput); + customContainer.appendChild(customSubmit); + div.appendChild(customContainer); + messagesEl.appendChild(div); + smartScrollToBottom(); + checkHeaderVisibility(); + } + `; +} diff --git a/src/views/segmentRenderer.ts b/src/views/segmentRenderer.ts new file mode 100644 index 0000000..171aeef --- /dev/null +++ b/src/views/segmentRenderer.ts @@ -0,0 +1,272 @@ +/** + * 分段消息渲染脚本模块 + * 功能:实时更新分段消息、工具调用展示 + * 依赖:toolHelpers, textFormatter, waveformPreviewContent + * 使用场景:webview 中的分段消息渲染 + */ + +export function getSegmentRendererScript(): string { + return ` + function updateSegmentsRealtime(segments, isComplete) { + if (!isComplete && (!segments || segments.length === 0)) return; + + if (!currentSegmentedMessage) { + if (currentStreamingMessage) { + currentStreamingMessage.remove(); + currentStreamingMessage = null; + } + const toolStatuses = messagesEl.querySelectorAll('.tool-status'); + toolStatuses.forEach(el => el.remove()); + const lastSegmented = messagesEl.querySelector('.segmented-message:last-child'); + if (lastSegmented && !lastSegmented.querySelector('.message-actions')) { + currentSegmentedMessage = lastSegmented; + } else { + currentSegmentedMessage = document.createElement('div'); + currentSegmentedMessage.className = 'message bot-message segmented-message'; + messagesEl.appendChild(currentSegmentedMessage); + } + renderedSegmentCount = 0; + } + + if (currentSegmentedMessage) { + const toolHeaders = currentSegmentedMessage.querySelectorAll('.tool-segment-header[data-collapsible="true"]'); + toolHeaders.forEach((header, idx) => { + const isCollapsed = header.classList.contains('collapsed'); + toolCollapseStates.set(idx, isCollapsed); + }); + } + + if (!isComplete) { + currentSegmentedMessage.innerHTML = ''; + } + + const mergedSegments = []; + let i = 0; + while (i < (segments?.length || 0)) { + const segment = segments[i]; + if (segment.type === 'tool') { + let count = 1; + while (i + count < segments.length && + segments[i + count].type === 'tool' && + segments[i + count].toolName === segment.toolName) { + count++; + } + mergedSegments.push({ ...segment, toolCount: count }); + i += count; + } else { + mergedSegments.push(segment); + i++; + } + } + + let toolIndex = 0; + mergedSegments.forEach((segment, index) => { + const segmentDiv = document.createElement('div'); + segmentDiv.className = 'message-segment segment-' + segment.type; + + if (segment.type === 'text' && segment.content) { + segmentDiv.className += ' segment-text'; + segmentDiv.innerHTML = formatText(segment.content); + } else if (segment.type === 'tool') { + if (segment.toolName === 'spawnExplorer') return; + segmentDiv.className += ' low-profile'; + const toolResult = segment.toolResult || ''; + const toolCount = segment.toolCount || 1; + const countSuffix = toolCount > 1 ? \` x\${toolCount}\` : ''; + const toolDescription = segment.toolDescription || ''; + const shouldCollapse = toolResult && toolResult.length > 60; + const savedState = toolCollapseStates.get(toolIndex); + const isCollapsed = savedState !== undefined ? savedState : shouldCollapse; + const currentToolIndex = toolIndex; + toolIndex++; + + segmentDiv.innerHTML = \` +
    + \${shouldCollapse ? \`\${collapseIconSvg}\` : getToolIcon(segment.toolName)} + \${getToolDisplayName(segment.toolName) || '工具'}\${countSuffix} + \${toolResult && !shouldCollapse ? \`\${toolResult}\` : ''} +
    + \${shouldCollapse ? \`
    \${toolResult}
    \` : ''} + \${toolDescription ? \`

    \${toolDescription}

    \` : ''} + \`; + + if (segment.toolName === 'simulation' && segment.toolStatus === 'success') { + if (typeof createWaveformPreview === 'function') { + const vcdPaths = parseMultiVcdPaths(segment.toolResult); + if (vcdPaths.length > 0) { + vcdPaths.forEach(vcdInfo => { + const waveformPreview = createWaveformPreview(vcdInfo.path, vcdInfo.name); + segmentDiv.appendChild(waveformPreview); + }); + } else { + let vcdPath = segment.vcdFilePath; + if (!vcdPath && segment.toolResult) { + const match = String(segment.toolResult).match(/(?:路径\\s*[::]\\s*|已生成[::]\\s*)(.+\\.vcd)/); + if (match && match[1]) { + vcdPath = match[1].trim(); + } + } + if (vcdPath) { + const fileName = segment.fileName || vcdPath.split(/[\\\\\\/]/).pop() || 'waveform.vcd'; + const waveformPreview = createWaveformPreview(vcdPath, fileName); + segmentDiv.appendChild(waveformPreview); + } + } + } else { + console.warn('[VCD Preview] createWaveformPreview function not found'); + } + } + + if (shouldCollapse) { + setTimeout(() => { + const header = segmentDiv.querySelector('.tool-segment-header'); + const content = segmentDiv.querySelector('.tool-segment-content'); + if (header && content) { + header.addEventListener('click', function() { + const isCollapsed = header.classList.contains('collapsed'); + const toolIdx = parseInt(header.getAttribute('data-tool-index') || '0'); + if (isCollapsed) { + header.classList.remove('collapsed'); + content.classList.remove('collapsed'); + content.style.maxHeight = content.scrollHeight + 'px'; + toolCollapseStates.set(toolIdx, false); + } else { + header.classList.add('collapsed'); + content.classList.add('collapsed'); + content.style.maxHeight = '0'; + toolCollapseStates.set(toolIdx, true); + } + }); + } + }, 0); + } + } else if (segment.type === 'question') { + segmentDiv.className += ' segment-question'; + const questions = segment.questions || (segment.question ? [{ + question: segment.question, + options: segment.options || [], + multiSelect: false + }] : []); + const isAnswered = answeredQuestions.has(segment.askId); + const savedAnswers = answeredQuestions.get(segment.askId) || {}; + if (isAnswered) { + segmentDiv.classList.add('answered'); + } + + const questionsHtml = questions.map((q, qIndex) => { + const inputType = q.multiSelect ? 'checkbox' : 'radio'; + const inputName = \`q\${qIndex}\`; + const selectedAnswers = savedAnswers[qIndex] || []; + let optionsHtml; + if (!q.options || q.options.length === 0) { + const savedText = selectedAnswers[0] || ''; + optionsHtml = \`\`; + } else { + optionsHtml = q.options.map(opt => { + const isSelected = selectedAnswers.includes(opt); + return \`\`; + }).join(''); + } + return \` +
    +
    \${formatText(q.question)}
    +
    \${optionsHtml}
    +
    + \`; + }).join(''); + + segmentDiv.innerHTML = \` + \${questionsHtml} + + \`; + + if (!isAnswered) { + setTimeout(() => { + const submitBtn = segmentDiv.querySelector('.custom-submit'); + if (submitBtn) { + submitBtn.addEventListener('click', function() { + const answers = {}; + questions.forEach((q, qIndex) => { + const textarea = segmentDiv.querySelector(\`textarea[name="q\${qIndex}"]\`); + if (textarea) { + const value = textarea.value.trim(); + answers[qIndex] = value ? [value] : []; + } else { + const inputs = segmentDiv.querySelectorAll(\`input[name="q\${qIndex}"]:checked\`); + answers[qIndex] = Array.from(inputs).map(input => input.value); + } + }); + handleMultiQuestionAnswer(segment.askId, answers, segmentDiv); + }); + } + }, 0); + } + } else if (segment.type === 'agent') { + renderAgentCard(segment, segmentDiv); + } else if (segment.type === 'plan') { + renderPlanCardInSegment(segment, segmentDiv, answeredQuestions); + } + + currentSegmentedMessage.appendChild(segmentDiv); + }); + + if (isComplete) { + currentSegmentedMessage = null; + } + + + smartScrollToBottom(); + } + + function renderSegments(segments) { + console.log('[WebView] renderSegments 被调用, segments:', segments); + if (!segments || segments.length === 0) { + console.log('[WebView] segments 为空,跳过渲染'); + return; + } + if (currentStreamingMessage) { + console.log('[WebView] 移除流式消息'); + currentStreamingMessage.remove(); + currentStreamingMessage = null; + } + const toolStatuses = messagesEl.querySelectorAll('.tool-status'); + console.log('[WebView] 找到工具状态消息数量:', toolStatuses.length); + toolStatuses.forEach(el => { + console.log('[WebView] 移除工具状态消息:', el.className); + el.remove(); + }); + updateSegmentsRealtime(segments, false); + + // 历史消息渲染完成后添加操作按钮 + if (currentSegmentedMessage) { + const actionsDiv = document.createElement('div'); + actionsDiv.className = 'message-actions'; + const copyBtn = document.createElement('button'); + copyBtn.className = 'action-btn'; + copyBtn.innerHTML = \`复制\`; + copyBtn.onclick = () => { + const textContent = segments.filter(s => s.type === 'text' && s.content).map(s => s.content).join('\\n'); + copyMessage(textContent, copyBtn); + }; + const likeBtn = document.createElement('button'); + likeBtn.className = 'action-btn'; + likeBtn.innerHTML = \`点赞\`; + likeBtn.onclick = () => toggleLike(likeBtn); + const dislikeBtn = document.createElement('button'); + dislikeBtn.className = 'action-btn'; + dislikeBtn.innerHTML = \`点踩\`; + dislikeBtn.onclick = () => toggleDislike(dislikeBtn); + actionsDiv.appendChild(copyBtn); + actionsDiv.appendChild(likeBtn); + actionsDiv.appendChild(dislikeBtn); + currentSegmentedMessage.appendChild(actionsDiv); + currentSegmentedMessage = null; + } + + smartScrollToBottom(); + } + `; +} diff --git a/src/views/textFormatter.ts b/src/views/textFormatter.ts new file mode 100644 index 0000000..9c0daee --- /dev/null +++ b/src/views/textFormatter.ts @@ -0,0 +1,56 @@ +/** + * 文本格式化模块 + * 功能:Markdown 文本转 HTML + * 依赖:无 + * 使用场景:消息内容格式化显示 + */ + +export function formatText(text: string): string { + if (!text) return ""; + + let html = text; + + const codeBlocks: string[] = []; + html = html.replace(/```(\w+)?\n([\s\S]*?)```/g, (match, lang, code) => { + const language = lang || "plaintext"; + const escapedCode = code + .trim() + .replace(/&/g, "&") + .replace(//g, ">"); + const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`; + codeBlocks.push(`
    ${escapedCode}
    `); + return placeholder; + }); + + const inlineCodes: string[] = []; + html = html.replace(/`([^`]+)`/g, (match, code) => { + const escapedCode = code.replace(/&/g, "&").replace(//g, ">"); + const placeholder = `___INLINE_CODE_${inlineCodes.length}___`; + inlineCodes.push(`${escapedCode}`); + return placeholder; + }); + + html = html.replace(/&/g, "&").replace(//g, ">"); + + html = html.replace(/^### (.+)$/gm, "

    $1

    "); + html = html.replace(/^## (.+)$/gm, "

    $1

    "); + html = html.replace(/^# (.+)$/gm, "

    $1

    "); + html = html.replace(/\*\*(.+?)\*\*/g, "$1"); + html = html.replace(/\*(.+?)\*/g, "$1"); + html = html.replace(/^[\-\*] (.+)$/gm, "
  • $1
  • "); + html = html.replace(/(
  • .*<\/li>\n?)+/g, "
      $&
    "); + html = html.replace(/^\d+\. (.+)$/gm, "
  • $1
  • "); + html = html.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '$1'); + html = html.replace(/\n/g, "
    "); + + codeBlocks.forEach((block, index) => { + html = html.replace(`___CODE_BLOCK_${index}___`, block); + }); + + inlineCodes.forEach((code, index) => { + html = html.replace(`___INLINE_CODE_${index}___`, code); + }); + + return html; +} diff --git a/src/views/toolHelpers.ts b/src/views/toolHelpers.ts new file mode 100644 index 0000000..bc0de77 --- /dev/null +++ b/src/views/toolHelpers.ts @@ -0,0 +1,106 @@ +/** + * 工具辅助函数模块 + * 功能:工具图标、名称映射、VCD 路径解析 + * 依赖:toolIcons + * 使用场景:工具调用显示 + */ + +import { + fileWriteIconSvg, + fileReadIconSvg, + fileDeleteIconSvg, + syntaxCheckIconSvg, + SearchCode, + saveKnowledgeIconSvg, + simulationIconSvg, + waveformIconSvg, + knowledgeLoadIconSvg, + stateTransitionIconSvg, + userQuestionIconSvg, + updateStageIconSvg, + successIconSvg, +} from "../constants/toolIcons"; + +export function getToolIcon(toolName: string): string { + const iconMap: Record = { + file_read: fileReadIconSvg, + file_write: fileWriteIconSvg, + file_delete: fileDeleteIconSvg, + file_list: SearchCode, + syntax_check: syntaxCheckIconSvg, + simulation: simulationIconSvg, + waveform_summary: waveformIconSvg, + knowledge_save: saveKnowledgeIconSvg, + knowledge_load: knowledgeLoadIconSvg, + queryKnowledgeSummary: knowledgeLoadIconSvg, + queryRules: knowledgeLoadIconSvg, + setModule: fileWriteIconSvg, + addSignal: fileWriteIconSvg, + addSignalExample: fileWriteIconSvg, + validateKnowledgeGraph: syntaxCheckIconSvg, + querySignals: SearchCode, + addPlan: fileWriteIconSvg, + addEdge: fileWriteIconSvg, + showPlan: SearchCode, + addRule: fileWriteIconSvg, + updateNode: fileWriteIconSvg, + addStateTransition: stateTransitionIconSvg, + askUser: userQuestionIconSvg, + updatePhase: updateStageIconSvg, + iverilog: successIconSvg, + }; + return iconMap[toolName] || ""; +} + +export function getToolDisplayName(toolName: string): string { + const toolNameMap: Record = { + file_read: "已完成文件读取", + file_write: "已完成文件写入", + file_delete: "已完成文件删除", + file_list: "已检索代码文件", + syntax_check: "已完成语法检查", + simulation: "已完成仿真", + waveform_summary: "已完成波形分析", + knowledge_save: "已保存知识库", + knowledge_load: "已加载知识库", + queryKnowledgeSummary: "已查询知识摘要", + queryRules: "已查询规则", + setModule: "已设置模块", + addSignal: "信号分析完成", + addSignalExample: "信号示例处理完成", + validateKnowledgeGraph: "已验证知识图谱", + querySignals: "已查询信号", + addPlan: "已添加计划", + addEdge: "已添加边", + showPlan: "已显示计划", + addRule: "已添加规则", + updateNode: "已更新节点", + addStateTransition: "已添加状态转换", + spawnExplorer: "代码探索", + spawnDebugger: "波形调试", + askUser: "用户提问", + updatePhase: "已更新阶段", + iverilog: "已完成编译", + }; + return toolNameMap[toolName] || toolName; +} + +export function parseMultiVcdPaths(toolResult: string): Array<{ name: string; path: string }> { + if (!toolResult) return []; + const result = String(toolResult); + + const vcdListMatch = result.match(/VCD 文件列表:[\s\S]*?(?=\n\n|$)/); + if (!vcdListMatch) return []; + + const paths: Array<{ name: string; path: string }> = []; + const lineRegex = /- (\w+): ([^\n]+)/g; + let match; + while ((match = lineRegex.exec(vcdListMatch[0])) !== null) { + const name = match[1]; + const pathOrError = match[2].trim(); + if (!pathOrError.startsWith("失败")) { + paths.push({ name: name + ".vcd", path: pathOrError }); + } + } + return paths; +} diff --git a/src/views/webviewContent.ts b/src/views/webviewContent.ts index 05c4ac3..6c8ca49 100644 --- a/src/views/webviewContent.ts +++ b/src/views/webviewContent.ts @@ -25,6 +25,7 @@ import { } from "./progressBar"; import { getHighlightJsLinks } from "../components/codeHighlight"; import { getCurrentEnv } from "../config/settings"; +import { taskCompleteIconSvg } from "../constants/toolIcons"; import { getInvitationModalContent, getInvitationModalStyles, @@ -304,7 +305,9 @@ export function getWebviewContent( } .segment-text { line-height: 1.6; - font-size:0.9rem + font-size:0.9rem; + color: var(--vscode-foreground); + letter-spacing: 0.5px; } .segment-tool { background: var(--vscode-textBlockQuote-background); @@ -375,16 +378,32 @@ export function getWebviewContent( font-size: 13px; color: var(--vscode-descriptionForeground); } + .status-bar #statusText { + background: linear-gradient(90deg, + var(--vscode-descriptionForeground) 0%, + var(--vscode-foreground) 50%, + var(--vscode-descriptionForeground) 100%); + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: textShimmer 2s linear infinite; + } + @keyframes textShimmer { + 0% { background-position: 200% 0; } + 100% { background-position: -200% 0; } + } .status-indicator { width: 8px; height: 8px; border-radius: 50%; background: var(--vscode-charts-blue); animation: statusPulse 1.5s ease-in-out infinite; + box-shadow: 0 0 8px currentColor; } @keyframes statusPulse { - 0%, 100% { opacity: 1; transform: scale(1); } - 50% { opacity: 0.5; transform: scale(0.8); } + 0%, 100% { opacity: 1; transform: scale(1.2); } + 50% { opacity: 0.3; transform: scale(0.6); } } .status-bar.working .status-indicator { background: var(--vscode-charts-orange); @@ -529,6 +548,9 @@ export function getWebviewContent( const modeSelect = document.getElementById('modeSelect'); const messagesEl = document.getElementById('messages'); + // 图标常量 + const taskCompleteIconSvg = ${JSON.stringify(taskCompleteIconSvg)}; + // 全局变量 let currentStreamingMessage = null; let loadingIndicator = null; @@ -760,6 +782,45 @@ export function getWebviewContent( // 隐藏加载指示器 hideLoadingIndicator(); break; + case 'taskComplete': + // 显示任务完成提示 + const taskDiv = document.createElement('div'); + taskDiv.className = 'message bot-message'; + const taskActionsDiv = document.createElement('div'); + taskActionsDiv.className = 'message-actions'; + const taskMessageContent = document.createElement('span'); + taskMessageContent.innerHTML = taskCompleteIconSvg + ' 任务完成'; + const taskCopyBtn = document.createElement('button'); + taskCopyBtn.className = 'action-btn'; + taskCopyBtn.innerHTML = \`复制\`; + taskCopyBtn.onclick = () => { + // 获取前一个 AI 消息的内容 + const prevMessage = taskDiv.previousElementSibling; + if (prevMessage && prevMessage.classList.contains('bot-message')) { + const textContent = prevMessage.textContent || ''; + copyMessage(textContent, taskCopyBtn); + } + }; + const taskLikeBtn = document.createElement('button'); + taskLikeBtn.className = 'action-btn'; + taskLikeBtn.innerHTML = \`点赞\`; + taskLikeBtn.onclick = () => toggleLike(taskLikeBtn); + const taskDislikeBtn = document.createElement('button'); + taskDislikeBtn.className = 'action-btn'; + taskDislikeBtn.innerHTML = \`点踩\`; + taskDislikeBtn.onclick = () => toggleDislike(taskDislikeBtn); + taskActionsDiv.appendChild(taskMessageContent); + taskActionsDiv.appendChild(taskCopyBtn); + taskActionsDiv.appendChild(taskLikeBtn); + taskActionsDiv.appendChild(taskDislikeBtn); + taskDiv.appendChild(taskActionsDiv); + messagesEl.appendChild(taskDiv); + messagesEl.scrollTop = messagesEl.scrollHeight; + break; + + case 'taskCompleteHistory': + // 历史记录不显示任务完成提示 + break; case 'workspaceStatus': // 更新工作区状态