import { useState, useEffect, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { FormLabel } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { toast } from "sonner"; import { ChevronDown, Download, Plus, Trash2, ChevronRight, Loader2, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ApiKeySection } from "./shared"; import { fetchModelsForConfig, showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; import { opencodeNpmPackages } from "@/config/opencodeProviderPresets"; import { cn } from "@/lib/utils"; import { getModelExtraFields, isKnownModelKey, } from "./helpers/opencodeFormUtils"; import type { ProviderCategory, OpenCodeModel } from "@/types"; /** * Model ID input with local state to prevent focus loss. * The key prop issue: when Model ID changes, React sees it as a new element * and unmounts/remounts the input, losing focus. Using local state + onBlur * keeps the key stable during editing. */ function ModelIdInput({ modelId, onChange, placeholder, }: { modelId: string; onChange: (newId: string) => void; placeholder?: string; }) { const [localValue, setLocalValue] = useState(modelId); // Sync when external modelId changes (e.g., undo operation) useEffect(() => { setLocalValue(modelId); }, [modelId]); return ( setLocalValue(e.target.value)} onBlur={() => { if (localValue !== modelId && localValue.trim()) { onChange(localValue); } }} placeholder={placeholder} className="flex-1" /> ); } /** * 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" /> ); } /** * 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); } // Reset to prop value: if parent accepted the rename, useEffect // will update localValue when the new optionKey prop arrives; // if parent rejected, this restores the correct display. setLocalValue(optionKey.startsWith("option-") ? "" : optionKey); }} placeholder={placeholder} className="flex-1" /> ); } /** Dropdown button to select from fetched models */ function ModelDropdown({ models, onSelect, }: { models: FetchedModel[]; onSelect: (id: string) => void; }) { const grouped: Record = {}; for (const model of models) { const vendor = model.ownedBy || "Other"; if (!grouped[vendor]) grouped[vendor] = []; grouped[vendor].push(model); } const vendors = Object.keys(grouped).sort(); return ( {vendors.map((vendor, vi) => (
{vi > 0 && } {vendor} {grouped[vendor].map((m) => ( onSelect(m.id)}> {m.id} ))}
))}
); } interface OpenCodeFormFieldsProps { // NPM Package npm: string; onNpmChange: (value: string) => void; // API Key apiKey: string; onApiKeyChange: (value: string) => void; category?: ProviderCategory; shouldShowApiKeyLink: boolean; websiteUrl: string; isPartner?: boolean; partnerPromotionKey?: string; // Base URL baseUrl: string; onBaseUrlChange: (value: string) => void; // Models models: Record; onModelsChange: (models: Record) => void; // Extra Options extraOptions: Record; onExtraOptionsChange: (options: Record) => void; } export function OpenCodeFormFields({ npm, onNpmChange, apiKey, onApiKeyChange, category, shouldShowApiKeyLink, websiteUrl, isPartner, partnerPromotionKey, baseUrl, onBaseUrlChange, models, onModelsChange, extraOptions, onExtraOptionsChange, }: OpenCodeFormFieldsProps) { const { t } = useTranslation(); const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); const handleFetchModels = useCallback(() => { if (!baseUrl || !apiKey) { showFetchModelsError(null, t, { hasApiKey: !!apiKey, hasBaseUrl: !!baseUrl, }); return; } setIsFetchingModels(true); fetchModelsForConfig(baseUrl, apiKey) .then((models) => { setFetchedModels(models); if (models.length === 0) { toast.info(t("providerForm.fetchModelsEmpty")); } else { toast.success( t("providerForm.fetchModelsSuccess", { count: models.length }), ); } }) .catch((err) => { console.warn("[ModelFetch] Failed:", err); showFetchModelsError(err, t); }) .finally(() => setIsFetchingModels(false)); }, [baseUrl, apiKey, t]); // 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()}`; onModelsChange({ ...models, [newKey]: { name: "" }, }); }; // Remove a model entry const handleRemoveModel = (key: string) => { 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) const handleModelIdChange = (oldKey: string, newKey: string) => { if (oldKey === newKey || !newKey.trim()) return; const newModels: Record = {}; for (const [k, v] of Object.entries(models)) { if (k === oldKey) { newModels[newKey] = v; } else { newModels[k] = v; } } 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 const handleModelNameChange = (key: string, name: string) => { onModelsChange({ ...models, [key]: { ...models[key], name }, }); }; // 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 }, }, }); }; // Model extra field handlers (top-level properties like variants, cost) const handleAddModelExtraField = (modelKey: string) => { const model = models[modelKey]; const newFieldKey = `option-${Date.now()}`; onModelsChange({ ...models, [modelKey]: { ...model, [newFieldKey]: "" }, }); }; const handleRemoveModelExtraField = (modelKey: string, fieldKey: string) => { const model = models[modelKey]; const newModel = { ...model }; delete newModel[fieldKey]; onModelsChange({ ...models, [modelKey]: newModel, }); }; const handleModelExtraFieldKeyChange = ( modelKey: string, oldKey: string, newKey: string, ) => { if (!newKey.trim() || oldKey === newKey) return; const model = models[modelKey]; // Reject reserved keys and duplicate extra field names if (isKnownModelKey(newKey) || (newKey !== oldKey && newKey in model)) return; const newModel: Record = {}; for (const [k, v] of Object.entries(model)) { if (k === oldKey) newModel[newKey] = v; else newModel[k] = v; } onModelsChange({ ...models, [modelKey]: newModel as OpenCodeModel, }); }; const handleModelExtraFieldValueChange = ( modelKey: string, fieldKey: string, value: string, ) => { const model = models[modelKey]; let parsedValue: unknown; try { parsedValue = JSON.parse(value); } catch { parsedValue = value; } onModelsChange({ ...models, [modelKey]: { ...model, [fieldKey]: parsedValue }, }); }; // 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 */}
{t("opencode.npmPackage", { defaultValue: "接口格式", })}

{t("opencode.npmPackageHint", { defaultValue: "Select the AI SDK package that matches your provider.", })}

{/* API Key */} {/* Base URL */}
{t("opencode.baseUrl", { defaultValue: "Base URL" })} onBaseUrlChange(e.target.value)} placeholder="https://api.example.com/v1" />

