13 Commits

Author SHA1 Message Date
ccbc40f5fb refactor: 优化 Vivado 配置检测逻辑
- 使用环境变量中的 vivado 命令替代磁盘路径搜索
   - 修改验证逻辑,支持环境变量命令
   - 简化自动检测函数实现
2026-03-17 10:20:46 +08:00
5adde3d40a feat: 实现 Vivado 综合功能并修正文档
- 实现 runVivadoSynthesis 工具,支持工程模式和无工程模式
   - 新增 generateSynthesisTcl 生成综合 TCL 脚本
   - 修正文档:明确约束文件依赖(实现阶段必需,综合阶段可选)
   - 补充生成比特流的核心依赖说明
2026-03-17 09:34:39 +08:00
fa5c2cdafd feat: 实现 Vivado 创建工程功能
- 添加 createVivadoProject 工具
   - 实现 Vivado 自动检测(支持所有盘符和版本)
   - 添加 TCL 脚本生成器
   - 添加配置管理模块
   - 添加测试命令
2026-03-16 17:46:25 +08:00
aa80088abc docs: 完善 Vivado 联动文档
- 添加后端工具调用控制前端的详细说明
   - 新增 mode 参数(batch/gui)支持批处理和图形界面模式
   - 补充参数询问流程和验证规则
   - 添加完整实现示例:生成比特流和布局布线
   - 更新所有调用示例包含必需参数
2026-03-16 14:05:34 +08:00
0ae627ca7c style: 优化消息样式配置 2026-03-12 18:58:53 +08:00
7732b11d37 style: 优化文本样式和可读性
- 统一使用 VSCode 主题颜色变量
   - 添加字母间距提升可读性
   - 优化工具段落和问题选项的文本显示
2026-03-12 18:40:40 +08:00
81717dc84f docs: 添加 Vivado 联动功能文档
- 添加 EDA 联动功能需求文档
   - 添加 Vivado 联动前后端对接文档
   - 添加 Vivado 联动功能技术设计文档
2026-03-12 18:01:31 +08:00
11c408ce0f feat: 优化消息操作按钮显示
- 添加任务完成图标和状态提示
   - 消息操作按钮改为内联显示
   - 优化复制功能获取消息内容
2026-03-12 18:00:25 +08:00
c138406217 refactor: 重构消息区域模块化架构
- 将 messageArea.ts 拆分为多个独立模块
   - 新增 messageRenderer.ts:消息渲染逻辑
   - 新增 messageStyles.ts:样式定义
   - 新增 questionHandler.ts:问题处理
   - 新增 segmentRenderer.ts:分段渲染
   - 新增 textFormatter.ts:文本格式化
   - 新增 toolHelpers.ts:工具辅助函数
2026-03-12 15:46:18 +08:00
2a280aaa93 refactor: 优化 ICHelperPanel 组件结构
- 将 1346 行的单文件拆分为 7 个职责单一的模块
   - authHelper: 认证和登录检查
   - userInfoHelper: 用户信息管理
   - conversationHelper: 会话历史加载
   - vcdHelper: VCD 文件处理
   - contextHelper: 上下文管理
   - fileHelper: 文件操作
   - messageRouter: 消息路由分发
   - 主组件精简至 157 行,提升可维护性
2026-03-12 11:58:43 +08:00
2f6eae9f2b fix: 优化代码选择提示位置 - 将提示显示在选区末尾行的行尾 2026-03-11 18:42:02 +08:00
d0ff876ba2 fix: AI提问时支持文本输入框
- 当options为空时显示textarea让用户自由输入答案
2026-03-11 11:14:17 +08:00
7fe87e515b feat:新增对话结束后添加结束语句 2026-03-10 20:53:13 +08:00
26 changed files with 4152 additions and 2996 deletions

View File

