diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index e4a032a8f..865ad84b8 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -521,6 +521,11 @@ pub struct OpenCodeProviderOptions { /// 自定义请求头 #[serde(skip_serializing_if = "Option::is_none")] pub headers: Option>, + + /// 额外选项(timeout, setCacheKey 等) + /// 使用 flatten 捕获所有未明确定义的字段 + #[serde(flatten, default, skip_serializing_if = "HashMap::is_empty")] + pub extra: HashMap, } /// OpenCode 模型定义 diff --git a/src/components/providers/forms/OpenCodeFormFields.tsx b/src/components/providers/forms/OpenCodeFormFields.tsx index 0a680f1ec..1aabe0fd1 100644 --- a/src/components/providers/forms/OpenCodeFormFields.tsx +++ b/src/components/providers/forms/OpenCodeFormFields.tsx @@ -52,6 +52,45 @@ function ModelIdInput({ ); } +/** + * Extra option key input with local state to prevent focus loss. + * Same pattern as ModelIdInput - use local state during editing, + * only commit changes on blur. + */ +function ExtraOptionKeyInput({ + optionKey, + onChange, + placeholder, +}: { + optionKey: string; + onChange: (newKey: string) => void; + placeholder?: string; +}) { + // For new options with placeholder keys like "option-123", show empty string + const displayValue = optionKey.startsWith("option-") ? "" : optionKey; + const [localValue, setLocalValue] = useState(displayValue); + + // Sync when external key changes + 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; @@ -71,6 +110,10 @@ interface OpenCodeFormFieldsProps { // Models models: Record; onModelsChange: (models: Record) => void; + + // Extra Options + extraOptions: Record; + onExtraOptionsChange: (options: Record) => void; } export function OpenCodeFormFields({ @@ -85,6 +128,8 @@ export function OpenCodeFormFields({ onBaseUrlChange, models, onModelsChange, + extraOptions, + onExtraOptionsChange, }: OpenCodeFormFieldsProps) { const { t } = useTranslation(); @@ -126,6 +171,41 @@ export function OpenCodeFormFields({ }); }; + // Extra Options handlers + const handleAddExtraOption = () => { + const newKey = `option-${Date.now()}`; + onExtraOptionsChange({ + ...extraOptions, + [newKey]: "", + }); + }; + + const handleRemoveExtraOption = (key: string) => { + const newOptions = { ...extraOptions }; + delete newOptions[key]; + onExtraOptionsChange(newOptions); + }; + + const handleExtraOptionKeyChange = (oldKey: string, newKey: string) => { + if (oldKey === newKey) return; + const newOptions: Record = {}; + for (const [k, v] of Object.entries(extraOptions)) { + if (k === oldKey) { + newOptions[newKey.trim() || oldKey] = v; + } else { + newOptions[k] = v; + } + } + onExtraOptionsChange(newOptions); + }; + + const handleExtraOptionValueChange = (key: string, value: string) => { + onExtraOptionsChange({ + ...extraOptions, + [key]: value, + }); + }; + return ( <> {/* NPM Package Selector */} @@ -187,6 +267,80 @@ export function OpenCodeFormFields({

+ {/* Extra Options Editor */} +
+
+ + {t("opencode.extraOptions", { defaultValue: "额外选项" })} + + +
+ + {Object.keys(extraOptions).length === 0 ? ( +

+ {t("opencode.noExtraOptions", { + defaultValue: "暂无额外选项", + })} +

+ ) : ( +
+
+ + {t("opencode.extraOptionKey", { defaultValue: "键名" })} + + + {t("opencode.extraOptionValue", { defaultValue: "值" })} + + +
+ {Object.entries(extraOptions).map(([key, value]) => ( +
+ handleExtraOptionKeyChange(key, newKey)} + placeholder={t("opencode.extraOptionKeyPlaceholder", { + defaultValue: "timeout", + })} + /> + handleExtraOptionValueChange(key, e.target.value)} + placeholder={t("opencode.extraOptionValuePlaceholder", { + defaultValue: "600000", + })} + className="flex-1" + /> + +
+ ))} +
+ )} + +

+ {t("opencode.extraOptionsHint", { + defaultValue: + "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。", + })} +

