# 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` 中模型选择是 `${(providers[config.provider]?.models ?? []).map(m => `` ).join('\n ')} ``` `setupView.js` 第 88-102 行的 `populateModelOptions()` 向 `` 替换为 `` + ``。`datalist` 提供预置建议列表,同时允许用户手动输入任意模型名。 #### HTML 变更(setupView.ts `getHtml()`) ```html
${(providers[config.provider]?.models ?? []).map(m => `
``` #### 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`,与现有结构一致。 --- ## 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(); 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 { const result: Record = {}; 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 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 { 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() — 供应商下拉框渲染 供应商下拉框保持 ` + `。 #### 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 行 | 模型区域 `` + `` | ### [MODIFY] `src/views/setupView.js` | 变更位置 | 变更内容 | |----------|----------| | 第 88-102 行 `populateModelOptions()` | 改为填充 `` 而非 `