From 864884926aec06b13c3d2f54ef53e3b677f037fb Mon Sep 17 00:00:00 2001 From: Jason Date: Thu, 15 Jan 2026 16:38:26 +0800 Subject: [PATCH] feat(opencode): Phase 9 - Frontend UI components for OpenCode - Create OpenCodeFormFields.tsx with: - NPM package selector (from AI SDK ecosystem) - API Key input using shared ApiKeySection component - Base URL input (shown for openai-compatible) - Dynamic models editor (add/remove models) - Update ProviderForm.tsx: - Import OpenCode presets and form fields - Add OPENCODE_DEFAULT_CONFIG constant - Add OpenCode to PresetEntry type union - Add OpenCode preset entries in useMemo - Add OpenCode state hooks (npm, apiKey, baseUrl, models) - Add OpenCode change handlers syncing to form - Add OpenCodeFormFields rendering section - Add OpenCode config editor using CommonConfigEditor - Update ProviderActions.tsx for OpenCode additive mode: - Add appId and isInConfig props - Implement "Add to Config" / "Remove from Config" buttons - Disable failover mode for OpenCode - Update delete button logic for additive mode - Update ProviderCard.tsx: - Pass appId and isInConfig to ProviderActions - Update AddProviderDialog.tsx: - Add OpenCode base URL extraction from options.baseURL --- .../providers/AddProviderDialog.tsx | 8 + src/components/providers/ProviderActions.tsx | 56 ++++- src/components/providers/ProviderCard.tsx | 2 + .../providers/forms/OpenCodeFormFields.tsx | 221 ++++++++++++++++++ .../providers/forms/ProviderForm.tsx | 173 +++++++++++++- 5 files changed, 451 insertions(+), 9 deletions(-) create mode 100644 src/components/providers/forms/OpenCodeFormFields.tsx diff --git a/src/components/providers/AddProviderDialog.tsx b/src/components/providers/AddProviderDialog.tsx index 00cb0d1cd..f3bfc6dcd 100644 --- a/src/components/providers/AddProviderDialog.tsx +++ b/src/components/providers/AddProviderDialog.tsx @@ -17,6 +17,7 @@ import { UniversalProviderPanel } from "@/components/universal"; import { providerPresets } from "@/config/claudeProviderPresets"; import { codexProviderPresets } from "@/config/codexProviderPresets"; import { geminiProviderPresets } from "@/config/geminiProviderPresets"; +// Note: opencodeProviderPresets is loaded via ProviderForm, not needed here import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; interface AddProviderDialogProps { @@ -153,6 +154,7 @@ export function AddProviderDialog({ } } } + // Note: OpenCode doesn't use endpointCandidates - it handles endpoints internally } if (appId === "claude") { @@ -175,6 +177,12 @@ export function AddProviderDialog({ if (env?.GOOGLE_GEMINI_BASE_URL) { addUrl(env.GOOGLE_GEMINI_BASE_URL); } + } else if (appId === "opencode") { + // OpenCode uses options.baseURL + const options = parsedConfig.options as Record | undefined; + if (options?.baseURL) { + addUrl(options.baseURL); + } } const urls = Array.from(urlSet); diff --git a/src/components/providers/ProviderActions.tsx b/src/components/providers/ProviderActions.tsx index e508167ac..a79320045 100644 --- a/src/components/providers/ProviderActions.tsx +++ b/src/components/providers/ProviderActions.tsx @@ -4,6 +4,7 @@ import { Copy, Edit, Loader2, + Minus, Play, Plus, Terminal, @@ -13,9 +14,13 @@ import { import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import type { AppId } from "@/lib/api"; interface ProviderActionsProps { + appId?: AppId; isCurrent: boolean; + /** OpenCode: 是否已添加到配置 */ + isInConfig?: boolean; isTesting?: boolean; isProxyTakeover?: boolean; onSwitch: () => void; @@ -32,7 +37,9 @@ interface ProviderActionsProps { } export function ProviderActions({ + appId, isCurrent, + isInConfig = false, isTesting, isProxyTakeover = false, onSwitch, @@ -50,12 +57,22 @@ export function ProviderActions({ const { t } = useTranslation(); const iconButtonClass = "h-8 w-8 p-1"; - // 故障转移模式下的按钮逻辑 - const isFailoverMode = isAutoFailoverEnabled && onToggleFailover; + // OpenCode 使用累加模式 + const isOpenCodeMode = appId === "opencode"; + + // 故障转移模式下的按钮逻辑(OpenCode 不支持故障转移) + const isFailoverMode = !isOpenCodeMode && isAutoFailoverEnabled && onToggleFailover; // 处理主按钮点击 const handleMainButtonClick = () => { - if (isFailoverMode) { + if (isOpenCodeMode) { + // OpenCode 模式:切换配置状态(添加/移除) + if (isInConfig) { + onDelete(); // 从配置移除 + } else { + onSwitch(); // 添加到配置 + } + } else if (isFailoverMode) { // 故障转移模式:切换队列状态 onToggleFailover(!isInFailoverQueue); } else { @@ -66,8 +83,30 @@ export function ProviderActions({ // 主按钮的状态和样式 const getMainButtonState = () => { + // OpenCode 累加模式 + if (isOpenCodeMode) { + if (isInConfig) { + return { + disabled: false, + variant: "secondary" as const, + className: + "bg-orange-100 text-orange-600 hover:bg-orange-200 dark:bg-orange-900/50 dark:text-orange-400 dark:hover:bg-orange-900/70", + icon: , + text: t("provider.removeFromConfig", { defaultValue: "移除" }), + }; + } + return { + disabled: false, + variant: "default" as const, + className: + "bg-emerald-500 hover:bg-emerald-600 dark:bg-emerald-600 dark:hover:bg-emerald-700", + icon: , + text: t("provider.addToConfig", { defaultValue: "添加" }), + }; + } + + // 故障转移模式 if (isFailoverMode) { - // 故障转移模式 if (isInFailoverQueue) { return { disabled: false, @@ -113,6 +152,9 @@ export function ProviderActions({ const buttonState = getMainButtonState(); + // OpenCode 模式下删除按钮的行为不同(主按钮已处理移除功能) + const canDelete = isOpenCodeMode ? !isInConfig : !isCurrent; + return (
+
+ + {Object.keys(models).length === 0 ? ( +

+ {t("provider.form.opencode.noModels", { + defaultValue: "No models configured. Click Add to add a model.", + })} +

+ ) : ( +
+ {Object.entries(models).map(([key, model]) => ( +
+ handleModelIdChange(key, e.target.value)} + placeholder={t("provider.form.opencode.modelId", { + defaultValue: "Model ID", + })} + className="flex-1" + /> + handleModelNameChange(key, e.target.value)} + placeholder={t("provider.form.opencode.modelName", { + defaultValue: "Display Name", + })} + className="flex-1" + /> + +
+ ))} +
+ )} + +

