import { useTranslation } from "react-i18next"; import { useState, useRef, useCallback } from "react"; import { FormLabel } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Switch } from "@/components/ui/switch"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from "@/components/ui/select"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@/components/ui/collapsible"; import { toast } from "sonner"; import { Download, Plus, Trash2, ChevronDown, ChevronRight, Loader2, } from "lucide-react"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { Checkbox } from "@/components/ui/checkbox"; import { ApiKeySection } from "./shared"; import { fetchModelsForConfig, showFetchModelsError, type FetchedModel, } from "@/lib/api/model-fetch"; import { openclawApiProtocols } from "@/config/openclawProviderPresets"; import type { ProviderCategory, OpenClawModel } from "@/types"; interface OpenClawFormFieldsProps { // Base URL baseUrl: string; onBaseUrlChange: (value: string) => void; // API Key apiKey: string; onApiKeyChange: (value: string) => void; category?: ProviderCategory; shouldShowApiKeyLink: boolean; websiteUrl: string; isPartner?: boolean; partnerPromotionKey?: string; // API Protocol api: string; onApiChange: (value: string) => void; // Models models: OpenClawModel[]; onModelsChange: (models: OpenClawModel[]) => void; // User-Agent userAgent: boolean; onUserAgentChange: (checked: boolean) => void; } export function OpenClawFormFields({ baseUrl, onBaseUrlChange, apiKey, onApiKeyChange, category, shouldShowApiKeyLink, websiteUrl, isPartner, partnerPromotionKey, api, onApiChange, models, onModelsChange, userAgent, onUserAgentChange, }: OpenClawFormFieldsProps) { const { t } = useTranslation(); const [expandedModels, setExpandedModels] = useState>( {}, ); const [fetchedModels, setFetchedModels] = useState([]); const [isFetchingModels, setIsFetchingModels] = useState(false); // Stable key tracking for models list const modelKeysRef = useRef([]); const getModelKeys = useCallback(() => { // Grow keys array if models were added externally while (modelKeysRef.current.length < models.length) { modelKeysRef.current.push(crypto.randomUUID()); } // Shrink if models were removed externally if (modelKeysRef.current.length > models.length) { modelKeysRef.current.length = models.length; } return modelKeysRef.current; }, [models.length]); const modelKeys = getModelKeys(); // Toggle advanced section for a model const toggleModelAdvanced = (index: number) => { setExpandedModels((prev) => ({ ...prev, [index]: !prev[index] })); }; // Add a new model entry const handleAddModel = () => { modelKeysRef.current.push(crypto.randomUUID()); onModelsChange([ ...models, { id: "", name: "", contextWindow: undefined, maxTokens: undefined, cost: undefined, input: ["text"], }, ]); }; // Fetch models from API 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]); // Remove a model entry const handleRemoveModel = (index: number) => { modelKeysRef.current.splice(index, 1); const newModels = [...models]; newModels.splice(index, 1); onModelsChange(newModels); // Clean up expanded state setExpandedModels((prev) => { const updated = { ...prev }; delete updated[index]; return updated; }); }; // Update model field const handleModelChange = ( index: number, field: keyof OpenClawModel, value: unknown, ) => { const newModels = [...models]; newModels[index] = { ...newModels[index], [field]: value }; onModelsChange(newModels); }; // Update model cost const handleCostChange = ( index: number, costField: "input" | "output" | "cacheRead" | "cacheWrite", value: string, ) => { const newModels = [...models]; const numValue = parseFloat(value); const currentCost = newModels[index].cost || { input: 0, output: 0 }; newModels[index] = { ...newModels[index], cost: { ...currentCost, [costField]: isNaN(numValue) ? undefined : numValue, }, }; onModelsChange(newModels); }; return ( <> {/* API Protocol Selector */}
{t("openclaw.apiProtocol", { defaultValue: "API 协议", })}

{t("openclaw.apiProtocolHint", { defaultValue: "选择与供应商 API 兼容的协议类型。大多数供应商使用 OpenAI Completions 格式。", })}

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

{t("openclaw.baseUrlHint", { defaultValue: "供应商的 API 端点地址。", })}

{/* API Key */} {/* User-Agent */}
{t("openclaw.userAgent", { defaultValue: "发送 User-Agent" })}

{t("openclaw.userAgentHint", { defaultValue: "部分供应商需要浏览器 User-Agent 才能正常访问。", })}

{/* Models Editor */}
{t("openclaw.models", { defaultValue: "模型列表" })}
{models.length === 0 ? (

{t("openclaw.noModels", { defaultValue: "暂无模型配置。点击添加模型来配置可用模型。", })}

) : (
{models.map((model, index) => (
{/* Role badge */}
{index === 0 ? t("openclaw.primaryModel", { defaultValue: "默认模型", }) : t("openclaw.fallbackModel", { defaultValue: "回退模型", })}
{/* Model ID and Name row */}
handleModelChange(index, "id", e.target.value) } placeholder={t("openclaw.modelIdPlaceholder", { defaultValue: "claude-3-sonnet", })} className="flex-1" /> {fetchedModels.length > 0 && ( {Object.entries( fetchedModels.reduce( (acc, m) => { const v = m.ownedBy || "Other"; if (!acc[v]) acc[v] = []; acc[v].push(m); return acc; }, {} as Record, ), ) .sort(([a], [b]) => a.localeCompare(b)) .map(([vendor, vModels], vi) => (
{vi > 0 && } {vendor} {vModels.map((m) => ( handleModelChange(index, "id", m.id) } > {m.id} ))}
))}
)}
handleModelChange(index, "name", e.target.value) } placeholder={t("openclaw.modelNamePlaceholder", { defaultValue: "Claude 3 Sonnet", })} />
{/* Advanced Options (Collapsible) */} toggleModelAdvanced(index)} > {/* Reasoning, Input Types row */}
handleModelChange(index, "reasoning", checked) } /> {model.reasoning ? t("openclaw.reasoningOn", { defaultValue: "启用", }) : t("openclaw.reasoningOff", { defaultValue: "关闭", })}
{/* "text" is checked by default but can be unchecked — some models genuinely don't support text input, and OpenClaw works fine with an empty or image-only array. */}
{(["text", "image"] as const).map((type) => ( ))}
{/* Context Window and Max Tokens row */}
handleModelChange( index, "contextWindow", e.target.value ? parseInt(e.target.value) : undefined, ) } placeholder="200000" />
handleModelChange( index, "maxTokens", e.target.value ? parseInt(e.target.value) : undefined, ) } placeholder="32000" />
{/* Cost row */}
handleCostChange(index, "input", e.target.value) } placeholder="3" />
handleCostChange(index, "output", e.target.value) } placeholder="15" />
{/* Cache Cost row */}
handleCostChange(index, "cacheRead", e.target.value) } placeholder="0.3" />
handleCostChange( index, "cacheWrite", e.target.value, ) } placeholder="3.75" />

{t("openclaw.cacheCostHint", { defaultValue: "缓存价格用于计算 Prompt Caching 的成本。如不使用缓存可留空。", })}

))}
)}

{t("openclaw.modelsHint", { defaultValue: "配置该供应商支持的模型。第一个模型为默认模型(Primary),其余为回退模型(Fallback)。拖拽或调整顺序可更改默认模型。", })}

); }