- 适配器 i18n 接入(eslint/pmd/sql-lint/stylelint) - Provider 动态注册机制(registry.ts + providers.json + factory 重构) - SetupView 全面重构(setupView.ts 新增 600+ 行) - i18n 消息扩展(messages.ts +210 行) - 规则导入流程优化(import-service / prompt-builder) - 新增 PMD jars 依赖及测试用例
856 lines
28 KiB
Markdown
856 lines
28 KiB
Markdown
# AI 供应商与模型动态化设计书
|
||
|
||
> 将写死的供应商注册表外置为 JSON 配置文件,模型下拉框改为可编辑输入框,并修复 `package.json` 不一致问题。
|
||
|
||
- **项目**: vscode-code-reviewer (Code Purifier)
|
||
- **分支**: vscode-code-reviewer
|
||
- **日期**: 2026-07-28
|
||
- **版本**: 1.1.0 → 1.2.0
|
||
|
||
---
|
||
|
||
## 目录
|
||
|
||
- [01 问题分析](#01-问题分析)
|
||
- [02 设计目标与范围](#02-设计目标与范围)
|
||
- [03 方案 A:模型输入框可编辑化](#03-方案-a模型输入框可编辑化)
|
||
- [04 方案 B:供应商注册表外置化](#04-方案-b供应商注册表外置化)
|
||
- [05 修复 package.json 不一致](#05-修复-packagejson-不一致)
|
||
- [06 数据结构设计](#06-数据结构设计)
|
||
- [07 注册表加载器实现](#07-注册表加载器实现)
|
||
- [08 工厂函数改造](#08-工厂函数改造)
|
||
- [09 侧边栏面板改造](#09-侧边栏面板改造)
|
||
- [10 文件级变更规格](#10-文件级变更规格)
|
||
- [11 迁移路径](#11-迁移路径)
|
||
- [12 测试要点](#12-测试要点)
|
||
|
||
---
|
||
|
||
## 01 问题分析
|
||
|
||
当前 `src/ai/factory.ts` 中的 `registry` 是一个纯静态对象,硬编码了 8 个供应商及其模型列表。这种写法在大模型快速迭代的环境下存在三类问题。
|
||
|
||
### 1.1 模型列表过期
|
||
|
||
`factory.ts` 中的模型列表是编码时手写的快照,一旦供应商发布新模型或下线旧模型,插件无法感知。用户只能使用列表中预置的模型名,即使供应商 API 已经支持新模型,也必须等插件发版后才能选用。
|
||
|
||
### 1.2 默认值不一致
|
||
|
||
| 位置 | 内容 | 问题 |
|
||
|------|------|------|
|
||
| `package.json` 第 92-96 行 | `ai.provider` enum 仅有 `deepseek`、`openai` | 工厂注册了 8 个供应商,但配置 schema 只声明了 2 个 |
|
||
| `package.json` 第 100 行 | 默认模型 `deepseek-chat` | `factory.ts` 中 DeepSeek 的模型列表为 `deepseek-v4-pro`、`deepseek-v4-flash`,不包含 `deepseek-chat` |
|
||
|
||
用户首次安装后,默认模型在下拉框中找不到对应项,`populateModelOptions()` 会回退到列表第一个模型,导致实际使用的模型与配置中记录的不一致。
|
||
|
||
### 1.3 无法使用列表外模型
|
||
|
||
`setupView.ts` 中模型选择是 `<select>` 元素,只能从预置列表中选择。如果用户想使用新发布的模型、供应商列表中不存在的 OpenAI 兼容服务(如本地 Ollama、vLLM 部署),无法手动输入模型名。
|
||
|
||
---
|
||
|
||
## 02 设计目标与范围
|
||
|
||
### 优化项清单
|
||
|
||
| # | 优化项 | 方案 | 涉及文件 | 变更类型 |
|
||
|---|--------|------|----------|----------|
|
||
| 1 | 模型选择改为可编辑输入框 | A | `views/setupView.ts`、`views/setupView.js` | 修改 UI + 逻辑 |
|
||
| 2 | 供应商注册表外置为 JSON | B | 新增 `providers.json`、`ai/registry.ts` | 新增文件 |
|
||
| 3 | 工厂函数改为动态加载 | B | `ai/factory.ts` | 重构 |
|
||
| 4 | 支持用户自定义供应商覆盖 | B | `ai/registry.ts` | 新增逻辑 |
|
||
| 5 | 修复 `package.json` 不一致 | 附加 | `package.json` | 修改 schema |
|
||
|
||
### 零破坏性原则
|
||
|
||
所有变更不改变 AI 审查引擎的消费侧接口。`engine.ts` 调用 `createProvider()` 和 `getAIModel()` 的方式不变,`runAIReview()` 的逻辑分支不受影响。现有用户的配置(provider、model、baseUrl、apiKey)在升级后自动保留,无需重新配置。
|
||
|
||
---
|
||
|
||
## 03 方案 A:模型输入框可编辑化
|
||
|
||
### 现状
|
||
|
||
`setupView.ts` 第 889-891 行渲染模型选择器:
|
||
|
||
```html
|
||
<select id="modelSelect">${(providers[config.provider]?.models ?? []).map(m =>
|
||
`<option value="${m}"${config.model === m ? ' selected' : ''}>${m}</option>`
|
||
).join('\n ')}</select>
|
||
```
|
||
|
||
`setupView.js` 第 88-102 行的 `populateModelOptions()` 向 `<select>` 填充 `<option>`:
|
||
|
||
```javascript
|
||
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];
|
||
}
|
||
}
|
||
```
|
||
|
||
### 改造方案
|
||
|
||
将 `<select>` 替换为 `<input type="text" list="...">` + `<datalist>`。`datalist` 提供预置建议列表,同时允许用户手动输入任意模型名。
|
||
|
||
#### HTML 变更(setupView.ts `getHtml()`)
|
||
|
||
```html
|
||
<div class="field">
|
||
<label class="field-label">${t('setup.model')}</label>
|
||
<input type="text" id="modelInput" list="modelOptions"
|
||
value="${config.model || ''}"
|
||
placeholder="${t('setup.modelPlaceholder')}"
|
||
onchange="postMsg('setModel', this.value)">
|
||
<datalist id="modelOptions">
|
||
${(providers[config.provider]?.models ?? []).map(m =>
|
||
`<option value="${m}">`
|
||
).join('\n ')}
|
||
</datalist>
|
||
</div>
|
||
```
|
||
|
||
#### JS 变更(setupView.js `populateModelOptions()`)
|
||
|
||
```javascript
|
||
function populateModelOptions(providerId, currentModel) {
|
||
var datalist = document.getElementById('modelOptions');
|
||
var input = document.getElementById('modelInput');
|
||
if (!datalist || !input) { return; }
|
||
|
||
var models = (PROVIDERS[providerId] && PROVIDERS[providerId].models) || [];
|
||
|
||
// 刷新 datalist 选项
|
||
datalist.innerHTML = '';
|
||
for (var i = 0; i < models.length; i++) {
|
||
var opt = document.createElement('option');
|
||
opt.value = models[i];
|
||
datalist.appendChild(opt);
|
||
}
|
||
|
||
// 保留用户已输入的模型名,仅当为空时填入第一个建议
|
||
if (!input.value && models.length > 0) {
|
||
input.value = models[0];
|
||
postMsg('setModel', models[0]);
|
||
}
|
||
}
|
||
```
|
||
|
||
#### 事件绑定变更
|
||
|
||
`setupView.js` 第 109-111 行原来监听 `modelSelect` 的 `change` 事件,改为监听 `modelInput`:
|
||
|
||
```javascript
|
||
document.getElementById('modelInput').addEventListener('change', function () {
|
||
postMsg('setModel', this.value.trim());
|
||
});
|
||
```
|
||
|
||
#### initConfig 消息处理变更
|
||
|
||
`setupView.js` 第 143-147 行原来调用 `populateModelOptions(c.provider, c.model)` 并设置 `select.value`。改为直接设置 `input.value`:
|
||
|
||
```javascript
|
||
if (c.provider && PROVIDERS[c.provider]) {
|
||
populateModelOptions(c.provider, c.model);
|
||
var mi = document.getElementById('modelInput');
|
||
if (mi && c.model) { mi.value = c.model; }
|
||
var ps = document.getElementById('providerSelect');
|
||
if (ps) { ps.value = c.provider; }
|
||
}
|
||
```
|
||
|
||
### 新增国际化键
|
||
|
||
| 键 | zh-CN | en | ja |
|
||
|----|-------|-----|-----|
|
||
| `setup.modelPlaceholder` | 输入或选择模型名 | Enter or select model name | モデル名を入力または選択 |
|
||
|
||
---
|
||
|
||
## 04 方案 B:供应商注册表外置化
|
||
|
||
### 整体架构
|
||
|
||
```
|
||
插件内置 providers.json(随 VSIX 打包)
|
||
↓
|
||
registry.ts 加载并解析
|
||
↓
|
||
用户工作区 .code-review/providers.json(可选覆盖)
|
||
↓
|
||
合并后的 ProviderConfig[] 供 factory.ts 和 setupView.ts 消费
|
||
```
|
||
|
||
### providers.json 格式
|
||
|
||
```json
|
||
{
|
||
"providers": [
|
||
{
|
||
"id": "deepseek",
|
||
"name": "DeepSeek",
|
||
"protocol": "openai-compatible",
|
||
"defaultBaseUrl": "https://api.deepseek.com/v1",
|
||
"models": ["deepseek-chat", "deepseek-reasoner"]
|
||
},
|
||
{
|
||
"id": "openai",
|
||
"name": "OpenAI",
|
||
"protocol": "openai-compatible",
|
||
"defaultBaseUrl": "https://api.openai.com/v1",
|
||
"models": ["gpt-4o", "gpt-4o-mini", "gpt-4-turbo"]
|
||
},
|
||
{
|
||
"id": "gemini",
|
||
"name": "Google Gemini",
|
||
"protocol": "gemini",
|
||
"defaultBaseUrl": "https://generativelanguage.googleapis.com/v1",
|
||
"models": ["gemini-2.0-flash", "gemini-1.5-pro"]
|
||
},
|
||
{
|
||
"id": "claude",
|
||
"name": "Anthropic Claude",
|
||
"protocol": "claude",
|
||
"defaultBaseUrl": "https://api.anthropic.com/v1",
|
||
"models": ["claude-sonnet-4-20250514", "claude-opus-4-20250514"]
|
||
},
|
||
{
|
||
"id": "hunyuan",
|
||
"name": "腾讯混元",
|
||
"protocol": "openai-compatible",
|
||
"defaultBaseUrl": "https://api.hunyuan.cloud.tencent.com/v1",
|
||
"models": ["hunyuan-pro"]
|
||
},
|
||
{
|
||
"id": "zhipu",
|
||
"name": "智谱AI",
|
||
"protocol": "openai-compatible",
|
||
"defaultBaseUrl": "https://open.bigmodel.cn/api/paas/v4",
|
||
"models": ["glm-4-plus", "glm-4-flash"]
|
||
},
|
||
{
|
||
"id": "moonshot",
|
||
"name": "月之暗面",
|
||
"protocol": "openai-compatible",
|
||
"defaultBaseUrl": "https://api.moonshot.cn/v1",
|
||
"models": ["moonshot-v1-8k", "moonshot-v1-32k"]
|
||
},
|
||
{
|
||
"id": "tongyi",
|
||
"name": "阿里通义",
|
||
"protocol": "openai-compatible",
|
||
"defaultBaseUrl": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||
"models": ["qwen-plus", "qwen-turbo"]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
### 字段说明
|
||
|
||
| 字段 | 类型 | 必填 | 说明 |
|
||
|------|------|------|------|
|
||
| `id` | string | 是 | 供应商唯一标识,用于配置存储和工厂查找 |
|
||
| `name` | string | 是 | 显示名称,出现在侧边栏下拉框 |
|
||
| `protocol` | string | 是 | 协议类型,决定实例化哪个 Provider 类。可选值:`openai-compatible`、`gemini`、`claude` |
|
||
| `defaultBaseUrl` | string | 是 | 该供应商的默认 API 端点 |
|
||
| `models` | string[] | 是 | 预置模型列表,作为 datalist 建议项。用户可输入列表外的模型名 |
|
||
|
||
### 用户覆盖机制
|
||
|
||
用户可在工作区根目录的 `.code-review/providers.json` 中放置同名文件,格式与内置文件一致。加载器按以下规则合并:
|
||
|
||
| 情况 | 合并结果 |
|
||
|------|----------|
|
||
| 用户文件包含内置中已有的供应商(id 相同) | 用户配置完整覆盖该供应商的所有字段 |
|
||
| 用户文件包含内置中没有的供应商 | 追加为新供应商 |
|
||
| 用户文件缺少内置中的某供应商 | 保留内置的该供应商 |
|
||
|
||
示例:用户想添加本地 Ollama 服务,在工作区放置 `.code-review/providers.json`:
|
||
|
||
```json
|
||
{
|
||
"providers": [
|
||
{
|
||
"id": "ollama",
|
||
"name": "Ollama (本地)",
|
||
"protocol": "openai-compatible",
|
||
"defaultBaseUrl": "http://localhost:11434/v1",
|
||
"models": ["llama3.1", "qwen2.5"]
|
||
},
|
||
{
|
||
"id": "deepseek",
|
||
"name": "DeepSeek (自定义代理)",
|
||
"protocol": "openai-compatible",
|
||
"defaultBaseUrl": "https://my-proxy.example.com/v1",
|
||
"models": ["deepseek-chat", "deepseek-coder"]
|
||
}
|
||
]
|
||
}
|
||
```
|
||
|
||
合并后:`ollama` 作为新供应商追加,`deepseek` 的 name/baseUrl/models 被用户配置完整覆盖,其余 6 个内置供应商保留。
|
||
|
||
---
|
||
|
||
## 05 修复 package.json 不一致
|
||
|
||
### `ai.provider` 配置项
|
||
|
||
去掉 `enum` 限制,改为自由 string,因为供应商列表现在是动态的:
|
||
|
||
```jsonc
|
||
"vscode-code-reviewer.ai.provider": {
|
||
"type": "string",
|
||
"default": "deepseek",
|
||
"description": "AI 模型提供商"
|
||
}
|
||
```
|
||
|
||
#### `ai.model` 配置项
|
||
|
||
去掉 `enum` 限制(如果有),保持为自由 string:
|
||
|
||
```jsonc
|
||
"vscode-code-reviewer.ai.model": {
|
||
"type": "string",
|
||
"default": "deepseek-chat",
|
||
"description": "AI 模型名称"
|
||
}
|
||
```
|
||
|
||
> 默认模型保持 `deepseek-chat`,与内置 `providers.json` 中 DeepSeek 的第一个模型对齐。用户升级后如果之前未手动改过模型,会自动匹配到列表中的建议项。
|
||
|
||
---
|
||
|
||
## 06 数据结构设计
|
||
|
||
### ProviderConfig 接口
|
||
|
||
```typescript
|
||
export type ProviderProtocol = 'openai-compatible' | 'gemini' | 'claude';
|
||
|
||
export interface ProviderConfig {
|
||
id: string;
|
||
name: string;
|
||
protocol: ProviderProtocol;
|
||
defaultBaseUrl: string;
|
||
models: string[];
|
||
}
|
||
|
||
export interface ProvidersFile {
|
||
providers: ProviderConfig[];
|
||
}
|
||
```
|
||
|
||
### ProviderMeta 接口(供 UI 消费)
|
||
|
||
```typescript
|
||
export interface ProviderMeta {
|
||
name: string;
|
||
models: string[];
|
||
}
|
||
```
|
||
|
||
`getAllProviderMeta()` 的返回类型保持 `Record<string, ProviderMeta>`,与现有结构一致。
|
||
|
||
---
|
||
|
||
## 07 注册表加载器实现
|
||
|
||
### 新建文件:`src/ai/registry.ts`
|
||
|
||
```typescript
|
||
import * as vscode from 'vscode';
|
||
import * as fs from 'fs';
|
||
import * as path from 'path';
|
||
import type { ProviderConfig, ProvidersFile, ProviderMeta } from './types';
|
||
|
||
let cachedProviders: ProviderConfig[] | null = null;
|
||
|
||
/** 读取插件内置的 providers.json */
|
||
function loadBuiltinProviders(extensionUri: vscode.Uri): ProviderConfig[] {
|
||
const filePath = vscode.Uri.joinPath(extensionUri, 'providers.json').fsPath;
|
||
try {
|
||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||
const data = JSON.parse(raw) as ProvidersFile;
|
||
return data.providers ?? [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** 读取用户工作区的 .code-review/providers.json */
|
||
function loadUserProviders(): ProviderConfig[] {
|
||
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;
|
||
if (!workspaceRoot) { return []; }
|
||
|
||
const filePath = path.join(workspaceRoot, '.code-review', 'providers.json');
|
||
if (!fs.existsSync(filePath)) { return []; }
|
||
|
||
try {
|
||
const raw = fs.readFileSync(filePath, 'utf-8');
|
||
const data = JSON.parse(raw) as ProvidersFile;
|
||
return data.providers ?? [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
/** 合并内置与用户配置:用户配置按 id 覆盖内置 */
|
||
function mergeProviders(
|
||
builtin: ProviderConfig[],
|
||
user: ProviderConfig[]
|
||
): ProviderConfig[] {
|
||
const map = new Map<string, ProviderConfig>();
|
||
|
||
for (const p of builtin) {
|
||
map.set(p.id, p);
|
||
}
|
||
|
||
for (const p of user) {
|
||
map.set(p.id, p);
|
||
}
|
||
|
||
return Array.from(map.values());
|
||
}
|
||
|
||
/**
|
||
* 获取合并后的供应商列表。
|
||
* 首次调用时加载并缓存,后续调用直接返回缓存。
|
||
* extensionUri 仅首次调用时需要传入。
|
||
*/
|
||
export function getProviders(extensionUri?: vscode.Uri): ProviderConfig[] {
|
||
if (cachedProviders) { return cachedProviders; }
|
||
|
||
if (!extensionUri) {
|
||
return [];
|
||
}
|
||
|
||
const builtin = loadBuiltinProviders(extensionUri);
|
||
const user = loadUserProviders();
|
||
cachedProviders = mergeProviders(builtin, user);
|
||
return cachedProviders;
|
||
}
|
||
|
||
/** 清除缓存,强制下次调用时重新加载 */
|
||
export function invalidateProviderCache(): void {
|
||
cachedProviders = null;
|
||
}
|
||
|
||
/** 按 id 查找单个供应商 */
|
||
export function getProviderById(
|
||
extensionUri: vscode.Uri,
|
||
id: string
|
||
): ProviderConfig | undefined {
|
||
return getProviders(extensionUri).find(p => p.id === id);
|
||
}
|
||
|
||
/** 获取所有供应商的 UI 元数据 */
|
||
export function getAllProviderMeta(
|
||
extensionUri: vscode.Uri
|
||
): Record<string, ProviderMeta> {
|
||
const result: Record<string, ProviderMeta> = {};
|
||
for (const p of getProviders(extensionUri)) {
|
||
result[p.id] = {
|
||
name: p.name,
|
||
models: p.models,
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
```
|
||
|
||
### 缓存失效策略
|
||
|
||
在 `setupView.ts` 的 `resolveWebviewView()` 中注册文件系统监听器,当用户编辑 `.code-review/providers.json` 后自动刷新:
|
||
|
||
```typescript
|
||
const watcher = vscode.workspace.createFileSystemWatcher(
|
||
'**/.code-review/providers.json'
|
||
);
|
||
|
||
watcher.onDidChange(() => {
|
||
invalidateProviderCache();
|
||
this.pushConfig();
|
||
});
|
||
|
||
watcher.onDidCreate(() => {
|
||
invalidateProviderCache();
|
||
this.pushConfig();
|
||
});
|
||
|
||
webviewView.onDidDispose(() => {
|
||
watcher.dispose();
|
||
});
|
||
```
|
||
|
||
---
|
||
|
||
## 08 工厂函数改造
|
||
|
||
### 现状
|
||
|
||
`factory.ts` 中的 `registry` 是静态对象,每个供应商的 `cls` 字段直接引用具体类。`createProvider()` 通过 `new info.cls(apiKey, baseUrl)` 实例化。
|
||
|
||
### 改造方案
|
||
|
||
`factory.ts` 不再持有静态注册表,改为从 `registry.ts` 动态加载。`protocol` 字段决定实例化哪个类。
|
||
|
||
### 改造后的 factory.ts
|
||
|
||
```typescript
|
||
import * as vscode from 'vscode';
|
||
import type { AIProvider } from './providers/base';
|
||
import type { ProviderConfig, ProviderMeta, ProviderProtocol } from './types';
|
||
import { OpenAICompatibleProvider } from './providers/openai-compatible';
|
||
import { GeminiProvider } from './providers/gemini';
|
||
import { ClaudeProvider } from './providers/claude';
|
||
import {
|
||
getProviders,
|
||
getProviderById,
|
||
getAllProviderMeta as getAllProviderMetaFromRegistry,
|
||
invalidateProviderCache,
|
||
} from './registry';
|
||
|
||
const PROTOCOL_MAP: Record<ProviderProtocol,
|
||
new (apiKey: string, baseUrl: string, id: string, name: string) => any
|
||
> = {
|
||
'openai-compatible': OpenAICompatibleProvider,
|
||
gemini: GeminiProvider,
|
||
claude: ClaudeProvider,
|
||
};
|
||
|
||
export function createProvider(
|
||
providerId: string,
|
||
apiKey: string,
|
||
baseUrl: string,
|
||
extensionUri: vscode.Uri
|
||
): AIProvider {
|
||
const config = getProviderById(extensionUri, providerId);
|
||
if (!config) {
|
||
throw new Error(`未知的 Provider: ${providerId}`);
|
||
}
|
||
|
||
const Cls = PROTOCOL_MAP[config.protocol];
|
||
if (!Cls) {
|
||
throw new Error(`未知的协议类型: ${config.protocol}`);
|
||
}
|
||
|
||
if (config.protocol === 'openai-compatible') {
|
||
return new Cls(apiKey, baseUrl, config.id, config.name);
|
||
}
|
||
return new Cls(apiKey, baseUrl);
|
||
}
|
||
|
||
export function getProviderModels(extensionUri: vscode.Uri, providerId: string): string[] {
|
||
return getProviderById(extensionUri, providerId)?.models ?? [];
|
||
}
|
||
|
||
export function getAllProviderMeta(
|
||
extensionUri: vscode.Uri
|
||
): Record<string, ProviderMeta> {
|
||
return getAllProviderMetaFromRegistry(extensionUri);
|
||
}
|
||
|
||
export { invalidateProviderCache };
|
||
```
|
||
|
||
### GeminiProvider / ClaudeProvider 适配
|
||
|
||
现有的 `GeminiProvider` 和 `ClaudeProvider` 构造函数签名为 `constructor(apiKey, baseUrl)`,不接收 `id` 和 `name`。保持不变,在 `createProvider()` 中根据 protocol 分别调用不同的构造方式。
|
||
|
||
### engine.ts 调用链变更
|
||
|
||
`engine.ts` 第 195 行调用 `createProvider()` 时需要传入 `extensionUri`:
|
||
|
||
```typescript
|
||
// 现状
|
||
provider = createProvider(providerId, apiKey, baseUrl);
|
||
|
||
// 改造后
|
||
provider = createProvider(providerId, apiKey, baseUrl, context.extensionUri);
|
||
```
|
||
|
||
`runAIReview()` 已经接收 `context: vscode.ExtensionContext` 参数(第 173 行),`context.extensionUri` 可直接获取。
|
||
|
||
### testConnection() 调用链变更
|
||
|
||
`setupView.ts` 第 380 行的 `testConnection()` 同样需要传入 `extensionUri`:
|
||
|
||
```typescript
|
||
const provider = createProvider(config.provider, apiKey, config.baseUrl, this.context.extensionUri);
|
||
```
|
||
|
||
### convertContentWithAI() 调用链变更
|
||
|
||
`src/rules/import-service.ts` 第 382 行的 `convertContentWithAI()` 也调用了 `createProvider()`。该函数已接收 `context: vscode.ExtensionContext` 参数(第 367 行),可直接使用 `context.extensionUri`:
|
||
|
||
```typescript
|
||
// 现状
|
||
const provider = createProvider(config.provider, apiKey, config.baseUrl);
|
||
|
||
// 改造后
|
||
const provider = createProvider(config.provider, apiKey, config.baseUrl, context.extensionUri);
|
||
```
|
||
|
||
---
|
||
|
||
## 09 侧边栏面板改造
|
||
|
||
### setupView.ts 变更
|
||
|
||
#### resolveWebviewView() — 初始化时传入 extensionUri
|
||
|
||
第 203 行:
|
||
|
||
```typescript
|
||
// 现状
|
||
const providers = getAllProviderMeta();
|
||
|
||
// 改造后
|
||
const providers = getAllProviderMeta(this.context.extensionUri);
|
||
```
|
||
|
||
#### pushConfig() — 推送供应商元数据
|
||
|
||
第 335 行:
|
||
|
||
```typescript
|
||
// 现状
|
||
providers: getAllProviderMeta(),
|
||
|
||
// 改造后
|
||
providers: getAllProviderMeta(this.context.extensionUri),
|
||
```
|
||
|
||
#### setProvider 消息处理 — 传入 extensionUri
|
||
|
||
第 245 行:
|
||
|
||
```typescript
|
||
// 现状
|
||
const models = getProviderModels(msg.value);
|
||
|
||
// 改造后
|
||
const models = getProviderModels(this.context.extensionUri, msg.value);
|
||
```
|
||
|
||
#### getHtml() — 供应商下拉框渲染
|
||
|
||
供应商下拉框保持 `<select>` 不变(供应商数量有限,且不允许用户手动输入供应商 id),但 `providers` 数据来源改为 `getAllProviderMeta(this.context.extensionUri)`。
|
||
|
||
模型区域改为方案 A 中的 `<input> + <datalist>`。
|
||
|
||
#### resolveWebviewView() — 新增 FileSystemWatcher
|
||
|
||
新增对 `.code-review/providers.json` 的文件系统监听,在 `onDidDispose` 中一并释放。
|
||
|
||
### setupView.js 变更
|
||
|
||
#### modelSelect → modelInput
|
||
|
||
按方案 A 的改造方案,将所有 `modelSelect` 引用替换为 `modelInput`,`populateModelOptions()` 改为填充 `datalist`。
|
||
|
||
#### initConfig 消息处理 — 设置 modelInput 值
|
||
|
||
```javascript
|
||
if (c.provider && PROVIDERS[c.provider]) {
|
||
populateModelOptions(c.provider, c.model);
|
||
var mi = document.getElementById('modelInput');
|
||
if (mi) { mi.value = c.model || ''; }
|
||
var ps = document.getElementById('providerSelect');
|
||
if (ps) { ps.value = c.provider; }
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## 10 文件级变更规格
|
||
|
||
### [NEW] `providers.json`(项目根目录)
|
||
|
||
插件内置的供应商配置文件,随 VSIX 打包。内容见 04 节的 JSON 示例。包含 8 个供应商的完整配置。
|
||
|
||
### [NEW] `src/ai/types.ts`
|
||
|
||
提取 `ProviderConfig`、`ProvidersFile`、`ProviderMeta`、`ProviderProtocol` 类型定义。`factory.ts` 和 `registry.ts` 均从此文件导入。
|
||
|
||
```typescript
|
||
export type ProviderProtocol = 'openai-compatible' | 'gemini' | 'claude';
|
||
|
||
export interface ProviderConfig {
|
||
id: string;
|
||
name: string;
|
||
protocol: ProviderProtocol;
|
||
defaultBaseUrl: string;
|
||
models: string[];
|
||
}
|
||
|
||
export interface ProvidersFile {
|
||
providers: ProviderConfig[];
|
||
}
|
||
|
||
export interface ProviderMeta {
|
||
name: string;
|
||
models: string[];
|
||
}
|
||
```
|
||
|
||
### [NEW] `src/ai/registry.ts`
|
||
|
||
注册表加载器,实现内置 JSON 读取、用户覆盖合并、缓存管理。完整实现见 07 节。
|
||
|
||
### [MODIFY] `src/ai/factory.ts`
|
||
|
||
| 变更位置 | 变更内容 |
|
||
|----------|----------|
|
||
| 删除 | 静态 `registry` 对象及其全部内容 |
|
||
| 删除 | `ProviderInfo` 接口定义 |
|
||
| 修改 | `createProvider()` 签名新增 `extensionUri` 参数,改为从 registry 动态查找 |
|
||
| 修改 | `getProviderModels()` 签名新增 `extensionUri` 参数 |
|
||
| 修改 | `getAllProviderMeta()` 签名新增 `extensionUri` 参数,委托给 registry.ts |
|
||
| 新增 | `PROTOCOL_MAP` 常量,映射 protocol → Provider 类 |
|
||
| 新增 | `invalidateProviderCache` re-export |
|
||
| 删除 | `getProviderIds()`、`getProviderInfo()`、`getProviderDefaultBaseUrl()`(改为通过 registry.ts 获取) |
|
||
|
||
### [MODIFY] `src/ai/engine.ts`
|
||
|
||
| 变更位置 | 变更内容 |
|
||
|----------|----------|
|
||
| 第 195 行 | `createProvider(providerId, apiKey, baseUrl)` → `createProvider(providerId, apiKey, baseUrl, context.extensionUri)` |
|
||
|
||
### [MODIFY] `src/rules/import-service.ts`
|
||
|
||
| 变更位置 | 变更内容 |
|
||
|----------|----------|
|
||
| 第 382 行 | `createProvider(config.provider, apiKey, config.baseUrl)` → `createProvider(config.provider, apiKey, config.baseUrl, context.extensionUri)` |
|
||
|
||
### [MODIFY] `src/views/setupView.ts`
|
||
|
||
| 变更位置 | 变更内容 |
|
||
|----------|----------|
|
||
| 第 203 行 | `getAllProviderMeta()` 调用改为传入 `this.context.extensionUri` |
|
||
| 第 245 行 | `getProviderModels(msg.value)` 调用改为传入 `this.context.extensionUri, msg.value` |
|
||
| 第 335 行 | `getAllProviderMeta()` 调用改为传入 `this.context.extensionUri` |
|
||
| 第 380 行 | `createProvider()` 调用新增 `this.context.extensionUri` 参数 |
|
||
| `resolveWebviewView()` | 新增 `FileSystemWatcher` 监听 `.code-review/providers.json` 变更 |
|
||
| 第 889-891 行 | 模型区域 `<select id="modelSelect">` 替换为 `<input id="modelInput" list="modelOptions">` + `<datalist id="modelOptions">` |
|
||
|
||
### [MODIFY] `src/views/setupView.js`
|
||
|
||
| 变更位置 | 变更内容 |
|
||
|----------|----------|
|
||
| 第 88-102 行 `populateModelOptions()` | 改为填充 `<datalist>` 而非 `<select>`,保留用户已输入的值 |
|
||
| 第 109-111 行 `modelSelect` change 事件 | 改为 `modelInput` change 事件 |
|
||
| 第 143-147 行 `initConfig` 消息处理 | `modelSelect.value` 设置改为 `modelInput.value` 设置 |
|
||
|
||
### [MODIFY] `package.json`
|
||
|
||
| 变更位置 | 变更内容 |
|
||
|----------|----------|
|
||
| 第 92-96 行 `ai.provider` 配置项 | 删除 `enum` 数组,改为自由 string |
|
||
| 版本号 | `1.1.0` → `1.2.0` |
|
||
|
||
### [MODIFY] `src/i18n/messages.ts`
|
||
|
||
新增 `setup.modelPlaceholder` 国际化键(三语言)。
|
||
|
||
### `.vscodeignore`
|
||
|
||
确认 `providers.json` 不在忽略列表中,确保随 VSIX 打包。
|
||
|
||
---
|
||
|
||
## 11 迁移路径
|
||
|
||
### 11.1 内置 providers.json 中的模型名修正
|
||
|
||
当前 `factory.ts` 中的模型名(如 `deepseek-v4-pro`、`GPT-5.6 Sol`、`Claude Fable 5`)并非真实模型名。新的 `providers.json` 中应使用各供应商 API 实际接受的模型标识符:
|
||
|
||
| 供应商 | 当前(虚构) | 修正为(真实) |
|
||
|--------|-------------|---------------|
|
||
| DeepSeek | `deepseek-v4-pro`, `deepseek-v4-flash` | `deepseek-chat`, `deepseek-reasoner` |
|
||
| OpenAI | `GPT-5.6 Sol`, `GPT-5.6 Terra` 等 | `gpt-4o`, `gpt-4o-mini`, `gpt-4-turbo` |
|
||
| Gemini | `Gemini 3.1 Pro` 等 | `gemini-2.0-flash`, `gemini-1.5-pro` |
|
||
| Claude | `Claude Fable 5` 等 | `claude-sonnet-4-20250514`, `claude-opus-4-20250514` |
|
||
| 混元 | `Hy3` | `hunyuan-pro` |
|
||
| 智谱 | `GLM-5.2` 等 | `glm-4-plus`, `glm-4-flash` |
|
||
| 月之暗面 | `Kimi K2.7 Code` 等 | `moonshot-v1-8k`, `moonshot-v1-32k` |
|
||
| 通义 | `Qwen3-2507` 等 | `qwen-plus`, `qwen-turbo` |
|
||
|
||
> 以上模型名基于截至 2026-07 各供应商 API 文档的公开模型标识符。实际发布前需再次验证各供应商 API 文档的最新模型列表。
|
||
|
||
### 11.2 用户配置兼容性
|
||
|
||
| 用户已有配置 | 升级后行为 |
|
||
|-------------|-----------|
|
||
| `ai.provider` = `deepseek` | 保留,`providers.json` 中存在该 id,正常工作 |
|
||
| `ai.model` = `deepseek-chat` | 保留,新 `providers.json` 中 DeepSeek 列表包含此项,datalist 中可见 |
|
||
| `ai.model` = `deepseek-v4-pro`(旧列表中的值) | 保留,`modelInput` 中显示该值,但不在 datalist 建议中。用户可继续使用或手动修改。API 是否接受取决于供应商 |
|
||
| `ai.baseUrl` 有值 | 保留不变,切换供应商时不自动覆盖 |
|
||
| API Key(SecretStorage) | 保留不变 |
|
||
| `ai.outputLanguage` | 保留不变 |
|
||
|
||
### 11.3 向后兼容保证
|
||
|
||
`createProvider()` 的调用方(`engine.ts`、`setupView.ts`)需要传入 `extensionUri`。这是签名变更,但均在插件内部调用,不影响用户侧 API。`runAIReview()` 的外部调用签名不变。
|
||
|
||
---
|
||
|
||
## 12 测试要点
|
||
|
||
### 方案 A:模型输入框
|
||
|
||
| 测试场景 | 操作步骤 | 期望结果 |
|
||
|----------|----------|----------|
|
||
| 选择预置模型 | 点击模型输入框,从 datalist 建议中选择一个 | 输入框填入选中值,`ai.model` 配置更新 |
|
||
| 手动输入模型名 | 在模型输入框中输入 `my-custom-model` 并失焦 | 输入框保留输入值,`ai.model` 更新为 `my-custom-model` |
|
||
| 切换供应商后保留手输模型 | 先手动输入模型名,再切换供应商 | 模型输入框清空并填入新供应商的第一个建议模型 |
|
||
| 空值处理 | 清空模型输入框并失焦 | `ai.model` 更新为空字符串,AI 审查时报错提示需配置模型 |
|
||
|
||
### 方案 B:供应商注册表
|
||
|
||
| 测试场景 | 前置条件 | 期望结果 |
|
||
|----------|----------|----------|
|
||
| 内置 JSON 加载 | 无用户覆盖文件 | `getProviders()` 返回 8 个内置供应商 |
|
||
| 用户追加供应商 | 工作区有 `.code-review/providers.json`,包含 `ollama` | 合并后返回 9 个供应商,`ollama` 出现在下拉框 |
|
||
| 用户覆盖内置供应商 | 用户文件中 `deepseek` 的 `defaultBaseUrl` 改为代理地址 | 合并后 DeepSeek 的 `defaultBaseUrl` 为用户配置的代理地址 |
|
||
| 用户文件格式错误 | `.code-review/providers.json` 内容为非法 JSON | 加载器静默返回空数组,不影响内置供应商加载 |
|
||
| 用户文件不存在 | 工作区无 `.code-review/` 目录 | 仅返回内置 8 个供应商 |
|
||
| 文件监听刷新 | 编辑并保存 `.code-review/providers.json` | 缓存失效,侧边栏自动刷新供应商列表 |
|
||
| 缓存命中 | 连续调用两次 `getProviders()` | 第二次直接返回缓存,不重复读取文件系统 |
|
||
|
||
### 附加修复项
|
||
|
||
| 测试场景 | 操作步骤 | 期望结果 |
|
||
|----------|----------|----------|
|
||
| package.json 无 enum 限制 | 在 settings.json 中手动写入 `ai.provider: "custom-provider"` | VS Code 不报 schema 校验错误,插件正常加载 |
|
||
| 连接测试使用 extensionUri | 配置完 API Key 后点击"保存并测试" | `createProvider()` 正确接收 extensionUri,测试请求发送到正确端点 |
|
||
|
||
### 回归测试
|
||
|
||
| 测试场景 | 期望结果 |
|
||
|----------|----------|
|
||
| AI 代码审查(`Ctrl+Shift+R`) | 正常运行,使用配置的供应商和模型 |
|
||
| 选中代码审查 | 正常运行 |
|
||
| 审查结果面板导出 | 正常导出报告 |
|
||
| 自定义规则管理 | 规则文件导入、删除正常 |
|
||
| 静态分析适配器面板 | 4 张适配器卡片正常渲染,不受供应商改造影响 |
|
||
| 保存文件自动分析 | 500ms 防抖后触发静态分析 |
|
||
|
||
---
|
||
|
||
*本设计书基于 vscode-code-reviewer 插件 v1.1.0 编写,覆盖方案 A(模型输入框可编辑化)、方案 B(供应商注册表外置化)及 `package.json` 修复的全部技术实现规格。实施时按第 10 节文件级变更规格逐文件执行,以第 06-09 节的数据结构和逻辑为实现依据。*
|