+ {t("provider.form.opencode.modelsHint", { + defaultValue: + "Configure available models. Model ID is the API identifier, Display Name is shown in the UI.", + })} +

+ + + ); +} diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 40cd688a0..255b78945 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -20,6 +20,12 @@ import { geminiProviderPresets, type GeminiProviderPreset, } from "@/config/geminiProviderPresets"; +import { + opencodeProviderPresets, + type OpenCodeProviderPreset, +} from "@/config/opencodeProviderPresets"; +import { OpenCodeFormFields } from "./OpenCodeFormFields"; +import type { OpenCodeModel } from "@/types"; import type { UniversalProviderPreset } from "@/config/universalProviderPresets"; import { applyTemplateValues } from "@/utils/providerConfigUtils"; import { mergeProviderMeta } from "@/utils/providerMetaUtils"; @@ -62,9 +68,22 @@ const GEMINI_DEFAULT_CONFIG = JSON.stringify( 2, ); +const OPENCODE_DEFAULT_CONFIG = JSON.stringify( + { + npm: "@ai-sdk/openai-compatible", + options: { + baseURL: "", + apiKey: "", + }, + models: {}, + }, + null, + 2, +); + type PresetEntry = { id: string; - preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset; + preset: ProviderPreset | CodexProviderPreset | GeminiProviderPreset | OpenCodeProviderPreset; }; interface ProviderFormProps { @@ -158,7 +177,9 @@ export function ProviderForm({ ? CODEX_DEFAULT_CONFIG : appId === "gemini" ? GEMINI_DEFAULT_CONFIG - : CLAUDE_DEFAULT_CONFIG, + : appId === "opencode" + ? OPENCODE_DEFAULT_CONFIG + : CLAUDE_DEFAULT_CONFIG, icon: initialData?.icon ?? "", iconColor: initialData?.iconColor ?? "", }), @@ -328,6 +349,11 @@ export function ProviderForm({ id: `gemini-${index}`, preset, })); + } else if (appId === "opencode") { + return opencodeProviderPresets.map((preset, index) => ({ + id: `opencode-${index}`, + preset, + })); } return providerPresets.map((preset, index) => ({ id: `claude-${index}`, @@ -469,6 +495,106 @@ export function ProviderForm({ selectedPresetId: selectedPresetId ?? undefined, }); + // OpenCode 配置状态 + const [opencodeNpm, setOpencodeNpm] = useState(() => { + if (appId !== "opencode") return "@ai-sdk/openai-compatible"; + try { + const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + return config.npm || "@ai-sdk/openai-compatible"; + } catch { + return "@ai-sdk/openai-compatible"; + } + }); + + const [opencodeApiKey, setOpencodeApiKey] = useState(() => { + if (appId !== "opencode") return ""; + try { + const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + return config.options?.apiKey || ""; + } catch { + return ""; + } + }); + + const [opencodeBaseUrl, setOpencodeBaseUrl] = useState(() => { + if (appId !== "opencode") return ""; + try { + const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + return config.options?.baseURL || ""; + } catch { + return ""; + } + }); + + const [opencodeModels, setOpencodeModels] = useState>(() => { + if (appId !== "opencode") return {}; + try { + const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + return config.models || {}; + } catch { + return {}; + } + }); + + // OpenCode handlers - sync state to form + const handleOpencodeNpmChange = useCallback( + (npm: string) => { + setOpencodeNpm(npm); + try { + const config = JSON.parse(form.watch("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + config.npm = npm; + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + + const handleOpencodeApiKeyChange = useCallback( + (apiKey: string) => { + setOpencodeApiKey(apiKey); + try { + const config = JSON.parse(form.watch("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + if (!config.options) config.options = {}; + config.options.apiKey = apiKey; + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + + const handleOpencodeBaseUrlChange = useCallback( + (baseUrl: string) => { + setOpencodeBaseUrl(baseUrl); + try { + const config = JSON.parse(form.watch("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + if (!config.options) config.options = {}; + config.options.baseURL = baseUrl.trim().replace(/\/+$/, ""); + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + + const handleOpencodeModelsChange = useCallback( + (models: Record) => { + setOpencodeModels(models); + try { + const config = JSON.parse(form.watch("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + config.models = models; + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); const handleSubmit = (values: ProviderFormData) => { @@ -941,6 +1067,23 @@ export function ProviderForm({ /> )} + {/* OpenCode 专属字段 */} + {appId === "opencode" && ( + + )} + {/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */} {appId === "codex" ? ( <> @@ -1000,6 +1143,32 @@ export function ProviderForm({ )} /> + ) : appId === "opencode" ? ( + <> + form.setValue("settingsConfig", config)} + useCommonConfig={false} + onCommonConfigToggle={() => {}} + commonConfigSnippet="" + onCommonConfigSnippetChange={() => {}} + commonConfigError="" + onEditClick={() => {}} + isModalOpen={false} + onModalClose={() => {}} + onExtract={() => Promise.resolve()} + isExtracting={false} + /> + ( + + + + )} + /> + ) : ( <>