{t("opencode.baseUrlHint", { defaultValue: "The base URL for the API endpoint. Leave empty to use the default endpoint for official SDKs.", })}

{/* 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 */}
{t("opencode.models", { defaultValue: "Models" })}
{Object.keys(models).length === 0 ? (

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

) : (
{t("opencode.modelId", { defaultValue: "模型 ID" })} {t("opencode.modelName", { defaultValue: "显示名称" })}
{Object.entries(models).map(([key, model]) => (
{/* Model row */}
handleModelIdChange(key, newId)} placeholder={t("opencode.modelId", { defaultValue: "Model ID", })} /> {fetchedModels.length > 0 && ( handleModelIdChange(key, id)} /> )}
handleModelNameChange(key, e.target.value)} placeholder={t("opencode.modelName", { defaultValue: "Display Name", })} className="flex-1" />
{/* Expanded model details */} {expandedModels.has(key) && (
{/* Model Properties (extra fields like variants, cost) */}
{t("opencode.modelExtraFields", { defaultValue: "模型属性", })}
{Object.keys(getModelExtraFields(model)).length === 0 ? (

{t("opencode.noModelExtraFields", { defaultValue: "模型属性 (variants, cost 等),点击 + 添加", })}

) : ( Object.entries(getModelExtraFields(model)).map( ([fKey, fValue]) => (
handleModelExtraFieldKeyChange( key, fKey, newKey, ) } placeholder={t( "opencode.modelExtraFieldKeyPlaceholder", { defaultValue: "variants", }, )} /> handleModelExtraFieldValueChange( key, fKey, e.target.value, ) } placeholder={t( "opencode.modelOptionValuePlaceholder", { defaultValue: '{"order": ["baseten"]}', }, )} className="flex-1" />
), ) )}
{/* SDK Options (model.options) */}
{t("opencode.sdkOptions", { defaultValue: "SDK 选项", })}
{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" />
), ) )}
)}
))}
)}

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

); }