@ -0,0 +1,526 @@
# 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 命令工具,不控制流程
- **后端AI控制流程**所有执行顺序、依赖检查由后端AI决策
- **工具职责单一**:每个工具只负责执行一个具体命令
- **结果透明返回**:执行结果完整返回给后端,由后端决定下一步
### 2.2 设计原则
- 前端不做流程判断,只执行命令
- 前端不检查依赖关系,由后端保证顺序
- 前端返回详细的执行结果,包括成功/失败、输出、报告等
- 后端AI根据结果智能决策是否继续
## 3. 功能详细需求
### 3.1 前端提供的工具
前端提供 4 个独立的工具,每个工具只负责执行一个命令:
#### 3.1.1 createVivadoProject - 创建工程
- **输入**:项目名称、芯片型号、源文件列表、约束文件(可选)
- **输出**:工程文件(.xpr
- **说明**:创建 Vivado 工程,不执行任何构建操作
#### 3.1.2 runVivadoSynthesis - 综合
- **输入**:工程路径或源文件、芯片型号、顶层模块、约束文件(可选)
- **输出**.dcp 文件、综合报告
- **说明**:执行综合,前端不检查工程是否存在。约束文件在此阶段可选,主要用于时序约束
#### 3.1.3 runVivadoImplementation - 实现
- **输入**:综合后的 .dcp 文件路径、约束文件(必需,包含管脚约束)
- **输出**:实现后的 .dcp 文件、时序报告
- **说明**:执行实现,前端不检查 .dcp 是否存在。**管脚约束是必需的**,否则无法完成布局布线
#### 3.1.4 runVivadoBitstream - 生成比特流
- **输入**:实现后的 .dcp 文件路径
- **输出**.bit 文件(可下载到 FPGA 的配置文件)
- **核心依赖**
1. 实现已完成
2. 工程指定目标芯片型号
3. 已完成管脚约束(无管脚约束无法生成)
- **说明**:生成比特流,前端不检查 .dcp 是否存在
### 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 VivadoToolResponse {
success: boolean; // 是否成功
command: string; // 执行的命令
executionTime: number; // 执行时间(毫秒)
output: string; // 完整输出日志
error?: string; // 错误信息(如果失败)
outputFiles?: string[]; // 产出文件路径列表
reports?: {
resources?: string; // 资源使用摘要
timing?: string; // 时序信息摘要
};
}
```
#### 3.3.2 各工具的参数定义
**createVivadoProject**
```typescript
{
projectName: string; // 项目名称
part: string; // 芯片型号
topModule: string; // 顶层模块
files: string[]; // 源文件列表
constraints?: string; // 约束文件(可选)
mode: 'gui' | 'batch'; // 执行模式
}
```
**runVivadoSynthesis**
```typescript
{
projectPath?: string; // 工程路径(可选,如果有工程)
part: string; // 芯片型号
topModule: string; // 顶层模块
files?: string[]; // 源文件(如果没有工程)
constraints?: string; // 约束文件(可选)
mode: 'gui' | 'batch'; // 执行模式
}
```
**runVivadoImplementation**
```typescript
{
dcpFile: string; // 综合后的 .dcp 文件路径
constraints: string; // 约束文件(必需,包含管脚约束)
mode: 'gui' | 'batch'; // 执行模式
}
```
**runVivadoBitstream**
```typescript
{
dcpFile: string; // 实现后的 .dcp 文件路径
mode: 'gui' | 'batch'; // 执行模式
}
```
### 3.4 后端AI的职责
后端AI负责
1. 询问用户必要参数(芯片型号、执行模式等)
2. 理解用户意图,决定调用哪些工具
3. 按正确顺序调用工具(遵循依赖关系)
4. 检查每步执行结果,决定是否继续
5. 汇总结果并展示给用户
#### 3.4.1 询问用户参数
后端必须询问:
- **芯片型号**(必需):"请提供 FPGA 芯片型号例如xc7a35tcpg236-1"
- **执行模式**(必需):"选择执行模式1) 图形化 2) 后端执行"
- **约束文件**(必需):"请提供约束文件(.xdc包含管脚约束和时序约束"
#### 3.4.2 理解依赖关系
后端AI需要理解
```
创建工程 → 综合 → 实现 → 生成比特流
```
如果用户说"做实现",后端应该:
1. 先调用 `createVivadoProject` 创建工程
2. 再调用 `runVivadoSynthesis` 执行综合
3. 最后调用 `runVivadoImplementation` 执行实现
#### 3.4.3 逐步调用工具
```
步骤1: 调用 createVivadoProject
检查 response.success
如果失败 → 停止并报错
步骤2: 调用 runVivadoSynthesis
检查 response.success
如果失败 → 停止并报错
步骤3: 调用 runVivadoImplementation
检查 response.success
返回最终结果
```
### 3.5 UI 交互
#### 3.5.1 配置界面
- 在设置页面添加 "Vivado 配置" 选项
- 支持配置 Vivado 路径、FPGA 型号
- 支持测试 Vivado 可用性(点击按钮测试)
#### 3.5.2 调用界面
- 在聊天面板中AI 可以建议使用 Vivado
- 用户确认后,显示执行进度对话框
- 实时显示日志输出(可折叠)
- 显示执行状态:准备中 → 执行中 → 完成/失败
#### 3.5.3 结果展示
- 执行成功:显示执行时间、资源使用、时序信息
- 执行失败:显示错误信息、建议解决方案
- 导入文件:高亮显示已导入的文件,支持点击打开报告
### 3.6 后端集成
#### 3.6.1 工具定义
后端注册 4 个独立工具:
```json
{
"name": "createVivadoProject",
"description": "创建 Vivado 工程。需要先询问用户芯片型号和执行模式。",
"parameters": {
"projectName": "项目名称",
"part": "芯片型号(必须从用户获取)",
"topModule": "顶层模块名",
"files": "源文件列表",
"constraints": "约束文件(可选)",
"mode": "执行模式gui/batch"
}
},
{
"name": "runVivadoSynthesis",
"description": "执行 Vivado 综合。前端不检查依赖,后端需确保工程已创建。",
"parameters": {
"projectPath": "工程路径(可选)",
"part": "芯片型号",
"topModule": "顶层模块",
"files": "源文件(如果没有工程)",
"constraints": "约束文件(可选)",
"mode": "执行模式gui/batch"
}
},
{
"name": "runVivadoImplementation",
"description": "执行 Vivado 实现。前端不检查依赖,后端需确保综合已完成且提供约束文件。",
"parameters": {
"dcpFile": "综合后的 .dcp 文件路径",
"constraints": "约束文件(必需,包含管脚约束)",
"mode": "执行模式gui/batch"
}
},
{
"name": "runVivadoBitstream",
"description": "生成比特流。前端不检查依赖,后端需确保实现已完成。",
"parameters": {
"dcpFile": "实现后的 .dcp 文件路径",
"mode": "执行模式gui/batch"
}
}
```
#### 3.6.2 后端调用示例
**场景:用户要求完整流程**
```
用户: 用 Vivado 跑完整流程
AI: 请提供芯片型号和执行模式
用户: xc7a35tcpg236-1后端执行
AI 执行:
1. [调用] createVivadoProject({ projectName: "counter", part: "xc7a35tcpg236-1", ... })
[结果] { success: true, outputFiles: ["counter.xpr"] }
2. [调用] runVivadoSynthesis({ projectPath: "counter.xpr", ... })
[结果] { success: true, outputFiles: ["counter_synth.dcp"], reports: {...} }
3. [调用] runVivadoImplementation({ dcpFile: "counter_synth.dcp", constraints: "counter.xdc", ... })
[结果] { success: true, outputFiles: ["counter_impl.dcp"], reports: {...} }
4. [调用] runVivadoBitstream({ dcpFile: "counter_impl.dcp", ... })
[结果] { success: true, outputFiles: ["counter.bit"] }
AI 回复: 完成!已生成 counter.bit 文件
```
## 4. 用户场景
### 4.1 场景一:完整流程执行
**用户输入**"用 Vivado 跑完整个 FPGA 流程"
**AI 交互**
```
AI: 好的,我将使用 Vivado 执行完整的 FPGA 开发流程。请提供以下信息:
1. FPGA 芯片型号例如xc7a35tcpg236-1
2. 请选择要执行的步骤(可多选):
- 综合Synthesis
- 实现Implementation
- 生成比特流Bitstream
3. 执行模式:
- 图形化模式:打开 Vivado GUI
- 后端执行:自动在后台执行
4. 约束文件路径(.xdc必需
用户: xc7a35tcpg236-1全部步骤后端执行约束文件是 counter.xdc
AI: 收到!执行计划:
1. 创建工程
2. 综合
3. 实现
4. 生成比特流
开始执行...
[步骤 1/4] 创建工程...
✓ 工程创建成功(耗时 2s
[步骤 2/4] 综合中...
✓ 综合完成(耗时 45s
资源使用LUT 32/20800 (0.15%), FF 8/41600 (0.02%)
[步骤 3/4] 实现中...
✓ 实现完成(耗时 120s
时序WNS 5.234ns, TNS 0.000ns(满足要求)
[步骤 4/4] 生成比特流...
✓ 比特流生成完成(耗时 15s
所有步骤执行完毕!产出文件已导入到 vivado_output/ 目录。
```
### 4.2 场景二:只执行实现(自动补充依赖)
**用户输入**"用 Vivado 做实现"
**AI 交互**
```
AI: 请提供以下信息:
1. FPGA 芯片型号
2. 执行模式(图形化/后端执行)
用户: xc7a35tcpg236-1后端执行
AI: 注意:实现依赖综合,我将自动执行以下步骤:
1. 创建工程
2. 综合(前置依赖)
3. 实现(您选择的步骤)
是否继续?
用户: 是
AI: 开始执行...
[自动执行创建工程 → 综合 → 实现]
```
### 4.3 场景三:图形化模式
**用户输入**"打开 Vivado 图形界面做综合"
**AI 交互**
```
AI: 请提供芯片型号:
用户: xc7a35tcpg236-1
AI: 正在创建工程并打开 Vivado GUI...
✓ 工程已创建
✓ Vivado 图形界面已启动
您现在可以在 Vivado 中手动操作。完成后,产出文件将保存在工程目录中。
```
### 4.4 场景四:执行中断处理
**执行过程**
```
[步骤 1/3] 创建工程...
✓ 成功
[步骤 2/3] 综合中...
✗ 综合失败
错误ERROR: [Synth 8-439] module 'counter' not found
AI: 综合失败,发现以下问题:
- 找不到模块 'counter'
建议检查:
1. 模块名是否正确
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 许可证
- **需要提供 .xdc 约束文件**
- **管脚约束**必需定义信号与 FPGA 引脚的映射关系实现阶段必须提供
- **时序约束**强烈推荐定义时钟频率和时序要求确保设计满足性能指标
## 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布局布线
- **比特流**BitstreamFPGA 配置文件
- **DCP**Design CheckpointVivado 设计检查点文件
- **XDC**Xilinx Design Constraints约束文件
- **LUT**Look-Up Table查找表FPGA 基本逻辑单元
- **FF**Flip-Flop触发器

View File

@ -0,0 +1,166 @@
# Vivado 联动前后端对接文档
## 1. 前端提供的工具
前端提供 4 个独立工具,每个工具执行一个 Vivado 命令。
### 1.1 createVivadoProject - 创建工程
**参数**
```typescript
{
projectName: string; // 项目名称
part: string; // 芯片型号(如 xc7a35tcpg236-1
topModule: string; // 顶层模块名
files: string[]; // 源文件路径列表
constraints?: string; // 约束文件路径(可选)
mode: 'gui' | 'batch'; // gui=打开图形界面batch=后台执行
}
```
**返回**
```typescript
{
success: boolean; // 是否成功
command: "create_project";
executionTime: number; // 执行时间(毫秒)
output: string; // 完整日志
error?: string; // 失败原因(如果失败)
outputFiles?: string[]; // 产出的工程文件路径
}
```
### 1.2 runVivadoSynthesis - 综合
**参数**
```typescript
{
projectPath?: string; // 工程路径(可选)
part: string; // 芯片型号
topModule: string; // 顶层模块
files?: string[]; // 源文件(如果没有工程)
constraints?: string; // 约束文件(可选)
mode: 'gui' | 'batch';
}
```
**返回**
```typescript
{
success: boolean;
command: "synthesis";
executionTime: number;
output: string;
error?: string; // 失败原因
outputFiles?: string[]; // .dcp 文件等
reports?: {
resources?: string; // 资源使用摘要
timing?: string; // 时序摘要
};
}
```
### 1.3 runVivadoImplementation - 实现
**参数**
```typescript
{
dcpFile: string; // 综合后的 .dcp 文件路径
mode: 'gui' | 'batch';
}
```
**返回**
```typescript
{
success: boolean;
command: "implementation";
executionTime: number;
output: string;
error?: string; // 失败原因
outputFiles?: string[]; // 实现后的 .dcp 文件等
reports?: {
resources?: string;
timing?: string;
};
}
```
### 1.4 runVivadoBitstream - 生成比特流
**参数**
```typescript
{
dcpFile: string; // 实现后的 .dcp 文件路径
mode: 'gui' | 'batch';
}
```
**返回**
```typescript
{
success: boolean;
command: "bitstream";
executionTime: number;
output: string;
error?: string; // 失败原因
outputFiles?: string[]; // .bit 文件路径
}
```
## 2. 前端职责
- 接收后端工具调用
- 生成对应的 TCL 脚本
- 执行 Vivado 命令
- 捕获输出日志
- 解析报告文件(提取资源和时序摘要)
- 返回执行结果
**前端不做**
- 不检查依赖关系
- 不验证执行顺序
- 不控制流程
## 3. 后端职责
- 询问用户参数(芯片型号、执行模式等)
- 理解依赖关系(创建工程 → 综合 → 实现 → 生成比特流)
- 按正确顺序调用工具
- 检查每步的 `success` 字段
- 如果失败,读取 `error` 字段并提示用户
- 汇总结果展示给用户
## 4. 调用示例
```
用户: 用 Vivado 做综合
后端:
1. 询问芯片型号 → xc7a35tcpg236-1
2. 询问执行模式 → batch
3. 调用 createVivadoProject(...)
返回: { success: true, outputFiles: ["counter.xpr"] }
4. 调用 runVivadoSynthesis(...)
返回: { success: true, outputFiles: ["counter_synth.dcp"], reports: {...} }
5. 展示结果给用户
```
## 5. 错误处理
如果某步失败:
```typescript
{
success: false,
error: "ERROR: [Synth 8-439] module 'counter' not found",
output: "详细日志..."
}
```
后端应该:
1. 停止后续步骤
2. 提取 `error` 字段
3. 给用户提示和建议

View File

@ -0,0 +1,153 @@
# Vivado 联动功能技术设计文档
## 1. 架构设计
```
后端 AI
↓ 调用工具
前端 Extension (messageHandler.ts)
↓ 调用
VivadoRunner (utils/vivadoRunner.ts)
↓ 生成 TCL 脚本并执行
本地 Vivado
```
## 2. 核心模块
### 2.1 VivadoRunner
**职责**:执行单个 Vivado 命令
**主要方法**
- `createProject()` - 创建工程
- `runSynthesis()` - 执行综合
- `runImplementation()` - 执行实现
- `runBitstream()` - 生成比特流
**实现要点**
- 根据参数生成 TCL 脚本
- 启动子进程执行 Vivado
- 捕获输出日志
- 解析报告文件
- 返回结果
### 2.2 TCL 脚本生成
**创建工程**
```tcl
create_project {projectName} {workDir} -part {part} -force
add_files -norecurse {files}
set_property top {topModule} [current_fileset]
```
**综合**
```tcl
synth_design -part {part} -top {topModule}
report_utilization -file utilization.rpt
write_checkpoint -force synth.dcp
```
**实现**
```tcl
open_checkpoint {dcpFile}
opt_design
place_design
route_design
report_timing_summary -file timing.rpt
write_checkpoint -force impl.dcp
```
**生成比特流**
```tcl
open_checkpoint {dcpFile}
write_bitstream -force output.bit
```
### 2.3 MessageHandler 集成
```typescript
// 处理工具调用
async function handleToolCall(toolName: string, params: any) {
switch(toolName) {
case 'createVivadoProject':
return await vivadoRunner.createProject(params);
case 'runVivadoSynthesis':
return await vivadoRunner.runSynthesis(params);
case 'runVivadoImplementation':
return await vivadoRunner.runImplementation(params);
case 'runVivadoBitstream':
return await vivadoRunner.runBitstream(params);
}
}
```
## 3. 配置管理
**配置文件位置**
- 全局:`settings.json` 中的 `ic-coder.vivado`
- 项目:`.vscode/ic-coder-vivado.json`
**配置项**
```json
{
"vivado": {
"enabled": true,
"executablePath": "C:/Xilinx/Vivado/2023.1/bin/vivado.bat",
"workingDir": "${workspaceFolder}/vivado_project"
}
}
```
## 4. 文件结构
```
src/
├── utils/
│ ├── vivadoRunner.ts # Vivado 执行器
│ ├── vivadoConfig.ts # 配置读取
│ └── tclGenerator.ts # TCL 脚本生成
└── utils/
└── messageHandler.ts # 工具调用处理(新增部分)
```
## 5. 实现要点
### 5.1 子进程执行
```typescript
const process = spawn(vivadoPath, ['-mode', 'batch', '-source', tclPath]);
process.stdout.on('data', (data) => {
output += data.toString();
});
process.on('close', (code) => {
resolve({ success: code === 0, output });
});
```
### 5.2 报告解析
`.rpt` 文件中提取关键信息:
- 资源使用LUT、FF、BRAM 数量
- 时序信息WNS、TNS
### 5.3 错误处理
捕获常见错误:
- Vivado 未配置
- 文件不存在
- 综合/实现失败
- 时序不满足
返回清晰的错误信息给后端。
## 6. 测试
**单元测试**
- TCL 脚本生成正确性
- 配置读取
**集成测试**
- 完整流程测试(需要本地 Vivado
- 错误处理测试

View File

@ -200,3 +200,8 @@ export const setting = `<svg t="1768535209135" class="icon" viewBox="0 0 1024 10
* 成功的图标svg * 成功的图标svg
*/ */
export const successIconSvg = `<svg t="1771828214449" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5360" width="16" height="16"><path d="M748.864 302.528a49.024 49.024 0 0 1 68.288-1.28 46.656 46.656 0 0 1 5.952 61.44l-4.608 5.44-346.56 353.344a49.024 49.024 0 0 1-64.384 4.608l-5.504-4.864-196.8-203.264a46.72 46.72 0 0 1 1.792-66.88A49.024 49.024 0 0 1 270.08 448l5.312 4.736 162.048 167.232L748.8 302.528z" fill="#8a8a8a" p-id="5361"></path></svg>`; export const successIconSvg = `<svg t="1771828214449" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5360" width="16" height="16"><path d="M748.864 302.528a49.024 49.024 0 0 1 68.288-1.28 46.656 46.656 0 0 1 5.952 61.44l-4.608 5.44-346.56 353.344a49.024 49.024 0 0 1-64.384 4.608l-5.504-4.864-196.8-203.264a46.72 46.72 0 0 1 1.792-66.88A49.024 49.024 0 0 1 270.08 448l5.312 4.736 162.048 167.232L748.8 302.528z" fill="#8a8a8a" p-id="5361"></path></svg>`;
/**
* 任务完成的图标svg
*/
export const taskCompleteIconSvg = `<svg t="1773302386044" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4798" width="16" height="16"><path d="M512 42.666667C253.866667 42.666667 42.666667 253.866667 42.666667 512s211.2 469.333333 469.333333 469.333333 469.333333-211.2 469.333333-469.333333S770.133333 42.666667 512 42.666667z m221.866667 377.6L488.533333 663.466667c-8.533333 8.533333-19.2 12.8-29.866666 12.8s-21.333333-4.266667-29.866667-12.8l-138.666667-138.666667c-17.066667-17.066667-17.066667-42.666667 0-59.733333 17.066667-17.066667 42.666667-17.066667 59.733334 0l108.8 108.8 215.466666-215.466667c17.066667-17.066667 42.666667-17.066667 59.733334 0 17.066667 17.066667 17.066667 44.8 0 61.866667z" fill="#1afa29" p-id="4799" data-spm-anchor-id="a313x.search_index.0.i0.123d3a812ZEn1Z" class=""></path></svg>`;

View File

@ -31,7 +31,11 @@ export async function activate(context: vscode.ExtensionContext) {
if (!editor) return; if (!editor) return;
if (!editor.selection.isEmpty) { 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 }; const decoration = { range };
editor.setDecorations(decorationType, [decoration]); editor.setDecorations(decorationType, [decoration]);
} else { } else {

File diff suppressed because it is too large Load Diff

View File

@ -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<boolean> {
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;
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string, string> = {
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,
});
}
});
}

View File

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

View File

@ -14,6 +14,7 @@ import {
checkVerilogProject, checkVerilogProject,
checkIverilogAvailable, checkIverilogAvailable,
} from "./iverilogRunner"; } from "./iverilogRunner";
import { createVivadoProject, runVivadoSynthesis } from "./vivadoRunner";
import { ChatHistoryManager } from "./chatHistoryManager"; import { ChatHistoryManager } from "./chatHistoryManager";
import { dialogManager, DialogSession } from "../services/dialogService"; import { dialogManager, DialogSession } from "../services/dialogService";
import { userInteractionManager } from "../services/userInteraction"; import { userInteractionManager } from "../services/userInteraction";
@ -393,6 +394,11 @@ async function handleUserMessageWithBackend(
isComplete: true, isComplete: true,
}); });
// 发送任务完成消息
panel.webview.postMessage({
command: "taskComplete",
});
// 发送系统通知 - AI 响应完成 // 发送系统通知 - AI 响应完成
const notificationService = NotificationService.getInstance(); const notificationService = NotificationService.getInstance();
notificationService.success( notificationService.success(
@ -1463,3 +1469,38 @@ export async function handleOpenFileDiff(
vscode.window.showErrorMessage(`打开 diff 失败: ${error}`); vscode.window.showErrorMessage(`打开 diff 失败: ${error}`);
} }
} }
/**
* 处理 Vivado 工具调用
*/
export async function handleVivadoToolCall(
toolName: string,
params: any
): Promise<any> {
try {
switch (toolName) {
case 'createVivadoProject':
return await createVivadoProject(params);
case 'runVivadoSynthesis':
return await runVivadoSynthesis(params);
default:
return {
success: false,
command: toolName,
executionTime: 0,
output: '',
error: `未知的工具: ${toolName}`
};
}
} catch (error: any) {
return {
success: false,
command: toolName,
executionTime: 0,
output: '',
error: error.message || String(error)
};
}
}

