From 3bd3845ec0b3dd27bf3d4750f3f473f63838f124 Mon Sep 17 00:00:00 2001 From: Jason Date: Mon, 19 Jan 2026 15:05:01 +0800 Subject: [PATCH] feat(opencode): add model-level options editor Add support for configuring per-model options like provider routing. Each model row now has an expand/collapse toggle to show a key-value editor for model-specific options (e.g., provider order, fallbacks). - Add options field to OpenCodeModel in Rust and TypeScript - Add expandable key-value editor UI for each model - Use local state pattern for option key input to prevent focus loss - Add i18n translations for zh/en/ja --- src-tauri/src/provider.rs | 4 + .../providers/forms/OpenCodeFormFields.tsx | 288 ++++++++++++++++-- src/i18n/locales/en.json | 5 +- src/i18n/locales/ja.json | 5 +- src/i18n/locales/zh.json | 5 +- src/types.ts | 1 + 6 files changed, 279 insertions(+), 29 deletions(-) diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index 865ad84b8..5e9ab7328 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -537,6 +537,10 @@ pub struct OpenCodeModel { /// 模型限制(上下文和输出 token 数) #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, + + /// 模型额外选项(provider 路由等) + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option>, } /// OpenCode 模型限制 diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index 1aabe0fd1..71ff46516 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -10,9 +10,10 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select"; -import { Plus, Trash2 } from "lucide-react"; +import { Plus, Trash2, ChevronRight } from "lucide-react"; import { ApiKeySection } from "./shared"; import { opencodeNpmPackages } from "@/config/opencodeProviderPresets"; +import { cn } from "@/lib/utils"; import type { ProviderCategory, OpenCodeModel } from "@/types"; /** @@ -91,6 +92,42 @@ function ExtraOptionKeyInput({ ); } +/** + * Model option key input with local state to prevent focus loss. + * Reuses the same pattern as ExtraOptionKeyInput. + */ +function ModelOptionKeyInput({ + optionKey, + onChange, + placeholder, +}: { + optionKey: string; + onChange: (newKey: string) => void; + placeholder?: string; +}) { + const displayValue = optionKey.startsWith("option-") ? "" : optionKey; + const [localValue, setLocalValue] = useState(displayValue); + + useEffect(() => { + setLocalValue(optionKey.startsWith("option-") ? "" : optionKey); + }, [optionKey]); + + return ( + setLocalValue(e.target.value)} + onBlur={() => { + const trimmed = localValue.trim(); + if (trimmed && trimmed !== optionKey) { + onChange(trimmed); + } + }} + placeholder={placeholder} + className="flex-1" + /> + ); +} + interface OpenCodeFormFieldsProps { // NPM Package npm: string; @@ -133,6 +170,19 @@ export function OpenCodeFormFields({ }: OpenCodeFormFieldsProps) { const { t } = useTranslation(); + // Track which models have expanded options panel + const [expandedModels, setExpandedModels] = useState>(new Set()); + + // Toggle model expand state + const toggleModelExpand = (key: string) => { + setExpandedModels((prev) => { + const next = new Set(prev); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + }; + // Add a new model entry const handleAddModel = () => { const newKey = `model-${Date.now()}`; @@ -147,6 +197,12 @@ export function OpenCodeFormFields({ const newModels = { ...models }; delete newModels[key]; onModelsChange(newModels); + // Also remove from expanded set + setExpandedModels((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); }; // Update model ID (key) @@ -161,6 +217,15 @@ export function OpenCodeFormFields({ } } onModelsChange(newModels); + // Update expanded set if this model was expanded + if (expandedModels.has(oldKey)) { + setExpandedModels((prev) => { + const next = new Set(prev); + next.delete(oldKey); + next.add(newKey); + return next; + }); + } }; // Update model name @@ -171,6 +236,71 @@ export function OpenCodeFormFields({ }); }; + // Model options handlers + const handleAddModelOption = (modelKey: string) => { + const model = models[modelKey]; + const newOptionKey = `option-${Date.now()}`; + onModelsChange({ + ...models, + [modelKey]: { + ...model, + options: { ...model.options, [newOptionKey]: "" }, + }, + }); + }; + + const handleRemoveModelOption = (modelKey: string, optionKey: string) => { + const model = models[modelKey]; + const newOptions = { ...model.options }; + delete newOptions[optionKey]; + onModelsChange({ + ...models, + [modelKey]: { + ...model, + options: Object.keys(newOptions).length > 0 ? newOptions : undefined, + }, + }); + }; + + const handleModelOptionKeyChange = ( + modelKey: string, + oldKey: string, + newKey: string + ) => { + if (!newKey.trim() || oldKey === newKey) return; + const model = models[modelKey]; + const newOptions: Record = {}; + for (const [k, v] of Object.entries(model.options || {})) { + if (k === oldKey) newOptions[newKey] = v; + else newOptions[k] = v; + } + onModelsChange({ + ...models, + [modelKey]: { ...model, options: newOptions }, + }); + }; + + const handleModelOptionValueChange = ( + modelKey: string, + optionKey: string, + value: string + ) => { + const model = models[modelKey]; + let parsedValue: unknown; + try { + parsedValue = JSON.parse(value); + } catch { + parsedValue = value; + } + onModelsChange({ + ...models, + [modelKey]: { + ...model, + options: { ...model.options, [optionKey]: parsedValue }, + }, + }); + }; + // Extra Options handlers const handleAddExtraOption = () => { const newKey = `option-${Date.now()}`; @@ -368,6 +498,7 @@ export function OpenCodeFormFields({ ) : (
+ {t("opencode.modelId", { defaultValue: "模型 ID" })} @@ -377,31 +508,136 @@ export function OpenCodeFormFields({
{Object.entries(models).map(([key, model]) => ( -
- handleModelIdChange(key, newId)} - placeholder={t("opencode.modelId", { - defaultValue: "Model ID", - })} - /> - handleModelNameChange(key, e.target.value)} - placeholder={t("opencode.modelName", { - defaultValue: "Display Name", - })} - className="flex-1" - /> - +
+ {/* Model row */} +
+ + handleModelIdChange(key, newId)} + placeholder={t("opencode.modelId", { + defaultValue: "Model ID", + })} + /> + handleModelNameChange(key, e.target.value)} + placeholder={t("opencode.modelName", { + defaultValue: "Display Name", + })} + className="flex-1" + /> + +
+ + {/* Expanded model options */} + {expandedModels.has(key) && ( +
+ {Object.keys(model.options || {}).length === 0 ? ( +
+

+ {t("opencode.noModelOptions", { + defaultValue: "模型选项,点击 + 添加", + })} +

+ +
+ ) : ( + <> +
+ +
+ {Object.entries(model.options || {}).map( + ([optKey, optValue]) => ( +
+ + handleModelOptionKeyChange(key, optKey, newKey) + } + placeholder={t( + "opencode.modelOptionKeyPlaceholder", + { + defaultValue: "provider", + } + )} + /> + + handleModelOptionValueChange( + key, + optKey, + e.target.value + ) + } + placeholder={t( + "opencode.modelOptionValuePlaceholder", + { + defaultValue: '{"order": ["baseten"]}', + } + )} + className="flex-1" + /> + +
+ ) + )} + + )} +
+ )}
))}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 1bc1c0617..cf246739f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -489,7 +489,10 @@ "extraOptionValue": "Value", "extraOptionKeyPlaceholder": "timeout", "extraOptionValuePlaceholder": "600000", - "noExtraOptions": "No extra options configured" + "noExtraOptions": "No extra options configured", + "noModelOptions": "Model options, click + to add", + "modelOptionKeyPlaceholder": "provider", + "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, "providerPreset": { "label": "Provider Preset", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 754c030a4..81a19a50d 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -489,7 +489,10 @@ "extraOptionValue": "値", "extraOptionKeyPlaceholder": "timeout", "extraOptionValuePlaceholder": "600000", - "noExtraOptions": "追加オプションはありません" + "noExtraOptions": "追加オプションはありません", + "noModelOptions": "モデルオプション、+ をクリックして追加", + "modelOptionKeyPlaceholder": "provider", + "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, "providerPreset": { "label": "プロバイダータイプ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 4f5c46be5..1f90c7217 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -489,7 +489,10 @@ "extraOptionValue": "值", "extraOptionKeyPlaceholder": "timeout", "extraOptionValuePlaceholder": "600000", - "noExtraOptions": "暂无额外选项" + "noExtraOptions": "暂无额外选项", + "noModelOptions": "模型选项,点击 + 添加", + "modelOptionKeyPlaceholder": "provider", + "modelOptionValuePlaceholder": "{\"order\": [\"baseten\"]}" }, "providerPreset": { "label": "预设供应商", diff --git a/src/types.ts b/src/types.ts index e389da94b..dcf0b5347 100644 --- a/src/types.ts +++ b/src/types.ts @@ -262,6 +262,7 @@ export interface OpenCodeModel { context?: number; output?: number; }; + options?: Record; // 模型级别额外选项(provider 路由等) } // OpenCode 供应商选项