From 805737fcfc99f1ba30babf1981fec76c1fad9573 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8C=83=E6=99=BA=E9=B9=8F?= Date: Thu, 16 Jul 2026 22:20:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=BE=E7=BD=AE=E9=9D=A2=E6=9D=BF=20?= =?UTF-8?q?+=20Provider=20=E6=89=A9=E5=B1=95=20+=20=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置面板:三步引导 / AI 配置 / API Key / 规则管理 / 连接测试 - Provider 重构:统一 OpenAICompatibleProvider 基类,新增 Gemini/Claude/混元/智谱等 - 审查面板 Webview:三 Tab、统计卡片、问题列表、postMessage 通信 - esbuild 构建脚本 + 生产打包 + PMD 下载 - 保存文件自动静态分析(500ms debounce) --- .code-review/config.yaml | 18 + .vscodeignore | 8 + AGENTS.md | 4 +- README.md | 77 +- _AI_USAGE_LOG.md | 22 + .../specs/2026-07-10-code-reviewer-design.md | 14 +- .../specs/2026-07-10-implementation-plan.md | 10 +- .../01-phase1-base-layer.md | 6 +- .../09-phase4.1-ai-provider.md | 12 +- .../10-phase4.2-ai-engine.md | 2 +- .../16-phase5.2-setup-panel.md | 2 +- images/icon.png | Bin 0 -> 478 bytes package.json | 8 +- scripts/build.mjs | 15 + scripts/copy-webview-js.mjs | 18 + src/activation/commands.ts | 159 +--- src/adapters/stylelint.ts | 35 +- src/ai/engine.ts | 6 +- src/ai/factory.ts | 116 ++- src/ai/providers/base.ts | 2 +- src/ai/providers/{openai.ts => claude.ts} | 20 +- src/ai/providers/gemini.ts | 51 ++ .../{deepseek.ts => openai-compatible.ts} | 14 +- src/config/ai.ts | 6 +- src/extension.ts | 6 +- src/views/setupView.js | 169 ++++ src/views/setupView.ts | 811 +++++++++++++----- 27 files changed, 1130 insertions(+), 481 deletions(-) create mode 100644 images/icon.png create mode 100644 scripts/copy-webview-js.mjs rename src/ai/providers/{openai.ts => claude.ts} (68%) create mode 100644 src/ai/providers/gemini.ts rename src/ai/providers/{deepseek.ts => openai-compatible.ts} (81%) create mode 100644 src/views/setupView.js diff --git a/.code-review/config.yaml b/.code-review/config.yaml index 9a7d470..6a65201 100644 --- a/.code-review/config.yaml +++ b/.code-review/config.yaml @@ -7,3 +7,21 @@ enabled: rules: no-magic-numbers: enabled: false + + no-console-log: + enabled: false + + no-hardcoded-secret: + enabled: false + + no-sql-injection: + enabled: false + + no-command-injection: + enabled: false + + naming-convention-pascal: + enabled: false + + naming-convention-camel: + enabled: false diff --git a/.vscodeignore b/.vscodeignore index 7d3e5c7..5679d79 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -9,3 +9,11 @@ vsc-extension-quickstart.md **/*.map **/*.ts **/.vscode-test.* +**/*.mjs +!node_modules/**/*.mjs +AGENTS.md +opencode.json +docs/** +scripts/** +**/*.d.ts +.opencode/** diff --git a/AGENTS.md b/AGENTS.md index c27f723..bc164eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,10 +10,10 @@ ## 日志规则(自动执行) 每次创建或修改代码文件后,在项目根目录的 `_AI_USAGE_LOG.md` 中追加一条记录,必须包含以下字段: -- **日期时间**:当前时间 +- **日期时间**:文件修改时间(用 `Get-Item LastWriteTime` 或 `Get-Date` 获取),不可乱填或估算 - **范式步骤**:当前已完成的阶段累积链(如 `① 用户提出 → ② 需求澄清`),按实际执行顺序 - **修改摘要**:简要描述改了什么 -- **中间产物**:AI 交互过程中被淘汰的草稿或过程稿 +- **中间产物**:AI 交互过程中被淘汰的草稿或过程稿,包括但不限于:先给了伪代码后改正式实现、输出了多个方案选了其中一个、调试时生成的废弃代码版本、类型试错(如先用 `typeof import()` 后改 `interface`)、先改文件 A 后发现不够又改了文件 B - **涉及文件**:文件路径列表 - **使用模型**:当前使用的模型名 diff --git a/README.md b/README.md index 1be1385..efbd144 100644 --- a/README.md +++ b/README.md @@ -1,71 +1,16 @@ -# vscode-code-reviewer README +# vscode-code-reviewer -This is the README for your extension "vscode-code-reviewer". After writing up a brief description, we recommend including the following sections. +VS Code 代码审查与规范检查一体化插件。集成 ESLint、Stylelint、PMD、SQLFluff 等多种 linter,并支持 AI 辅助审查。 -## Features +## 功能 -Describe specific features of your extension including screenshots of your extension in action. Image paths are relative to this README file. +- 一键运行代码审查 +- 选中代码片段审查 +- TreeView 面板展示审查结果 +- 自动修复 +- 报告导出 +- 自定义规则管理 -For example if there is an image subfolder under your extension project workspace: +## 配置 -\!\[feature X\]\(images/feature-x.png\) - -> Tip: Many popular extensions utilize animations. This is an excellent way to show off your extension! We recommend short, focused animations that are easy to follow. - -## Requirements - -If you have any requirements or dependencies, add a section describing those and how to install and configure them. - -## Extension Settings - -Include if your extension adds any VS Code settings through the `contributes.configuration` extension point. - -For example: - -This extension contributes the following settings: - -* `myExtension.enable`: Enable/disable this extension. -* `myExtension.thing`: Set to `blah` to do something. - -## Known Issues - -Calling out known issues can help limit users opening duplicate issues against your extension. - -## Release Notes - -Users appreciate release notes as you update your extension. - -### 1.0.0 - -Initial release of ... - -### 1.0.1 - -Fixed issue #. - -### 1.1.0 - -Added features X, Y, and Z. - ---- - -## Following extension guidelines - -Ensure that you've read through the extensions guidelines and follow the best practices for creating your extension. - -* [Extension Guidelines](https://code.visualstudio.com/api/references/extension-guidelines) - -## Working with Markdown - -You can author your README using Visual Studio Code. Here are some useful editor keyboard shortcuts: - -* Split the editor (`Cmd+\` on macOS or `Ctrl+\` on Windows and Linux). -* Toggle preview (`Shift+Cmd+V` on macOS or `Shift+Ctrl+V` on Windows and Linux). -* Press `Ctrl+Space` (Windows, Linux, macOS) to see a list of Markdown snippets. - -## For more information - -* [Visual Studio Code's Markdown Support](http://code.visualstudio.com/docs/languages/markdown) -* [Markdown Syntax Reference](https://help.github.com/articles/markdown-basics/) - -**Enjoy!** +通过 `Ctrl+Shift+P` → `CodeGuard: 打开设置面板` 进行配置,或编辑 `.vscode/settings.json`。 diff --git a/_AI_USAGE_LOG.md b/_AI_USAGE_LOG.md index 40486c4..a843ea0 100644 --- a/_AI_USAGE_LOG.md +++ b/_AI_USAGE_LOG.md @@ -36,3 +36,25 @@ | 2026-07-14 21:10 | ⑤ 编码实现 → ⑥ 审查验证 | Phase 5.3 审查面板:ReviewPanel Webview(三 Tab 切换/统计卡片/问题列表/postMessage 通信/降级提示)+ commands.ts 集成(review/openPanel/exportReport 对接面板) | 按 docs/superpowers/specs/implementation-steps/17-phase5.3-review-panel.md 直接编码,无中间产物 | src/panel/webview.ts src/activation/commands.ts | deepseek-v4-pro | | 2026-07-14 21:13 | ⑤ 编码实现 → ⑥ 审查验证 | Phase 6.1 构建脚本:esbuild 打包脚本 + package.json 脚本更新(build / vscode:prepublish) | 按 docs/superpowers/specs/implementation-steps/18-phase6.1-build-scripts.md 直接编码,无中间产物 | scripts/build.mjs package.json | deepseek-v4-flash | | 2026-07-14 21:14 | ⑤ 编码实现 → ⑥ 审查验证 | Phase 6.2 工具脚本:PMD JAR 下载脚本 + 生产打包脚本 + vsce 依赖 + package.json 脚本更新 | 按 docs/superpowers/specs/implementation-steps/19-phase6.2-tool-scripts.md 直接编码,无中间产物 | scripts/download-pmd.mjs scripts/package-prod.mjs package.json | deepseek-v4-flash | +| 2026-07-14 21:20 | ⑤ 编码实现 → ⑥ 审查验证 | 打包 VSIX:优化 .vscodeignore、补全 README、创建 placeholder icon | 无 | .vscodeignore README.md images/icon.png | deepseek-v4-flash | +| 2026-07-14 21:35 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 | Bug fix: codeReviewer.openSetup 添加 try/catch 错误处理和用户提示回退 | 无 | src/activation/commands.ts | deepseek-v4-flash | +| 2026-07-14 21:52 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: stylelint 模块找不到(.vscodeignore 排除 .mjs 文件 + CJS/ESM 互操作) | 初版只改了 .vscodeignore 没改 CJS/ESM,第二次迭代才补了动态 import | .vscodeignore src/adapters/stylelint.ts | deepseek-v4-flash | +| 2026-07-14 22:04 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 设置面板 TreeView → WebView 重构,对齐 setup-panel-preview.html 卡片式 UI 设计 | `type StylelintModule = typeof import('stylelint')` 类型废弃 → 改为内联 interface;`Awaited` 试错 → 改为 `as` 断言 | package.json src/views/setupView.ts src/extension.ts src/activation/commands.ts src/adapters/stylelint.ts | deepseek-v4-flash | +| 2026-07-14 22:16 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 重写快速开始区域:替换步骤圆圈为卡片布局,步骤编号改为 1/2/3 圆形数字,第三步增加快捷键和面板指引副标题 | 无 | src/views/setupView.ts | deepseek-v4-flash | +| 2026-07-14 22:24 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 审核引擎三卡片纵向 → 横向三等分布局 | 无 | src/views/setupView.ts | deepseek-v4-flash | +| 2026-07-14 23:00 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 新增 6 个 AI Provider(Gemini/Claude/腾讯混元/智谱AI/月之暗面/阿里通义),合并 DeepSeek/OpenAI 为通用兼容类,Provider 切换联动模型下拉 | 先试了为每家单独写类,后改为 OpenAICompatibleProvider 统一类 + 子类注入 ID | src/ai/providers/openai-compatible.ts src/ai/providers/gemini.ts src/ai/providers/claude.ts src/ai/providers/deepseek.ts(删) src/ai/providers/openai.ts(删) src/ai/factory.ts src/views/setupView.ts | deepseek-v4-flash | +| 2026-07-15 22:06 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: 模型提供商/名称下拉框不显示 — 真正根因是 JS 消息传递(ready→initConfig)未送达导致 select 永远空;改为在 getHtml() 模板中直接渲染 option,彻底不依赖异步消息 | 第一轮错误修复(只加 CSS min-height+try-catch)无效,第二轮改为 HTML 模板直接嵌入 option 数据才解决 | src/views/setupView.ts | deepseek-v4-flash | +| 2026-07-15 22:17 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: 更换提供商模型不联动 + 徽章显示异常 — 发现 JS 全程未执行(内联脚本被 CSP 屏蔽),JS 改为外部文件 vscode-resource 加载,provider 数据嵌入 JSON script tag,change 事件前端直接查表更新模型下拉 | 第 3 轮:内联 addEventListener(仍不执行),第 4 轮:改为外部 JS 文件才解决 | src/views/setupView.ts src/views/setupView.js scripts/build.mjs scripts/copy-webview-js.mjs package.json | deepseek-v4-flash | +| 2026-07-15 22:25 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 新增 Base URL 输入框 + setEndpoint 消息处理 + initConfig 同步端点值 | 第一版给了初始值(config.endpoint),用户要求去掉,第二版清空 value 保留 placeholder | src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash | +| 2026-07-15 23:04 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 | 修复 badge 判定逻辑:providerBadge 改为 provider&&model;apiKeyBadge 改为 apiKeyConfigured&&endpoint;pushConfig 补充 endpoint 字段 | 无 | src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash | +| 2026-07-15 23:14 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 API Key 模块三个问题:①去掉 programmatic 设置输入框值消除竞态;②去掉 URL 自动填入;③清理死代码 | 无 | src/views/setupView.js | deepseek-v4-flash | +| 2026-07-15 23:25 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 inline onclick/onchange 无法调用 IIFE 内函数(postMsg/addRule/toggleRule/deleteRule 未暴露到全局),暴露到 window | 初版分析为 CSP 问题已修复,实际是 IIFE 作用域隔离 | src/views/setupView.js | deepseek-v4-flash | +| 2026-07-15 23:34 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 全量重命名 endpoint → baseUrl:16 文件 73 处。config 键 ai.endpoint→ai.baseUrl(破坏性变更),所有函数/变量/接口/elementId 同步更名 | 无 | src/config/ai.ts src/ai/providers/base.ts src/ai/providers/openai-compatible.ts src/ai/providers/claude.ts src/ai/providers/gemini.ts src/ai/factory.ts src/ai/engine.ts src/views/setupView.ts src/views/setupView.js package.json docs/superpowers/specs/*.md | deepseek-v4-pro | +| 2026-07-16 18:22 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: Base URL 输入框不显示已保存值(initConfig 未设置 baseUrlInput.value)+ API Key 已配置时输入框填入 •••••••• 可编辑视觉提示 | 初版用 placeholder 用户反馈不可编辑 → 改为 value | src/views/setupView.js | deepseek-v4-flash | +| 2026-07-16 18:30 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: 用户输入 API Key 后失焦被 pushConfig 覆写为 ••••••••,增加保护条件仅初始/空值时覆写;讨论是否传真实密钥给 webview,确认安全策略,保持 •••••••• 方案 | 尝试评估是否传真实密钥给 webview → 用户确认保持 •••••••• 方案 | src/views/setupView.js | deepseek-v4-flash | +| 2026-07-16 18:41 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: isBaseUrlConfigured 误判空字符串(用户 settings.json 有 "" 导致 globalValue 非 undefined),改为 !! 检查非空;setBaseUrl 空值时存 undefined 而非 "" | 无 | src/views/setupView.ts | deepseek-v4-flash | +| 2026-07-16 18:48 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 去掉选择提供商自动填入 Base URL 的功能(移除 setProvider 中 cfg.update ai.baseUrl 行及无用 import) | 无 | src/views/setupView.ts | deepseek-v4-flash | +| 2026-07-16 18:53 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | Bug fix: 清除 URL 后 initConfig 又把默认值填回,改为 pushConfig 仅 baseUrlConfigured 为 true 时才传保存值 | 无 | src/views/setupView.ts | deepseek-v4-flash | +| 2026-07-16 19:06 | ① 用户提出 → ⑤ 编码实现 → ⑥ 审查验证 | 保存并测试连接时,Base URL 未配置也报错提示,不再静默使用默认值 | 无 | src/views/setupView.ts | deepseek-v4-flash | +| 2026-07-16 21:18 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 快速开始三步序号动态高亮:完成=绿色(#3fb950)、有后继步骤完成但本步未完成=黄色(#f0883e)、默认=灰色 | 三态逻辑先在"仅步骤3触发才变黄"和"任意后继触发变黄"间迭代一次 | src/views/setupView.ts src/views/setupView.js | deepseek-v4-flash | +| 2026-07-16 21:57 | ① 用户提出 → ② 需求澄清 → ③ 方案设计 → ④ 人类审批 → ⑤ 编码实现 → ⑥ 审查验证 | 修复 addRule/deleteRule 不同步规则定义文件:addRule 现在在 .code-review/rules/user-rules.yaml 创建模板定义;deleteRule 从 user-rules.yaml 移除规则项 | 无 | src/views/setupView.ts | deepseek-v4-flash | diff --git a/docs/superpowers/specs/2026-07-10-code-reviewer-design.md b/docs/superpowers/specs/2026-07-10-code-reviewer-design.md index ee66f61..7dfd188 100644 --- a/docs/superpowers/specs/2026-07-10-code-reviewer-design.md +++ b/docs/superpowers/specs/2026-07-10-code-reviewer-design.md @@ -243,7 +243,7 @@ export abstract class AIProvider { constructor( protected apiKey: string, - protected endpoint: string + protected baseUrl: string ) {} abstract chat( @@ -258,15 +258,15 @@ export abstract class AIProvider { ```typescript // factory.ts -const registry: Record AIProvider> = { +const registry: Record AIProvider> = { deepseek: DeepSeekProvider, openai: OpenAIProvider, }; -export function createProvider(providerId: string, apiKey: string, endpoint: string): AIProvider { +export function createProvider(providerId: string, apiKey: string, baseUrl: string): AIProvider { const Cls = registry[providerId]; if (!Cls) throw new Error(`Unknown provider: ${providerId}`); - return new Cls(apiKey, endpoint); + return new Cls(apiKey, baseUrl); } ``` @@ -471,7 +471,7 @@ interface AIFinding { |--------|------|--------|------| | `vscode-code-reviewer.ai.provider` | enum | `deepseek` | 模型提供商:`deepseek` / `openai` | | `vscode-code-reviewer.ai.model` | string | `deepseek-chat` | 模型名称 | -| `vscode-code-reviewer.ai.endpoint` | string | `https://api.deepseek.com/v1` | API 端点 | +| `vscode-code-reviewer.ai.baseUrl` | string | `https://api.deepseek.com/v1` | API Base URL | | `vscode-code-reviewer.ai.temperature` | number | 0.2 | AI 温度参数(建议 0.1-0.3) | | `vscode-code-reviewer.ai.timeout` | number | 300 | AI 请求超时(秒) | | `vscode-code-reviewer.ai.outputLanguage` | string | `zh-CN` | 输出语言:`zh-CN` / `en` / `ja` | @@ -975,7 +975,7 @@ rules: ``` src/config/ ├── index.ts # 统一导出 -├── ai.ts # AI 配置(provider/model/endpoint/temperature/timeout/outputLanguage) +├── ai.ts # AI 配置(provider/model/baseUrl/temperature/timeout/outputLanguage) ├── linter.ts # Linter 配置(linters.、PMD 路径) ├── fixer.ts # 修复器配置(contextLines) └── secret.ts # API Key SecretStorage(get/set/delete/isConfigured) @@ -1047,7 +1047,7 @@ src/config/ | 快速开始 | 三步引导(圆形序号,完成变紫色) | — | | 审核引擎 | 三个模块标签(紫/琥珀/绿圆点) | — | | AI 模型配置 | 卡片:提供商下拉框 + 模型下拉框 + 状态标签 | `ai.provider` / `ai.model` | -| API Key | 卡片:密码输入框 + Base URL + 状态标签 | SecretStorage / `ai.endpoint` | +| API Key | 卡片:密码输入框 + Base URL + 状态标签 | SecretStorage / `ai.baseUrl` | | 输出语言 | 下拉框 | `ai.outputLanguage` | | 自定义规则 | 卡片:开关列表 + 添加行 | `.code-review/config.yaml` | diff --git a/docs/superpowers/specs/2026-07-10-implementation-plan.md b/docs/superpowers/specs/2026-07-10-implementation-plan.md index 76f8b1d..dab7fcb 100644 --- a/docs/superpowers/specs/2026-07-10-implementation-plan.md +++ b/docs/superpowers/specs/2026-07-10-implementation-plan.md @@ -48,7 +48,7 @@ Phase 4 内部必须按 4.1 → 4.2 → 4.3 → 4.4 → 4.5 → 4.6 顺序,Fix |------|------|------| | `src/types.ts` | 新建 | `LinterDiagnostic`、`AdapterResult`、`LinterAdapter` 等公共类型 | | `src/config/index.ts` | 新建 | 统一导出 | -| `src/config/ai.ts` | 新建 | AI 配置 getter(provider/model/endpoint/temperature/timeout/outputLanguage) | +| `src/config/ai.ts` | 新建 | AI 配置 getter(provider/model/baseUrl/temperature/timeout/outputLanguage) | | `src/config/linter.ts` | 新建 | Linter 配置 getter(linters.xxx 语言-linter 映射、PMD 路径) | | `src/config/fixer.ts` | 新建 | 修复器配置(contextLines) | | `src/config/secret.ts` | 新建 | API Key SecretStorage(get/set/delete/isConfigured) | @@ -91,7 +91,7 @@ export interface LinterAdapter { |------|--------|------|--------| | ai | `ai.provider` | enum | `deepseek` | | ai | `ai.model` | string | `deepseek-chat` | -| ai | `ai.endpoint` | string | `https://api.deepseek.com/v1` | +| ai | `ai.baseUrl` | string | `https://api.deepseek.com/v1` | | ai | `ai.temperature` | number | 0.2 | | ai | `ai.timeout` | number | 300 | | ai | `ai.outputLanguage` | string | `zh-CN` | @@ -270,7 +270,7 @@ jars/pmd/ | `src/ai/providers/base.ts` | 新建 | `AIProvider` 抽象基类 + `ChatOptions` 接口 | | `src/ai/providers/deepseek.ts` | 新建 | `DeepSeekProvider extends AIProvider` | | `src/ai/providers/openai.ts` | 新建 | `OpenAIProvider extends AIProvider` | -| `src/ai/factory.ts` | 新建 | `createProvider(providerId, apiKey, endpoint)` 工厂函数 | +| `src/ai/factory.ts` | 新建 | `createProvider(providerId, apiKey, baseUrl)` 工厂函数 | **Provider 接口**: ```typescript @@ -283,7 +283,7 @@ interface ChatOptions { abstract class AIProvider { abstract id: string; abstract name: string; - constructor(protected apiKey: string, protected endpoint: string) {} + constructor(protected apiKey: string, protected baseUrl: string) {} abstract chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise; } ``` @@ -499,7 +499,7 @@ applyFix() 应用修复到编辑器(单条 / 批量倒序) | 快速开始 | 三步引导(①②③ 圆形序号,完成变紫色) | 实时状态 | | 审核引擎 | 三个模块标签(紫/琥珀/绿圆点) | 静态 | | AI 模型配置 | 提供商下拉框 + 模型下拉框 + 状态标签 | `ai.provider` / `ai.model` | -| API Key | 密码输入框 + Base URL + 状态标签 | SecretStorage / `ai.endpoint` | +| API Key | 密码输入框 + Base URL + 状态标签 | SecretStorage / `ai.baseUrl` | | 输出语言 | 下拉框 | `ai.outputLanguage` | | 自定义规则 | 开关列表 + 添加行 | `.code-review/config.yaml` | diff --git a/docs/superpowers/specs/implementation-steps/01-phase1-base-layer.md b/docs/superpowers/specs/implementation-steps/01-phase1-base-layer.md index 7b035ed..9caa2df 100644 --- a/docs/superpowers/specs/implementation-steps/01-phase1-base-layer.md +++ b/docs/superpowers/specs/implementation-steps/01-phase1-base-layer.md @@ -61,7 +61,7 @@ import * as vscode from 'vscode'; export interface AIConfig { provider: string; model: string; - endpoint: string; + baseUrl: string; temperature: number; timeout: number; outputLanguage: string; @@ -72,7 +72,7 @@ export function getAIConfig(): AIConfig { return { provider: config.get('ai.provider', 'deepseek'), model: config.get('ai.model', 'deepseek-chat'), - endpoint: config.get('ai.endpoint', 'https://api.deepseek.com/v1'), + baseUrl: config.get('ai.baseUrl', 'https://api.deepseek.com/v1'), temperature: config.get('ai.temperature', 0.2), timeout: config.get('ai.timeout', 300), outputLanguage: config.get('ai.outputLanguage', 'zh-CN'), @@ -86,7 +86,7 @@ export function getAIConfig(): AIConfig { |-----|------|--------| | `ai.provider` | enum | `deepseek` | | `ai.model` | string | `deepseek-chat` | -| `ai.endpoint` | string | `https://api.deepseek.com/v1` | +| `ai.baseUrl` | string | `https://api.deepseek.com/v1` | | `ai.temperature` | number | 0.2 | | `ai.timeout` | number | 300 | | `ai.outputLanguage` | string | `zh-CN` | diff --git a/docs/superpowers/specs/implementation-steps/09-phase4.1-ai-provider.md b/docs/superpowers/specs/implementation-steps/09-phase4.1-ai-provider.md index 62e1ca2..ff31588 100644 --- a/docs/superpowers/specs/implementation-steps/09-phase4.1-ai-provider.md +++ b/docs/superpowers/specs/implementation-steps/09-phase4.1-ai-provider.md @@ -33,7 +33,7 @@ export abstract class AIProvider { constructor( protected apiKey: string, - protected endpoint: string + protected baseUrl: string ) {} abstract chat( @@ -56,7 +56,7 @@ export class DeepSeekProvider extends AIProvider { name = 'DeepSeek'; async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise { - const url = `${this.endpoint}/chat/completions`; + const url = `${this.baseUrl}/chat/completions`; const body = JSON.stringify({ model: options.model, @@ -112,7 +112,7 @@ export class OpenAIProvider extends AIProvider { name = 'OpenAI'; async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise { - const url = `${this.endpoint}/chat/completions`; + const url = `${this.baseUrl}/chat/completions`; const body = JSON.stringify({ model: options.model, @@ -165,19 +165,19 @@ import { AIProvider } from './providers/base'; import { DeepSeekProvider } from './providers/deepseek'; import { OpenAIProvider } from './providers/openai'; -type ProviderConstructor = new (apiKey: string, endpoint: string) => AIProvider; +type ProviderConstructor = new (apiKey: string, baseUrl: string) => AIProvider; const registry: Record = { deepseek: DeepSeekProvider, openai: OpenAIProvider, }; -export function createProvider(providerId: string, apiKey: string, endpoint: string): AIProvider { +export function createProvider(providerId: string, apiKey: string, baseUrl: string): AIProvider { const Cls = registry[providerId]; if (!Cls) { throw new Error(`未知的 Provider: ${providerId}`); } - return new Cls(apiKey, endpoint); + return new Cls(apiKey, baseUrl); } export function getProviderIds(): string[] { diff --git a/docs/superpowers/specs/implementation-steps/10-phase4.2-ai-engine.md b/docs/superpowers/specs/implementation-steps/10-phase4.2-ai-engine.md index a41bdda..75fe0ce 100644 --- a/docs/superpowers/specs/implementation-steps/10-phase4.2-ai-engine.md +++ b/docs/superpowers/specs/implementation-steps/10-phase4.2-ai-engine.md @@ -142,7 +142,7 @@ export async function runAIReview( let provider: AIProvider; try { - provider = createProvider(config.provider, apiKey, config.endpoint); + provider = createProvider(config.provider, apiKey, config.baseUrl); } catch (err) { return { customRuleResults: [], diff --git a/docs/superpowers/specs/implementation-steps/16-phase5.2-setup-panel.md b/docs/superpowers/specs/implementation-steps/16-phase5.2-setup-panel.md index a02a057..29a5132 100644 --- a/docs/superpowers/specs/implementation-steps/16-phase5.2-setup-panel.md +++ b/docs/superpowers/specs/implementation-steps/16-phase5.2-setup-panel.md @@ -343,7 +343,7 @@ context.subscriptions.push( cancellable: false, }, async () => { try { - const provider = createProvider(config.provider, apiKey, config.endpoint); + const provider = createProvider(config.provider, apiKey, config.baseUrl); await provider.chat('回复 ok', 'ping', { model: config.model, temperature: 0, diff --git a/images/icon.png b/images/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..ba641849a6cb76f0845c4905e090c7388b812590 GIT binary patch literal 478 zcmeAS@N?(olHy`uVBq!ia0vp^1|ZDA1|-9oezpTC#^NA%Cx&(BWL^TKq$yjVB?030`Uiq%YP89;S`KNAl|93z>%b=#3AN1p+)8S+c$NE zDV9Y$Z`a-^Do# ze~vl458|1CdKsBOm}P|nSP@XY!9IrPyIFj=FDO57hv) { - vscode.commands.executeCommand('workbench.view.extension.code-reviewer'); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('codeReviewer.setApiKey', async () => { - const key = await vscode.window.showInputBox({ - prompt: '请输入 API Key', - password: true, - placeHolder: 'sk-...', - }); - if (key) { - await setApiKey(context, key); - setupProvider.refresh(); - vscode.window.showInformationMessage('API Key 已保存'); - } - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('codeReviewer.focusApiKey', async () => { - const key = await vscode.window.showInputBox({ - prompt: '请输入 API Key', - password: true, - placeHolder: 'sk-...', - }); - if (key) { - await setApiKey(context, key); - setupProvider.refresh(); - vscode.window.showInformationMessage('API Key 已保存'); - } - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('codeReviewer.selectProvider', async () => { - const config = vscode.workspace.getConfiguration('vscode-code-reviewer'); - const selected = await vscode.window.showQuickPick(['deepseek', 'openai'], { - placeHolder: '选择模型提供商', - }); - if (selected) { - await config.update('ai.provider', selected, vscode.ConfigurationTarget.Global); - setupProvider.refresh(); - } - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('codeReviewer.selectModel', async () => { - const config = vscode.workspace.getConfiguration('vscode-code-reviewer'); - const current = config.get('ai.model', ''); - const selected = await vscode.window.showInputBox({ - prompt: '输入模型名称', - value: current, - placeHolder: 'deepseek-chat', - }); - if (selected) { - await config.update('ai.model', selected, vscode.ConfigurationTarget.Global); - setupProvider.refresh(); - } - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('codeReviewer.selectLanguage', async () => { - const config = vscode.workspace.getConfiguration('vscode-code-reviewer'); - const selected = await vscode.window.showQuickPick( - [ - { label: '中文(简体)', description: 'zh-CN' }, - { label: 'English', description: 'en' }, - { label: '日本語', description: 'ja' }, - ], - { placeHolder: '选择输出语言' } - ); - if (selected) { - await config.update('ai.outputLanguage', selected.description, vscode.ConfigurationTarget.Global); - setupProvider.refresh(); - } - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('codeReviewer.saveAndTest', async () => { - const apiKey = await getApiKey(context); - if (!apiKey) { - vscode.window.showWarningMessage('请先设置 API Key'); - return; - } - - const config = getAIConfig(); - - await vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: '测试连接...', - cancellable: false, - }, async () => { - try { - const provider = createProvider(config.provider, apiKey, config.endpoint); - await provider.chat('回复 ok', 'ping', { - model: config.model, - temperature: 0, - timeoutMs: 15000, - }); - setupProvider.connectionTested = true; - setupProvider.connectionSuccess = true; - setupProvider.refresh(); - vscode.window.showInformationMessage('✓ 连接成功', { modal: false }); - } catch (err) { - setupProvider.connectionTested = true; - setupProvider.connectionSuccess = false; - setupProvider.refresh(); - const message = err instanceof Error ? err.message : String(err); - vscode.window.showErrorMessage(`✗ 连接失败: ${message}`, { modal: false }); + vscode.commands.registerCommand('codeReviewer.openSetup', async () => { + try { + await vscode.commands.executeCommand('workbench.view.extension.code-reviewer'); + } catch { + const action = await vscode.window.showErrorMessage( + '无法打开设置面板', + '打开设置 (JSON)' + ); + if (action === '打开设置 (JSON)') { + await vscode.commands.executeCommand('workbench.action.openSettingsJson'); } - }); - }) - ); - - context.subscriptions.push( - vscode.commands.registerCommand('codeReviewer.toggleRule', async (ruleId: string) => { - const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!workspaceRoot) { return; } - - const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml'); - if (!fs.existsSync(configPath)) { return; } - - let content = fs.readFileSync(configPath, 'utf-8'); - const enabledPattern = new RegExp(`^(\\s*${ruleId}\\s*:\\s*\\n\\s*enabled\\s*:\\s*)(true|false)`, 'm'); - - if (enabledPattern.test(content)) { - const match = enabledPattern.exec(content); - if (match) { - const newValue = match[2] === 'true' ? 'false' : 'true'; - content = content.replace(enabledPattern, `$1${newValue}`); - } - } else { - content += `\n ${ruleId}:\n enabled: false\n`; } - - fs.writeFileSync(configPath, content, 'utf-8'); - setupProvider.refresh(); }) ); } diff --git a/src/adapters/stylelint.ts b/src/adapters/stylelint.ts index a747f6e..b8f41e9 100644 --- a/src/adapters/stylelint.ts +++ b/src/adapters/stylelint.ts @@ -1,17 +1,46 @@ import * as vscode from 'vscode'; -import stylelint from 'stylelint'; -import type { LinterAdapter, AdapterResult, LinterDiagnostic } from './adapter'; +import type { LinterAdapter, AdapterResult, LinterDiagnostic, Severity } from './adapter'; + +interface LinterOptions { + code?: string; + codeFilename?: string; + cwd?: string; +} + +interface LinterResult { + results: Array<{ + warnings: Array<{ + line: number; + column: number; + endLine?: number; + endColumn?: number; + rule: string; + severity: string; + text: string; + }>; + }>; +} export class StylelintAdapter implements LinterAdapter { id = 'stylelint'; supportedLanguages = ['css']; + private _module: { lint: (opts: LinterOptions) => Promise } | undefined; + + private async getModule(): Promise<{ lint: (opts: LinterOptions) => Promise }> { + if (!this._module) { + this._module = await import('stylelint') as { lint: (opts: LinterOptions) => Promise }; + } + return this._module; + } + isAvailable(): boolean { return true; } async check(document: vscode.TextDocument, workingDir: string): Promise { try { + const stylelint = await this.getModule(); const result = await stylelint.lint({ code: document.getText(), codeFilename: document.fileName, @@ -22,7 +51,7 @@ export class StylelintAdapter implements LinterAdapter { for (const res of result.results) { for (const w of res.warnings) { diagnostics.push({ - severity: w.severity, + severity: w.severity as Severity, ruleId: `stylelint:${w.rule}`, message: w.text, range: new vscode.Range( diff --git a/src/ai/engine.ts b/src/ai/engine.ts index a141637..bb697d2 100644 --- a/src/ai/engine.ts +++ b/src/ai/engine.ts @@ -1,7 +1,7 @@ import * as vscode from 'vscode'; import type { AIProvider } from './providers/base'; import { createProvider } from './factory'; -import { getAIProvider, getAIModel, getAIEndpoint, getAITemperature, getAITimeout, getAIOutputLanguage, getApiKey } from '../config'; +import { getAIProvider, getAIModel, getAIBaseUrl, getAITemperature, getAITimeout, getAIOutputLanguage, getApiKey } from '../config'; import type { LinterDiagnostic, CustomRule } from '../types'; import type { AIEngineResult, @@ -73,11 +73,11 @@ export async function runAIReview( } const providerId = getAIProvider(); - const endpoint = getAIEndpoint(); + const baseUrl = getAIBaseUrl(); let provider: AIProvider; try { - provider = createProvider(providerId, apiKey, endpoint); + provider = createProvider(providerId, apiKey, baseUrl); } catch (err) { return { customRuleResults: [], diff --git a/src/ai/factory.ts b/src/ai/factory.ts index 055201f..171f8a6 100644 --- a/src/ai/factory.ts +++ b/src/ai/factory.ts @@ -1,22 +1,118 @@ import { AIProvider } from './providers/base'; -import { DeepSeekProvider } from './providers/deepseek'; -import { OpenAIProvider } from './providers/openai'; +import { OpenAICompatibleProvider } from './providers/openai-compatible'; +import { GeminiProvider } from './providers/gemini'; +import { ClaudeProvider } from './providers/claude'; -type ProviderConstructor = new (apiKey: string, endpoint: string) => AIProvider; +interface ProviderInfo { + cls: new (apiKey: string, baseUrl: string) => AIProvider; + defaultBaseUrl: string; + models: string[]; + name: string; +} -const registry: Record = { - deepseek: DeepSeekProvider, - openai: OpenAIProvider, +const registry: Record = { + deepseek: { + cls: class extends OpenAICompatibleProvider { + constructor(apiKey: string, baseUrl: string) { + super(apiKey, baseUrl, 'deepseek', 'DeepSeek'); + } + }, + defaultBaseUrl: 'https://api.deepseek.com/v1', + models: ['deepseek-v4-pro', 'deepseek-v4-flash'], + name: 'DeepSeek', + }, + openai: { + cls: class extends OpenAICompatibleProvider { + constructor(apiKey: string, baseUrl: string) { + super(apiKey, baseUrl, 'openai', 'OpenAI'); + } + }, + defaultBaseUrl: 'https://api.openai.com/v1', + models: ['GPT-5.6 Sol', 'GPT-5.6 Terra', 'GPT-5.6 Luna', 'GPT-5.5', 'GPT-5.4'], + name: 'OpenAI', + }, + gemini: { + cls: GeminiProvider, + defaultBaseUrl: 'https://generativelanguage.googleapis.com/v1', + models: ['Gemini 3.1 Pro', 'Gemini 3.1 Flash', 'Gemini 3.1 Flash-Lite', 'Gemini 3 Pro', 'Gemini 3 Flash'], + name: 'Google Gemini', + }, + claude: { + cls: ClaudeProvider, + defaultBaseUrl: 'https://api.anthropic.com/v1', + models: ['Claude Fable 5', 'Claude Mythos 5', 'Claude Opus 4.8', 'Claude Sonnet 4.6'], + name: 'Anthropic Claude', + }, + hunyuan: { + cls: class extends OpenAICompatibleProvider { + constructor(apiKey: string, baseUrl: string) { + super(apiKey, baseUrl, 'hunyuan', '腾讯混元'); + } + }, + defaultBaseUrl: 'https://api.hunyuan.cloud.tencent.com/v1', + models: ['Hy3'], + name: '腾讯混元', + }, + zhipu: { + cls: class extends OpenAICompatibleProvider { + constructor(apiKey: string, baseUrl: string) { + super(apiKey, baseUrl, 'zhipu', '智谱AI'); + } + }, + defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', + models: ['GLM-5.2', 'GLM-5.1', 'GLM-5'], + name: '智谱AI', + }, + moonshot: { + cls: class extends OpenAICompatibleProvider { + constructor(apiKey: string, baseUrl: string) { + super(apiKey, baseUrl, 'moonshot', '月之暗面'); + } + }, + defaultBaseUrl: 'https://api.moonshot.cn/v1', + models: ['Kimi K2.7 Code', 'Kimi K2.6', 'Kimi K2.5'], + name: '月之暗面', + }, + tongyi: { + cls: class extends OpenAICompatibleProvider { + constructor(apiKey: string, baseUrl: string) { + super(apiKey, baseUrl, 'tongyi', '阿里通义'); + } + }, + defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', + models: ['Qwen3-2507', 'Qwen3.6-35B-A3B', 'Qwen3.5-397B-A17B'], + name: '阿里通义', + }, }; -export function createProvider(providerId: string, apiKey: string, endpoint: string): AIProvider { - const Cls = registry[providerId]; - if (!Cls) { +export function createProvider(providerId: string, apiKey: string, baseUrl: string): AIProvider { + const info = registry[providerId]; + if (!info) { throw new Error(`未知的 Provider: ${providerId}`); } - return new Cls(apiKey, endpoint); + return new info.cls(apiKey, baseUrl); } export function getProviderIds(): string[] { return Object.keys(registry); } + +export function getProviderInfo(providerId: string): ProviderInfo | undefined { + return registry[providerId]; +} + +export function getProviderDefaultBaseUrl(providerId: string): string { + return registry[providerId]?.defaultBaseUrl ?? ''; +} + +export function getProviderModels(providerId: string): string[] { + return registry[providerId]?.models ?? []; +} + +export function getAllProviderMeta(): Record { + const result: Record = {}; + for (const [id, info] of Object.entries(registry)) { + result[id] = { name: info.name, models: info.models }; + } + return result; +} diff --git a/src/ai/providers/base.ts b/src/ai/providers/base.ts index 8350f08..aa8dd38 100644 --- a/src/ai/providers/base.ts +++ b/src/ai/providers/base.ts @@ -10,7 +10,7 @@ export abstract class AIProvider { constructor( protected apiKey: string, - protected endpoint: string + protected baseUrl: string ) {} abstract chat( diff --git a/src/ai/providers/openai.ts b/src/ai/providers/claude.ts similarity index 68% rename from src/ai/providers/openai.ts rename to src/ai/providers/claude.ts index d2188fe..9c994eb 100644 --- a/src/ai/providers/openai.ts +++ b/src/ai/providers/claude.ts @@ -1,17 +1,18 @@ import { AIProvider, ChatOptions } from './base'; -export class OpenAIProvider extends AIProvider { - id = 'openai'; - name = 'OpenAI'; +export class ClaudeProvider extends AIProvider { + id = 'claude'; + name = 'Anthropic Claude'; async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise { - const url = `${this.endpoint}/chat/completions`; + const url = `${this.baseUrl}/messages`; const body = JSON.stringify({ model: options.model, + max_tokens: 4096, temperature: options.temperature, + system: systemPrompt, messages: [ - { role: 'system', content: systemPrompt }, { role: 'user', content: userPrompt }, ], }); @@ -24,7 +25,8 @@ export class OpenAIProvider extends AIProvider { method: 'POST', headers: { 'Content-Type': 'application/json', - Authorization: `Bearer ${this.apiKey}`, + 'x-api-key': this.apiKey, + 'anthropic-version': '2023-06-01', }, body, signal: controller.signal, @@ -35,13 +37,13 @@ export class OpenAIProvider extends AIProvider { if (response.status === 401) { throw new Error('API Key 无效,请重新设置'); } - throw new Error(`API 请求失败 (${response.status}): ${errorText}`); + throw new Error(`Claude API 请求失败 (${response.status}): ${errorText}`); } const data = await response.json() as { - choices: Array<{ message: { content: string } }>; + content?: Array<{ text?: string }>; }; - return data.choices[0]?.message?.content ?? ''; + return data.content?.[0]?.text ?? ''; } finally { clearTimeout(timeout); } diff --git a/src/ai/providers/gemini.ts b/src/ai/providers/gemini.ts new file mode 100644 index 0000000..fd45fec --- /dev/null +++ b/src/ai/providers/gemini.ts @@ -0,0 +1,51 @@ +import { AIProvider, ChatOptions } from './base'; + +export class GeminiProvider extends AIProvider { + id = 'gemini'; + name = 'Google Gemini'; + + async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise { + const url = `${this.baseUrl}/models/${options.model}:generateContent?key=${this.apiKey}`; + + const body = JSON.stringify({ + systemInstruction: { + parts: [{ text: systemPrompt }], + }, + contents: [ + { + parts: [{ text: userPrompt }], + }, + ], + generationConfig: { + temperature: options.temperature, + }, + }); + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), options.timeoutMs); + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body, + signal: controller.signal, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`Gemini API 请求失败 (${response.status}): ${errorText}`); + } + + const data = await response.json() as { + candidates?: Array<{ + content?: { parts?: Array<{ text?: string }> }; + }>; + }; + + return data.candidates?.[0]?.content?.parts?.[0]?.text ?? ''; + } finally { + clearTimeout(timeout); + } + } +} diff --git a/src/ai/providers/deepseek.ts b/src/ai/providers/openai-compatible.ts similarity index 81% rename from src/ai/providers/deepseek.ts rename to src/ai/providers/openai-compatible.ts index f50e2c3..3774058 100644 --- a/src/ai/providers/deepseek.ts +++ b/src/ai/providers/openai-compatible.ts @@ -1,11 +1,17 @@ import { AIProvider, ChatOptions } from './base'; -export class DeepSeekProvider extends AIProvider { - id = 'deepseek'; - name = 'DeepSeek'; +export class OpenAICompatibleProvider extends AIProvider { + id: string; + name: string; + + constructor(apiKey: string, baseUrl: string, id: string, name: string) { + super(apiKey, baseUrl); + this.id = id; + this.name = name; + } async chat(systemPrompt: string, userPrompt: string, options: ChatOptions): Promise { - const url = `${this.endpoint}/chat/completions`; + const url = `${this.baseUrl}/chat/completions`; const body = JSON.stringify({ model: options.model, diff --git a/src/config/ai.ts b/src/config/ai.ts index 7312ba8..3a06c0a 100644 --- a/src/config/ai.ts +++ b/src/config/ai.ts @@ -10,8 +10,8 @@ export function getAIModel(): string { return vscode.workspace.getConfiguration(ROOT).get('ai.model', 'deepseek-chat'); } -export function getAIEndpoint(): string { - return vscode.workspace.getConfiguration(ROOT).get('ai.endpoint', 'https://api.deepseek.com/v1'); +export function getAIBaseUrl(): string { + return vscode.workspace.getConfiguration(ROOT).get('ai.baseUrl', 'https://api.deepseek.com/v1'); } export function getAITemperature(): number { @@ -30,7 +30,7 @@ export function getAIConfig() { return { provider: getAIProvider(), model: getAIModel(), - endpoint: getAIEndpoint(), + baseUrl: getAIBaseUrl(), outputLanguage: getAIOutputLanguage(), }; } diff --git a/src/extension.ts b/src/extension.ts index 3498fe1..a6565fb 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -11,9 +11,11 @@ export function activate(context: vscode.ExtensionContext) { orchestrator = new Orchestrator(); const setupProvider = new SetupViewProvider(context); - vscode.window.registerTreeDataProvider('codeReviewer.setupView', setupProvider); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider('codeReviewer.setupView', setupProvider) + ); - registerCommands(context, orchestrator, setupProvider); + registerCommands(context, orchestrator); const debounceTimers = new Map(); diff --git a/src/views/setupView.js b/src/views/setupView.js new file mode 100644 index 0000000..72547eb --- /dev/null +++ b/src/views/setupView.js @@ -0,0 +1,169 @@ +(function () { + var data = JSON.parse(document.getElementById('setupViewData').textContent); + var PROVIDERS = data.providers; + + var vscode = acquireVsCodeApi(); + + function postMsg(type, value) { + vscode.postMessage({ type: type, value: value }); + } + + function addRule() { + var input = document.getElementById('newRuleInput'); + var name = input.value.trim(); + if (!name) { return; } + vscode.postMessage({ type: 'addRule', name: name }); + input.value = ''; + } + + function toggleRule(ruleId) { + vscode.postMessage({ type: 'toggleRule', ruleId: ruleId }); + } + + function deleteRule(ruleId) { + vscode.postMessage({ type: 'deleteRule', ruleId: ruleId }); + } + + function escapeHtml(text) { + var d = document.createElement('div'); + d.textContent = text; + return d.innerHTML; + } + + function populateModelOptions(providerId, selectModel) { + var modelSelect = document.getElementById('modelSelect'); + if (!modelSelect) { return; } + var models = (PROVIDERS[providerId] && PROVIDERS[providerId].models) || []; + modelSelect.innerHTML = ''; + for (var i = 0; i < models.length; i++) { + var opt = document.createElement('option'); + opt.value = models[i]; + opt.textContent = models[i]; + modelSelect.appendChild(opt); + } + if (models.length > 0) { + modelSelect.value = selectModel && models.indexOf(selectModel) !== -1 ? selectModel : models[0]; + } + } + + document.getElementById('providerSelect').addEventListener('change', function () { + populateModelOptions(this.value, null); + postMsg('setProvider', this.value); + }); + + document.getElementById('modelSelect').addEventListener('change', function () { + postMsg('setModel', this.value); + }); + + document.getElementById('baseUrlInput').addEventListener('change', function () { + postMsg('setBaseUrl', this.value); + }); + + window.addEventListener('message', function (event) { + var msg = event.data; + + if (msg.type === 'initConfig') { + var c = msg.config; + document.getElementById('languageSelect').value = c.language || 'zh-CN'; + + var bu = document.getElementById('baseUrlInput'); + if (bu) { bu.value = c.baseUrl || ''; } + + var ak = document.getElementById('apiKeyInput'); + if (ak) { + if (c.apiKeyConfigured) { + if (!ak.value || ak.value === '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022') { + ak.value = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'; + } + ak.placeholder = ''; + } else { + if (ak.value === '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022') { + ak.value = ''; + } + ak.placeholder = 'sk-...'; + } + } + + if (c.provider && PROVIDERS[c.provider]) { + populateModelOptions(c.provider, c.model); + var ps = document.getElementById('providerSelect'); + if (ps) { ps.value = c.provider; } + } + + var pb = document.getElementById('providerBadge'); + if (pb) { + pb.textContent = c.provider && c.model ? '已配置' : '未配置'; + pb.className = 'badge ' + (c.provider && c.model ? 'badge-configured' : 'badge-unconfigured'); + } + + var akb = document.getElementById('apiKeyBadge'); + akb.textContent = c.apiKeyConfigured && c.baseUrlConfigured ? '已配置' : '未配置'; + akb.className = 'badge ' + (c.apiKeyConfigured && c.baseUrlConfigured ? 'badge-configured' : 'badge-unconfigured'); + + var connDone = msg.connectionTested && msg.connectionSuccess; + var btnTest = document.getElementById('btnTest'); + if (connDone) { + btnTest.innerHTML = '✓ 已连接'; + } else if (msg.connectionTested && !msg.connectionSuccess) { + btnTest.innerHTML = '✗ 重试'; + } else { + btnTest.innerHTML = '保存并测试连接'; + } + btnTest.disabled = false; + + var ruleList = document.getElementById('ruleList'); + var countBadge = document.getElementById('ruleCountBadge'); + if (msg.rules && msg.rules.length > 0) { + countBadge.textContent = msg.rules.length + ' 条'; + countBadge.className = 'badge badge-configured'; + ruleList.innerHTML = msg.rules.map(function (r) { + return '
' + + '' + + '' + escapeHtml(r.id) + '' + + '
'; + }).join(''); + } else { + countBadge.textContent = '0 条'; + countBadge.className = 'badge badge-unconfigured'; + ruleList.innerHTML = '
暂无规则
'; + } + + var step1Done = c.provider && c.model && c.apiKeyConfigured && c.baseUrlConfigured; + var step2Done = msg.rules && msg.rules.length > 0; + var step3Done = msg.connectionTested && msg.connectionSuccess; + var steps = [step1Done, step2Done, step3Done]; + for (var i = 0; i < steps.length; i++) { + var el = document.querySelector('.gs-step[data-step="' + (i + 1) + '"]'); + if (!el) { continue; } + el.classList.remove('gs-step-done', 'gs-step-skip'); + if (steps[i]) { + el.classList.add('gs-step-done'); + } else if (step3Done) { + el.classList.add('gs-step-skip'); + } + } + } + + if (msg.type === 'testResult') { + var toast = document.getElementById('toast'); + toast.textContent = msg.message; + toast.className = 'toast show ' + (msg.success ? 'toast-success' : 'toast-error'); + setTimeout(function () { toast.className = 'toast'; }, 5000); + + var btnTest = document.getElementById('btnTest'); + btnTest.disabled = false; + if (msg.success) { + btnTest.innerHTML = '✓ 已连接'; + } else { + btnTest.innerHTML = '✗ 重试'; + } + } + }); + + vscode.postMessage({ type: 'ready' }); + + window.postMsg = postMsg; + window.addRule = addRule; + window.toggleRule = toggleRule; + window.deleteRule = deleteRule; +})(); diff --git a/src/views/setupView.ts b/src/views/setupView.ts index d90d165..17894f7 100644 --- a/src/views/setupView.ts +++ b/src/views/setupView.ts @@ -1,232 +1,635 @@ import * as vscode from 'vscode'; import * as path from 'path'; import * as fs from 'fs'; -import { getAIConfig, setApiKey, getApiKey, isApiKeyConfigured } from '../config'; -import { createProvider } from '../ai/factory'; +import { getAIProvider, getAIModel, getAIOutputLanguage, getAIConfig } from '../config/ai'; +import { getApiKey, setApiKey } from '../config/secret'; +import { createProvider, getAllProviderMeta, getProviderModels } from '../ai/factory'; import { loadActiveRules } from '../rules/yaml-parser'; import type { CustomRule } from '../types'; -type SetupItemType = 'section' | 'step' | 'providerGroup' | 'provider' | 'model' | 'apiKey' | 'language' | 'rule' | 'ruleAdd' | 'action'; +const languageLabels: Record = { + 'zh-CN': '中文(简体)', + 'en': 'English', + 'ja': '日本語', +}; -class SetupItem extends vscode.TreeItem { - constructor( - public readonly label: string, - public readonly itemType: SetupItemType, - public readonly collapsibleState: vscode.TreeItemCollapsibleState, - public readonly command?: vscode.Command, - public readonly iconPath?: vscode.ThemeIcon, - public readonly description?: string, - public readonly contextValue?: string, - ) { - super(label, collapsibleState); - } -} - -export class SetupViewProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData = new vscode.EventEmitter(); - readonly onDidChangeTreeData = this._onDidChangeTreeData.event; - - private customRules: CustomRule[] = []; - private apiKeyConfigured = false; +export class SetupViewProvider implements vscode.WebviewViewProvider { + private _view?: vscode.WebviewView; public connectionTested = false; public connectionSuccess = false; - constructor(private context: vscode.ExtensionContext) { - this.refresh(); + constructor(private context: vscode.ExtensionContext) {} + + resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken, + ): void { + this._view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [this.context.extensionUri], + }; + + const config = getAIConfig(); + const providers = getAllProviderMeta(); + const scriptUri = webviewView.webview.asWebviewUri( + vscode.Uri.joinPath(this.context.extensionUri, 'out', 'views', 'setupView.js') + ); + const aiConfig = getAIConfig(); + webviewView.webview.html = this.getHtml(providers, aiConfig, scriptUri); + + webviewView.webview.onDidReceiveMessage(async (msg) => { + switch (msg.type) { + case 'ready': + try { + await this.pushConfig(); + } catch (err) { + console.error('pushConfig failed:', err); + } + break; + case 'setApiKey': + await setApiKey(this.context, msg.value); + await this.pushConfig(); + break; + case 'setProvider': { + const cfg = vscode.workspace.getConfiguration('vscode-code-reviewer'); + await cfg.update('ai.provider', msg.value, vscode.ConfigurationTarget.Global); + const models = getProviderModels(msg.value); + if (models.length > 0) { + await cfg.update('ai.model', models[0], vscode.ConfigurationTarget.Global); + } + await this.pushConfig(); + break; + } + case 'setModel': + await vscode.workspace.getConfiguration('vscode-code-reviewer').update('ai.model', msg.value, vscode.ConfigurationTarget.Global); + await this.pushConfig(); + break; + case 'setBaseUrl': + await vscode.workspace.getConfiguration('vscode-code-reviewer') + .update('ai.baseUrl', msg.value || undefined, vscode.ConfigurationTarget.Global); + await this.pushConfig(); + break; + case 'setLanguage': + await vscode.workspace.getConfiguration('vscode-code-reviewer').update('ai.outputLanguage', msg.value, vscode.ConfigurationTarget.Global); + await this.pushConfig(); + break; + case 'saveAndTest': + await this.testConnection(); + break; + case 'toggleRule': + await this.toggleRule(msg.ruleId); + await this.pushConfig(); + break; + case 'deleteRule': + await this.deleteRule(msg.ruleId); + await this.pushConfig(); + break; + case 'addRule': + await this.addRule(msg.name); + await this.pushConfig(); + break; + case 'reset': + await this.resetConfig(); + await this.pushConfig(); + break; + } + }); } - async refresh(): Promise { + private async pushConfig(): Promise { + if (!this._view) {return;} + + const config = getAIConfig(); + const apiKeyConfigured = await isApiKeyConfigured(this.context); const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath ?? ''; - this.customRules = loadActiveRules(workspaceRoot); - this.apiKeyConfigured = await isApiKeyConfigured(this.context); - this._onDidChangeTreeData.fire(undefined); - } + const rules = loadActiveRules(workspaceRoot); - getTreeItem(element: SetupItem): vscode.TreeItem { - return element; - } + const ruleItems = rules.map(r => ({ + id: r.id, + severity: r.severity, + description: r.description, + })); - async getChildren(element?: SetupItem): Promise { - if (!element) { - return this.getRootItems(); - } + const baseUrlConfigured = isBaseUrlConfigured(); - switch (element.itemType) { - case 'providerGroup': return this.getProviderItems(); - case 'apiKey': return this.getApiKeyItems(); - case 'language': return this.getLanguageItems(); - default: return []; - } - } - - private getRootItems(): SetupItem[] { - const items: SetupItem[] = []; - - const step1Done = this.apiKeyConfigured; - const step2Done = this.customRules.some(r => r.id); - const step3Done = this.connectionTested && this.connectionSuccess; - - items.push(new SetupItem( - '快速开始', - 'section', - vscode.TreeItemCollapsibleState.Expanded, - undefined, - undefined, - undefined, - 'section' - )); - - items.push(new SetupItem( - step1Done ? '① 完成 AI 模型配置' : '① 配置 AI 模型及 API Key', - 'step', - vscode.TreeItemCollapsibleState.None, - step1Done ? undefined : { - command: 'codeReviewer.focusApiKey', - title: '配置 API Key', + this._view.webview.postMessage({ + type: 'initConfig', + config: { + provider: config.provider, + model: config.model, + baseUrl: baseUrlConfigured ? config.baseUrl : '', + baseUrlConfigured, + language: config.outputLanguage, + languageLabel: languageLabels[config.outputLanguage] || config.outputLanguage, + apiKeyConfigured, }, - step1Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined - )); + providers: getAllProviderMeta(), + rules: ruleItems, + connectionTested: this.connectionTested, + connectionSuccess: this.connectionSuccess, + }); + } - items.push(new SetupItem( - step2Done ? '② 完成规则启用' : '② 启用自定义规则', - 'step', - vscode.TreeItemCollapsibleState.None, - undefined, - step2Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined - )); - - items.push(new SetupItem( - step3Done ? '③ 完成连接测试' : '③ 保存并测试连接', - 'step', - vscode.TreeItemCollapsibleState.None, - step3Done ? undefined : { - command: 'codeReviewer.saveAndTest', - title: '测试连接', - }, - step3Done ? new vscode.ThemeIcon('pass-filled', new vscode.ThemeColor('charts.purple')) : undefined - )); - - items.push(new SetupItem( - '审核引擎', - 'section', - vscode.TreeItemCollapsibleState.Collapsed - )); - - items.push(new SetupItem( - 'AI 模型配置', - 'providerGroup', - vscode.TreeItemCollapsibleState.Collapsed - )); - - items.push(new SetupItem( - 'API Key', - 'apiKey', - vscode.TreeItemCollapsibleState.Collapsed - )); - - items.push(new SetupItem( - '输出语言', - 'language', - vscode.TreeItemCollapsibleState.Collapsed - )); - - items.push(new SetupItem( - `自定义规则 [${this.customRules.length} 条]`, - 'section', - vscode.TreeItemCollapsibleState.Expanded - )); - - for (const rule of this.customRules) { - items.push(new SetupItem( - rule.id, - 'rule', - vscode.TreeItemCollapsibleState.None, - { - command: 'codeReviewer.toggleRule', - title: '切换规则', - arguments: [rule.id], - }, - undefined, - rule.severity, - 'rule' - )); + private async testConnection(): Promise { + const apiKey = await getApiKey(this.context); + if (!apiKey) { + this._view?.webview.postMessage({ type: 'testResult', success: false, message: '请先设置 API Key' }); + return; } - items.push(new SetupItem( - '输入规则名称... [+ 添加]', - 'ruleAdd', - vscode.TreeItemCollapsibleState.None, - { - command: 'codeReviewer.addCustomRule', - title: '添加规则', - } - )); + if (!isBaseUrlConfigured()) { + this._view?.webview.postMessage({ type: 'testResult', success: false, message: '请先设置 Base URL' }); + return; + } - const connectionLabel = this.connectionTested - ? (this.connectionSuccess ? '✓ 已连接' : '✗ 重试') - : '保存并测试连接'; - - items.push(new SetupItem( - connectionLabel, - 'action', - vscode.TreeItemCollapsibleState.None, - { - command: 'codeReviewer.saveAndTest', - title: '测试连接', - } - )); - - return items; - } - - private getProviderItems(): SetupItem[] { const config = getAIConfig(); - return [ - new SetupItem(`提供商: ${config.provider}`, - 'provider', - vscode.TreeItemCollapsibleState.None, - { - command: 'codeReviewer.selectProvider', - title: '选择提供商', - } - ), - new SetupItem(`模型: ${config.model}`, - 'model', - vscode.TreeItemCollapsibleState.None, - { - command: 'codeReviewer.selectModel', - title: '选择模型', - } - ), - ]; + + try { + const provider = createProvider(config.provider, apiKey, config.baseUrl); + await provider.chat('回复 ok', 'ping', { + model: config.model, + temperature: 0, + timeoutMs: 15000, + }); + this.connectionTested = true; + this.connectionSuccess = true; + this._view?.webview.postMessage({ type: 'testResult', success: true, message: '✓ 连接成功' }); + } catch (err) { + this.connectionTested = true; + this.connectionSuccess = false; + const message = err instanceof Error ? err.message : String(err); + this._view?.webview.postMessage({ type: 'testResult', success: false, message: `✗ 连接失败: ${message}` }); + } + + await this.pushConfig(); } - private getApiKeyItems(): SetupItem[] { - return [ - new SetupItem( - '设置 API Key...', - 'apiKey', - vscode.TreeItemCollapsibleState.None, - { - command: 'codeReviewer.setApiKey', - title: '设置 API Key', - } - ), - ]; + private async toggleRule(ruleId: string): Promise { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) {return;} + + const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml'); + if (!fs.existsSync(configPath)) {return;} + + let content = fs.readFileSync(configPath, 'utf-8'); + const pattern = new RegExp(`^(\\s*${ruleId}\\s*:\\s*\\n\\s*enabled\\s*:\\s*)(true|false)`, 'm'); + + if (pattern.test(content)) { + const match = pattern.exec(content); + if (match) { + const newValue = match[2] === 'true' ? 'false' : 'true'; + content = content.replace(pattern, `$1${newValue}`); + } + } else { + content += `\n ${ruleId}:\n enabled: false\n`; + } + + fs.writeFileSync(configPath, content, 'utf-8'); } - private getLanguageItems(): SetupItem[] { - const config = getAIConfig(); - return [ - new SetupItem( - `当前: ${config.outputLanguage === 'zh-CN' ? '中文(简体)' : config.outputLanguage}`, - 'language', - vscode.TreeItemCollapsibleState.None, - { - command: 'codeReviewer.selectLanguage', - title: '选择输出语言', - } - ), - ]; + private async deleteRule(ruleId: string): Promise { + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) {return;} + + const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml'); + if (!fs.existsSync(configPath)) {return;} + + let content = fs.readFileSync(configPath, 'utf-8'); + const pattern = new RegExp(`^\\s*${ruleId}\\s*:\\n(?:\\s+.*\\n)*`, 'm'); + content = content.replace(pattern, ''); + fs.writeFileSync(configPath, content, 'utf-8'); + + const userRulesPath = path.join(workspaceRoot, '.code-review', 'rules', 'user-rules.yaml'); + if (fs.existsSync(userRulesPath)) { + let rulesContent = fs.readFileSync(userRulesPath, 'utf-8'); + const rulePattern = new RegExp(`(?:^|\\n)- id: ${ruleId}\\n(?: .*\\n)*`, ''); + rulesContent = rulesContent.replace(rulePattern, ''); + fs.writeFileSync(userRulesPath, rulesContent, 'utf-8'); + } + } + + private async addRule(name: string): Promise { + if (!name.trim()) {return;} + + const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; + if (!workspaceRoot) {return;} + + const configPath = path.join(workspaceRoot, '.code-review', 'config.yaml'); + if (!fs.existsSync(configPath)) { + fs.writeFileSync(configPath, 'rules:\n', 'utf-8'); + } + + let content = fs.readFileSync(configPath, 'utf-8'); + if (!content.includes('rules:')) { + content += '\nrules:\n'; + } + + content += ` ${name}:\n enabled: true\n`; + fs.writeFileSync(configPath, content, 'utf-8'); + + const rulesDir = path.join(workspaceRoot, '.code-review', 'rules'); + if (!fs.existsSync(rulesDir)) { + fs.mkdirSync(rulesDir, { recursive: true }); + } + + const userRulesPath = path.join(rulesDir, 'user-rules.yaml'); + const ruleEntry = `\n- id: ${name}\n severity: warning\n description: 请编辑规则描述\n message: 违反规则,请修改\n`; + fs.appendFileSync(userRulesPath, ruleEntry, 'utf-8'); + } + + private async resetConfig(): Promise { + const config = vscode.workspace.getConfiguration('vscode-code-reviewer'); + await config.update('ai.provider', undefined, vscode.ConfigurationTarget.Global); + await config.update('ai.model', undefined, vscode.ConfigurationTarget.Global); + await config.update('ai.outputLanguage', undefined, vscode.ConfigurationTarget.Global); + await this.deleteApiKey(); + this.connectionTested = false; + this.connectionSuccess = false; + } + + private async deleteApiKey(): Promise { + await this.context.secrets.delete('vscode-code-reviewer.apiKey'); + } + + private getHtml( + providers: Record, + config: { provider: string; model: string; outputLanguage: string; baseUrl: string }, + scriptUri: vscode.Uri, + ): string { + return ` + + + + + + + + +
+
+ + + + + 代码审查 · 设置 +
+ + +
+
快速开始
+
+
+ + 三步启用代码审核 +
+
+
+ 1 + 安装插件后,配置 AI 模型及 API Key,激活智能审核能力 +
+
+ 2 + 启用 自定义规则,补充团队特有的编码规范 +
+
+ 3 +
+ 保存并测试连接,验证配置无误后即可触发审核 + Ctrl + Shift + R 快捷键触发审核,结果实时显示在 审核结果报告页面中 +
+
+
+
+
+ + +
+
审核引擎
+
+
+ +
+
共通规则
+
Linter 静态分析
+
+
+
+ +
+
自定义规则
+
团队编码规范
+
+
+
+ +
+
AI 审核
+
深度代码审查
+
+
+
+
+ + +
+
AI 模型配置
+
+
+ 模型提供商 + 未配置 +
+
+ +
+
+ + +
+
建议使用支持结构化输出的模型。
+
+
+ + +
+
API Key
+
+
+ API Key + 未配置 +
+
+ +
+
+ + +
+
Key 仅存储在本地 VS Code 安全存储中。
+
+
+ + +
+
输出语言
+
+ + +
+
+ + +
+
自定义规则
+
+
+ 规则列表 + 0 条 +
+
+
+
+ + +
+
+
+
+ + +
+ + +
+ + +
+
+ + + + +`; } } + +async function isApiKeyConfigured(context: vscode.ExtensionContext): Promise { + const key = await context.secrets.get('vscode-code-reviewer.apiKey'); + return !!key; +} + +function isBaseUrlConfigured(): boolean { + const info = vscode.workspace.getConfiguration('vscode-code-reviewer').inspect('ai.baseUrl'); + return !!info?.globalValue || !!info?.workspaceValue; +}