104
src/utils/tclGenerator.ts Normal file
View File

@ -0,0 +1,104 @@
/**
* TCL 脚本生成器
* 功能:生成 Vivado TCL 脚本
*/
import * as path from 'path';
/**
* 生成创建工程的 TCL 脚本
*/
export function generateCreateProjectTcl(
projectName: string,
projectDir: string,
part: string,
topModule: string,
files: string[],
constraints?: string,
runSynthesis?: boolean
): string {
// 转换路径为 TCL 格式(正斜杠)
const tclPath = (p: string) => p.replace(/\\/g, '/');
let tcl = `# 创建 Vivado 工程\n\n`;
tcl += `create_project ${projectName} {${tclPath(projectDir)}} -part ${part} -force\n\n`;
// 添加源文件
tcl += `# 添加源文件\n`;
files.forEach(file => {
tcl += `add_files -norecurse {${tclPath(file)}}\n`;
});
tcl += `\n`;
// 添加约束文件
if (constraints) {
tcl += `# 添加约束文件\n`;
tcl += `add_files -fileset constrs_1 -norecurse {${tclPath(constraints)}}\n\n`;
}
// 设置顶层模块
tcl += `# 设置顶层模块\n`;
tcl += `set_property top ${topModule} [current_fileset]\n\n`;
if (runSynthesis) {
tcl += `# 执行综合\n`;
tcl += `launch_runs synth_1\n`;
tcl += `wait_on_run synth_1\n\n`;
tcl += `# 打开综合结果\n`;
tcl += `open_run synth_1\n\n`;
tcl += `# 生成报告\n`;
tcl += `report_utilization -file {${tclPath(path.join(projectDir, `${projectName}_utilization.rpt`))}}\n`;
tcl += `report_timing_summary -file {${tclPath(path.join(projectDir, `${projectName}_timing.rpt`))}}\n\n`;
}
return tcl;
}
/**
* 生成综合的 TCL 脚本
*/
export function generateSynthesisTcl(
projectPath: string | undefined,
part: string,
topModule: string,
files?: string[],
constraints?: string,
outputDir?: string
): string {
const tclPath = (p: string) => p.replace(/\\/g, '/');
let tcl = `# Vivado 综合\n\n`;
if (projectPath) {
// 使用现有工程
tcl += `open_project {${tclPath(projectPath)}}\n\n`;
} else {
// 无工程模式
if (!files || files.length === 0) {
throw new Error('无工程模式需要提供源文件');
}
tcl += `# 读取源文件\n`;
files.forEach(file => {
tcl += `read_verilog {${tclPath(file)}}\n`;
});
tcl += `\n`;
if (constraints) {
tcl += `read_xdc {${tclPath(constraints)}}\n\n`;
}
}
tcl += `# 执行综合\n`;
tcl += `synth_design -top ${topModule} -part ${part}\n\n`;
if (outputDir) {
const dcpFile = tclPath(path.join(outputDir, `${topModule}_synth.dcp`));
tcl += `# 保存检查点\n`;
tcl += `write_checkpoint -force {${dcpFile}}\n\n`;
tcl += `# 生成报告\n`;
tcl += `report_utilization -file {${tclPath(path.join(outputDir, `${topModule}_utilization.rpt`))}}\n`;
tcl += `report_timing_summary -file {${tclPath(path.join(outputDir, `${topModule}_timing.rpt`))}}\n`;
}
return tcl;
}

