Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface ModelConfig {
subagentModel?: string;
codeBuddyModels?: string[];
workbuddyModels?: string[];
opencodeModels?: string[];
opencodeModel?: string;
hermesModel?: string;
}

Expand All @@ -30,6 +32,8 @@ interface Config {
subagentModel?: string;
codeBuddyModels?: string[];
workbuddyModels?: string[];
opencodeModels?: string[];
opencodeModel?: string;
hermesModel?: string;
}

Expand Down Expand Up @@ -138,6 +142,8 @@ class ConfigManager {
subagentModel: this.config.subagentModel,
codeBuddyModels: this.config.codeBuddyModels,
workbuddyModels: this.config.workbuddyModels,
opencodeModels: this.config.opencodeModels,
opencodeModel: this.config.opencodeModel,
hermesModel: this.config.hermesModel,
};
}
Expand All @@ -151,6 +157,8 @@ 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;
if ('hermesModel' in models) this.config.hermesModel = models.hermesModel;
this.save();
}
Expand Down
2 changes: 2 additions & 0 deletions src/lib/tool-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
import { HermesTool } from './tools/hermes-tool.js';

// 工具注册中心
Expand All @@ -15,6 +16,7 @@ class ToolManager {
this.register(new CodexTool());
this.register(new CodeBuddyTool());
this.register(new WorkBuddyTool());
this.register(new OpenCodeTool());
this.register(new HermesTool());
}

Expand Down
1 change: 1 addition & 0 deletions src/lib/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,5 @@ 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';
export { HermesTool } from './hermes-tool.js';
253 changes: 253 additions & 0 deletions src/lib/tools/opencode-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,253 @@
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 { 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';

// 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<string, unknown> {
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<void> {
const content = readOpenCodeConfig();
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<void> {
writeOpenCodeConfig(removeManagedOpenCodeConfig(readOpenCodeConfig()));
}

async runModelConfigFlow(): Promise<boolean> {
return runOpenCodeModelSelectionFlow();
}

renderModelConfigSummary(): void {
const models = configManager.getModels();
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,
modelOrModels?: string | string[],
): string {
const config = parseJson(existing);
const selectedModels = normalizeModelIds(modelOrModels);

if (!config.$schema) {
config.$schema = 'https://opencode.ai/config.json';
}

// OpenCode model 字段格式: <provider-id>/<raw-model-id>
// 给用户选的 model 前置 qiniu/ 让 OpenCode 路由到我们托管的 provider
if (selectedModels.length > 0) {
config.model = `${PROVIDER_KEY}/${selectedModels[0]}`;
}

const provider = (isPlainObject(config.provider) ? config.provider : {}) as Record<string, unknown>;
// 七牛走 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<string, unknown>)
: undefined;
const models = isPlainObject(existingQiniu?.models)
? { ...(existingQiniu!.models as Record<string, unknown>) }
: {};
for (const model of selectedModels) {
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<string, unknown>) : 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<string, unknown> {
if (!content.trim()) return {};
try {
const parsed = JSON.parse(content);
return isPlainObject(parsed) ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
return value !== null && typeof value === 'object' && !Array.isArray(value);
}

// 识别本工具写入的 provider.qiniu —— 用 npm 字段作为管理标志,
// 避免误删用户自己手写的同名 provider
function isManagedQiniuProvider(provider: Record<string, unknown> | 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<string, unknown>) : undefined;
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<string>();
return models
.map(model => model.trim())
.filter(model => {
if (!model || seen.has(model)) return false;
seen.add(model);
return true;
});
}

82 changes: 82 additions & 0 deletions src/lib/wizard/flows/opencode-model-selection-flow.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import ora from 'ora';
import { t } from '../../i18n.js';
import { configManager } from '../../config.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';

// OpenCode 模型单选流程
export async function runOpenCodeModelSelectionFlow(): Promise<boolean> {
uiRenderer.renderHeader();

const apiKey = configManager.getApiKey();
if (!apiKey) {
uiRenderer.renderError(t('apikey_not_set'));
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: MarketModelInfo[] = [];
try {
models = await marketModelService.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 }));
// 获取失败时仍允许手动输入
}

uiRenderer.renderHeader();

if (models.length === 0) {
uiRenderer.renderWarning(t('model_no_models'));
}

const choices = models.map((m) => ({
name: `${m.name}${buildCapabilityTags(m)}${current.includes(m.id) ? ' (current)' : ''}`,
value: m.id,
}));

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({ 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(' ')}` : '';
}
Loading