diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 36451b04f..5203ae444 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState, useCallback, useRef } from "react"; +import { useEffect, useMemo, useState, useCallback } from "react"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; @@ -7,16 +7,13 @@ import { Button } from "@/components/ui/button"; import { Form, FormField, FormItem, FormMessage } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; -import { providersApi, type AppId } from "@/lib/api"; +import type { AppId } from "@/lib/api"; import type { ProviderCategory, ProviderMeta, ProviderTestConfig, ProviderProxyConfig, ClaudeApiFormat, - OpenCodeModel, - OpenCodeProviderConfig, - OpenClawModel, } from "@/types"; import { providerPresets, @@ -32,7 +29,6 @@ import { } from "@/config/geminiProviderPresets"; import { opencodeProviderPresets, - OPENCODE_PRESET_MODEL_VARIANTS, type OpenCodeProviderPreset, } from "@/config/opencodeProviderPresets"; import { @@ -57,11 +53,8 @@ import { ClaudeFormFields } from "./ClaudeFormFields"; import { CodexFormFields } from "./CodexFormFields"; import { GeminiFormFields } from "./GeminiFormFields"; import { OmoFormFields } from "./OmoFormFields"; -import { type OmoGlobalConfigFieldsRef } from "./OmoGlobalConfigFields"; import { OmoCommonConfigEditor } from "./OmoCommonConfigEditor"; -import * as configApi from "@/lib/api/config"; -import type { OmoGlobalConfig } from "@/types/omo"; -import { mergeOmoConfigPreview, parseOmoOtherFieldsObject } from "@/types/omo"; +import { parseOmoOtherFieldsObject } from "@/types/omo"; import { ProviderAdvancedConfig, type PricingModelSourceOption, @@ -80,115 +73,20 @@ import { useCodexTomlValidation, useGeminiConfigState, useGeminiCommonConfig, + useOmoModelSource, + useOpencodeFormState, + useOmoDraftState, + useOpenclawFormState, } from "./hooks"; -import { useProvidersQuery } from "@/lib/query/queries"; import { useOmoGlobalConfig } from "@/lib/query/omo"; - -const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2); -const CODEX_DEFAULT_CONFIG = JSON.stringify({ auth: {}, config: "" }, null, 2); -const GEMINI_DEFAULT_CONFIG = JSON.stringify( - { - env: { - GOOGLE_GEMINI_BASE_URL: "", - GEMINI_API_KEY: "", - GEMINI_MODEL: "gemini-3-pro-preview", - }, - }, - null, - 2, -); - -const OPENCODE_DEFAULT_NPM = "@ai-sdk/openai-compatible"; -const OPENCODE_DEFAULT_CONFIG = JSON.stringify( - { - npm: OPENCODE_DEFAULT_NPM, - options: { - baseURL: "", - apiKey: "", - }, - models: {}, - }, - null, - 2, -); -const OPENCODE_KNOWN_OPTION_KEYS = ["baseURL", "apiKey", "headers"] as const; -const isKnownOpencodeOptionKey = (key: string) => - OPENCODE_KNOWN_OPTION_KEYS.includes( - key as (typeof OPENCODE_KNOWN_OPTION_KEYS)[number], - ); - -function parseOpencodeConfig( - settingsConfig?: Record, -): OpenCodeProviderConfig { - const normalize = ( - parsed: Partial, - ): OpenCodeProviderConfig => ({ - npm: parsed.npm || OPENCODE_DEFAULT_NPM, - options: - parsed.options && typeof parsed.options === "object" - ? (parsed.options as OpenCodeProviderConfig["options"]) - : {}, - models: - parsed.models && typeof parsed.models === "object" - ? (parsed.models as Record) - : {}, - }); - - try { - const parsed = JSON.parse( - settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG, - ) as Partial; - return normalize(parsed); - } catch { - return { - npm: OPENCODE_DEFAULT_NPM, - options: {}, - models: {}, - }; - } -} - -function parseOpencodeConfigStrict( - settingsConfig?: Record, -): OpenCodeProviderConfig { - const parsed = JSON.parse( - settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG, - ) as Partial; - return { - npm: parsed.npm || OPENCODE_DEFAULT_NPM, - options: - parsed.options && typeof parsed.options === "object" - ? (parsed.options as OpenCodeProviderConfig["options"]) - : {}, - models: - parsed.models && typeof parsed.models === "object" - ? (parsed.models as Record) - : {}, - }; -} - -function toOpencodeExtraOptions( - options: OpenCodeProviderConfig["options"], -): Record { - const extra: Record = {}; - for (const [k, v] of Object.entries(options || {})) { - if (!isKnownOpencodeOptionKey(k)) { - extra[k] = typeof v === "string" ? v : JSON.stringify(v); - } - } - return extra; -} - -const OPENCLAW_DEFAULT_CONFIG = JSON.stringify( - { - baseUrl: "", - apiKey: "", - api: "openai-completions", - models: [], - }, - null, - 2, -); +import { + CLAUDE_DEFAULT_CONFIG, + CODEX_DEFAULT_CONFIG, + GEMINI_DEFAULT_CONFIG, + OPENCODE_DEFAULT_CONFIG, + OPENCLAW_DEFAULT_CONFIG, + normalizePricingSource, +} from "./helpers/opencodeFormUtils"; type PresetEntry = { id: string; @@ -221,41 +119,6 @@ interface ProviderFormProps { showButtons?: boolean; } -const normalizePricingSource = (value?: string): PricingModelSourceOption => - value === "request" || value === "response" ? value : "inherit"; - -function buildOmoProfilePreview( - agents: Record>, - categories: Record>, - otherFieldsStr: string, -): Record { - const profileOnly: Record = {}; - if (Object.keys(agents).length > 0) { - profileOnly.agents = agents; - } - if (Object.keys(categories).length > 0) { - profileOnly.categories = categories; - } - if (otherFieldsStr.trim()) { - try { - const other = parseOmoOtherFieldsObject(otherFieldsStr); - if (other) { - Object.assign(profileOnly, other); - } - } catch {} - } - return profileOnly; -} - -const EMPTY_OMO_GLOBAL_CONFIG: OmoGlobalConfig = { - id: "global", - disabledAgents: [], - disabledMcps: [], - disabledHooks: [], - disabledSkills: [], - updatedAt: "", -}; - export function ProviderForm({ appId, providerId, @@ -625,617 +488,44 @@ export function ProviderForm({ selectedPresetId: selectedPresetId ?? undefined, }); - const { data: opencodeProvidersData } = useProvidersQuery("opencode"); - const existingOpencodeKeys = useMemo(() => { - if (!opencodeProvidersData?.providers) return []; - return Object.keys(opencodeProvidersData.providers).filter( - (k) => k !== providerId, - ); - }, [opencodeProvidersData?.providers, providerId]); - const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState< - string[] | null - >(null); - const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false); - const lastOmoModelSourceWarningRef = useRef(""); + // ── Extracted hooks: OpenCode / OMO / OpenClaw ───────────────────── - useEffect(() => { - let active = true; - if (!isOmoCategory) { - setEnabledOpencodeProviderIds(null); - setOmoLiveIdsLoadFailed(false); - return () => { - active = false; - }; - } + const { + omoModelOptions, + omoModelVariantsMap, + omoPresetMetaMap, + existingOpencodeKeys, + } = useOmoModelSource({ isOmoCategory, providerId }); - setEnabledOpencodeProviderIds(null); - setOmoLiveIdsLoadFailed(false); - - (async () => { - try { - const ids = await providersApi.getOpenCodeLiveProviderIds(); - if (active) { - setEnabledOpencodeProviderIds(ids); - } - } catch (error) { - console.warn( - "[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids", - error, - ); - if (active) { - setOmoLiveIdsLoadFailed(true); - setEnabledOpencodeProviderIds(null); - } - } - })(); - - return () => { - active = false; - }; - }, [isOmoCategory]); - - const omoModelBuild = useMemo(() => { - const empty = { - options: [] as Array<{ value: string; label: string }>, - variantsMap: {} as Record, - presetMetaMap: {} as Record< - string, - { - options?: Record; - limit?: { context?: number; output?: number }; - } - >, - parseFailedProviders: [] as string[], - usedFallbackSource: false, - }; - if (!isOmoCategory) { - return empty; - } - - const allProviders = opencodeProvidersData?.providers; - if (!allProviders) { - return empty; - } - - const shouldFilterByLive = !omoLiveIdsLoadFailed; - if (shouldFilterByLive && enabledOpencodeProviderIds === null) { - return empty; - } - const liveSet = - shouldFilterByLive && enabledOpencodeProviderIds - ? new Set(enabledOpencodeProviderIds) - : null; - - const dedupedOptions = new Map(); - const variantsMap: Record = {}; - const presetMetaMap: Record< - string, - { - options?: Record; - limit?: { context?: number; output?: number }; - } - > = {}; - const parseFailedProviders: string[] = []; - - for (const [providerKey, provider] of Object.entries(allProviders)) { - if (provider.category === "omo") { - continue; - } - if (liveSet && !liveSet.has(providerKey)) { - continue; - } - - let parsedConfig: OpenCodeProviderConfig; - try { - parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig); - } catch (error) { - parseFailedProviders.push(providerKey); - console.warn( - "[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings", - { - providerKey, - error, - }, - ); - continue; - } - for (const [modelId, model] of Object.entries( - parsedConfig.models || {}, - )) { - const modelName = - typeof model.name === "string" && model.name.trim() - ? model.name - : modelId; - const providerDisplayName = - typeof provider.name === "string" && provider.name.trim() - ? provider.name - : providerKey; - const value = `${providerKey}/${modelId}`; - const label = `${providerDisplayName} / ${modelName} (${modelId})`; - if (!dedupedOptions.has(value)) { - dedupedOptions.set(value, label); - } - - const rawVariants = model.variants; - if ( - rawVariants && - typeof rawVariants === "object" && - !Array.isArray(rawVariants) - ) { - const variantKeys = Object.keys(rawVariants).filter(Boolean); - if (variantKeys.length > 0) { - variantsMap[value] = variantKeys; - } - } - } - - // Preset fallback: for models without config-defined variants, - // check if the npm package has preset variant definitions. - // Also collect preset metadata (options, limit) for enrichment. - const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm]; - if (presetModels) { - for (const modelId of Object.keys(parsedConfig.models || {})) { - const fullKey = `${providerKey}/${modelId}`; - const preset = presetModels.find((p) => p.id === modelId); - if (!preset) continue; - - // Variant fallback - if (!variantsMap[fullKey] && preset.variants) { - const presetKeys = Object.keys(preset.variants).filter(Boolean); - if (presetKeys.length > 0) { - variantsMap[fullKey] = presetKeys; - } - } - - // Collect preset metadata for model enrichment - const meta: (typeof presetMetaMap)[string] = {}; - if (preset.options) meta.options = preset.options; - if (preset.contextLimit || preset.outputLimit) { - meta.limit = {}; - if (preset.contextLimit) meta.limit.context = preset.contextLimit; - if (preset.outputLimit) meta.limit.output = preset.outputLimit; - } - if (Object.keys(meta).length > 0) { - presetMetaMap[fullKey] = meta; - } - } - } - } - - return { - options: Array.from(dedupedOptions.entries()) - .map(([value, label]) => ({ value, label })) - .sort((a, b) => a.label.localeCompare(b.label, "zh-CN")), - variantsMap, - presetMetaMap, - parseFailedProviders, - usedFallbackSource: omoLiveIdsLoadFailed, - }; - }, [ - isOmoCategory, - opencodeProvidersData?.providers, - enabledOpencodeProviderIds, - omoLiveIdsLoadFailed, - ]); - const omoModelOptions = omoModelBuild.options; - const omoModelVariantsMap = omoModelBuild.variantsMap; - const omoPresetMetaMap = omoModelBuild.presetMetaMap; - - useEffect(() => { - if (!isOmoCategory) return; - const failed = omoModelBuild.parseFailedProviders; - const fallback = omoModelBuild.usedFallbackSource; - if (failed.length === 0 && !fallback) return; - - const signature = `${fallback ? "fallback:" : ""}${failed - .slice() - .sort() - .join(",")}`; - if (lastOmoModelSourceWarningRef.current === signature) return; - lastOmoModelSourceWarningRef.current = signature; - - if (failed.length > 0) { - toast.warning( - t("omo.modelSourcePartialWarning", { - count: failed.length, - defaultValue: - "Some provider model configs are invalid and were skipped.", - }), - ); - } - if (fallback) { - toast.warning( - t("omo.modelSourceFallbackWarning", { - defaultValue: - "Failed to load live provider state. Falling back to configured providers.", - }), - ); - } - }, [ - isOmoCategory, - omoModelBuild.parseFailedProviders, - omoModelBuild.usedFallbackSource, - t, - ]); + const opencodeForm = useOpencodeFormState({ + initialData, + appId, + providerId, + onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), + getSettingsConfig: () => form.getValues("settingsConfig"), + }); const initialOmoSettings = appId === "opencode" && initialData?.category === "omo" ? (initialData.settingsConfig as Record | undefined) : undefined; - const initialOpencodeConfig = - appId === "opencode" - ? parseOpencodeConfig(initialData?.settingsConfig) - : null; - const initialOpencodeOptions = initialOpencodeConfig?.options || {}; - const [opencodeProviderKey, setOpencodeProviderKey] = useState(() => { - if (appId !== "opencode") return ""; - return providerId || ""; + const omoDraft = useOmoDraftState({ + initialOmoSettings, + queriedOmoGlobalConfig, + isEditMode, + appId, + category, }); - // OpenClaw: query existing providers for duplicate key checking - const { data: openclawProvidersData } = useProvidersQuery("openclaw"); - const existingOpenclawKeys = useMemo(() => { - if (!openclawProvidersData?.providers) return []; - // Exclude current provider ID when in edit mode - return Object.keys(openclawProvidersData.providers).filter( - (k) => k !== providerId, - ); - }, [openclawProvidersData?.providers, providerId]); - - // OpenClaw Provider Key state - const [openclawProviderKey, setOpenclawProviderKey] = useState(() => { - if (appId !== "openclaw") return ""; - // In edit mode, use the existing provider ID as the key - return providerId || ""; + const openclawForm = useOpenclawFormState({ + initialData, + appId, + providerId, + onSettingsConfigChange: (config) => form.setValue("settingsConfig", config), + getSettingsConfig: () => form.getValues("settingsConfig"), }); - // OpenCode 配置状态 - const [opencodeNpm, setOpencodeNpm] = useState(() => { - if (appId !== "opencode") return OPENCODE_DEFAULT_NPM; - return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM; - }); - - const [opencodeApiKey, setOpencodeApiKey] = useState(() => { - if (appId !== "opencode") return ""; - const value = initialOpencodeOptions.apiKey; - return typeof value === "string" ? value : ""; - }); - - const [opencodeBaseUrl, setOpencodeBaseUrl] = useState(() => { - if (appId !== "opencode") return ""; - const value = initialOpencodeOptions.baseURL; - return typeof value === "string" ? value : ""; - }); - - const [opencodeModels, setOpencodeModels] = useState< - Record - >(() => { - if (appId !== "opencode") return {}; - return initialOpencodeConfig?.models || {}; - }); - - const [opencodeExtraOptions, setOpencodeExtraOptions] = useState< - Record - >(() => { - if (appId !== "opencode") return {}; - return toOpencodeExtraOptions(initialOpencodeOptions); - }); - - const [omoAgents, setOmoAgents] = useState< - Record> - >( - () => - (initialOmoSettings?.agents as Record>) || - {}, - ); - const [omoCategories, setOmoCategories] = useState< - Record> - >( - () => - (initialOmoSettings?.categories as Record< - string, - Record - >) || {}, - ); - const [omoOtherFieldsStr, setOmoOtherFieldsStr] = useState(() => { - const otherFields = initialOmoSettings?.otherFields; - return otherFields ? JSON.stringify(otherFields, null, 2) : ""; - }); - - const [omoGlobalState, setOmoGlobalState] = useState( - null, - ); - - const [isOmoConfigModalOpen, setIsOmoConfigModalOpen] = useState(false); - const [useOmoCommonConfig, setUseOmoCommonConfig] = useState(() => { - const raw = initialOmoSettings?.useCommonConfig; - return typeof raw === "boolean" ? raw : true; - }); - const [isOmoSaving, setIsOmoSaving] = useState(false); - const omoGlobalConfigRef = useRef(null); - const [omoFieldsKey, setOmoFieldsKey] = useState(0); - const effectiveOmoGlobalConfig = - omoGlobalState ?? queriedOmoGlobalConfig ?? EMPTY_OMO_GLOBAL_CONFIG; - - const mergedOmoJsonPreview = useMemo(() => { - if (useOmoCommonConfig) { - const merged = mergeOmoConfigPreview( - effectiveOmoGlobalConfig, - omoAgents, - omoCategories, - omoOtherFieldsStr, - ); - return JSON.stringify(merged, null, 2); - } else { - return JSON.stringify( - buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr), - null, - 2, - ); - } - }, [ - useOmoCommonConfig, - effectiveOmoGlobalConfig, - omoAgents, - omoCategories, - omoOtherFieldsStr, - ]); - - useEffect(() => { - if (appId !== "opencode" || category !== "omo" || isEditMode) return; - let active = true; - (async () => { - let next = false; - try { - const raw = await configApi.getCommonConfigSnippet("omo"); - if (raw) { - const parsed = JSON.parse(raw) as Record; - next = Object.keys(parsed).some( - (k) => k !== "id" && k !== "updatedAt", - ); - } - } catch {} - if (active) setUseOmoCommonConfig(next); - })(); - return () => { - active = false; - }; - }, [appId, category, isEditMode]); - - const handleOmoGlobalConfigSave = useCallback(async () => { - if (!omoGlobalConfigRef.current) return; - setIsOmoSaving(true); - try { - const config = omoGlobalConfigRef.current.buildCurrentConfigStrict(); - await configApi.setCommonConfigSnippet("omo", JSON.stringify(config)); - setIsOmoConfigModalOpen(false); - toast.success( - t("omo.globalConfigSaved", { defaultValue: "Global config saved" }), - ); - } catch (err) { - toast.error(String(err)); - } finally { - setIsOmoSaving(false); - } - }, [t]); - - const handleOmoEditClick = useCallback(() => { - setOmoFieldsKey((k) => k + 1); - setIsOmoConfigModalOpen(true); - }, []); - - const resetOmoDraftState = useCallback((useCommonConfig = true) => { - setOmoAgents({}); - setOmoCategories({}); - setOmoOtherFieldsStr(""); - setUseOmoCommonConfig(useCommonConfig); - }, []); - - // OpenClaw 配置状态 - const [openclawBaseUrl, setOpenclawBaseUrl] = useState(() => { - if (appId !== "openclaw") return ""; - try { - const config = JSON.parse( - initialData?.settingsConfig - ? JSON.stringify(initialData.settingsConfig) - : OPENCLAW_DEFAULT_CONFIG, - ); - return config.baseUrl || ""; - } catch { - return ""; - } - }); - - const [openclawApiKey, setOpenclawApiKey] = useState(() => { - if (appId !== "openclaw") return ""; - try { - const config = JSON.parse( - initialData?.settingsConfig - ? JSON.stringify(initialData.settingsConfig) - : OPENCLAW_DEFAULT_CONFIG, - ); - return config.apiKey || ""; - } catch { - return ""; - } - }); - - const [openclawApi, setOpenclawApi] = useState(() => { - if (appId !== "openclaw") return "openai-completions"; - try { - const config = JSON.parse( - initialData?.settingsConfig - ? JSON.stringify(initialData.settingsConfig) - : OPENCLAW_DEFAULT_CONFIG, - ); - return config.api || "openai-completions"; - } catch { - return "openai-completions"; - } - }); - - const [openclawModels, setOpenclawModels] = useState(() => { - if (appId !== "openclaw") return []; - try { - const config = JSON.parse( - initialData?.settingsConfig - ? JSON.stringify(initialData.settingsConfig) - : OPENCLAW_DEFAULT_CONFIG, - ); - return config.models || []; - } catch { - return []; - } - }); - - // OpenClaw handlers - sync state to form - const handleOpenclawBaseUrlChange = useCallback( - (baseUrl: string) => { - setOpenclawBaseUrl(baseUrl); - try { - const config = JSON.parse( - form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG, - ); - config.baseUrl = baseUrl.trim().replace(/\/+$/, ""); - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); - } catch { - // ignore - } - }, - [form], - ); - - const handleOpenclawApiKeyChange = useCallback( - (apiKey: string) => { - setOpenclawApiKey(apiKey); - try { - const config = JSON.parse( - form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG, - ); - config.apiKey = apiKey; - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); - } catch { - // ignore - } - }, - [form], - ); - - const handleOpenclawApiChange = useCallback( - (api: string) => { - setOpenclawApi(api); - try { - const config = JSON.parse( - form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG, - ); - config.api = api; - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); - } catch { - // ignore - } - }, - [form], - ); - - const handleOpenclawModelsChange = useCallback( - (models: OpenClawModel[]) => { - setOpenclawModels(models); - try { - const config = JSON.parse( - form.getValues("settingsConfig") || OPENCLAW_DEFAULT_CONFIG, - ); - config.models = models; - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); - } catch { - // ignore - } - }, - [form], - ); - - const updateOpencodeSettings = useCallback( - (updater: (config: Record) => void) => { - try { - const config = JSON.parse( - form.getValues("settingsConfig") || OPENCODE_DEFAULT_CONFIG, - ) as Record; - updater(config); - form.setValue("settingsConfig", JSON.stringify(config, null, 2)); - } catch {} - }, - [form], - ); - - const handleOpencodeNpmChange = useCallback( - (npm: string) => { - setOpencodeNpm(npm); - updateOpencodeSettings((config) => { - config.npm = npm; - }); - }, - [updateOpencodeSettings], - ); - - const handleOpencodeApiKeyChange = useCallback( - (apiKey: string) => { - setOpencodeApiKey(apiKey); - updateOpencodeSettings((config) => { - if (!config.options) config.options = {}; - config.options.apiKey = apiKey; - }); - }, - [updateOpencodeSettings], - ); - - const handleOpencodeBaseUrlChange = useCallback( - (baseUrl: string) => { - setOpencodeBaseUrl(baseUrl); - updateOpencodeSettings((config) => { - if (!config.options) config.options = {}; - config.options.baseURL = baseUrl.trim().replace(/\/+$/, ""); - }); - }, - [updateOpencodeSettings], - ); - - const handleOpencodeModelsChange = useCallback( - (models: Record) => { - setOpencodeModels(models); - updateOpencodeSettings((config) => { - config.models = models; - }); - }, - [updateOpencodeSettings], - ); - - const handleOpencodeExtraOptionsChange = useCallback( - (options: Record) => { - setOpencodeExtraOptions(options); - updateOpencodeSettings((config) => { - if (!config.options) config.options = {}; - - for (const k of Object.keys(config.options)) { - if (!isKnownOpencodeOptionKey(k)) { - delete config.options[k]; - } - } - - for (const [k, v] of Object.entries(options)) { - const trimmedKey = k.trim(); - if (trimmedKey && !trimmedKey.startsWith("option-")) { - try { - config.options[trimmedKey] = JSON.parse(v); - } catch { - config.options[trimmedKey] = v; - } - } - } - }); - }, - [updateOpencodeSettings], - ); - const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); const handleSubmit = (values: ProviderFormData) => { @@ -1263,19 +553,22 @@ export function ProviderForm({ if (appId === "opencode" && category !== "omo") { const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; - if (!opencodeProviderKey.trim()) { + if (!opencodeForm.opencodeProviderKey.trim()) { toast.error(t("opencode.providerKeyRequired")); return; } - if (!keyPattern.test(opencodeProviderKey)) { + if (!keyPattern.test(opencodeForm.opencodeProviderKey)) { toast.error(t("opencode.providerKeyInvalid")); return; } - if (!isEditMode && existingOpencodeKeys.includes(opencodeProviderKey)) { + if ( + !isEditMode && + existingOpencodeKeys.includes(opencodeForm.opencodeProviderKey) + ) { toast.error(t("opencode.providerKeyDuplicate")); return; } - if (Object.keys(opencodeModels).length === 0) { + if (Object.keys(opencodeForm.opencodeModels).length === 0) { toast.error(t("opencode.modelsRequired")); return; } @@ -1284,15 +577,20 @@ export function ProviderForm({ // OpenClaw: validate provider key if (appId === "openclaw") { const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; - if (!openclawProviderKey.trim()) { + if (!openclawForm.openclawProviderKey.trim()) { toast.error(t("openclaw.providerKeyRequired")); return; } - if (!keyPattern.test(openclawProviderKey)) { + if (!keyPattern.test(openclawForm.openclawProviderKey)) { toast.error(t("openclaw.providerKeyInvalid")); return; } - if (!isEditMode && existingOpenclawKeys.includes(openclawProviderKey)) { + if ( + !isEditMode && + openclawForm.existingOpenclawKeys.includes( + openclawForm.openclawProviderKey, + ) + ) { toast.error(t("openclaw.providerKeyDuplicate")); return; } @@ -1381,16 +679,18 @@ export function ProviderForm({ } } else if (appId === "opencode" && category === "omo") { const omoConfig: Record = {}; - omoConfig.useCommonConfig = useOmoCommonConfig; - if (Object.keys(omoAgents).length > 0) { - omoConfig.agents = omoAgents; + omoConfig.useCommonConfig = omoDraft.useOmoCommonConfig; + if (Object.keys(omoDraft.omoAgents).length > 0) { + omoConfig.agents = omoDraft.omoAgents; } - if (Object.keys(omoCategories).length > 0) { - omoConfig.categories = omoCategories; + if (Object.keys(omoDraft.omoCategories).length > 0) { + omoConfig.categories = omoDraft.omoCategories; } - if (omoOtherFieldsStr.trim()) { + if (omoDraft.omoOtherFieldsStr.trim()) { try { - const otherFields = parseOmoOtherFieldsObject(omoOtherFieldsStr); + const otherFields = parseOmoOtherFieldsObject( + omoDraft.omoOtherFieldsStr, + ); if (!otherFields) { toast.error( t("omo.jsonMustBeObject", { @@ -1430,10 +730,10 @@ export function ProviderForm({ payload.providerKey = `omo-${crypto.randomUUID().slice(0, 8)}`; } } else { - payload.providerKey = opencodeProviderKey; + payload.providerKey = opencodeForm.opencodeProviderKey; } } else if (appId === "openclaw") { - payload.providerKey = openclawProviderKey; + payload.providerKey = openclawForm.openclawProviderKey; } if (category === "omo" && !payload.presetCategory) { @@ -1628,21 +928,12 @@ export function ProviderForm({ resetGeminiConfig({}, {}); } if (appId === "opencode") { - setOpencodeProviderKey(""); - setOpencodeNpm(OPENCODE_DEFAULT_NPM); - setOpencodeBaseUrl(""); - setOpencodeApiKey(""); - setOpencodeModels({}); - setOpencodeExtraOptions({}); - resetOmoDraftState(); + opencodeForm.resetOpencodeState(); + omoDraft.resetOmoDraftState(); } // OpenClaw 自定义模式:重置为空配置 if (appId === "openclaw") { - setOpenclawProviderKey(""); - setOpenclawBaseUrl(""); - setOpenclawApiKey(""); - setOpenclawApi("openai-completions"); - setOpenclawModels([]); + openclawForm.resetOpenclawState(); } return; } @@ -1698,7 +989,7 @@ export function ProviderForm({ const config = preset.settingsConfig; if (preset.category === "omo") { - resetOmoDraftState(); + omoDraft.resetOmoDraftState(); form.reset({ name: "OMO", websiteUrl: preset.websiteUrl ?? "", @@ -1709,13 +1000,7 @@ export function ProviderForm({ return; } - setOpencodeProviderKey(""); - - setOpencodeNpm(config.npm || OPENCODE_DEFAULT_NPM); - setOpencodeBaseUrl(config.options?.baseURL || ""); - setOpencodeApiKey(config.options?.apiKey || ""); - setOpencodeModels(config.models || {}); - setOpencodeExtraOptions(toOpencodeExtraOptions(config.options || {})); + opencodeForm.resetOpencodeState(config); form.reset({ name: preset.name, @@ -1741,14 +1026,7 @@ export function ProviderForm({ suggestedDefaults: preset.suggestedDefaults, }); - // Clear provider key (user must enter their own unique key) - setOpenclawProviderKey(""); - - // Update OpenClaw-specific states - setOpenclawBaseUrl(config.baseUrl || ""); - setOpenclawApiKey(config.apiKey || ""); - setOpenclawApi(config.api || "openai-completions"); - setOpenclawModels(config.models || []); + openclawForm.resetOpenclawState(config); // Update form fields form.reset({ @@ -1825,41 +1103,52 @@ export function ProviderForm({ - setOpencodeProviderKey( + opencodeForm.setOpencodeProviderKey( e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), ) } placeholder={t("opencode.providerKeyPlaceholder")} disabled={isEditMode} className={ - (existingOpencodeKeys.includes(opencodeProviderKey) && + (existingOpencodeKeys.includes( + opencodeForm.opencodeProviderKey, + ) && !isEditMode) || - (opencodeProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) + (opencodeForm.opencodeProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + opencodeForm.opencodeProviderKey, + )) ? "border-destructive" : "" } /> - {existingOpencodeKeys.includes(opencodeProviderKey) && + {existingOpencodeKeys.includes( + opencodeForm.opencodeProviderKey, + ) && !isEditMode && (

{t("opencode.providerKeyDuplicate")}

)} - {opencodeProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey) && ( + {opencodeForm.opencodeProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + opencodeForm.opencodeProviderKey, + ) && (

{t("opencode.providerKeyInvalid")}

)} {!( - existingOpencodeKeys.includes(opencodeProviderKey) && - !isEditMode + existingOpencodeKeys.includes( + opencodeForm.opencodeProviderKey, + ) && !isEditMode ) && - (opencodeProviderKey.trim() === "" || - /^[a-z0-9]+(-[a-z0-9]+)*$/.test(opencodeProviderKey)) && ( + (opencodeForm.opencodeProviderKey.trim() === "" || + /^[a-z0-9]+(-[a-z0-9]+)*$/.test( + opencodeForm.opencodeProviderKey, + )) && (

{t("opencode.providerKeyHint")}

@@ -1873,41 +1162,52 @@ export function ProviderForm({ - setOpenclawProviderKey( + openclawForm.setOpenclawProviderKey( e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""), ) } placeholder={t("openclaw.providerKeyPlaceholder")} disabled={isEditMode} className={ - (existingOpenclawKeys.includes(openclawProviderKey) && + (openclawForm.existingOpenclawKeys.includes( + openclawForm.openclawProviderKey, + ) && !isEditMode) || - (openclawProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey)) + (openclawForm.openclawProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + openclawForm.openclawProviderKey, + )) ? "border-destructive" : "" } /> - {existingOpenclawKeys.includes(openclawProviderKey) && + {openclawForm.existingOpenclawKeys.includes( + openclawForm.openclawProviderKey, + ) && !isEditMode && (

{t("openclaw.providerKeyDuplicate")}

)} - {openclawProviderKey.trim() !== "" && - !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey) && ( + {openclawForm.openclawProviderKey.trim() !== "" && + !/^[a-z0-9]+(-[a-z0-9]+)*$/.test( + openclawForm.openclawProviderKey, + ) && (

{t("openclaw.providerKeyInvalid")}

)} {!( - existingOpenclawKeys.includes(openclawProviderKey) && - !isEditMode + openclawForm.existingOpenclawKeys.includes( + openclawForm.openclawProviderKey, + ) && !isEditMode ) && - (openclawProviderKey.trim() === "" || - /^[a-z0-9]+(-[a-z0-9]+)*$/.test(openclawProviderKey)) && ( + (openclawForm.openclawProviderKey.trim() === "" || + /^[a-z0-9]+(-[a-z0-9]+)*$/.test( + openclawForm.openclawProviderKey, + )) && (

{t("openclaw.providerKeyHint")}

@@ -2016,21 +1316,21 @@ export function ProviderForm({ {appId === "opencode" && category !== "omo" && ( )} @@ -2039,31 +1339,31 @@ export function ProviderForm({ modelOptions={omoModelOptions} modelVariantsMap={omoModelVariantsMap} presetMetaMap={omoPresetMetaMap} - agents={omoAgents} - onAgentsChange={setOmoAgents} - categories={omoCategories} - onCategoriesChange={setOmoCategories} - otherFieldsStr={omoOtherFieldsStr} - onOtherFieldsStrChange={setOmoOtherFieldsStr} + agents={omoDraft.omoAgents} + onAgentsChange={omoDraft.setOmoAgents} + categories={omoDraft.omoCategories} + onCategoriesChange={omoDraft.setOmoCategories} + otherFieldsStr={omoDraft.omoOtherFieldsStr} + onOtherFieldsStrChange={omoDraft.setOmoOtherFieldsStr} /> )} {/* OpenClaw 专属字段 */} {appId === "openclaw" && ( )} @@ -2110,17 +1410,17 @@ export function ProviderForm({ ) : appId === "opencode" && category === "omo" ? ( setIsOmoConfigModalOpen(false)} - onSave={handleOmoGlobalConfigSave} - isSaving={isOmoSaving} - onGlobalConfigStateChange={setOmoGlobalState} - globalConfigRef={omoGlobalConfigRef} - fieldsKey={omoFieldsKey} + previewValue={omoDraft.mergedOmoJsonPreview} + useCommonConfig={omoDraft.useOmoCommonConfig} + onCommonConfigToggle={omoDraft.setUseOmoCommonConfig} + isModalOpen={omoDraft.isOmoConfigModalOpen} + onEditClick={omoDraft.handleOmoEditClick} + onModalClose={() => omoDraft.setIsOmoConfigModalOpen(false)} + onSave={omoDraft.handleOmoGlobalConfigSave} + isSaving={omoDraft.isOmoSaving} + onGlobalConfigStateChange={omoDraft.setOmoGlobalState} + globalConfigRef={omoDraft.omoGlobalConfigRef} + fieldsKey={omoDraft.omoFieldsKey} /> ) : appId === "opencode" && category !== "omo" ? ( <> diff --git a/src/components/providers/forms/helpers/opencodeFormUtils.ts b/src/components/providers/forms/helpers/opencodeFormUtils.ts new file mode 100644 index 000000000..a87ae69a0 --- /dev/null +++ b/src/components/providers/forms/helpers/opencodeFormUtils.ts @@ -0,0 +1,161 @@ +import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types"; +import type { OmoGlobalConfig } from "@/types/omo"; +import { parseOmoOtherFieldsObject } from "@/types/omo"; +import type { PricingModelSourceOption } from "../ProviderAdvancedConfig"; + +// ── Default configs ────────────────────────────────────────────────── + +export const CLAUDE_DEFAULT_CONFIG = JSON.stringify({ env: {} }, null, 2); +export const CODEX_DEFAULT_CONFIG = JSON.stringify( + { auth: {}, config: "" }, + null, + 2, +); +export const GEMINI_DEFAULT_CONFIG = JSON.stringify( + { + env: { + GOOGLE_GEMINI_BASE_URL: "", + GEMINI_API_KEY: "", + GEMINI_MODEL: "gemini-3-pro-preview", + }, + }, + null, + 2, +); + +export const OPENCODE_DEFAULT_NPM = "@ai-sdk/openai-compatible"; +export const OPENCODE_DEFAULT_CONFIG = JSON.stringify( + { + npm: OPENCODE_DEFAULT_NPM, + options: { + baseURL: "", + apiKey: "", + }, + models: {}, + }, + null, + 2, +); +export const OPENCODE_KNOWN_OPTION_KEYS = [ + "baseURL", + "apiKey", + "headers", +] as const; + +export const OPENCLAW_DEFAULT_CONFIG = JSON.stringify( + { + baseUrl: "", + apiKey: "", + api: "openai-completions", + models: [], + }, + null, + 2, +); + +export const EMPTY_OMO_GLOBAL_CONFIG: OmoGlobalConfig = { + id: "global", + disabledAgents: [], + disabledMcps: [], + disabledHooks: [], + disabledSkills: [], + updatedAt: "", +}; + +// ── Pure functions ─────────────────────────────────────────────────── + +export function isKnownOpencodeOptionKey(key: string): boolean { + return OPENCODE_KNOWN_OPTION_KEYS.includes( + key as (typeof OPENCODE_KNOWN_OPTION_KEYS)[number], + ); +} + +export function parseOpencodeConfig( + settingsConfig?: Record, +): OpenCodeProviderConfig { + const normalize = ( + parsed: Partial, + ): OpenCodeProviderConfig => ({ + npm: parsed.npm || OPENCODE_DEFAULT_NPM, + options: + parsed.options && typeof parsed.options === "object" + ? (parsed.options as OpenCodeProviderConfig["options"]) + : {}, + models: + parsed.models && typeof parsed.models === "object" + ? (parsed.models as Record) + : {}, + }); + + try { + const parsed = JSON.parse( + settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG, + ) as Partial; + return normalize(parsed); + } catch { + return { + npm: OPENCODE_DEFAULT_NPM, + options: {}, + models: {}, + }; + } +} + +export function parseOpencodeConfigStrict( + settingsConfig?: Record, +): OpenCodeProviderConfig { + const parsed = JSON.parse( + settingsConfig ? JSON.stringify(settingsConfig) : OPENCODE_DEFAULT_CONFIG, + ) as Partial; + return { + npm: parsed.npm || OPENCODE_DEFAULT_NPM, + options: + parsed.options && typeof parsed.options === "object" + ? (parsed.options as OpenCodeProviderConfig["options"]) + : {}, + models: + parsed.models && typeof parsed.models === "object" + ? (parsed.models as Record) + : {}, + }; +} + +export function toOpencodeExtraOptions( + options: OpenCodeProviderConfig["options"], +): Record { + const extra: Record = {}; + for (const [k, v] of Object.entries(options || {})) { + if (!isKnownOpencodeOptionKey(k)) { + extra[k] = typeof v === "string" ? v : JSON.stringify(v); + } + } + return extra; +} + +export function buildOmoProfilePreview( + agents: Record>, + categories: Record>, + otherFieldsStr: string, +): Record { + const profileOnly: Record = {}; + if (Object.keys(agents).length > 0) { + profileOnly.agents = agents; + } + if (Object.keys(categories).length > 0) { + profileOnly.categories = categories; + } + if (otherFieldsStr.trim()) { + try { + const other = parseOmoOtherFieldsObject(otherFieldsStr); + if (other) { + Object.assign(profileOnly, other); + } + } catch {} + } + return profileOnly; +} + +export const normalizePricingSource = ( + value?: string, +): PricingModelSourceOption => + value === "request" || value === "response" ? value : "inherit"; diff --git a/src/components/providers/forms/hooks/index.ts b/src/components/providers/forms/hooks/index.ts index 88e586418..760904339 100644 --- a/src/components/providers/forms/hooks/index.ts +++ b/src/components/providers/forms/hooks/index.ts @@ -12,3 +12,7 @@ export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints"; export { useCodexTomlValidation } from "./useCodexTomlValidation"; export { useGeminiConfigState } from "./useGeminiConfigState"; export { useGeminiCommonConfig } from "./useGeminiCommonConfig"; +export { useOmoModelSource } from "./useOmoModelSource"; +export { useOpencodeFormState } from "./useOpencodeFormState"; +export { useOmoDraftState } from "./useOmoDraftState"; +export { useOpenclawFormState } from "./useOpenclawFormState"; diff --git a/src/components/providers/forms/hooks/useOmoDraftState.ts b/src/components/providers/forms/hooks/useOmoDraftState.ts new file mode 100644 index 000000000..e02649ca1 --- /dev/null +++ b/src/components/providers/forms/hooks/useOmoDraftState.ts @@ -0,0 +1,190 @@ +import { useState, useCallback, useEffect, useRef, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import type { OmoGlobalConfig } from "@/types/omo"; +import { mergeOmoConfigPreview } from "@/types/omo"; +import { type OmoGlobalConfigFieldsRef } from "../OmoGlobalConfigFields"; +import * as configApi from "@/lib/api/config"; +import { + EMPTY_OMO_GLOBAL_CONFIG, + buildOmoProfilePreview, +} from "../helpers/opencodeFormUtils"; + +interface UseOmoDraftStateParams { + initialOmoSettings: Record | undefined; + queriedOmoGlobalConfig: OmoGlobalConfig | undefined; + isEditMode: boolean; + appId: string; + category?: string; +} + +export interface OmoDraftState { + omoAgents: Record>; + setOmoAgents: React.Dispatch< + React.SetStateAction>> + >; + omoCategories: Record>; + setOmoCategories: React.Dispatch< + React.SetStateAction>> + >; + omoOtherFieldsStr: string; + setOmoOtherFieldsStr: React.Dispatch>; + useOmoCommonConfig: boolean; + setUseOmoCommonConfig: React.Dispatch>; + isOmoConfigModalOpen: boolean; + setIsOmoConfigModalOpen: React.Dispatch>; + isOmoSaving: boolean; + omoGlobalConfigRef: React.RefObject; + omoFieldsKey: number; + effectiveOmoGlobalConfig: OmoGlobalConfig; + mergedOmoJsonPreview: string; + handleOmoGlobalConfigSave: () => Promise; + handleOmoEditClick: () => void; + resetOmoDraftState: (useCommonConfig?: boolean) => void; + setOmoGlobalState: React.Dispatch< + React.SetStateAction + >; +} + +export function useOmoDraftState({ + initialOmoSettings, + queriedOmoGlobalConfig, + isEditMode, + appId, + category, +}: UseOmoDraftStateParams): OmoDraftState { + const { t } = useTranslation(); + + const [omoAgents, setOmoAgents] = useState< + Record> + >( + () => + (initialOmoSettings?.agents as Record>) || + {}, + ); + const [omoCategories, setOmoCategories] = useState< + Record> + >( + () => + (initialOmoSettings?.categories as Record< + string, + Record + >) || {}, + ); + const [omoOtherFieldsStr, setOmoOtherFieldsStr] = useState(() => { + const otherFields = initialOmoSettings?.otherFields; + return otherFields ? JSON.stringify(otherFields, null, 2) : ""; + }); + + const [omoGlobalState, setOmoGlobalState] = useState( + null, + ); + + const [isOmoConfigModalOpen, setIsOmoConfigModalOpen] = useState(false); + const [useOmoCommonConfig, setUseOmoCommonConfig] = useState(() => { + const raw = initialOmoSettings?.useCommonConfig; + return typeof raw === "boolean" ? raw : true; + }); + const [isOmoSaving, setIsOmoSaving] = useState(false); + const omoGlobalConfigRef = useRef(null); + const [omoFieldsKey, setOmoFieldsKey] = useState(0); + const effectiveOmoGlobalConfig = + omoGlobalState ?? queriedOmoGlobalConfig ?? EMPTY_OMO_GLOBAL_CONFIG; + + const mergedOmoJsonPreview = useMemo(() => { + if (useOmoCommonConfig) { + const merged = mergeOmoConfigPreview( + effectiveOmoGlobalConfig, + omoAgents, + omoCategories, + omoOtherFieldsStr, + ); + return JSON.stringify(merged, null, 2); + } else { + return JSON.stringify( + buildOmoProfilePreview(omoAgents, omoCategories, omoOtherFieldsStr), + null, + 2, + ); + } + }, [ + useOmoCommonConfig, + effectiveOmoGlobalConfig, + omoAgents, + omoCategories, + omoOtherFieldsStr, + ]); + + // Auto-detect whether common config has content for new OMO profiles + useEffect(() => { + if (appId !== "opencode" || category !== "omo" || isEditMode) return; + let active = true; + (async () => { + let next = false; + try { + const raw = await configApi.getCommonConfigSnippet("omo"); + if (raw) { + const parsed = JSON.parse(raw) as Record; + next = Object.keys(parsed).some( + (k) => k !== "id" && k !== "updatedAt", + ); + } + } catch {} + if (active) setUseOmoCommonConfig(next); + })(); + return () => { + active = false; + }; + }, [appId, category, isEditMode]); + + const handleOmoGlobalConfigSave = useCallback(async () => { + if (!omoGlobalConfigRef.current) return; + setIsOmoSaving(true); + try { + const config = omoGlobalConfigRef.current.buildCurrentConfigStrict(); + await configApi.setCommonConfigSnippet("omo", JSON.stringify(config)); + setIsOmoConfigModalOpen(false); + toast.success( + t("omo.globalConfigSaved", { defaultValue: "Global config saved" }), + ); + } catch (err) { + toast.error(String(err)); + } finally { + setIsOmoSaving(false); + } + }, [t]); + + const handleOmoEditClick = useCallback(() => { + setOmoFieldsKey((k) => k + 1); + setIsOmoConfigModalOpen(true); + }, []); + + const resetOmoDraftState = useCallback((useCommonConfig = true) => { + setOmoAgents({}); + setOmoCategories({}); + setOmoOtherFieldsStr(""); + setUseOmoCommonConfig(useCommonConfig); + }, []); + + return { + omoAgents, + setOmoAgents, + omoCategories, + setOmoCategories, + omoOtherFieldsStr, + setOmoOtherFieldsStr, + useOmoCommonConfig, + setUseOmoCommonConfig, + isOmoConfigModalOpen, + setIsOmoConfigModalOpen, + isOmoSaving, + omoGlobalConfigRef, + omoFieldsKey, + effectiveOmoGlobalConfig, + mergedOmoJsonPreview, + handleOmoGlobalConfigSave, + handleOmoEditClick, + resetOmoDraftState, + setOmoGlobalState, + }; +} diff --git a/src/components/providers/forms/hooks/useOmoModelSource.ts b/src/components/providers/forms/hooks/useOmoModelSource.ts new file mode 100644 index 000000000..b90401ae4 --- /dev/null +++ b/src/components/providers/forms/hooks/useOmoModelSource.ts @@ -0,0 +1,280 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { toast } from "sonner"; +import { providersApi } from "@/lib/api"; +import { useProvidersQuery } from "@/lib/query/queries"; +import type { OpenCodeProviderConfig } from "@/types"; +import { OPENCODE_PRESET_MODEL_VARIANTS } from "@/config/opencodeProviderPresets"; +import { parseOpencodeConfigStrict } from "../helpers/opencodeFormUtils"; + +interface UseOmoModelSourceParams { + isOmoCategory: boolean; + providerId?: string; +} + +interface OmoModelBuild { + options: Array<{ value: string; label: string }>; + variantsMap: Record; + presetMetaMap: Record< + string, + { + options?: Record; + limit?: { context?: number; output?: number }; + } + >; + parseFailedProviders: string[]; + usedFallbackSource: boolean; +} + +export interface OmoModelSourceResult { + omoModelOptions: Array<{ value: string; label: string }>; + omoModelVariantsMap: Record; + omoPresetMetaMap: Record< + string, + { + options?: Record; + limit?: { context?: number; output?: number }; + } + >; + existingOpencodeKeys: string[]; +} + +export function useOmoModelSource({ + isOmoCategory, + providerId, +}: UseOmoModelSourceParams): OmoModelSourceResult { + const { t } = useTranslation(); + + const { data: opencodeProvidersData } = useProvidersQuery("opencode"); + const existingOpencodeKeys = useMemo(() => { + if (!opencodeProvidersData?.providers) return []; + return Object.keys(opencodeProvidersData.providers).filter( + (k) => k !== providerId, + ); + }, [opencodeProvidersData?.providers, providerId]); + + const [enabledOpencodeProviderIds, setEnabledOpencodeProviderIds] = useState< + string[] | null + >(null); + const [omoLiveIdsLoadFailed, setOmoLiveIdsLoadFailed] = useState(false); + const lastOmoModelSourceWarningRef = useRef(""); + + useEffect(() => { + let active = true; + if (!isOmoCategory) { + setEnabledOpencodeProviderIds(null); + setOmoLiveIdsLoadFailed(false); + return () => { + active = false; + }; + } + + setEnabledOpencodeProviderIds(null); + setOmoLiveIdsLoadFailed(false); + + (async () => { + try { + const ids = await providersApi.getOpenCodeLiveProviderIds(); + if (active) { + setEnabledOpencodeProviderIds(ids); + } + } catch (error) { + console.warn( + "[OMO_MODEL_SOURCE_LIVE_IDS_FAILED] failed to load live provider ids", + error, + ); + if (active) { + setOmoLiveIdsLoadFailed(true); + setEnabledOpencodeProviderIds(null); + } + } + })(); + + return () => { + active = false; + }; + }, [isOmoCategory]); + + const omoModelBuild = useMemo(() => { + const empty: OmoModelBuild = { + options: [], + variantsMap: {}, + presetMetaMap: {}, + parseFailedProviders: [], + usedFallbackSource: false, + }; + if (!isOmoCategory) { + return empty; + } + + const allProviders = opencodeProvidersData?.providers; + if (!allProviders) { + return empty; + } + + const shouldFilterByLive = !omoLiveIdsLoadFailed; + if (shouldFilterByLive && enabledOpencodeProviderIds === null) { + return empty; + } + const liveSet = + shouldFilterByLive && enabledOpencodeProviderIds + ? new Set(enabledOpencodeProviderIds) + : null; + + const dedupedOptions = new Map(); + const variantsMap: Record = {}; + const presetMetaMap: Record< + string, + { + options?: Record; + limit?: { context?: number; output?: number }; + } + > = {}; + const parseFailedProviders: string[] = []; + + for (const [providerKey, provider] of Object.entries(allProviders)) { + if (provider.category === "omo") { + continue; + } + if (liveSet && !liveSet.has(providerKey)) { + continue; + } + + let parsedConfig: OpenCodeProviderConfig; + try { + parsedConfig = parseOpencodeConfigStrict(provider.settingsConfig); + } catch (error) { + parseFailedProviders.push(providerKey); + console.warn( + "[OMO_MODEL_SOURCE_PARSE_FAILED] failed to parse provider settings", + { + providerKey, + error, + }, + ); + continue; + } + for (const [modelId, model] of Object.entries( + parsedConfig.models || {}, + )) { + const modelName = + typeof model.name === "string" && model.name.trim() + ? model.name + : modelId; + const providerDisplayName = + typeof provider.name === "string" && provider.name.trim() + ? provider.name + : providerKey; + const value = `${providerKey}/${modelId}`; + const label = `${providerDisplayName} / ${modelName} (${modelId})`; + if (!dedupedOptions.has(value)) { + dedupedOptions.set(value, label); + } + + const rawVariants = model.variants; + if ( + rawVariants && + typeof rawVariants === "object" && + !Array.isArray(rawVariants) + ) { + const variantKeys = Object.keys(rawVariants).filter(Boolean); + if (variantKeys.length > 0) { + variantsMap[value] = variantKeys; + } + } + } + + // Preset fallback: for models without config-defined variants, + // check if the npm package has preset variant definitions. + // Also collect preset metadata (options, limit) for enrichment. + const presetModels = OPENCODE_PRESET_MODEL_VARIANTS[parsedConfig.npm]; + if (presetModels) { + for (const modelId of Object.keys(parsedConfig.models || {})) { + const fullKey = `${providerKey}/${modelId}`; + const preset = presetModels.find((p) => p.id === modelId); + if (!preset) continue; + + // Variant fallback + if (!variantsMap[fullKey] && preset.variants) { + const presetKeys = Object.keys(preset.variants).filter(Boolean); + if (presetKeys.length > 0) { + variantsMap[fullKey] = presetKeys; + } + } + + // Collect preset metadata for model enrichment + const meta: (typeof presetMetaMap)[string] = {}; + if (preset.options) meta.options = preset.options; + if (preset.contextLimit || preset.outputLimit) { + meta.limit = {}; + if (preset.contextLimit) meta.limit.context = preset.contextLimit; + if (preset.outputLimit) meta.limit.output = preset.outputLimit; + } + if (Object.keys(meta).length > 0) { + presetMetaMap[fullKey] = meta; + } + } + } + } + + return { + options: Array.from(dedupedOptions.entries()) + .map(([value, label]) => ({ value, label })) + .sort((a, b) => a.label.localeCompare(b.label, "zh-CN")), + variantsMap, + presetMetaMap, + parseFailedProviders, + usedFallbackSource: omoLiveIdsLoadFailed, + }; + }, [ + isOmoCategory, + opencodeProvidersData?.providers, + enabledOpencodeProviderIds, + omoLiveIdsLoadFailed, + ]); + + // Warning toast for parse failures / fallback + useEffect(() => { + if (!isOmoCategory) return; + const failed = omoModelBuild.parseFailedProviders; + const fallback = omoModelBuild.usedFallbackSource; + if (failed.length === 0 && !fallback) return; + + const signature = `${fallback ? "fallback:" : ""}${failed + .slice() + .sort() + .join(",")}`; + if (lastOmoModelSourceWarningRef.current === signature) return; + lastOmoModelSourceWarningRef.current = signature; + + if (failed.length > 0) { + toast.warning( + t("omo.modelSourcePartialWarning", { + count: failed.length, + defaultValue: + "Some provider model configs are invalid and were skipped.", + }), + ); + } + if (fallback) { + toast.warning( + t("omo.modelSourceFallbackWarning", { + defaultValue: + "Failed to load live provider state. Falling back to configured providers.", + }), + ); + } + }, [ + isOmoCategory, + omoModelBuild.parseFailedProviders, + omoModelBuild.usedFallbackSource, + t, + ]); + + return { + omoModelOptions: omoModelBuild.options, + omoModelVariantsMap: omoModelBuild.variantsMap, + omoPresetMetaMap: omoModelBuild.presetMetaMap, + existingOpencodeKeys, + }; +} diff --git a/src/components/providers/forms/hooks/useOpenclawFormState.ts b/src/components/providers/forms/hooks/useOpenclawFormState.ts new file mode 100644 index 000000000..45217a587 --- /dev/null +++ b/src/components/providers/forms/hooks/useOpenclawFormState.ts @@ -0,0 +1,180 @@ +import { useState, useCallback, useMemo } from "react"; +import type { OpenClawModel } from "@/types"; +import type { AppId } from "@/lib/api"; +import { useProvidersQuery } from "@/lib/query/queries"; +import { OPENCLAW_DEFAULT_CONFIG } from "../helpers/opencodeFormUtils"; + +interface UseOpenclawFormStateParams { + initialData?: { + settingsConfig?: Record; + }; + appId: AppId; + providerId?: string; + onSettingsConfigChange: (config: string) => void; + getSettingsConfig: () => string; +} + +export interface OpenclawFormState { + openclawProviderKey: string; + setOpenclawProviderKey: (key: string) => void; + openclawBaseUrl: string; + openclawApiKey: string; + openclawApi: string; + openclawModels: OpenClawModel[]; + existingOpenclawKeys: string[]; + handleOpenclawBaseUrlChange: (baseUrl: string) => void; + handleOpenclawApiKeyChange: (apiKey: string) => void; + handleOpenclawApiChange: (api: string) => void; + handleOpenclawModelsChange: (models: OpenClawModel[]) => void; + resetOpenclawState: (config?: { + baseUrl?: string; + apiKey?: string; + api?: string; + models?: OpenClawModel[]; + }) => void; +} + +function parseOpenclawField( + initialData: UseOpenclawFormStateParams["initialData"], + field: string, + fallback: T, +): T { + try { + const config = JSON.parse( + initialData?.settingsConfig + ? JSON.stringify(initialData.settingsConfig) + : OPENCLAW_DEFAULT_CONFIG, + ); + return (config[field] as T) || fallback; + } catch { + return fallback; + } +} + +export function useOpenclawFormState({ + initialData, + appId, + providerId, + onSettingsConfigChange, + getSettingsConfig, +}: UseOpenclawFormStateParams): OpenclawFormState { + // Query existing providers for duplicate key checking + const { data: openclawProvidersData } = useProvidersQuery("openclaw"); + const existingOpenclawKeys = useMemo(() => { + if (!openclawProvidersData?.providers) return []; + return Object.keys(openclawProvidersData.providers).filter( + (k) => k !== providerId, + ); + }, [openclawProvidersData?.providers, providerId]); + + const [openclawProviderKey, setOpenclawProviderKey] = useState(() => { + if (appId !== "openclaw") return ""; + return providerId || ""; + }); + + const [openclawBaseUrl, setOpenclawBaseUrl] = useState(() => { + if (appId !== "openclaw") return ""; + return parseOpenclawField(initialData, "baseUrl", ""); + }); + + const [openclawApiKey, setOpenclawApiKey] = useState(() => { + if (appId !== "openclaw") return ""; + return parseOpenclawField(initialData, "apiKey", ""); + }); + + const [openclawApi, setOpenclawApi] = useState(() => { + if (appId !== "openclaw") return "openai-completions"; + return parseOpenclawField(initialData, "api", "openai-completions"); + }); + + const [openclawModels, setOpenclawModels] = useState(() => { + if (appId !== "openclaw") return []; + return parseOpenclawField(initialData, "models", []); + }); + + const updateOpenclawConfig = useCallback( + (updater: (config: Record) => void) => { + try { + const config = JSON.parse( + getSettingsConfig() || OPENCLAW_DEFAULT_CONFIG, + ); + updater(config); + onSettingsConfigChange(JSON.stringify(config, null, 2)); + } catch { + // ignore + } + }, + [getSettingsConfig, onSettingsConfigChange], + ); + + const handleOpenclawBaseUrlChange = useCallback( + (baseUrl: string) => { + setOpenclawBaseUrl(baseUrl); + updateOpenclawConfig((config) => { + config.baseUrl = baseUrl.trim().replace(/\/+$/, ""); + }); + }, + [updateOpenclawConfig], + ); + + const handleOpenclawApiKeyChange = useCallback( + (apiKey: string) => { + setOpenclawApiKey(apiKey); + updateOpenclawConfig((config) => { + config.apiKey = apiKey; + }); + }, + [updateOpenclawConfig], + ); + + const handleOpenclawApiChange = useCallback( + (api: string) => { + setOpenclawApi(api); + updateOpenclawConfig((config) => { + config.api = api; + }); + }, + [updateOpenclawConfig], + ); + + const handleOpenclawModelsChange = useCallback( + (models: OpenClawModel[]) => { + setOpenclawModels(models); + updateOpenclawConfig((config) => { + config.models = models; + }); + }, + [updateOpenclawConfig], + ); + + const resetOpenclawState = useCallback( + (config?: { + baseUrl?: string; + apiKey?: string; + api?: string; + models?: OpenClawModel[]; + }) => { + setOpenclawProviderKey(""); + setOpenclawBaseUrl(config?.baseUrl || ""); + setOpenclawApiKey(config?.apiKey || ""); + setOpenclawApi(config?.api || "openai-completions"); + setOpenclawModels(config?.models || []); + }, + [], + ); + + return { + openclawProviderKey, + setOpenclawProviderKey, + openclawBaseUrl, + openclawApiKey, + openclawApi, + openclawModels, + existingOpenclawKeys, + handleOpenclawBaseUrlChange, + handleOpenclawApiKeyChange, + handleOpenclawApiChange, + handleOpenclawModelsChange, + resetOpenclawState, + }; +} diff --git a/src/components/providers/forms/hooks/useOpencodeFormState.ts b/src/components/providers/forms/hooks/useOpencodeFormState.ts new file mode 100644 index 000000000..9bfba4a3c --- /dev/null +++ b/src/components/providers/forms/hooks/useOpencodeFormState.ts @@ -0,0 +1,192 @@ +import { useState, useCallback } from "react"; +import type { OpenCodeModel, OpenCodeProviderConfig } from "@/types"; +import { + OPENCODE_DEFAULT_NPM, + OPENCODE_DEFAULT_CONFIG, + isKnownOpencodeOptionKey, + parseOpencodeConfig, + toOpencodeExtraOptions, +} from "../helpers/opencodeFormUtils"; + +interface UseOpencodeFormStateParams { + initialData?: { + settingsConfig?: Record; + }; + appId: string; + providerId?: string; + onSettingsConfigChange: (config: string) => void; + getSettingsConfig: () => string; +} + +export interface OpencodeFormState { + opencodeProviderKey: string; + setOpencodeProviderKey: (key: string) => void; + opencodeNpm: string; + opencodeApiKey: string; + opencodeBaseUrl: string; + opencodeModels: Record; + opencodeExtraOptions: Record; + handleOpencodeNpmChange: (npm: string) => void; + handleOpencodeApiKeyChange: (apiKey: string) => void; + handleOpencodeBaseUrlChange: (baseUrl: string) => void; + handleOpencodeModelsChange: (models: Record) => void; + handleOpencodeExtraOptionsChange: (options: Record) => void; + resetOpencodeState: (config?: OpenCodeProviderConfig) => void; +} + +export function useOpencodeFormState({ + initialData, + appId, + providerId, + onSettingsConfigChange, + getSettingsConfig, +}: UseOpencodeFormStateParams): OpencodeFormState { + const initialOpencodeConfig = + appId === "opencode" + ? parseOpencodeConfig(initialData?.settingsConfig) + : null; + const initialOpencodeOptions = initialOpencodeConfig?.options || {}; + + const [opencodeProviderKey, setOpencodeProviderKey] = useState(() => { + if (appId !== "opencode") return ""; + return providerId || ""; + }); + + const [opencodeNpm, setOpencodeNpm] = useState(() => { + if (appId !== "opencode") return OPENCODE_DEFAULT_NPM; + return initialOpencodeConfig?.npm || OPENCODE_DEFAULT_NPM; + }); + + const [opencodeApiKey, setOpencodeApiKey] = useState(() => { + if (appId !== "opencode") return ""; + const value = initialOpencodeOptions.apiKey; + return typeof value === "string" ? value : ""; + }); + + const [opencodeBaseUrl, setOpencodeBaseUrl] = useState(() => { + if (appId !== "opencode") return ""; + const value = initialOpencodeOptions.baseURL; + return typeof value === "string" ? value : ""; + }); + + const [opencodeModels, setOpencodeModels] = useState< + Record + >(() => { + if (appId !== "opencode") return {}; + return initialOpencodeConfig?.models || {}; + }); + + const [opencodeExtraOptions, setOpencodeExtraOptions] = useState< + Record + >(() => { + if (appId !== "opencode") return {}; + return toOpencodeExtraOptions(initialOpencodeOptions); + }); + + const updateOpencodeSettings = useCallback( + (updater: (config: Record) => void) => { + try { + const config = JSON.parse( + getSettingsConfig() || OPENCODE_DEFAULT_CONFIG, + ) as Record; + updater(config); + onSettingsConfigChange(JSON.stringify(config, null, 2)); + } catch {} + }, + [getSettingsConfig, onSettingsConfigChange], + ); + + const handleOpencodeNpmChange = useCallback( + (npm: string) => { + setOpencodeNpm(npm); + updateOpencodeSettings((config) => { + config.npm = npm; + }); + }, + [updateOpencodeSettings], + ); + + const handleOpencodeApiKeyChange = useCallback( + (apiKey: string) => { + setOpencodeApiKey(apiKey); + updateOpencodeSettings((config) => { + if (!config.options) config.options = {}; + config.options.apiKey = apiKey; + }); + }, + [updateOpencodeSettings], + ); + + const handleOpencodeBaseUrlChange = useCallback( + (baseUrl: string) => { + setOpencodeBaseUrl(baseUrl); + updateOpencodeSettings((config) => { + if (!config.options) config.options = {}; + config.options.baseURL = baseUrl.trim().replace(/\/+$/, ""); + }); + }, + [updateOpencodeSettings], + ); + + const handleOpencodeModelsChange = useCallback( + (models: Record) => { + setOpencodeModels(models); + updateOpencodeSettings((config) => { + config.models = models; + }); + }, + [updateOpencodeSettings], + ); + + const handleOpencodeExtraOptionsChange = useCallback( + (options: Record) => { + setOpencodeExtraOptions(options); + updateOpencodeSettings((config) => { + if (!config.options) config.options = {}; + + for (const k of Object.keys(config.options)) { + if (!isKnownOpencodeOptionKey(k)) { + delete config.options[k]; + } + } + + for (const [k, v] of Object.entries(options)) { + const trimmedKey = k.trim(); + if (trimmedKey && !trimmedKey.startsWith("option-")) { + try { + config.options[trimmedKey] = JSON.parse(v); + } catch { + config.options[trimmedKey] = v; + } + } + } + }); + }, + [updateOpencodeSettings], + ); + + const resetOpencodeState = useCallback((config?: OpenCodeProviderConfig) => { + setOpencodeProviderKey(""); + setOpencodeNpm(config?.npm || OPENCODE_DEFAULT_NPM); + setOpencodeBaseUrl(config?.options?.baseURL || ""); + setOpencodeApiKey(config?.options?.apiKey || ""); + setOpencodeModels(config?.models || {}); + setOpencodeExtraOptions(toOpencodeExtraOptions(config?.options || {})); + }, []); + + return { + opencodeProviderKey, + setOpencodeProviderKey, + opencodeNpm, + opencodeApiKey, + opencodeBaseUrl, + opencodeModels, + opencodeExtraOptions, + handleOpencodeNpmChange, + handleOpencodeApiKeyChange, + handleOpencodeBaseUrlChange, + handleOpencodeModelsChange, + handleOpencodeExtraOptionsChange, + resetOpencodeState, + }; +}