From 558271285f399bcfebcddfeaa29ce39fd89434f9 Mon Sep 17 00:00:00 2001 From: S0ngRu1 <1922909737@qq.com> Date: Thu, 21 May 2026 15:38:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(opencode):=20=E6=96=B0=E5=A2=9E=20Open?= =?UTF-8?q?Code=20=E5=B7=A5=E5=85=B7=E9=85=8D=E7=BD=AE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 OpenCodeTool 实现 ITool 接口,配置写入 ~/.config/opencode/opencode.json - 采用统一自定义 provider qiniu(@ai-sdk/openai-compatible 适配器),覆盖 deepseek/qwen/anthropic 等任意前缀的模型;baseURL 自动补 /v1 - API Key 明文写入配置文件,目录权限 0o700、文件权限 0o600 兜底(对齐 Claude Code / Codex 行为) - 用 provider.qiniu.npm 字段作为托管标志,避免误删用户手写的同名 provider;卸载时只删 qiniu/-前缀的 model - 新增单选模型流程 runOpenCodeModelSelectionFlow,复用七牛 /v1/models 列表 - 扩展 ModelConfig 增加 opencodeModel 字段 - 中英文 i18n 同步新增翻译键 - 单测覆盖配置生成、幂等累积 models、用户 provider 保护、model 篡改保护 --- src/lib/config.ts | 4 + src/lib/tool-manager.ts | 2 + src/lib/tools/index.ts | 1 + src/lib/tools/opencode-tool.ts | 204 ++++++++++++++++++ .../flows/opencode-model-selection-flow.ts | 50 +++++ src/locales/en_US.json | 6 + src/locales/zh_CN.json | 6 + tests/opencode-tool.test.mjs | 139 ++++++++++++ 8 files changed, 412 insertions(+) create mode 100644 src/lib/tools/opencode-tool.ts create mode 100644 src/lib/wizard/flows/opencode-model-selection-flow.ts create mode 100644 tests/opencode-tool.test.mjs diff --git a/src/lib/config.ts b/src/lib/config.ts index a1b4d39..b95c899 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -16,6 +16,7 @@ export interface ModelConfig { subagentModel?: string; codeBuddyModels?: string[]; workbuddyModels?: string[]; + opencodeModel?: string; } // 配置文件结构 @@ -29,6 +30,7 @@ interface Config { subagentModel?: string; codeBuddyModels?: string[]; workbuddyModels?: string[]; + opencodeModel?: string; } // 配置管理器单例 @@ -136,6 +138,7 @@ class ConfigManager { subagentModel: this.config.subagentModel, codeBuddyModels: this.config.codeBuddyModels, workbuddyModels: this.config.workbuddyModels, + opencodeModel: this.config.opencodeModel, }; } @@ -148,6 +151,7 @@ class ConfigManager { if ('subagentModel' in models) this.config.subagentModel = models.subagentModel; if ('codeBuddyModels' in models) this.config.codeBuddyModels = models.codeBuddyModels; if ('workbuddyModels' in models) this.config.workbuddyModels = models.workbuddyModels; + if ('opencodeModel' in models) this.config.opencodeModel = models.opencodeModel; this.save(); } diff --git a/src/lib/tool-manager.ts b/src/lib/tool-manager.ts index dee3955..bed867b 100644 --- a/src/lib/tool-manager.ts +++ b/src/lib/tool-manager.ts @@ -3,6 +3,7 @@ import { ClaudeCodeTool } from './tools/claude-code-tool.js'; import { CodexTool } from './tools/codex-tool.js'; import { CodeBuddyTool } from './tools/codebuddy-tool.js'; import { WorkBuddyTool } from './tools/workbuddy-tool.js'; +import { OpenCodeTool } from './tools/opencode-tool.js'; // 工具注册中心 class ToolManager { @@ -14,6 +15,7 @@ class ToolManager { this.register(new CodexTool()); this.register(new CodeBuddyTool()); this.register(new WorkBuddyTool()); + this.register(new OpenCodeTool()); } // 注册工具 diff --git a/src/lib/tools/index.ts b/src/lib/tools/index.ts index 4957ff2..c53785f 100644 --- a/src/lib/tools/index.ts +++ b/src/lib/tools/index.ts @@ -3,3 +3,4 @@ export { ClaudeCodeTool } from './claude-code-tool.js'; export { CodexTool } from './codex-tool.js'; export { CodeBuddyTool } from './codebuddy-tool.js'; export { WorkBuddyTool } from './workbuddy-tool.js'; +export { OpenCodeTool } from './opencode-tool.js'; diff --git a/src/lib/tools/opencode-tool.ts b/src/lib/tools/opencode-tool.ts new file mode 100644 index 0000000..84f36ad --- /dev/null +++ b/src/lib/tools/opencode-tool.ts @@ -0,0 +1,204 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { execSync } from 'node:child_process'; +import type { ITool } from './base-tool.js'; +import { configManager, type ModelConfig } from '../config.js'; +import { t } from '../i18n.js'; +import { uiRenderer } from '../wizard/ui/ui-renderer.js'; +import { runOpenCodeModelSelectionFlow } from '../wizard/flows/opencode-model-selection-flow.js'; + +// OpenCode 跨平台配置路径(官方约定,Windows 也用此路径) +const OPENCODE_DIR = path.join(os.homedir(), '.config', 'opencode'); +const OPENCODE_CONFIG_FILE = path.join(OPENCODE_DIR, 'opencode.json'); + +// 七牛是多 provider 聚合端点(同时支持 OpenAI / Anthropic 协议), +// 用统一的自定义 provider + openai-compatible 适配器路由所有模型, +// 这样不论用户选 deepseek / qwen / anthropic / 任何前缀的模型都走同一入口。 +const PROVIDER_KEY = 'qiniu'; +const PROVIDER_DISPLAY_NAME = 'Qiniu'; +const PROVIDER_NPM = '@ai-sdk/openai-compatible'; + +// OpenCode 工具实现 +export class OpenCodeTool implements ITool { + name = 'opencode'; + displayName = 'OpenCode'; + command = 'opencode'; + installCommand = 'npm install -g opencode-ai'; + updateCommand = 'npm install -g opencode-ai@latest'; + npmPackageName = 'opencode-ai'; + aliases = ['oc']; + + getVersion(): string | null { + try { + const output = execSync('opencode --version', { stdio: 'pipe', encoding: 'utf-8' }).trim(); + const match = output.match(/(\d+\.\d+\.\d+)/); + return match ? match[1] : output; + } catch { + return null; + } + } + + isInstalled(): boolean { + try { + const command = process.platform === 'win32' ? 'where opencode' : 'which opencode'; + execSync(command, { stdio: 'pipe' }); + return true; + } catch { + return false; + } + } + + getConfig(): Record { + const content = readOpenCodeConfig(); + return { + configPath: OPENCODE_CONFIG_FILE, + configured: hasManagedOpenCodeConfig(content), + }; + } + + clearModelConfig(): void { + writeOpenCodeConfig(removeManagedOpenCodeConfig(readOpenCodeConfig())); + } + + async loadConfig(apiKey: string, baseUrl: string, models: ModelConfig): Promise { + const content = readOpenCodeConfig(); + writeOpenCodeConfig(buildOpenCodeConfig(content, baseUrl, apiKey, models.opencodeModel)); + } + + async unloadConfig(): Promise { + writeOpenCodeConfig(removeManagedOpenCodeConfig(readOpenCodeConfig())); + } + + async runModelConfigFlow(): Promise { + return runOpenCodeModelSelectionFlow(); + } + + renderModelConfigSummary(): void { + const models = configManager.getModels(); + uiRenderer.renderModelConfigItem(t('config_view_opencode_model'), models.opencodeModel); + } +} + +// ========== 纯函数(导出供测试) ========== + +// 在已有 JSON 上写入七牛托管的 model + provider.qiniu,保留所有其他字段 +export function buildOpenCodeConfig(existing: string, baseUrl: string, apiKey: string, model?: string): string { + const config = parseJson(existing); + + if (!config.$schema) { + config.$schema = 'https://opencode.ai/config.json'; + } + + // OpenCode model 字段格式: / + // 给用户选的 model 前置 qiniu/ 让 OpenCode 路由到我们托管的 provider + if (model) { + config.model = `${PROVIDER_KEY}/${model}`; + } + + const provider = (isPlainObject(config.provider) ? config.provider : {}) as Record; + // 七牛走 OpenAI 兼容协议,baseURL 必须带 /v1 后缀 + const sanitizedBaseUrl = `${(baseUrl || 'https://api.qnaigc.com').replace(/\/+$/, '')}/v1`; + + // 复用已有的 models 字段(OpenCode 会用此声明展示可选模型),追加当前选的 + const existingQiniu = isPlainObject(provider[PROVIDER_KEY]) + ? (provider[PROVIDER_KEY] as Record) + : undefined; + const models = isPlainObject(existingQiniu?.models) + ? { ...(existingQiniu!.models as Record) } + : {}; + if (model) { + models[model] = {}; + } + + provider[PROVIDER_KEY] = { + npm: PROVIDER_NPM, + name: PROVIDER_DISPLAY_NAME, + options: { + apiKey, + baseURL: sanitizedBaseUrl, + }, + models, + }; + config.provider = provider; + + return `${JSON.stringify(config, null, 2)}\n`; +} + +// 仅在 provider.qiniu 由本工具托管时移除它和顶层 model(以 qiniu/ 开头才删) +export function removeManagedOpenCodeConfig(existing: string): string { + const config = parseJson(existing); + const provider = isPlainObject(config.provider) ? (config.provider as Record) : undefined; + + if (isManagedQiniuProvider(provider)) { + delete provider![PROVIDER_KEY]; + if (Object.keys(provider!).length === 0) { + delete config.provider; + } + if (typeof config.model === 'string' && config.model.startsWith(`${PROVIDER_KEY}/`)) { + delete config.model; + } + } + + const keys = Object.keys(config); + if (keys.length === 0 || (keys.length === 1 && keys[0] === '$schema')) { + return ''; + } + + return `${JSON.stringify(config, null, 2)}\n`; +} + +// ========== 私有辅助 ========== + +function readOpenCodeConfig(): string { + try { + if (fs.existsSync(OPENCODE_CONFIG_FILE)) { + return fs.readFileSync(OPENCODE_CONFIG_FILE, 'utf-8'); + } + } catch { + // 文件不存在或读取失败按空配置处理 + } + return ''; +} + +function writeOpenCodeConfig(content: string): void { + if (content.trim()) { + if (!fs.existsSync(OPENCODE_DIR)) { + fs.mkdirSync(OPENCODE_DIR, { recursive: true, mode: 0o700 }); + } + fs.writeFileSync(OPENCODE_CONFIG_FILE, content, { encoding: 'utf-8', mode: 0o600 }); + } else if (fs.existsSync(OPENCODE_CONFIG_FILE)) { + // 完全空时删除文件,避免遗留无意义的空 JSON + fs.unlinkSync(OPENCODE_CONFIG_FILE); + } +} + +function parseJson(content: string): Record { + if (!content.trim()) return {}; + try { + const parsed = JSON.parse(content); + return isPlainObject(parsed) ? (parsed as Record) : {}; + } catch { + return {}; + } +} + +function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +// 识别本工具写入的 provider.qiniu —— 用 npm 字段作为管理标志, +// 避免误删用户自己手写的同名 provider +function isManagedQiniuProvider(provider: Record | undefined): boolean { + if (!provider) return false; + const qiniu = provider[PROVIDER_KEY]; + if (!isPlainObject(qiniu)) return false; + return qiniu.npm === PROVIDER_NPM; +} + +function hasManagedOpenCodeConfig(content: string): boolean { + const config = parseJson(content); + const provider = isPlainObject(config.provider) ? (config.provider as Record) : undefined; + return isManagedQiniuProvider(provider); +} + diff --git a/src/lib/wizard/flows/opencode-model-selection-flow.ts b/src/lib/wizard/flows/opencode-model-selection-flow.ts new file mode 100644 index 0000000..31a7c43 --- /dev/null +++ b/src/lib/wizard/flows/opencode-model-selection-flow.ts @@ -0,0 +1,50 @@ +import ora from 'ora'; +import { t } from '../../i18n.js'; +import { configManager } from '../../config.js'; +import { modelService, type ModelInfo } from '../../model-service.js'; +import { promptHelper } from '../ui/prompt-helper.js'; +import { uiRenderer } from '../ui/ui-renderer.js'; + +// OpenCode 模型单选流程 +export async function runOpenCodeModelSelectionFlow(): Promise { + uiRenderer.renderHeader(); + + const apiKey = configManager.getApiKey(); + if (!apiKey) { + uiRenderer.renderError(t('apikey_not_set')); + return false; + } + + const spinner = ora(t('model_fetching')).start(); + let models: ModelInfo[] = []; + try { + models = await modelService.fetchModels(configManager.baseUrl, apiKey); + spinner.succeed(); + } catch (error: unknown) { + const msg = error instanceof Error ? error.message : t('error_unknown'); + spinner.fail(t('model_fetch_failed', { error: msg })); + // 获取失败时仍允许手动输入 + } + + if (models.length === 0) { + uiRenderer.renderWarning(t('model_no_models')); + } + + uiRenderer.renderHeader(); + + const current = configManager.getModels().opencodeModel; + const choices = models.map((m) => ({ + name: m.id + (current === m.id ? ' (current)' : ''), + value: m.id, + })); + + const picked = await promptHelper.searchSelect(t('opencode_select_model'), choices, true); + if (!picked) { + uiRenderer.renderWarning(t('opencode_no_model_selected')); + return false; + } + + configManager.setModels({ opencodeModel: picked }); + uiRenderer.renderSuccess(t('opencode_config_success', { model: picked })); + return true; +} diff --git a/src/locales/en_US.json b/src/locales/en_US.json index 1342a61..4a3c5fd 100644 --- a/src/locales/en_US.json +++ b/src/locales/en_US.json @@ -143,8 +143,14 @@ "config_view_codebuddy_models": "CodeBuddy Models", "config_view_workbuddy_models": "WorkBuddy Models", "config_view_codex_model": "Model", + "config_view_opencode_model": "OpenCode Model", "codex_fixed_model_hint": "Codex uses a fixed model \"{{model}}\" via Qiniu proxy. No selection needed.", + "opencode_select_model": "Select model for OpenCode (type to search or enter custom model ID): ", + "opencode_no_model_selected": "No model selected, configuration cancelled", + "opencode_config_success": "OpenCode model set to: {{model}}", + "opencode_models_not_configured": "OpenCode model not configured. Please use \"Configure models\" first.", + "tool_desktop_launch_hint": "{{tool}} is a desktop application. Please launch it manually.", "tool_desktop_update_hint": "{{tool}} is a desktop application. Please visit the official website to download the latest version.", "tool_status_unknown": "Cannot detect (desktop app)", diff --git a/src/locales/zh_CN.json b/src/locales/zh_CN.json index 8132d62..91bf5f6 100644 --- a/src/locales/zh_CN.json +++ b/src/locales/zh_CN.json @@ -156,8 +156,14 @@ "config_view_codebuddy_models": "CodeBuddy 模型", "config_view_workbuddy_models": "WorkBuddy 模型", "config_view_codex_model": "模型", + "config_view_opencode_model": "OpenCode 模型", "codex_fixed_model_hint": "Codex 通过七牛代理固定使用模型「{{model}}」,无需选择", + "opencode_select_model": "请选择 OpenCode 使用的模型(可输入搜索或自定义模型 ID):", + "opencode_no_model_selected": "未选择模型,配置已取消", + "opencode_config_success": "OpenCode 已选择模型: {{model}}", + "opencode_models_not_configured": "尚未配置 OpenCode 模型,请先选择「配置模型」", + "tool_desktop_launch_hint": "{{tool}} 是桌面应用,请手动启动应用程序。", "tool_desktop_update_hint": "{{tool}} 是桌面应用,请访问官方网站下载最新版本。", "tool_status_unknown": "无法检测(桌面应用)", diff --git a/tests/opencode-tool.test.mjs b/tests/opencode-tool.test.mjs new file mode 100644 index 0000000..3231de1 --- /dev/null +++ b/tests/opencode-tool.test.mjs @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildOpenCodeConfig, + removeManagedOpenCodeConfig, +} from '../dist/lib/tools/opencode-tool.js'; + +test('buildOpenCodeConfig writes managed qiniu provider + qiniu/-prefixed model, preserves user fields', () => { + const existing = JSON.stringify({ + model: 'openai/gpt-4', + provider: { + openai: { options: { apiKey: 'sk-user', baseURL: 'https://api.openai.com/v1' } }, + anthropic: { options: { apiKey: 'sk-user-anthropic' } }, + }, + agent: { plan: { mode: 'subagent' } }, + mcp: { fs: { type: 'local', command: ['node', 'mcp.js'] } }, + }); + + const next = JSON.parse(buildOpenCodeConfig(existing, 'https://api.qnaigc.com', 'sk-qiniu-key', 'deepseek/deepseek-v4-flash')); + + // model 字段必须前置 qiniu/ 前缀,让 OpenCode 路由到我们的 provider + assert.equal(next.model, 'qiniu/deepseek/deepseek-v4-flash'); + + // qiniu provider 通过 openai-compatible 适配器路由 + assert.equal(next.provider.qiniu.npm, '@ai-sdk/openai-compatible'); + assert.equal(next.provider.qiniu.name, 'Qiniu'); + // apiKey 明文写入(与 Claude Code / Codex 一致),文件权限 0o600 保证安全 + assert.equal(next.provider.qiniu.options.apiKey, 'sk-qiniu-key'); + // baseURL 必须带 /v1 后缀(OpenAI 协议) + assert.equal(next.provider.qiniu.options.baseURL, 'https://api.qnaigc.com/v1'); + // models 字段声明当前选的模型 + assert.deepEqual(next.provider.qiniu.models, { 'deepseek/deepseek-v4-flash': {} }); + + // 用户原本的 openai / anthropic provider 必须保留 + assert.deepEqual(next.provider.openai, { + options: { apiKey: 'sk-user', baseURL: 'https://api.openai.com/v1' }, + }); + assert.deepEqual(next.provider.anthropic, { options: { apiKey: 'sk-user-anthropic' } }); + + // 其他用户字段保留 + assert.deepEqual(next.agent, { plan: { mode: 'subagent' } }); + assert.deepEqual(next.mcp, { fs: { type: 'local', command: ['node', 'mcp.js'] } }); + // 自动补 $schema + assert.equal(next.$schema, 'https://opencode.ai/config.json'); +}); + +test('buildOpenCodeConfig is idempotent — second write overwrites prior qiniu block, accumulates models', () => { + let content = buildOpenCodeConfig('', 'https://api.qnaigc.com', 'sk-1', 'anthropic/claude-sonnet-4-5'); + content = buildOpenCodeConfig(content, 'https://openai.sufy.com', 'sk-2', 'deepseek/deepseek-v4-flash'); + + const parsed = JSON.parse(content); + // 最新的 model 生效 + assert.equal(parsed.model, 'qiniu/deepseek/deepseek-v4-flash'); + // baseURL / apiKey 跟着最新的来 + assert.equal(parsed.provider.qiniu.options.baseURL, 'https://openai.sufy.com/v1'); + assert.equal(parsed.provider.qiniu.options.apiKey, 'sk-2'); + // models 字段累积,方便用户在 OpenCode UI 切换之前选过的模型 + assert.deepEqual(parsed.provider.qiniu.models, { + 'anthropic/claude-sonnet-4-5': {}, + 'deepseek/deepseek-v4-flash': {}, + }); + // provider 仍然只有 qiniu 一个(没有重复注入) + assert.equal(Object.keys(parsed.provider).length, 1); +}); + +test('buildOpenCodeConfig strips trailing slash from baseURL and appends /v1', () => { + const next = JSON.parse(buildOpenCodeConfig('', 'https://api.qnaigc.com///', 'sk', 'deepseek/v3')); + assert.equal(next.provider.qiniu.options.baseURL, 'https://api.qnaigc.com/v1'); +}); + +test('buildOpenCodeConfig handles empty / malformed existing config', () => { + assert.doesNotThrow(() => buildOpenCodeConfig('', 'https://api.qnaigc.com', 'sk', 'deepseek/v3')); + assert.doesNotThrow(() => buildOpenCodeConfig('not valid json', 'https://api.qnaigc.com', 'sk', 'deepseek/v3')); + assert.doesNotThrow(() => buildOpenCodeConfig('null', 'https://api.qnaigc.com', 'sk', 'deepseek/v3')); + assert.doesNotThrow(() => buildOpenCodeConfig('[]', 'https://api.qnaigc.com', 'sk', 'deepseek/v3')); + + const next = JSON.parse(buildOpenCodeConfig('not valid json', 'https://api.qnaigc.com', 'sk', 'deepseek/v3')); + assert.equal(next.model, 'qiniu/deepseek/v3'); + assert.equal(next.provider.qiniu.options.apiKey, 'sk'); +}); + +test('removeManagedOpenCodeConfig removes only managed qiniu + qiniu/-model, preserves other providers', () => { + const built = buildOpenCodeConfig( + JSON.stringify({ + provider: { + openai: { options: { apiKey: 'sk-user', baseURL: 'https://api.openai.com/v1' } }, + anthropic: { options: { apiKey: 'sk-user-anthropic' } }, + }, + agent: { plan: { mode: 'subagent' } }, + }), + 'https://api.qnaigc.com', + 'sk-qiniu', + 'deepseek/v3', + ); + + const cleaned = JSON.parse(removeManagedOpenCodeConfig(built)); + + // 我们写入的 model 和 qiniu provider 被移除 + assert.equal(cleaned.model, undefined); + assert.equal(cleaned.provider.qiniu, undefined); + // 用户原本的 provider 保留 + assert.deepEqual(cleaned.provider.openai, { + options: { apiKey: 'sk-user', baseURL: 'https://api.openai.com/v1' }, + }); + assert.deepEqual(cleaned.provider.anthropic, { options: { apiKey: 'sk-user-anthropic' } }); + assert.deepEqual(cleaned.agent, { plan: { mode: 'subagent' } }); +}); + +test('removeManagedOpenCodeConfig does NOT touch a user-owned qiniu provider (no npm marker)', () => { + // 用户自己写的同名 provider 没有 npm:@ai-sdk/openai-compatible 标志,必须保留 + const content = JSON.stringify({ + model: 'qiniu/some-model', + provider: { + qiniu: { + options: { apiKey: 'sk-user-own', baseURL: 'https://custom.qiniu.example/v1' }, + }, + }, + }); + + const next = JSON.parse(removeManagedOpenCodeConfig(content)); + assert.equal(next.model, 'qiniu/some-model'); + assert.equal(next.provider.qiniu.options.apiKey, 'sk-user-own'); +}); + +test('removeManagedOpenCodeConfig preserves non-qiniu/ model field even when qiniu provider is managed', () => { + // 边界场景:用户手动把 model 改成了别的 provider(如 openai/gpt-4),不应该被删 + const built = buildOpenCodeConfig('', 'https://api.qnaigc.com', 'sk', 'deepseek/v3'); + const tampered = JSON.parse(built); + tampered.model = 'openai/gpt-4'; + const cleaned = JSON.parse(removeManagedOpenCodeConfig(JSON.stringify(tampered)) || '{}'); + assert.equal(cleaned.model, 'openai/gpt-4'); +}); + +test('removeManagedOpenCodeConfig returns empty string when only $schema remains', () => { + const built = buildOpenCodeConfig('', 'https://api.qnaigc.com', 'sk', 'deepseek/v3'); + const cleaned = removeManagedOpenCodeConfig(built); + assert.equal(cleaned, ''); +}); From eb2e7667497531ed78b4dbc5146a8f2f5407985a Mon Sep 17 00:00:00 2001 From: S0ngRu1 <1922909737@qq.com> Date: Mon, 25 May 2026 15:22:33 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(opencode):=20=E8=B0=83=E6=95=B4?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E9=80=89=E6=8B=A9=E6=B5=81=E7=A8=8B=E6=B8=B2?= =?UTF-8?q?=E6=9F=93=E9=A1=BA=E5=BA=8F=E9=81=BF=E5=85=8D=E8=AD=A6=E5=91=8A?= =?UTF-8?q?=E8=A2=AB=E6=B8=85=E5=B1=8F=E8=A6=86=E7=9B=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit renderHeader() 内部会清屏,原本放在 renderWarning 之后会立即清掉 "未获取到模型" 提示,用户看不到。改为先 renderHeader 再 renderWarning。 --- src/lib/wizard/flows/opencode-model-selection-flow.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/wizard/flows/opencode-model-selection-flow.ts b/src/lib/wizard/flows/opencode-model-selection-flow.ts index 31a7c43..6d33416 100644 --- a/src/lib/wizard/flows/opencode-model-selection-flow.ts +++ b/src/lib/wizard/flows/opencode-model-selection-flow.ts @@ -26,12 +26,12 @@ export async function runOpenCodeModelSelectionFlow(): Promise { // 获取失败时仍允许手动输入 } + uiRenderer.renderHeader(); + if (models.length === 0) { uiRenderer.renderWarning(t('model_no_models')); } - uiRenderer.renderHeader(); - const current = configManager.getModels().opencodeModel; const choices = models.map((m) => ({ name: m.id + (current === m.id ? ' (current)' : ''), From 4826bdc51dcfbc21b3b55dae5bb5a39f99b9704a Mon Sep 17 00:00:00 2001 From: S0ngRu1 <1922909737@qq.com> Date: Mon, 25 May 2026 17:32:35 +0800 Subject: [PATCH 3/3] feat(opencode): support multiple qiniu models --- src/lib/config.ts | 4 ++ src/lib/tools/opencode-tool.ts | 61 +++++++++++++++++-- .../flows/opencode-model-selection-flow.ts | 50 ++++++++++++--- src/locales/en_US.json | 8 ++- src/locales/zh_CN.json | 8 ++- tests/opencode-tool.test.mjs | 15 +++++ 6 files changed, 129 insertions(+), 17 deletions(-) diff --git a/src/lib/config.ts b/src/lib/config.ts index b95c899..f7bb460 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -16,6 +16,7 @@ export interface ModelConfig { subagentModel?: string; codeBuddyModels?: string[]; workbuddyModels?: string[]; + opencodeModels?: string[]; opencodeModel?: string; } @@ -30,6 +31,7 @@ interface Config { subagentModel?: string; codeBuddyModels?: string[]; workbuddyModels?: string[]; + opencodeModels?: string[]; opencodeModel?: string; } @@ -138,6 +140,7 @@ class ConfigManager { subagentModel: this.config.subagentModel, codeBuddyModels: this.config.codeBuddyModels, workbuddyModels: this.config.workbuddyModels, + opencodeModels: this.config.opencodeModels, opencodeModel: this.config.opencodeModel, }; } @@ -151,6 +154,7 @@ class ConfigManager { if ('subagentModel' in models) this.config.subagentModel = models.subagentModel; if ('codeBuddyModels' in models) this.config.codeBuddyModels = models.codeBuddyModels; if ('workbuddyModels' in models) this.config.workbuddyModels = models.workbuddyModels; + if ('opencodeModels' in models) this.config.opencodeModels = models.opencodeModels; if ('opencodeModel' in models) this.config.opencodeModel = models.opencodeModel; this.save(); } diff --git a/src/lib/tools/opencode-tool.ts b/src/lib/tools/opencode-tool.ts index 84f36ad..29f71f6 100644 --- a/src/lib/tools/opencode-tool.ts +++ b/src/lib/tools/opencode-tool.ts @@ -4,6 +4,7 @@ import os from 'node:os'; import { execSync } from 'node:child_process'; import type { ITool } from './base-tool.js'; import { configManager, type ModelConfig } from '../config.js'; +import { marketModelService } from '../market-model-service.js'; import { t } from '../i18n.js'; import { uiRenderer } from '../wizard/ui/ui-renderer.js'; import { runOpenCodeModelSelectionFlow } from '../wizard/flows/opencode-model-selection-flow.js'; @@ -63,7 +64,24 @@ export class OpenCodeTool implements ITool { async loadConfig(apiKey: string, baseUrl: string, models: ModelConfig): Promise { const content = readOpenCodeConfig(); - writeOpenCodeConfig(buildOpenCodeConfig(content, baseUrl, apiKey, models.opencodeModel)); + const modelIds = getConfiguredOpenCodeModels(models); + if (modelIds.length === 0) { + throw new Error(t('opencode_models_not_configured')); + } + + const allModels = await marketModelService.fetchModels(baseUrl.replace(/\/+$/, ''), apiKey); + const selectedModels = allModels.filter(m => modelIds.includes(m.id)); + if (selectedModels.length === 0) { + throw new Error(t('opencode_all_models_unavailable', { models: modelIds.join(', ') })); + } + + const foundIds = new Set(selectedModels.map(m => m.id)); + const missingIds = modelIds.filter(id => !foundIds.has(id)); + if (missingIds.length > 0) { + uiRenderer.renderWarning(t('opencode_models_unavailable', { models: missingIds.join(', ') })); + } + + writeOpenCodeConfig(buildOpenCodeConfig(content, baseUrl, apiKey, selectedModels.map(m => m.id))); } async unloadConfig(): Promise { @@ -76,15 +94,23 @@ export class OpenCodeTool implements ITool { renderModelConfigSummary(): void { const models = configManager.getModels(); - uiRenderer.renderModelConfigItem(t('config_view_opencode_model'), models.opencodeModel); + const ids = getConfiguredOpenCodeModels(models); + const value = ids.length > 0 ? ids.join(', ') : undefined; + uiRenderer.renderModelConfigItem(t('config_view_opencode_models'), value); } } // ========== 纯函数(导出供测试) ========== // 在已有 JSON 上写入七牛托管的 model + provider.qiniu,保留所有其他字段 -export function buildOpenCodeConfig(existing: string, baseUrl: string, apiKey: string, model?: string): string { +export function buildOpenCodeConfig( + existing: string, + baseUrl: string, + apiKey: string, + modelOrModels?: string | string[], +): string { const config = parseJson(existing); + const selectedModels = normalizeModelIds(modelOrModels); if (!config.$schema) { config.$schema = 'https://opencode.ai/config.json'; @@ -92,8 +118,8 @@ export function buildOpenCodeConfig(existing: string, baseUrl: string, apiKey: s // OpenCode model 字段格式: / // 给用户选的 model 前置 qiniu/ 让 OpenCode 路由到我们托管的 provider - if (model) { - config.model = `${PROVIDER_KEY}/${model}`; + if (selectedModels.length > 0) { + config.model = `${PROVIDER_KEY}/${selectedModels[0]}`; } const provider = (isPlainObject(config.provider) ? config.provider : {}) as Record; @@ -107,7 +133,7 @@ export function buildOpenCodeConfig(existing: string, baseUrl: string, apiKey: s const models = isPlainObject(existingQiniu?.models) ? { ...(existingQiniu!.models as Record) } : {}; - if (model) { + for (const model of selectedModels) { models[model] = {}; } @@ -202,3 +228,26 @@ function hasManagedOpenCodeConfig(content: string): boolean { return isManagedQiniuProvider(provider); } +function getConfiguredOpenCodeModels(models: ModelConfig): string[] { + if (models.opencodeModels && models.opencodeModels.length > 0) { + return deduplicateModelIds(models.opencodeModels); + } + return models.opencodeModel ? [models.opencodeModel] : []; +} + +function normalizeModelIds(modelOrModels?: string | string[]): string[] { + if (!modelOrModels) return []; + return deduplicateModelIds(Array.isArray(modelOrModels) ? modelOrModels : [modelOrModels]); +} + +function deduplicateModelIds(models: string[]): string[] { + const seen = new Set(); + return models + .map(model => model.trim()) + .filter(model => { + if (!model || seen.has(model)) return false; + seen.add(model); + return true; + }); +} + diff --git a/src/lib/wizard/flows/opencode-model-selection-flow.ts b/src/lib/wizard/flows/opencode-model-selection-flow.ts index 6d33416..33b8932 100644 --- a/src/lib/wizard/flows/opencode-model-selection-flow.ts +++ b/src/lib/wizard/flows/opencode-model-selection-flow.ts @@ -1,7 +1,11 @@ import ora from 'ora'; import { t } from '../../i18n.js'; import { configManager } from '../../config.js'; -import { modelService, type ModelInfo } from '../../model-service.js'; +import { + marketModelService, + getModelCapabilities, + type MarketModelInfo, +} from '../../market-model-service.js'; import { promptHelper } from '../ui/prompt-helper.js'; import { uiRenderer } from '../ui/ui-renderer.js'; @@ -15,10 +19,22 @@ export async function runOpenCodeModelSelectionFlow(): Promise { return false; } + const current = getCurrentOpenCodeModels(); + if (current.length > 0) { + uiRenderer.renderHeader(); + uiRenderer.renderHint( + t('opencode_current_models', { models: current.join(', ') }), + ); + const reuse = await promptHelper.confirm(t('opencode_reuse_previous_selection'), true); + if (reuse) { + return true; + } + } + const spinner = ora(t('model_fetching')).start(); - let models: ModelInfo[] = []; + let models: MarketModelInfo[] = []; try { - models = await modelService.fetchModels(configManager.baseUrl, apiKey); + models = await marketModelService.fetchModels(configManager.baseUrl, apiKey); spinner.succeed(); } catch (error: unknown) { const msg = error instanceof Error ? error.message : t('error_unknown'); @@ -32,19 +48,35 @@ export async function runOpenCodeModelSelectionFlow(): Promise { uiRenderer.renderWarning(t('model_no_models')); } - const current = configManager.getModels().opencodeModel; const choices = models.map((m) => ({ - name: m.id + (current === m.id ? ' (current)' : ''), + name: `${m.name}${buildCapabilityTags(m)}${current.includes(m.id) ? ' (current)' : ''}`, value: m.id, })); - const picked = await promptHelper.searchSelect(t('opencode_select_model'), choices, true); - if (!picked) { + const selected = await promptHelper.checkbox(t('opencode_select_models'), choices); + if (!selected || selected.length === 0) { uiRenderer.renderWarning(t('opencode_no_model_selected')); return false; } - configManager.setModels({ opencodeModel: picked }); - uiRenderer.renderSuccess(t('opencode_config_success', { model: picked })); + configManager.setModels({ opencodeModels: selected, opencodeModel: selected[0] }); + uiRenderer.renderSuccess(t('opencode_config_success', { count: selected.length.toString() })); return true; } + +function getCurrentOpenCodeModels(): string[] { + const models = configManager.getModels(); + if (models.opencodeModels && models.opencodeModels.length > 0) { + return models.opencodeModels; + } + return models.opencodeModel ? [models.opencodeModel] : []; +} + +function buildCapabilityTags(model: MarketModelInfo): string { + const caps = getModelCapabilities(model); + const tags: string[] = []; + if (caps.toolCall) tags.push(t('codebuddy_model_tag_tool_call')); + if (caps.images) tags.push(t('codebuddy_model_tag_images')); + if (caps.reasoning) tags.push(t('codebuddy_model_tag_reasoning')); + return tags.length > 0 ? ` ${tags.join(' ')}` : ''; +} diff --git a/src/locales/en_US.json b/src/locales/en_US.json index 4a3c5fd..80dde40 100644 --- a/src/locales/en_US.json +++ b/src/locales/en_US.json @@ -144,12 +144,18 @@ "config_view_workbuddy_models": "WorkBuddy Models", "config_view_codex_model": "Model", "config_view_opencode_model": "OpenCode Model", + "config_view_opencode_models": "OpenCode Models", "codex_fixed_model_hint": "Codex uses a fixed model \"{{model}}\" via Qiniu proxy. No selection needed.", "opencode_select_model": "Select model for OpenCode (type to search or enter custom model ID): ", + "opencode_select_models": "Select models for OpenCode (/models will show these models)", "opencode_no_model_selected": "No model selected, configuration cancelled", - "opencode_config_success": "OpenCode model set to: {{model}}", + "opencode_config_success": "Configured {{count}} model(s) for OpenCode", + "opencode_current_models": "Current configured OpenCode models: {{models}}", + "opencode_reuse_previous_selection": "Previous OpenCode model configuration detected, reuse it?", "opencode_models_not_configured": "OpenCode model not configured. Please use \"Configure models\" first.", + "opencode_models_unavailable": "The following configured OpenCode models are no longer available in the Qiniu model market (likely delisted) and will be skipped: {{models}}", + "opencode_all_models_unavailable": "None of the configured OpenCode models are available in the Qiniu model market (likely delisted): {{models}}. Please reconfigure the models and try again.", "tool_desktop_launch_hint": "{{tool}} is a desktop application. Please launch it manually.", "tool_desktop_update_hint": "{{tool}} is a desktop application. Please visit the official website to download the latest version.", diff --git a/src/locales/zh_CN.json b/src/locales/zh_CN.json index 91bf5f6..894b44a 100644 --- a/src/locales/zh_CN.json +++ b/src/locales/zh_CN.json @@ -157,12 +157,18 @@ "config_view_workbuddy_models": "WorkBuddy 模型", "config_view_codex_model": "模型", "config_view_opencode_model": "OpenCode 模型", + "config_view_opencode_models": "OpenCode 模型", "codex_fixed_model_hint": "Codex 通过七牛代理固定使用模型「{{model}}」,无需选择", "opencode_select_model": "请选择 OpenCode 使用的模型(可输入搜索或自定义模型 ID):", + "opencode_select_models": "请选择 OpenCode 可用模型(这些模型会出现在 /models 列表中)", "opencode_no_model_selected": "未选择模型,配置已取消", - "opencode_config_success": "OpenCode 已选择模型: {{model}}", + "opencode_config_success": "OpenCode 已配置 {{count}} 个模型", + "opencode_current_models": "当前已配置的 OpenCode 模型:{{models}}", + "opencode_reuse_previous_selection": "检测到已有 OpenCode 模型配置,是否复用?", "opencode_models_not_configured": "尚未配置 OpenCode 模型,请先选择「配置模型」", + "opencode_models_unavailable": "以下已配置的 OpenCode 模型已不在七牛模型市场中(可能已下架),将跳过:{{models}}", + "opencode_all_models_unavailable": "已配置的 OpenCode 模型都不在七牛模型市场中(可能已下架):{{models}}。请重新配置模型后再试。", "tool_desktop_launch_hint": "{{tool}} 是桌面应用,请手动启动应用程序。", "tool_desktop_update_hint": "{{tool}} 是桌面应用,请访问官方网站下载最新版本。", diff --git a/tests/opencode-tool.test.mjs b/tests/opencode-tool.test.mjs index 3231de1..a8a3149 100644 --- a/tests/opencode-tool.test.mjs +++ b/tests/opencode-tool.test.mjs @@ -69,6 +69,21 @@ test('buildOpenCodeConfig strips trailing slash from baseURL and appends /v1', ( assert.equal(next.provider.qiniu.options.baseURL, 'https://api.qnaigc.com/v1'); }); +test('buildOpenCodeConfig writes multiple qiniu models for OpenCode /models selection', () => { + const next = JSON.parse(buildOpenCodeConfig( + '', + 'https://api.qnaigc.com', + 'sk', + ['deepseek/deepseek-v4-flash', 'anthropic/claude-sonnet-4-5'], + )); + + assert.equal(next.model, 'qiniu/deepseek/deepseek-v4-flash'); + assert.deepEqual(next.provider.qiniu.models, { + 'deepseek/deepseek-v4-flash': {}, + 'anthropic/claude-sonnet-4-5': {}, + }); +}); + test('buildOpenCodeConfig handles empty / malformed existing config', () => { assert.doesNotThrow(() => buildOpenCodeConfig('', 'https://api.qnaigc.com', 'sk', 'deepseek/v3')); assert.doesNotThrow(() => buildOpenCodeConfig('not valid json', 'https://api.qnaigc.com', 'sk', 'deepseek/v3'));