87
src/utils/vivadoConfig.ts Normal file
View File

@ -0,0 +1,87 @@
/**
* Vivado 配置管理
* 功能:读取和验证 Vivado 配置
* 依赖vscode
*/
import * as vscode from 'vscode';
import * as fs from 'fs';
import * as path from 'path';
export interface VivadoConfig {
enabled: boolean;
executablePath: string;
workingDir: string;
}
/**
* 获取 Vivado 配置
*/
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');
const vivadoConfig = config.get<VivadoConfig>('vivado');
if (vivadoConfig) {
return vivadoConfig;
}
// 自动检测 Vivado
return autoDetectVivado();
}
/**
* 验证配置
*/
export function validateConfig(config: VivadoConfig): string | null {
if (!config.enabled) {
return 'Vivado 未启用';
}
// 如果是完整路径,检查文件是否存在
if (path.isAbsolute(config.executablePath) && !fs.existsSync(config.executablePath)) {
return `Vivado 可执行文件不存在: ${config.executablePath}`;
}
// 环境变量命令不检查,运行时会自动报错
return null;
}
/**
* 解析工作目录
*/
export function resolveWorkingDir(workingDir: string): string {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
if (workspaceFolder) {
return workingDir.replace('${workspaceFolder}', workspaceFolder.uri.fsPath);
}
return workingDir;
}
/**
* 自动检测 Vivado
*/
function autoDetectVivado(): VivadoConfig | null {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0];
// 默认使用环境变量中的 vivado 命令
return {
enabled: true,
executablePath: 'vivado',
workingDir: workspaceFolder
? path.join(workspaceFolder.uri.fsPath, 'vivado_projects')
: path.join(process.env.USERPROFILE || 'C:\\Users\\Default', 'vivado_projects')
};
}