+
+ {/* Models Editor */}
diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index d837bfd3c..2c366a16a 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -558,6 +558,26 @@ export function ProviderForm({ } }); + // OpenCode extra options state (e.g., timeout, setCacheKey) + const [opencodeExtraOptions, setOpencodeExtraOptions] = useState>(() => { + if (appId !== "opencode") return {}; + try { + const config = JSON.parse(initialData?.settingsConfig ? JSON.stringify(initialData.settingsConfig) : OPENCODE_DEFAULT_CONFIG); + const options = config.options || {}; + const extra: Record = {}; + const knownKeys = ["baseURL", "apiKey", "headers"]; + for (const [k, v] of Object.entries(options)) { + if (!knownKeys.includes(k)) { + // Convert value to string for display + extra[k] = typeof v === "string" ? v : JSON.stringify(v); + } + } + return extra; + } catch { + return {}; + } + }); + // OpenCode handlers - sync state to form const handleOpencodeNpmChange = useCallback( (npm: string) => { @@ -617,6 +637,43 @@ export function ProviderForm({ [form], ); + const handleOpencodeExtraOptionsChange = useCallback( + (options: Record) => { + setOpencodeExtraOptions(options); + try { + const config = JSON.parse(form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG); + if (!config.options) config.options = {}; + + // Remove old extra options (keep only known keys) + const knownKeys = ["baseURL", "apiKey", "headers"]; + for (const k of Object.keys(config.options)) { + if (!knownKeys.includes(k)) { + delete config.options[k]; + } + } + + // Add new extra options (auto-parse value types) + for (const [k, v] of Object.entries(options)) { + const trimmedKey = k.trim(); + if (trimmedKey && !trimmedKey.startsWith("option-")) { + try { + // Try to parse as JSON (number, boolean, object, array) + config.options[trimmedKey] = JSON.parse(v); + } catch { + // If parsing fails, keep as string + config.options[trimmedKey] = v; + } + } + } + + form.setValue("settingsConfig", JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [form], + ); + const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); const handleSubmit = (values: ProviderFormData) => { @@ -925,6 +982,7 @@ export function ProviderForm({ setOpencodeBaseUrl(""); setOpencodeApiKey(""); setOpencodeModels({}); + setOpencodeExtraOptions({}); } return; } @@ -993,6 +1051,17 @@ export function ProviderForm({ setOpencodeApiKey(config.options?.apiKey || ""); setOpencodeModels(config.models || {}); + // Extract extra options from preset + const options = config.options || {}; + const extra: Record = {}; + const knownKeys = ["baseURL", "apiKey", "headers"]; + for (const [k, v] of Object.entries(options)) { + if (!knownKeys.includes(k)) { + extra[k] = typeof v === "string" ? v : JSON.stringify(v); + } + } + setOpencodeExtraOptions(extra); + // Update form fields form.reset({ name: preset.name, @@ -1199,6 +1268,8 @@ export function ProviderForm({ onBaseUrlChange={handleOpencodeBaseUrlChange} models={opencodeModels} onModelsChange={handleOpencodeModelsChange} + extraOptions={opencodeExtraOptions} + onExtraOptionsChange={handleOpencodeExtraOptionsChange} /> )} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 4df6f3dad..1bc1c0617 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -481,7 +481,15 @@ "providerKeyHint": "Unique identifier in config file. Cannot be changed after creation. Use lowercase letters, numbers, and hyphens only.", "providerKeyRequired": "Provider key is required", "providerKeyDuplicate": "This key is already in use", - "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only." + "providerKeyInvalid": "Invalid format. Use lowercase letters, numbers, and hyphens only.", + "extraOptions": "Extra Options", + "extraOptionsHint": "Configure extra SDK options like timeout, setCacheKey, etc. Values are auto-parsed to appropriate types (number, boolean, etc.).", + "addExtraOption": "Add", + "extraOptionKey": "Key", + "extraOptionValue": "Value", + "extraOptionKeyPlaceholder": "timeout", + "extraOptionValuePlaceholder": "600000", + "noExtraOptions": "No extra options configured" }, "providerPreset": { "label": "Provider Preset", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 03c83036b..754c030a4 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -481,7 +481,15 @@ "providerKeyHint": "設定ファイルの一意の識別子。作成後は変更できません。小文字、数字、ハイフンのみ使用できます。", "providerKeyRequired": "プロバイダーキーを入力してください", "providerKeyDuplicate": "このキーは既に使用されています", - "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。" + "providerKeyInvalid": "無効な形式です。小文字、数字、ハイフンのみ使用できます。", + "extraOptions": "追加オプション", + "extraOptionsHint": "timeout、setCacheKey などの SDK オプションを設定。値は自動的に適切な型(数値、真偽値など)に変換されます。", + "addExtraOption": "追加", + "extraOptionKey": "キー名", + "extraOptionValue": "値", + "extraOptionKeyPlaceholder": "timeout", + "extraOptionValuePlaceholder": "600000", + "noExtraOptions": "追加オプションはありません" }, "providerPreset": { "label": "プロバイダータイプ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 30c27457e..4f5c46be5 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -481,7 +481,15 @@ "providerKeyHint": "配置文件中的唯一标识符,创建后无法修改,只能使用小写字母、数字和连字符", "providerKeyRequired": "请填写供应商标识", "providerKeyDuplicate": "此标识已被使用,请更换", - "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符" + "providerKeyInvalid": "标识格式无效,只能使用小写字母、数字和连字符", + "extraOptions": "额外选项", + "extraOptionsHint": "配置额外的 SDK 选项,如 timeout、setCacheKey 等。值会自动解析类型(数字、布尔值等)。", + "addExtraOption": "添加", + "extraOptionKey": "键名", + "extraOptionValue": "值", + "extraOptionKeyPlaceholder": "timeout", + "extraOptionValuePlaceholder": "600000", + "noExtraOptions": "暂无额外选项" }, "providerPreset": { "label": "预设供应商", diff --git a/src/types.ts b/src/types.ts index 5980eaedd..e389da94b 100644 --- a/src/types.ts +++ b/src/types.ts @@ -269,6 +269,8 @@ export interface OpenCodeProviderOptions { baseURL?: string; apiKey?: string; headers?: Record; + // 支持额外选项(timeout, setCacheKey 等) + [key: string]: unknown; } // OpenCode 供应商配置(settings_config 结构)