Files
2026Technology-Competition/docs/superpowers/specs/2026-07-28-provider-registry-dynamic-design.md
T
范智鹏 effcf30802 feat: 适配器 i18n + Provider 动态注册 + SetupView 重构 + jars 资源
- 适配器 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 依赖及测试用例
2026-07-28 22:57:15 +08:00

856 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 KeySecretStorage | 保留不变 |
| `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 节的数据结构和逻辑为实现依据。*