246
src/utils/vivadoRunner.ts Normal file
View File

@ -0,0 +1,246 @@
/**
* Vivado 执行器
* 功能:执行 Vivado 命令
* 依赖vivadoConfig, tclGenerator
*/
import * as vscode from 'vscode';
import * as path from 'path';
import * as fs from 'fs';
import { spawn } from 'child_process';
import { getVivadoConfig, validateConfig, resolveWorkingDir } from './vivadoConfig';
import { generateCreateProjectTcl, generateSynthesisTcl } from './tclGenerator';
export interface VivadoToolResponse {
success: boolean;
command: string;
executionTime: number;
output: string;
error?: string;
outputFiles?: string[];
reports?: {
resources?: string;
timing?: string;
};
}
/**
* 创建 Vivado 工程
*/
export async function createVivadoProject(params: {
projectName: string;
part: string;
topModule: string;
files: string[];
constraints?: string;
mode: 'gui' | 'batch';
runSynthesis?: boolean;
}): Promise<VivadoToolResponse> {
const startTime = Date.now();
// 读取配置
const config = getVivadoConfig();
if (!config) {
return {
success: false,
command: 'create_project',
executionTime: 0,
output: '',
error: 'Vivado 未配置'
};
}
// 验证配置
const configError = validateConfig(config);
if (configError) {
return {
success: false,
command: 'create_project',
executionTime: 0,
output: '',
error: configError
};
}
// 准备工作目录
const workingDir = resolveWorkingDir(config.workingDir);
if (!fs.existsSync(workingDir)) {
fs.mkdirSync(workingDir, { recursive: true });
}
const projectDir = path.join(workingDir, params.projectName);
// 生成 TCL 脚本
const tclScript = generateCreateProjectTcl(
params.projectName,
projectDir,
params.part,
params.topModule,
params.files,
params.constraints,
params.runSynthesis
);
const tclPath = path.join(workingDir, 'create_project.tcl');
fs.writeFileSync(tclPath, tclScript);
// 执行 Vivado
const result = await executeVivado(
config.executablePath,
tclPath,
workingDir,
params.mode
);
const executionTime = Date.now() - startTime;
// 查找产出文件
const xprFile = path.join(projectDir, `${params.projectName}.xpr`);
const outputFiles = fs.existsSync(xprFile) ? [xprFile] : [];
return {
success: result.success,
command: 'create_project',
executionTime,
output: result.output,
error: result.error,
outputFiles
};
}
/**
* 执行 Vivado 综合
*/
export async function runVivadoSynthesis(params: {
projectPath?: string;
part: string;
topModule: string;
files?: string[];
constraints?: string;
mode: 'gui' | 'batch';
}): Promise<VivadoToolResponse> {
const startTime = Date.now();
const config = getVivadoConfig();
if (!config) {
return {
success: false,
command: 'synthesis',
executionTime: 0,
output: '',
error: 'Vivado 未配置'
};
}
const configError = validateConfig(config);
if (configError) {
return {
success: false,
command: 'synthesis',
executionTime: 0,
output: '',
error: configError
};
}
const workingDir = resolveWorkingDir(config.workingDir);
if (!fs.existsSync(workingDir)) {
fs.mkdirSync(workingDir, { recursive: true });
}
const outputDir = path.join(workingDir, 'synth_output');
if (!fs.existsSync(outputDir)) {
fs.mkdirSync(outputDir, { recursive: true });
}
const tclScript = generateSynthesisTcl(
params.projectPath,
params.part,
params.topModule,
params.files,
params.constraints,
outputDir
);
const tclPath = path.join(workingDir, 'synthesis.tcl');
fs.writeFileSync(tclPath, tclScript);
const result = await executeVivado(
config.executablePath,
tclPath,
workingDir,
params.mode
);
const executionTime = Date.now() - startTime;
const dcpFile = path.join(outputDir, `${params.topModule}_synth.dcp`);
const utilizationRpt = path.join(outputDir, `${params.topModule}_utilization.rpt`);
const timingRpt = path.join(outputDir, `${params.topModule}_timing.rpt`);
const outputFiles = [];
if (fs.existsSync(dcpFile)) outputFiles.push(dcpFile);
if (fs.existsSync(utilizationRpt)) outputFiles.push(utilizationRpt);
if (fs.existsSync(timingRpt)) outputFiles.push(timingRpt);
return {
success: result.success,
command: 'synthesis',
executionTime,
output: result.output,
error: result.error,
outputFiles
};
}
/**
* 执行 Vivado 命令
*/
async function executeVivado(
executablePath: string,
tclPath: string,
workingDir: string,
mode: 'gui' | 'batch'
): Promise<{ success: boolean; output: string; error?: string }> {
return new Promise((resolve) => {
let output = '';
let errorOutput = '';
const args = mode === 'gui'
? ['-source', tclPath]
: ['-mode', 'batch', '-source', tclPath];
const process = spawn(executablePath, args, {
cwd: workingDir,
shell: true
});
process.stdout.on('data', (data) => {
output += data.toString();
});
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 || `执行失败,退出码: ${code}`
});
}
});
process.on('error', (err) => {
resolve({
success: false,
output,
error: `启动 Vivado 失败: ${err.message}`
});
});
});
}

