import { useState, useEffect } 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 { 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"; /** * 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); } }} placeholder={placeholder} className="flex-1" /> ); } 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(); // 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 }, }, }); }; // 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", })} /> 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" />
), )}
)}
)}
))}
)}

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

); }