File diff suppressed because it is too large Load Diff

View File

@ -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 = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M761.088 715.3152a38.7072 38.7072 0 0 1 0-77.4144 37.4272 37.4272 0 0 0 37.4272-37.4272V265.0112a37.4272 37.4272 0 0 0-37.4272-37.4272H425.6256a37.4272 37.4272 0 0 0-37.4272 37.4272 38.7072 38.7072 0 1 1-77.4144 0 115.0976 115.0976 0 0 1 114.8416-114.8416h335.4624a115.0976 115.0976 0 0 1 114.8416 114.8416v335.4624a115.0976 115.0976 0 0 1-114.8416 114.8416z" fill="currentColor"/><path d="M589.4656 883.0976H268.1856a121.1392 121.1392 0 0 1-121.2928-121.2928v-322.56a121.1392 121.1392 0 0 1 121.2928-121.344h321.28a121.1392 121.1392 0 0 1 121.2928 121.2928v322.56c1.28 67.1232-54.1696 121.344-121.2928 121.344zM268.1856 395.3152a43.52 43.52 0 0 0-43.8784 43.8784v322.56a43.52 43.52 0 0 0 43.8784 43.8784h321.28a43.52 43.52 0 0 0 43.8784-43.8784v-322.56a43.52 43.52 0 0 0-43.8784-43.8784z" fill="currentColor"/></svg><span class="action-tooltip">复制</span>\`;
copyBtn.onclick = () => copyMessage(text, copyBtn);
const likeBtn = document.createElement('button');
likeBtn.className = 'action-btn';
likeBtn.innerHTML = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M923.5 411.2c-28.6-33.9-72.1-53-116.4-51.1h-68c6.4-31.6 10.1-63.9 11.2-96v-0.8c-0.5-60.9-18.7-112-51.2-144-22.6-22.2-50.8-33.7-81.5-33.3-38.3 0-69.1 11.5-91.7 34.2-26.5 26.5-39.9 66.8-39.8 119.6 0.1 40.1-19.4 83.4-52.1 115.9-32 31.8-71.7 49.3-111.8 49.3H295.6c-3 0-6 0.3-8.9 0.8v-1.2H140.8c-39.7 0-72.2 32.5-72.2 72.2v392.9c0 39.7 32.5 72.2 72.2 72.2h146.8v-0.6c2.9 0.4 5.9 0.7 8.9 0.7h464.7c33.3-0.8 65.6-13 91.1-34.4s43.1-51.1 49.6-83.8l52.3-289.1c9.4-43.4-2.1-89.6-30.7-123.5zM147.7 843.7v-344c0-9 7.3-16.3 16.3-16.3h70.4V860H164c-9 0-16.3-7.3-16.3-16.3z m726.4-324.9l-0.2 0.6-51.7 290.3c-6.7 29.1-32.3 50.2-62.2 51.3l-4.9 0.2-0.4 0.3h-440V486h7.3c61.4 0 121-25.7 168.1-72.4 48.6-48.2 76.5-111.7 76.5-174.2-0.1-31.5 4.9-51.8 15.3-62.2 7.4-7.4 18.7-10.8 35.8-10.8h0.2c9-0.1 17.4 3.6 24.9 11 16.3 16.2 25.7 47.3 25.8 85.4-1.2 41.8-7.9 83.3-19.9 123.4l-21.6 54.3h181.5c24.5-0.6 48.2 8.9 65.1 26.1 16.9 17.2 25.3 40.8 23 64.9z" fill="currentColor"/></svg><span class="action-tooltip">点赞</span>\`;
likeBtn.onclick = () => toggleLike(likeBtn);
const dislikeBtn = document.createElement('button');
dislikeBtn.className = 'action-btn';
dislikeBtn.innerHTML = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M360 640c60.992 40.107 88 87.381 88 149.333 0 5.611 0.427 12.864 1.579 21.462a174.933 174.933 0 0 0 11.2 42.666c19.584 48.192 60.864 83.008 119.018 85.12 28.843 2.56 60.886-3.584 91.414-25.045 58.709-41.237 81.706-117.248 69.973-230.87h48.15V640v42.667c17.173 0 38.4-2.475 61.823-10.667 66.304-23.125 107.627-84.117 84.928-162.816l-53.674-254.55a213.333 213.333 0 0 0-208.747-169.3H207.019a85.333 85.333 0 0 0-85.078 78.783l-29.546 384A85.333 85.333 0 0 0 177.493 640H360z m-61.333-109.333v24H177.493l29.526-384h91.648v360z m85.333-360h289.664a128 128 0 0 1 125.227 101.589l54.442 258.07c21.334 67.007-64 67.007-64 67.007H640c64 277.334-54.613 256-54.613 256-52.054 0-52.054-64-52.054-64 0-92.8-43.264-167.082-129.77-222.805A42.667 42.667 0 0 1 384 530.667v-360z" fill="currentColor"/></svg><span class="action-tooltip">点踩</span>\`;
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 = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M912 190h-69.9c-9.8 0-19.1 4.5-25.1 12.2L404.7 724.5 207 474c-6.1-7.7-15.3-12.2-25.1-12.2H112c-6.7 0-10.4 7.7-6.3 12.9l273.9 347c12.8 16.2 37.4 16.2 50.3 0l488.4-618.9c4.1-5.1 0.4-12.8-6.3-12.8z" fill="currentColor"/></svg>\`;
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 = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
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 = \`
<div class="loading-dots">
<span></span><span></span><span></span>
</div>
<span class="loading-text">\${text}</span>
\`;
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 = \`
<span class="tool-icon">\${statusIcons[status]}</span>
<span class="tool-name">\${getToolDisplayName(toolName)}</span>
<span class="tool-status-text">\${statusTexts[status]}</span>
\${detail ? \`<div class="tool-detail">\${detail}</div>\` : ''}
\`;
messagesEl.appendChild(div);
smartScrollToBottom();
checkHeaderVisibility();
}
${getWaveformPreviewScript()}
${getCodeHighlightScript()}
`;
}

632
src/views/messageStyles.ts Normal file
View File

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

View File

@ -59,7 +59,7 @@ export function getPlanCardStyles(): string {
.plan-summary h2 { font-size: 16px; } .plan-summary h2 { font-size: 16px; }
.plan-summary h3 { font-size: 14px; } .plan-summary h3 { font-size: 14px; }
.plan-summary h4 { font-size: 13px; } .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 { .plan-summary ul, .plan-summary ol {
margin: 8px 0; margin: 8px 0;
padding-left: 0; padding-left: 0;

View File

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

View File

@ -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 = \`
<div class="tool-segment-header\${isCollapsed ? ' collapsed' : ''}" data-collapsible="\${shouldCollapse}" data-tool-index="\${currentToolIndex}">
\${shouldCollapse ? \`<span class="tool-collapse-icon">\${collapseIconSvg}</span>\` : getToolIcon(segment.toolName)}
<span class="tool-segment-name">\${getToolDisplayName(segment.toolName) || '工具'}\${countSuffix}</span>
\${toolResult && !shouldCollapse ? \`<span class="tool-segment-result">\${toolResult}</span>\` : ''}
</div>
\${shouldCollapse ? \`<div class="tool-segment-content\${isCollapsed ? ' collapsed' : ''}" style="max-height:\${isCollapsed ? '0' : 'none'}"><span class="tool-segment-result" style="display:block;white-space:pre-wrap;max-width:100%;margin-top:8px;margin-left:18px;">\${toolResult}</span></div>\` : ''}
\${toolDescription ? \`<p class="tool-segment-description">\${toolDescription}</p>\` : ''}
\`;
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 = \`<textarea class="question-text-input" name="\${inputName}" placeholder="请输入您的答案..." style="width:100%;min-height:80px;padding:8px;border:1px solid var(--vscode-input-border);border-radius:4px;background:var(--vscode-input-background);color:var(--vscode-input-foreground);resize:vertical;" \${isAnswered ? 'disabled' : ''}>\${savedText}</textarea>\`;
} else {
optionsHtml = q.options.map(opt => {
const isSelected = selectedAnswers.includes(opt);
return \`<label class="question-option\${isSelected ? ' selected' : ''}" style="display:flex;align-items:center;gap:6px;cursor:pointer;padding:5px 5px 5px 0;">
<input type="\${inputType}" name="\${inputName}" value="\${opt}" \${isSelected ? 'checked' : ''} \${isAnswered ? 'disabled' : ''}>
<span>\${opt}</span>
</label>\`;
}).join('');
}
return \`
<div class="question-item" data-question-index="\${qIndex}" style="margin-bottom:12px;">
<div class="question-text" style="margin-bottom:8px;">\${formatText(q.question)}</div>
<div class="question-options">\${optionsHtml}</div>
</div>
\`;
}).join('');
segmentDiv.innerHTML = \`
\${questionsHtml}
<button class="custom-submit" style="display:\${isAnswered ? 'none' : 'block'};margin-top:8px;padding:8px 16px;background:var(--vscode-button-background);color:var(--vscode-button-foreground);border:none;border-radius:6px;cursor:pointer;">提交答案</button>
\`;
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 = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M761.088 715.3152a38.7072 38.7072 0 0 1 0-77.4144 37.4272 37.4272 0 0 0 37.4272-37.4272V265.0112a37.4272 37.4272 0 0 0-37.4272-37.4272H425.6256a37.4272 37.4272 0 0 0-37.4272 37.4272 38.7072 38.7072 0 1 1-77.4144 0 115.0976 115.0976 0 0 1 114.8416-114.8416h335.4624a115.0976 115.0976 0 0 1 114.8416 114.8416v335.4624a115.0976 115.0976 0 0 1-114.8416 114.8416z" fill="currentColor"/><path d="M589.4656 883.0976H268.1856a121.1392 121.1392 0 0 1-121.2928-121.2928v-322.56a121.1392 121.1392 0 0 1 121.2928-121.344h321.28a121.1392 121.1392 0 0 1 121.2928 121.2928v322.56c1.28 67.1232-54.1696 121.344-121.2928 121.344zM268.1856 395.3152a43.52 43.52 0 0 0-43.8784 43.8784v322.56a43.52 43.52 0 0 0 43.8784 43.8784h321.28a43.52 43.52 0 0 0 43.8784-43.8784v-322.56a43.52 43.52 0 0 0-43.8784-43.8784z" fill="currentColor"/></svg><span class="action-tooltip">复制</span>\`;
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 = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M923.5 411.2c-28.6-33.9-72.1-53-116.4-51.1h-68c6.4-31.6 10.1-63.9 11.2-96v-0.8c-0.5-60.9-18.7-112-51.2-144-22.6-22.2-50.8-33.7-81.5-33.3-38.3 0-69.1 11.5-91.7 34.2-26.5 26.5-39.9 66.8-39.8 119.6 0.1 40.1-19.4 83.4-52.1 115.9-32 31.8-71.7 49.3-111.8 49.3H295.6c-3 0-6 0.3-8.9 0.8v-1.2H140.8c-39.7 0-72.2 32.5-72.2 72.2v392.9c0 39.7 32.5 72.2 72.2 72.2h146.8v-0.6c2.9 0.4 5.9 0.7 8.9 0.7h464.7c33.3-0.8 65.6-13 91.1-34.4s43.1-51.1 49.6-83.8l52.3-289.1c9.4-43.4-2.1-89.6-30.7-123.5zM147.7 843.7v-344c0-9 7.3-16.3 16.3-16.3h70.4V860H164c-9 0-16.3-7.3-16.3-16.3z m726.4-324.9l-0.2 0.6-51.7 290.3c-6.7 29.1-32.3 50.2-62.2 51.3l-4.9 0.2-0.4 0.3h-440V486h7.3c61.4 0 121-25.7 168.1-72.4 48.6-48.2 76.5-111.7 76.5-174.2-0.1-31.5 4.9-51.8 15.3-62.2 7.4-7.4 18.7-10.8 35.8-10.8h0.2c9-0.1 17.4 3.6 24.9 11 16.3 16.2 25.7 47.3 25.8 85.4-1.2 41.8-7.9 83.3-19.9 123.4l-21.6 54.3h181.5c24.5-0.6 48.2 8.9 65.1 26.1 16.9 17.2 25.3 40.8 23 64.9z" fill="currentColor"/></svg><span class="action-tooltip">点赞</span>\`;
likeBtn.onclick = () => toggleLike(likeBtn);
const dislikeBtn = document.createElement('button');
dislikeBtn.className = 'action-btn';
dislikeBtn.innerHTML = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M360 640c60.992 40.107 88 87.381 88 149.333 0 5.611 0.427 12.864 1.579 21.462a174.933 174.933 0 0 0 11.2 42.666c19.584 48.192 60.864 83.008 119.018 85.12 28.843 2.56 60.886-3.584 91.414-25.045 58.709-41.237 81.706-117.248 69.973-230.87h48.15V640v42.667c17.173 0 38.4-2.475 61.823-10.667 66.304-23.125 107.627-84.117 84.928-162.816l-53.674-254.55a213.333 213.333 0 0 0-208.747-169.3H207.019a85.333 85.333 0 0 0-85.078 78.783l-29.546 384A85.333 85.333 0 0 0 177.493 640H360z m-61.333-109.333v24H177.493l29.526-384h91.648v360z m85.333-360h289.664a128 128 0 0 1 125.227 101.589l54.442 258.07c21.334 67.007-64 67.007-64 67.007H640c64 277.334-54.613 256-54.613 256-52.054 0-52.054-64-52.054-64 0-92.8-43.264-167.082-129.77-222.805A42.667 42.667 0 0 1 384 530.667v-360z" fill="currentColor"/></svg><span class="action-tooltip">点踩</span>\`;
dislikeBtn.onclick = () => toggleDislike(dislikeBtn);
actionsDiv.appendChild(copyBtn);
actionsDiv.appendChild(likeBtn);
actionsDiv.appendChild(dislikeBtn);
currentSegmentedMessage.appendChild(actionsDiv);
currentSegmentedMessage = null;
}
smartScrollToBottom();
}
`;
}

View File

@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
const placeholder = `___CODE_BLOCK_${codeBlocks.length}___`;
codeBlocks.push(`<pre><code class="language-${language}">${escapedCode}</code></pre>`);
return placeholder;
});
const inlineCodes: string[] = [];
html = html.replace(/`([^`]+)`/g, (match, code) => {
const escapedCode = code.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const placeholder = `___INLINE_CODE_${inlineCodes.length}___`;
inlineCodes.push(`<code>${escapedCode}</code>`);
return placeholder;
});
html = html.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
html = html.replace(/^### (.+)$/gm, "<h3>$1</h3>");
html = html.replace(/^## (.+)$/gm, "<h2>$1</h2>");
html = html.replace(/^# (.+)$/gm, "<h1>$1</h1>");
html = html.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
html = html.replace(/\*(.+?)\*/g, "<em>$1</em>");
html = html.replace(/^[\-\*] (.+)$/gm, "<li>$1</li>");
html = html.replace(/(<li>.*<\/li>\n?)+/g, "<ul>$&</ul>");
html = html.replace(/^\d+\. (.+)$/gm, "<li>$1</li>");
html = html.replace(/\[([^\]]+)\]\(([^\)]+)\)/g, '<a href="$2" target="_blank">$1</a>');
html = html.replace(/\n/g, "<br>");
codeBlocks.forEach((block, index) => {
html = html.replace(`___CODE_BLOCK_${index}___`, block);
});
inlineCodes.forEach((code, index) => {
html = html.replace(`___INLINE_CODE_${index}___`, code);
});
return html;
}

106
src/views/toolHelpers.ts Normal file
View File

@ -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<string, string> = {
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<string, string> = {
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;
}

View File

@ -25,6 +25,7 @@ import {
} from "./progressBar"; } from "./progressBar";
import { getHighlightJsLinks } from "../components/codeHighlight"; import { getHighlightJsLinks } from "../components/codeHighlight";
import { getCurrentEnv } from "../config/settings"; import { getCurrentEnv } from "../config/settings";
import { taskCompleteIconSvg } from "../constants/toolIcons";
import { import {
getInvitationModalContent, getInvitationModalContent,
getInvitationModalStyles, getInvitationModalStyles,
@ -304,7 +305,9 @@ export function getWebviewContent(
} }
.segment-text { .segment-text {
line-height: 1.6; line-height: 1.6;
font-size:0.9rem font-size:0.9rem;
color: var(--vscode-foreground);
letter-spacing: 0.5px;
} }
.segment-tool { .segment-tool {
background: var(--vscode-textBlockQuote-background); background: var(--vscode-textBlockQuote-background);
@ -545,6 +548,9 @@ export function getWebviewContent(
const modeSelect = document.getElementById('modeSelect'); const modeSelect = document.getElementById('modeSelect');
const messagesEl = document.getElementById('messages'); const messagesEl = document.getElementById('messages');
// 图标常量
const taskCompleteIconSvg = ${JSON.stringify(taskCompleteIconSvg)};
// 全局变量 // 全局变量
let currentStreamingMessage = null; let currentStreamingMessage = null;
let loadingIndicator = null; let loadingIndicator = null;
@ -769,6 +775,45 @@ export function getWebviewContent(
// 隐藏加载指示器 // 隐藏加载指示器
hideLoadingIndicator(); hideLoadingIndicator();
break; 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 = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M761.088 715.3152a38.7072 38.7072 0 0 1 0-77.4144 37.4272 37.4272 0 0 0 37.4272-37.4272V265.0112a37.4272 37.4272 0 0 0-37.4272-37.4272H425.6256a37.4272 37.4272 0 0 0-37.4272 37.4272 38.7072 38.7072 0 1 1-77.4144 0 115.0976 115.0976 0 0 1 114.8416-114.8416h335.4624a115.0976 115.0976 0 0 1 114.8416 114.8416v335.4624a115.0976 115.0976 0 0 1-114.8416 114.8416z" fill="currentColor"/><path d="M589.4656 883.0976H268.1856a121.1392 121.1392 0 0 1-121.2928-121.2928v-322.56a121.1392 121.1392 0 0 1 121.2928-121.344h321.28a121.1392 121.1392 0 0 1 121.2928 121.2928v322.56c1.28 67.1232-54.1696 121.344-121.2928 121.344zM268.1856 395.3152a43.52 43.52 0 0 0-43.8784 43.8784v322.56a43.52 43.52 0 0 0 43.8784 43.8784h321.28a43.52 43.52 0 0 0 43.8784-43.8784v-322.56a43.52 43.52 0 0 0-43.8784-43.8784z" fill="currentColor"/></svg><span class="action-tooltip">复制</span>\`;
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 = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M923.5 411.2c-28.6-33.9-72.1-53-116.4-51.1h-68c6.4-31.6 10.1-63.9 11.2-96v-0.8c-0.5-60.9-18.7-112-51.2-144-22.6-22.2-50.8-33.7-81.5-33.3-38.3 0-69.1 11.5-91.7 34.2-26.5 26.5-39.9 66.8-39.8 119.6 0.1 40.1-19.4 83.4-52.1 115.9-32 31.8-71.7 49.3-111.8 49.3H295.6c-3 0-6 0.3-8.9 0.8v-1.2H140.8c-39.7 0-72.2 32.5-72.2 72.2v392.9c0 39.7 32.5 72.2 72.2 72.2h146.8v-0.6c2.9 0.4 5.9 0.7 8.9 0.7h464.7c33.3-0.8 65.6-13 91.1-34.4s43.1-51.1 49.6-83.8l52.3-289.1c9.4-43.4-2.1-89.6-30.7-123.5zM147.7 843.7v-344c0-9 7.3-16.3 16.3-16.3h70.4V860H164c-9 0-16.3-7.3-16.3-16.3z m726.4-324.9l-0.2 0.6-51.7 290.3c-6.7 29.1-32.3 50.2-62.2 51.3l-4.9 0.2-0.4 0.3h-440V486h7.3c61.4 0 121-25.7 168.1-72.4 48.6-48.2 76.5-111.7 76.5-174.2-0.1-31.5 4.9-51.8 15.3-62.2 7.4-7.4 18.7-10.8 35.8-10.8h0.2c9-0.1 17.4 3.6 24.9 11 16.3 16.2 25.7 47.3 25.8 85.4-1.2 41.8-7.9 83.3-19.9 123.4l-21.6 54.3h181.5c24.5-0.6 48.2 8.9 65.1 26.1 16.9 17.2 25.3 40.8 23 64.9z" fill="currentColor"/></svg><span class="action-tooltip">点赞</span>\`;
taskLikeBtn.onclick = () => toggleLike(taskLikeBtn);
const taskDislikeBtn = document.createElement('button');
taskDislikeBtn.className = 'action-btn';
taskDislikeBtn.innerHTML = \`<svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"><path d="M360 640c60.992 40.107 88 87.381 88 149.333 0 5.611 0.427 12.864 1.579 21.462a174.933 174.933 0 0 0 11.2 42.666c19.584 48.192 60.864 83.008 119.018 85.12 28.843 2.56 60.886-3.584 91.414-25.045 58.709-41.237 81.706-117.248 69.973-230.87h48.15V640v42.667c17.173 0 38.4-2.475 61.823-10.667 66.304-23.125 107.627-84.117 84.928-162.816l-53.674-254.55a213.333 213.333 0 0 0-208.747-169.3H207.019a85.333 85.333 0 0 0-85.078 78.783l-29.546 384A85.333 85.333 0 0 0 177.493 640H360z m-61.333-109.333v24H177.493l29.526-384h91.648v360z m85.333-360h289.664a128 128 0 0 1 125.227 101.589l54.442 258.07c21.334 67.007-64 67.007-64 67.007H640c64 277.334-54.613 256-54.613 256-52.054 0-52.054-64-52.054-64 0-92.8-43.264-167.082-129.77-222.805A42.667 42.667 0 0 1 384 530.667v-360z" fill="currentColor"/></svg><span class="action-tooltip">点踩</span>\`;
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': case 'workspaceStatus':
// 更新工作区状态 // 更新工作区状态