Files
CC-Switch/src/components/providers/forms/ProviderForm.tsx
T
allen_xln 85f0be9e1d feat(provider-form): soften validation with "save anyway" prompt (#2307)
* feat(provider-form): soften business-rule validation with "save anyway" prompt

Refactor handleSubmit so empty-field / missing-item validations (provider
name, endpoint, API key, opencode model, template variables, provider key
required) no longer hard-reject with toast.error. Instead they are collected
into an issues list and presented via a ConfirmDialog; the user can cancel
or choose "Save anyway" to proceed.

Integrity constraints stay as hard rejections:
- providerKey regex / duplicate (would corrupt other providers)
- Copilot / Codex OAuth not authenticated (no token, cannot establish)
- omo Other Fields JSON not an object / parse failure

This aligns the frontend with the backend's existing "relaxed save / strict
switch" split (see gemini_config.rs: validate_gemini_settings vs
validate_gemini_settings_strict) and unblocks legitimate configs such as
AWS Bedrock, Vertex AI, and custom Gemini base URLs that the UI previously
refused to save.

Refs: #2196, #1204

* fix(provider-form): address review feedback on soft-validation

P1: move empty providerKey back to hard rejection for OpenCode / OpenClaw /
Hermes. Since providerKey is the primary identity for these apps and the
mutations layer throws "Provider key is required" when absent, letting users
click "save anyway" would surface a generic error toast instead of a
precise, actionable one. Treat empty providerKey as an integrity constraint
alongside regex / duplicate checks.

P2: give the soft-confirm submit path its own submitting state. The
confirm-dialog path bypassed react-hook-form's isSubmitting lifecycle, so
slow or failing saves left the outer submit button responsive and could
spawn unhandled rejections. Now the confirm handler awaits performSubmit
inside try/catch/finally, uses an isConfirmSubmitting flag to gate both
confirm and cancel clicks, and folds the flag into the outer disabled
state and onSubmittingChange callback.

Refs: #2307 review comments

* chore(clippy): use push for single char '…' in truncate_body

Clippy 1.95 added single_char_add_str which flagged the push_str("…")
in truncate_body. Rebased onto latest upstream/main and applied the
suggested fix so the Backend Checks clippy job passes.

Unrelated to this PR's core changes; bundled in so the PR is mergeable
without waiting for a separate upstream fix.

---------

Co-authored-by: Allen <allen@AllenMacBook-M4-Pro.local>
2026-04-25 09:28:28 +08:00

2194 lines
74 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState, useCallback } from "react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
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, settingsApi, type AppId } from "@/lib/api";
import type {
ProviderCategory,
ProviderMeta,
ProviderTestConfig,
ClaudeApiFormat,
ClaudeApiKeyField,
} from "@/types";
import {
providerPresets,
type ProviderPreset,
} from "@/config/claudeProviderPresets";
import {
codexProviderPresets,
type CodexProviderPreset,
} from "@/config/codexProviderPresets";
import {
geminiProviderPresets,
type GeminiProviderPreset,
} from "@/config/geminiProviderPresets";
import {
opencodeProviderPresets,
type OpenCodeProviderPreset,
} from "@/config/opencodeProviderPresets";
import {
openclawProviderPresets,
type OpenClawProviderPreset,
type OpenClawSuggestedDefaults,
} from "@/config/openclawProviderPresets";
import {
hermesProviderPresets,
type HermesProviderPreset,
} from "@/config/hermesProviderPresets";
import { OpenCodeFormFields } from "./OpenCodeFormFields";
import { OpenClawFormFields } from "./OpenClawFormFields";
import { HermesFormFields } from "./HermesFormFields";
import type { UniversalProviderPreset } from "@/config/universalProviderPresets";
import {
applyTemplateValues,
hasApiKeyField,
} from "@/utils/providerConfigUtils";
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
import { getCodexCustomTemplate } from "@/config/codexTemplates";
import CodexConfigEditor from "./CodexConfigEditor";
import { CommonConfigEditor } from "./CommonConfigEditor";
import GeminiConfigEditor from "./GeminiConfigEditor";
import JsonEditor from "@/components/JsonEditor";
import { Label } from "@/components/ui/label";
import { ProviderPresetSelector } from "./ProviderPresetSelector";
import { BasicFormFields } from "./BasicFormFields";
import { ClaudeFormFields } from "./ClaudeFormFields";
import { CodexFormFields } from "./CodexFormFields";
import { GeminiFormFields } from "./GeminiFormFields";
import { OmoFormFields } from "./OmoFormFields";
import { parseOmoOtherFieldsObject } from "@/types/omo";
import {
ProviderAdvancedConfig,
type PricingModelSourceOption,
} from "./ProviderAdvancedConfig";
import {
useProviderCategory,
useApiKeyState,
useBaseUrlState,
useModelState,
useCodexConfigState,
useApiKeyLink,
useTemplateValues,
useCommonConfigSnippet,
useCodexCommonConfig,
useSpeedTestEndpoints,
useCodexTomlValidation,
useGeminiConfigState,
useGeminiCommonConfig,
useOmoModelSource,
useOpencodeFormState,
useOmoDraftState,
useOpenclawFormState,
useHermesFormState,
useCopilotAuth,
useCodexOauth,
} from "./hooks";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { useSettingsQuery } from "@/lib/query";
import {
CLAUDE_DEFAULT_CONFIG,
CODEX_DEFAULT_CONFIG,
GEMINI_DEFAULT_CONFIG,
OPENCODE_DEFAULT_CONFIG,
OPENCLAW_DEFAULT_CONFIG,
normalizePricingSource,
} from "./helpers/opencodeFormUtils";
import { HERMES_DEFAULT_CONFIG } from "./hooks/useHermesFormState";
import { resolveManagedAccountId } from "@/lib/authBinding";
import { useOpenClawLiveProviderIds } from "@/hooks/useOpenClaw";
import { useHermesLiveProviderIds } from "@/hooks/useHermes";
type PresetEntry = {
id: string;
preset:
| ProviderPreset
| CodexProviderPreset
| GeminiProviderPreset
| OpenCodeProviderPreset
| OpenClawProviderPreset
| HermesProviderPreset;
};
interface ProviderFormProps {
appId: AppId;
providerId?: string;
submitLabel: string;
onSubmit: (values: ProviderFormValues) => Promise<void> | void;
onCancel: () => void;
onUniversalPresetSelect?: (preset: UniversalProviderPreset) => void;
onManageUniversalProviders?: () => void;
onSubmittingChange?: (isSubmitting: boolean) => void;
initialData?: {
name?: string;
websiteUrl?: string;
notes?: string;
settingsConfig?: Record<string, unknown>;
category?: ProviderCategory;
meta?: ProviderMeta;
icon?: string;
iconColor?: string;
};
showButtons?: boolean;
}
export function ProviderForm({
appId,
providerId,
submitLabel,
onSubmit,
onCancel,
onUniversalPresetSelect,
onManageUniversalProviders,
onSubmittingChange,
initialData,
showButtons = true,
}: ProviderFormProps) {
const { t } = useTranslation();
const isEditMode = Boolean(initialData);
const queryClient = useQueryClient();
const { data: settingsData } = useSettingsQuery();
const showCommonConfigNotice =
settingsData != null && settingsData.commonConfigConfirmed !== true;
const handleCommonConfigConfirm = async () => {
try {
if (settingsData) {
const { webdavSync: _, ...rest } = settingsData;
await settingsApi.save({ ...rest, commonConfigConfirmed: true });
await queryClient.invalidateQueries({ queryKey: ["settings"] });
}
} catch (error) {
console.error("Failed to save commonConfigConfirmed:", error);
}
};
const [selectedPresetId, setSelectedPresetId] = useState<string | null>(
initialData ? null : "custom",
);
const [activePreset, setActivePreset] = useState<{
id: string;
category?: ProviderCategory;
isPartner?: boolean;
partnerPromotionKey?: string;
suggestedDefaults?: OpenClawSuggestedDefaults;
} | null>(null);
const [isEndpointModalOpen, setIsEndpointModalOpen] = useState(false);
const [isCodexEndpointModalOpen, setIsCodexEndpointModalOpen] =
useState(false);
const [draftCustomEndpoints, setDraftCustomEndpoints] = useState<string[]>(
() => {
if (initialData) return [];
return [];
},
);
const [endpointAutoSelect, setEndpointAutoSelect] = useState<boolean>(
() => initialData?.meta?.endpointAutoSelect ?? true,
);
const supportsFullUrl = appId === "claude" || appId === "codex";
const [localIsFullUrl, setLocalIsFullUrl] = useState<boolean>(() => {
if (!supportsFullUrl) return false;
return initialData?.meta?.isFullUrl ?? false;
});
const [testConfig, setTestConfig] = useState<ProviderTestConfig>(
() => initialData?.meta?.testConfig ?? { enabled: false },
);
const [pricingConfig, setPricingConfig] = useState<{
enabled: boolean;
costMultiplier?: string;
pricingModelSource: PricingModelSourceOption;
}>(() => ({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
initialData?.meta?.pricingModelSource !== undefined,
costMultiplier: initialData?.meta?.costMultiplier,
pricingModelSource: normalizePricingSource(
initialData?.meta?.pricingModelSource,
),
}));
const { category } = useProviderCategory({
appId,
selectedPresetId,
isEditMode,
initialCategory: initialData?.category,
});
const isOmoCategory = appId === "opencode" && category === "omo";
const isOmoSlimCategory = appId === "opencode" && category === "omo-slim";
const isAnyOmoCategory = isOmoCategory || isOmoSlimCategory;
useEffect(() => {
setSelectedPresetId(initialData ? null : "custom");
setActivePreset(null);
if (!initialData) {
setDraftCustomEndpoints([]);
}
setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true);
setLocalIsFullUrl(
supportsFullUrl ? (initialData?.meta?.isFullUrl ?? false) : false,
);
setTestConfig(initialData?.meta?.testConfig ?? { enabled: false });
setPricingConfig({
enabled:
initialData?.meta?.costMultiplier !== undefined ||
initialData?.meta?.pricingModelSource !== undefined,
costMultiplier: initialData?.meta?.costMultiplier,
pricingModelSource: normalizePricingSource(
initialData?.meta?.pricingModelSource,
),
});
}, [appId, initialData, supportsFullUrl]);
const defaultValues: ProviderFormData = useMemo(
() => ({
name: initialData?.name ?? "",
websiteUrl: initialData?.websiteUrl ?? "",
notes: initialData?.notes ?? "",
settingsConfig: initialData?.settingsConfig
? JSON.stringify(initialData.settingsConfig, null, 2)
: appId === "codex"
? CODEX_DEFAULT_CONFIG
: appId === "gemini"
? GEMINI_DEFAULT_CONFIG
: appId === "opencode"
? OPENCODE_DEFAULT_CONFIG
: appId === "openclaw"
? OPENCLAW_DEFAULT_CONFIG
: appId === "hermes"
? HERMES_DEFAULT_CONFIG
: CLAUDE_DEFAULT_CONFIG,
icon: initialData?.icon ?? "",
iconColor: initialData?.iconColor ?? "",
}),
[initialData, appId],
);
const form = useForm<ProviderFormData>({
resolver: zodResolver(providerSchema),
defaultValues,
mode: "onSubmit",
});
const { isSubmitting } = form.formState;
const handleSettingsConfigChange = useCallback(
(config: string) => {
form.setValue("settingsConfig", config);
},
[form],
);
const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(
() => {
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField;
// Infer from existing config env
const env = (initialData?.settingsConfig as Record<string, unknown>)
?.env as Record<string, unknown> | undefined;
if (env?.ANTHROPIC_API_KEY !== undefined) return "ANTHROPIC_API_KEY";
return "ANTHROPIC_AUTH_TOKEN";
},
);
// 软校验:收集"业务约束"类问题(空值/缺项),由用户决定是否仍要保存
const [softIssues, setSoftIssues] = useState<string[] | null>(null);
const [pendingFormValues, setPendingFormValues] =
useState<ProviderFormData | null>(null);
// 确认框走的提交路径绕过了 react-hook-form 的 isSubmitting,单独追踪
const [isConfirmSubmitting, setIsConfirmSubmitting] = useState(false);
useEffect(() => {
onSubmittingChange?.(isSubmitting || isConfirmSubmitting);
}, [isSubmitting, isConfirmSubmitting, onSubmittingChange]);
const {
apiKey,
handleApiKeyChange,
showApiKey: shouldShowApiKey,
} = useApiKeyState({
initialConfig: form.getValues("settingsConfig"),
onConfigChange: handleSettingsConfigChange,
selectedPresetId,
category,
appType: appId,
apiKeyField: appId === "claude" ? localApiKeyField : undefined,
});
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
appType: appId,
category,
settingsConfig: form.getValues("settingsConfig"),
codexConfig: "",
onSettingsConfigChange: handleSettingsConfigChange,
onCodexConfigChange: () => {},
});
const {
claudeModel,
defaultHaikuModel,
defaultSonnetModel,
defaultOpusModel,
handleModelChange,
} = useModelState({
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: handleSettingsConfigChange,
});
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
if (appId !== "claude") return "anthropic";
return initialData?.meta?.apiFormat ?? "anthropic";
});
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
setLocalApiFormat(format);
}, []);
const handleApiKeyFieldChange = useCallback(
(field: ClaudeApiKeyField) => {
const prev = localApiKeyField;
setLocalApiKeyField(field);
// Swap the env key name in settingsConfig
try {
const raw = form.getValues("settingsConfig");
const config = JSON.parse(raw || "{}");
if (config?.env && prev in config.env) {
const value = config.env[prev];
delete config.env[prev];
config.env[field] = value;
const updated = JSON.stringify(config, null, 2);
form.setValue("settingsConfig", updated);
handleSettingsConfigChange(updated);
}
} catch {
// ignore parse errors during editing
}
},
[localApiKeyField, form, handleSettingsConfigChange],
);
// Copilot OAuth 认证状态(仅 Claude 应用需要)
const { isAuthenticated: isCopilotAuthenticated } = useCopilotAuth();
// Codex OAuth 认证状态(ChatGPT Plus/Pro 反代)
const { isAuthenticated: isCodexOauthAuthenticated } = useCodexOauth();
// 选中的 GitHub 账号 ID(多账号支持)
const [selectedGitHubAccountId, setSelectedGitHubAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "github_copilot"));
// 选中的 ChatGPT 账号 IDCodex OAuth 多账号支持)
const [selectedCodexAccountId, setSelectedCodexAccountId] = useState<
string | null
>(() => resolveManagedAccountId(initialData?.meta, "codex_oauth"));
const [codexFastMode, setCodexFastMode] = useState<boolean>(
() => initialData?.meta?.codexFastMode ?? false,
);
const {
codexAuth,
codexConfig,
codexApiKey,
codexBaseUrl,
codexModelName,
codexAuthError,
setCodexAuth,
handleCodexApiKeyChange,
handleCodexBaseUrlChange,
handleCodexModelNameChange,
handleCodexConfigChange: originalHandleCodexConfigChange,
resetCodexConfig,
} = useCodexConfigState({ initialData });
const { configError: codexConfigError, debouncedValidate } =
useCodexTomlValidation();
const handleCodexConfigChange = useCallback(
(value: string) => {
originalHandleCodexConfigChange(value);
debouncedValidate(value);
},
[originalHandleCodexConfigChange, debouncedValidate],
);
useEffect(() => {
if (appId === "codex" && !initialData && selectedPresetId === "custom") {
const template = getCodexCustomTemplate();
resetCodexConfig(template.auth, template.config);
}
}, [appId, initialData, selectedPresetId, resetCodexConfig]);
useEffect(() => {
form.reset(defaultValues);
}, [defaultValues, form]);
const presetCategoryLabels: Record<string, string> = useMemo(
() => ({
official: t("providerForm.categoryOfficial", {
defaultValue: "官方",
}),
cn_official: t("providerForm.categoryCnOfficial", {
defaultValue: "国内官方",
}),
aggregator: t("providerForm.categoryAggregation", {
defaultValue: "聚合服务",
}),
third_party: t("providerForm.categoryThirdParty", {
defaultValue: "第三方",
}),
omo: "OMO",
}),
[t],
);
const presetEntries = useMemo(() => {
if (appId === "codex") {
return codexProviderPresets.map<PresetEntry>((preset, index) => ({
id: `codex-${index}`,
preset,
}));
} else if (appId === "gemini") {
return geminiProviderPresets.map<PresetEntry>((preset, index) => ({
id: `gemini-${index}`,
preset,
}));
} else if (appId === "opencode") {
return opencodeProviderPresets.map<PresetEntry>((preset, index) => ({
id: `opencode-${index}`,
preset,
}));
} else if (appId === "openclaw") {
return openclawProviderPresets.map<PresetEntry>((preset, index) => ({
id: `openclaw-${index}`,
preset,
}));
} else if (appId === "hermes") {
return hermesProviderPresets.map<PresetEntry>((preset, index) => ({
id: `hermes-${index}`,
preset,
}));
}
return providerPresets
.filter((p) => !p.hidden)
.map<PresetEntry>((preset, index) => ({
id: `claude-${index}`,
preset,
}));
}, [appId]);
const {
templateValues,
templateValueEntries,
selectedPreset: templatePreset,
handleTemplateValueChange,
validateTemplateValues,
} = useTemplateValues({
selectedPresetId: appId === "claude" ? selectedPresetId : null,
presetEntries: appId === "claude" ? presetEntries : [],
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: handleSettingsConfigChange,
});
const {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
isExtracting: isClaudeExtracting,
handleExtract: handleClaudeExtract,
} = useCommonConfigSnippet({
settingsConfig: form.getValues("settingsConfig"),
onConfigChange: handleSettingsConfigChange,
initialData: appId === "claude" ? initialData : undefined,
initialEnabled:
appId === "claude" ? initialData?.meta?.commonConfigEnabled : undefined,
selectedPresetId: selectedPresetId ?? undefined,
enabled: appId === "claude",
});
const {
useCommonConfig: useCodexCommonConfigFlag,
commonConfigSnippet: codexCommonConfigSnippet,
commonConfigError: codexCommonConfigError,
handleCommonConfigToggle: handleCodexCommonConfigToggle,
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
isExtracting: isCodexExtracting,
handleExtract: handleCodexExtract,
clearCommonConfigError: clearCodexCommonConfigError,
} = useCodexCommonConfig({
codexConfig,
onConfigChange: handleCodexConfigChange,
initialData: appId === "codex" ? initialData : undefined,
initialEnabled:
appId === "codex" ? initialData?.meta?.commonConfigEnabled : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
const {
geminiEnv,
geminiConfig,
geminiApiKey,
geminiBaseUrl,
geminiModel,
envError,
configError: geminiConfigError,
handleGeminiApiKeyChange: originalHandleGeminiApiKeyChange,
handleGeminiBaseUrlChange: originalHandleGeminiBaseUrlChange,
handleGeminiModelChange: originalHandleGeminiModelChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
} = useGeminiConfigState({
initialData: appId === "gemini" ? initialData : undefined,
});
const updateGeminiEnvField = useCallback(
(
key: "GEMINI_API_KEY" | "GOOGLE_GEMINI_BASE_URL" | "GEMINI_MODEL",
value: string,
) => {
try {
const config = JSON.parse(form.getValues("settingsConfig") || "{}") as {
env?: Record<string, unknown>;
};
if (!config.env || typeof config.env !== "object") {
config.env = {};
}
config.env[key] = value;
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
} catch {}
},
[form],
);
const handleGeminiApiKeyChange = useCallback(
(key: string) => {
originalHandleGeminiApiKeyChange(key);
updateGeminiEnvField("GEMINI_API_KEY", key.trim());
},
[originalHandleGeminiApiKeyChange, updateGeminiEnvField],
);
const handleGeminiBaseUrlChange = useCallback(
(url: string) => {
originalHandleGeminiBaseUrlChange(url);
updateGeminiEnvField(
"GOOGLE_GEMINI_BASE_URL",
url.trim().replace(/\/+$/, ""),
);
},
[originalHandleGeminiBaseUrlChange, updateGeminiEnvField],
);
const handleGeminiModelChange = useCallback(
(model: string) => {
originalHandleGeminiModelChange(model);
updateGeminiEnvField("GEMINI_MODEL", model.trim());
},
[originalHandleGeminiModelChange, updateGeminiEnvField],
);
const {
useCommonConfig: useGeminiCommonConfigFlag,
commonConfigSnippet: geminiCommonConfigSnippet,
commonConfigError: geminiCommonConfigError,
handleCommonConfigToggle: handleGeminiCommonConfigToggle,
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
isExtracting: isGeminiExtracting,
handleExtract: handleGeminiExtract,
clearCommonConfigError: clearGeminiCommonConfigError,
} = useGeminiCommonConfig({
envValue: geminiEnv,
onEnvChange: handleGeminiEnvChange,
envStringToObj,
envObjToString,
initialData: appId === "gemini" ? initialData : undefined,
initialEnabled:
appId === "gemini" ? initialData?.meta?.commonConfigEnabled : undefined,
selectedPresetId: selectedPresetId ?? undefined,
});
// ── Extracted hooks: OpenCode / OMO / OpenClaw ─────────────────────
const {
omoModelOptions,
omoModelVariantsMap,
omoPresetMetaMap,
existingOpencodeKeys,
} = useOmoModelSource({ isOmoCategory: isAnyOmoCategory, providerId });
const {
data: opencodeLiveProviderIds = [],
isLoading: isOpencodeLiveProviderIdsLoading,
} = useQuery({
queryKey: ["opencodeLiveProviderIds"],
queryFn: () => providersApi.getOpenCodeLiveProviderIds(),
enabled: appId === "opencode" && !isAnyOmoCategory,
});
const opencodeForm = useOpencodeFormState({
initialData,
appId,
providerId,
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const initialOmoSettings =
appId === "opencode" &&
(initialData?.category === "omo" || initialData?.category === "omo-slim")
? (initialData.settingsConfig as Record<string, unknown> | undefined)
: undefined;
const omoDraft = useOmoDraftState({
initialOmoSettings,
isEditMode,
appId,
category,
});
const openclawForm = useOpenclawFormState({
initialData,
appId,
providerId,
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const {
data: openclawLiveProviderIds = [],
isLoading: isOpenclawLiveProviderIdsLoading,
} = useOpenClawLiveProviderIds(appId === "openclaw");
const hermesForm = useHermesFormState({
initialData,
appId,
providerId,
onSettingsConfigChange: (config) => form.setValue("settingsConfig", config),
getSettingsConfig: () => form.getValues("settingsConfig"),
});
const {
data: hermesLiveProviderIds = [],
isLoading: isHermesLiveProviderIdsLoading,
} = useHermesLiveProviderIds(appId === "hermes");
const additiveExistingProviderKeys = useMemo(() => {
if (appId === "opencode" && !isAnyOmoCategory) {
return Array.from(
new Set(
[...existingOpencodeKeys, ...opencodeLiveProviderIds].filter(
(key) => key !== providerId,
),
),
);
}
if (appId === "openclaw") {
return Array.from(
new Set(
[
...openclawForm.existingOpenclawKeys,
...openclawLiveProviderIds,
].filter((key) => key !== providerId),
),
);
}
if (appId === "hermes") {
return Array.from(
new Set(
[...hermesForm.existingHermesKeys, ...hermesLiveProviderIds].filter(
(key) => key !== providerId,
),
),
);
}
return [];
}, [
appId,
existingOpencodeKeys,
hermesForm.existingHermesKeys,
hermesLiveProviderIds,
isAnyOmoCategory,
openclawForm.existingOpenclawKeys,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
const isProviderKeyLockStateLoading = useMemo(() => {
if (!isEditMode) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return isOpencodeLiveProviderIdsLoading;
}
if (appId === "openclaw") {
return isOpenclawLiveProviderIdsLoading;
}
if (appId === "hermes") {
return isHermesLiveProviderIdsLoading;
}
return false;
}, [
appId,
isAnyOmoCategory,
isEditMode,
isHermesLiveProviderIdsLoading,
isOpenclawLiveProviderIdsLoading,
isOpencodeLiveProviderIdsLoading,
]);
const isProviderKeyLocked = useMemo(() => {
if (!isEditMode || !providerId) return false;
if (appId === "opencode" && !isAnyOmoCategory) {
return opencodeLiveProviderIds.includes(providerId);
}
if (appId === "openclaw") {
return openclawLiveProviderIds.includes(providerId);
}
if (appId === "hermes") {
return hermesLiveProviderIds.includes(providerId);
}
return false;
}, [
appId,
hermesLiveProviderIds,
isAnyOmoCategory,
isEditMode,
openclawLiveProviderIds,
opencodeLiveProviderIds,
providerId,
]);
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
const handleSubmit = async (values: ProviderFormData) => {
// 软性问题(业务约束,用户可选择仍要保存)
const issues: string[] = [];
// 模板变量未填:A 类(空值)
if (appId === "claude" && templateValueEntries.length > 0) {
const validation = validateTemplateValues();
if (!validation.isValid && validation.missingField) {
issues.push(
t("providerForm.fillParameter", {
label: validation.missingField.label,
defaultValue: `请填写 ${validation.missingField.label}`,
}),
);
}
}
// 供应商名空:A 类
if (!values.name.trim()) {
issues.push(
t("providerForm.fillSupplierName", {
defaultValue: "请填写供应商名称",
}),
);
}
// opencode / openclaw / hermes: providerKey 相关
// A 类(空)归到 issues;B 类(正则不合法 / 重复 / 状态加载中)仍硬拒绝
const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
if (appId === "opencode" && !isAnyOmoCategory) {
// providerKey 是 opencode / openclaw / hermes 的主键 ID,空或格式不合法
// 都属于完整性约束,保留硬拒绝(mutations 层也会 throw,软化只会让错误更晦涩)
if (!opencodeForm.opencodeProviderKey.trim()) {
toast.error(t("opencode.providerKeyRequired"));
return;
}
if (!keyPattern.test(opencodeForm.opencodeProviderKey)) {
toast.error(t("opencode.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(opencodeForm.opencodeProviderKey)
) {
toast.error(t("opencode.providerKeyDuplicate"));
return;
}
if (Object.keys(opencodeForm.opencodeModels).length === 0) {
issues.push(t("opencode.modelsRequired"));
}
}
if (appId === "openclaw") {
if (!openclawForm.openclawProviderKey.trim()) {
toast.error(t("openclaw.providerKeyRequired"));
return;
}
if (!keyPattern.test(openclawForm.openclawProviderKey)) {
toast.error(t("openclaw.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(openclawForm.openclawProviderKey)
) {
toast.error(t("openclaw.providerKeyDuplicate"));
return;
}
}
if (appId === "hermes") {
if (!hermesForm.hermesProviderKey.trim()) {
toast.error(t("hermes.form.providerKeyRequired"));
return;
}
if (!keyPattern.test(hermesForm.hermesProviderKey)) {
toast.error(t("hermes.form.providerKeyInvalid"));
return;
}
if (isProviderKeyLockStateLoading) {
toast.error(
t("providerForm.providerKeyStatusLoading", {
defaultValue: "正在加载供应商标识状态,请稍后再试",
}),
);
return;
}
if (
!isProviderKeyLocked &&
additiveExistingProviderKeys.includes(hermesForm.hermesProviderKey)
) {
toast.error(t("hermes.form.providerKeyDuplicate"));
return;
}
}
// OAuth 未登录:B 类(token 根本不存在,保存了也没法建立)
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
if (isCopilotProvider && !isCopilotAuthenticated) {
toast.error(
t("copilot.loginRequired", {
defaultValue: "请先登录 GitHub Copilot",
}),
);
return;
}
if (isCodexOauthProvider && !isCodexOauthAuthenticated) {
toast.error(
t("codexOauth.loginRequired", {
defaultValue: "请先登录 ChatGPT 账号",
}),
);
return;
}
// OMO Other Fields JSONB 类(格式错了保存下去数据就坏了)
if (
appId === "opencode" &&
isAnyOmoCategory &&
omoDraft.omoOtherFieldsStr.trim()
) {
try {
const otherFields = parseOmoOtherFieldsObject(
omoDraft.omoOtherFieldsStr,
);
if (!otherFields) {
toast.error(
t("omo.jsonMustBeObject", {
field: t("omo.otherFields", {
defaultValue: "Other Config",
}),
defaultValue: "{{field}} must be a JSON object",
}),
);
return;
}
} catch {
toast.error(
t("omo.invalidJson", {
defaultValue: "Other Fields contains invalid JSON",
}),
);
return;
}
}
// 非官方供应商端点 / API Key 空:A 类
// cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验
if (category !== "official" && category !== "cloud_provider") {
if (appId === "claude") {
if (!isCodexOauthProvider && !baseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
}
} else if (appId === "codex") {
if (!codexBaseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!codexApiKey.trim()) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
}
} else if (appId === "gemini") {
if (!geminiBaseUrl.trim()) {
issues.push(
t("providerForm.endpointRequired", {
defaultValue: "非官方供应商请填写 API 端点",
}),
);
}
if (!geminiApiKey.trim()) {
issues.push(
t("providerForm.apiKeyRequired", {
defaultValue: "非官方供应商请填写 API Key",
}),
);
}
}
}
if (issues.length > 0) {
// 弹确认框让用户决定是否仍要保存
setSoftIssues(issues);
setPendingFormValues(values);
return;
}
await performSubmit(values);
};
const performSubmit = async (values: ProviderFormData) => {
// OAuth / 其它身份识别(与 handleSubmit 保持一致)
const isCopilotProvider =
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com");
const isCodexOauthProvider =
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth";
let settingsConfig: string;
if (appId === "codex") {
try {
const authJson = JSON.parse(codexAuth);
const configObj = {
auth: authJson,
config: codexConfig ?? "",
};
settingsConfig = JSON.stringify(configObj);
} catch (err) {
settingsConfig = values.settingsConfig.trim();
}
} else if (appId === "gemini") {
try {
const envObj = envStringToObj(geminiEnv);
const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {};
const combined = {
env: envObj,
config: configObj,
};
settingsConfig = JSON.stringify(combined);
} catch (err) {
settingsConfig = values.settingsConfig.trim();
}
} else if (
appId === "opencode" &&
(category === "omo" || category === "omo-slim")
) {
const omoConfig: Record<string, unknown> = {};
if (Object.keys(omoDraft.omoAgents).length > 0) {
omoConfig.agents = omoDraft.omoAgents;
}
if (
category === "omo" &&
Object.keys(omoDraft.omoCategories).length > 0
) {
omoConfig.categories = omoDraft.omoCategories;
}
if (omoDraft.omoOtherFieldsStr.trim()) {
// 格式已在 handleSubmit 前置校验中验证过,此处可以安全解析
const otherFields = parseOmoOtherFieldsObject(
omoDraft.omoOtherFieldsStr,
);
if (otherFields) {
omoConfig.otherFields = otherFields;
}
}
settingsConfig = JSON.stringify(omoConfig);
} else {
settingsConfig = values.settingsConfig.trim();
}
const payload: ProviderFormValues = {
...values,
name: values.name.trim(),
websiteUrl: values.websiteUrl?.trim() ?? "",
settingsConfig,
};
if (appId === "opencode") {
if (isAnyOmoCategory) {
if (!isEditMode) {
const prefix = category === "omo" ? "omo" : "omo-slim";
payload.providerKey = `${prefix}-${crypto.randomUUID().slice(0, 8)}`;
}
} else {
payload.providerKey = opencodeForm.opencodeProviderKey;
}
} else if (appId === "openclaw") {
payload.providerKey = openclawForm.openclawProviderKey;
} else if (appId === "hermes") {
payload.providerKey = hermesForm.hermesProviderKey;
}
if (isAnyOmoCategory && !payload.presetCategory) {
payload.presetCategory = category;
}
if (activePreset) {
payload.presetId = activePreset.id;
if (activePreset.category) {
payload.presetCategory = activePreset.category;
}
if (activePreset.isPartner) {
payload.isPartner = activePreset.isPartner;
}
// OpenClaw: 传递预设的 suggestedDefaults 到提交数据
if (activePreset.suggestedDefaults) {
payload.suggestedDefaults = activePreset.suggestedDefaults;
}
}
if (!isEditMode && draftCustomEndpoints.length > 0) {
const customEndpointsToSave: Record<
string,
import("@/types").CustomEndpoint
> = draftCustomEndpoints.reduce(
(acc, url) => {
const now = Date.now();
acc[url] = { url, addedAt: now, lastUsed: undefined };
return acc;
},
{} as Record<string, import("@/types").CustomEndpoint>,
);
const hadEndpoints =
initialData?.meta?.custom_endpoints &&
Object.keys(initialData.meta.custom_endpoints).length > 0;
const needsClearEndpoints =
hadEndpoints && draftCustomEndpoints.length === 0;
let mergedMeta = needsClearEndpoints
? mergeProviderMeta(initialData?.meta, {})
: mergeProviderMeta(initialData?.meta, customEndpointsToSave);
if (activePreset?.isPartner) {
mergedMeta = {
...(mergedMeta ?? {}),
isPartner: true,
};
}
if (activePreset?.partnerPromotionKey) {
mergedMeta = {
...(mergedMeta ?? {}),
partnerPromotionKey: activePreset.partnerPromotionKey,
};
}
if (mergedMeta !== undefined) {
payload.meta = mergedMeta;
}
}
const baseMeta: ProviderMeta | undefined =
payload.meta ?? (initialData?.meta ? { ...initialData.meta } : undefined);
// 确定 providerType(新建时从预设获取,编辑时从现有数据获取)
const providerType =
templatePreset?.providerType || initialData?.meta?.providerType;
const nextMeta: ProviderMeta = {
...(baseMeta ?? {}),
commonConfigEnabled:
appId === "claude"
? useCommonConfig
: appId === "codex"
? useCodexCommonConfigFlag
: appId === "gemini"
? useGeminiCommonConfigFlag
: undefined,
endpointAutoSelect,
// 保存 providerType(用于识别 Copilot / Codex OAuth 等特殊供应商)
providerType,
authBinding: isCopilotProvider
? {
source: "managed_account",
authProvider: "github_copilot",
accountId: selectedGitHubAccountId ?? undefined,
}
: isCodexOauthProvider
? {
source: "managed_account",
authProvider: "codex_oauth",
accountId: selectedCodexAccountId ?? undefined,
}
: undefined,
// GitHub Copilot 多账号:保存关联的账号 ID
githubAccountId:
isCopilotProvider && selectedGitHubAccountId
? selectedGitHubAccountId
: undefined,
codexFastMode: isCodexOauthProvider ? codexFastMode : undefined,
testConfig: testConfig.enabled ? testConfig : undefined,
costMultiplier: pricingConfig.enabled
? pricingConfig.costMultiplier
: undefined,
pricingModelSource:
pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit"
? pricingConfig.pricingModelSource
: undefined,
apiFormat:
appId === "claude" && category !== "official"
? localApiFormat
: undefined,
apiKeyField:
appId === "claude" &&
category !== "official" &&
localApiKeyField !== "ANTHROPIC_AUTH_TOKEN"
? localApiKeyField
: undefined,
isFullUrl:
supportsFullUrl && category !== "official" && localIsFullUrl
? true
: undefined,
};
if (!isCodexOauthProvider && "codexFastMode" in nextMeta) {
delete nextMeta.codexFastMode;
}
payload.meta = nextMeta;
await onSubmit(payload);
};
const groupedPresets = useMemo(() => {
return presetEntries.reduce<Record<string, PresetEntry[]>>((acc, entry) => {
const category = entry.preset.category ?? "others";
if (!acc[category]) {
acc[category] = [];
}
acc[category].push(entry);
return acc;
}, {});
}, [presetEntries]);
const categoryKeys = useMemo(() => {
return Object.keys(groupedPresets).filter(
(key) => key !== "custom" && groupedPresets[key]?.length,
);
}, [groupedPresets]);
const shouldShowSpeedTest =
category !== "official" && category !== "cloud_provider";
const {
shouldShowApiKeyLink: shouldShowClaudeApiKeyLink,
websiteUrl: claudeWebsiteUrl,
isPartner: isClaudePartner,
partnerPromotionKey: claudePartnerPromotionKey,
} = useApiKeyLink({
appId: "claude",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
const {
shouldShowApiKeyLink: shouldShowCodexApiKeyLink,
websiteUrl: codexWebsiteUrl,
isPartner: isCodexPartner,
partnerPromotionKey: codexPartnerPromotionKey,
} = useApiKeyLink({
appId: "codex",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
const {
shouldShowApiKeyLink: shouldShowGeminiApiKeyLink,
websiteUrl: geminiWebsiteUrl,
isPartner: isGeminiPartner,
partnerPromotionKey: geminiPartnerPromotionKey,
} = useApiKeyLink({
appId: "gemini",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
const {
shouldShowApiKeyLink: shouldShowOpencodeApiKeyLink,
websiteUrl: opencodeWebsiteUrl,
isPartner: isOpencodePartner,
partnerPromotionKey: opencodePartnerPromotionKey,
} = useApiKeyLink({
appId: "opencode",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用 API Key 链接 hook (OpenClaw)
const {
shouldShowApiKeyLink: shouldShowOpenclawApiKeyLink,
websiteUrl: openclawWebsiteUrl,
isPartner: isOpenclawPartner,
partnerPromotionKey: openclawPartnerPromotionKey,
} = useApiKeyLink({
appId: "openclaw",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用 API Key 链接 hook (Hermes)
const {
shouldShowApiKeyLink: shouldShowHermesApiKeyLink,
websiteUrl: hermesWebsiteUrl,
isPartner: isHermesPartner,
partnerPromotionKey: hermesPartnerPromotionKey,
} = useApiKeyLink({
appId: "hermes",
category,
selectedPresetId,
presetEntries,
formWebsiteUrl: form.watch("websiteUrl") || "",
});
// 使用端点测速候选 hook
const speedTestEndpoints = useSpeedTestEndpoints({
appId,
selectedPresetId,
presetEntries,
baseUrl,
codexBaseUrl,
initialData,
});
const handlePresetChange = (value: string) => {
setSelectedPresetId(value);
if (value === "custom") {
setActivePreset(null);
form.reset(defaultValues);
if (appId === "codex") {
const template = getCodexCustomTemplate();
resetCodexConfig(template.auth, template.config);
}
if (appId === "gemini") {
resetGeminiConfig({}, {});
}
if (appId === "opencode") {
opencodeForm.resetOpencodeState();
omoDraft.resetOmoDraftState();
}
// OpenClaw 自定义模式:重置为空配置
if (appId === "openclaw") {
openclawForm.resetOpenclawState();
}
if (appId === "hermes") {
hermesForm.resetHermesState();
}
return;
}
const entry = presetEntries.find((item) => item.id === value);
if (!entry) {
return;
}
setActivePreset({
id: value,
category: entry.preset.category,
isPartner: entry.preset.isPartner,
partnerPromotionKey: entry.preset.partnerPromotionKey,
});
if (appId === "codex") {
const preset = entry.preset as CodexProviderPreset;
const auth = preset.auth ?? {};
const config = preset.config ?? "";
resetCodexConfig(auth, config);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({ auth, config }, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
if (appId === "gemini") {
const preset = entry.preset as GeminiProviderPreset;
const env = (preset.settingsConfig as any)?.env ?? {};
const config = (preset.settingsConfig as any)?.config ?? {};
resetGeminiConfig(env, config);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(preset.settingsConfig, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
if (appId === "opencode") {
const preset = entry.preset as OpenCodeProviderPreset;
const config = preset.settingsConfig;
if (preset.category === "omo" || preset.category === "omo-slim") {
omoDraft.resetOmoDraftState();
form.reset({
name: preset.category === "omo" ? "OMO" : "OMO Slim",
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify({}, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
opencodeForm.resetOpencodeState(config);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
// OpenClaw preset handling
if (appId === "openclaw") {
const preset = entry.preset as OpenClawProviderPreset;
const config = preset.settingsConfig;
// Update activePreset with suggestedDefaults for OpenClaw
setActivePreset({
id: value,
category: preset.category,
isPartner: preset.isPartner,
partnerPromotionKey: preset.partnerPromotionKey,
suggestedDefaults: preset.suggestedDefaults,
});
openclawForm.resetOpenclawState(config);
// Update form fields
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
// Hermes preset handling
if (appId === "hermes") {
const preset = entry.preset as HermesProviderPreset;
const config = preset.settingsConfig;
hermesForm.resetHermesState(config);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
return;
}
const preset = entry.preset as ProviderPreset;
const config = applyTemplateValues(
preset.settingsConfig,
preset.templateValues,
);
if (preset.apiFormat) {
setLocalApiFormat(preset.apiFormat);
} else {
setLocalApiFormat("anthropic");
}
setLocalApiKeyField(preset.apiKeyField ?? "ANTHROPIC_AUTH_TOKEN");
setLocalIsFullUrl(false);
form.reset({
name: preset.nameKey ? t(preset.nameKey) : preset.name,
websiteUrl: preset.websiteUrl ?? "",
settingsConfig: JSON.stringify(config, null, 2),
icon: preset.icon ?? "",
iconColor: preset.iconColor ?? "",
});
};
const settingsConfigErrorField = (
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormMessage />
</FormItem>
)}
/>
);
return (
<>
<Form {...form}>
<form
id="provider-form"
onSubmit={form.handleSubmit(handleSubmit)}
className="space-y-6 glass rounded-xl p-6 border border-white/10"
>
{!initialData && (
<ProviderPresetSelector
selectedPresetId={selectedPresetId}
groupedPresets={groupedPresets}
categoryKeys={categoryKeys}
presetCategoryLabels={presetCategoryLabels}
onPresetChange={handlePresetChange}
onUniversalPresetSelect={onUniversalPresetSelect}
onManageUniversalProviders={onManageUniversalProviders}
category={category}
/>
)}
<BasicFormFields
form={form}
beforeNameSlot={
appId === "opencode" && !isAnyOmoCategory ? (
<div className="space-y-2">
<Label htmlFor="opencode-key">
{t("opencode.providerKey")}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="opencode-key"
value={opencodeForm.opencodeProviderKey}
onChange={(e) =>
opencodeForm.setOpencodeProviderKey(
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
)
}
placeholder={t("opencode.providerKeyPlaceholder")}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={
(additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) &&
!isProviderKeyLocked) ||
(opencodeForm.opencodeProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
))
? "border-destructive"
: ""
}
/>
{additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) &&
!isProviderKeyLocked && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyDuplicate")}
</p>
)}
{opencodeForm.opencodeProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
) && (
<p className="text-xs text-destructive">
{t("opencode.providerKeyInvalid")}
</p>
)}
{!(
additiveExistingProviderKeys.includes(
opencodeForm.opencodeProviderKey,
) && !isProviderKeyLocked
) &&
(opencodeForm.opencodeProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
opencodeForm.opencodeProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{isProviderKeyLocked
? t("opencode.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("opencode.providerKeyHint")}
</p>
)}
</div>
) : appId === "openclaw" ? (
<div className="space-y-2">
<Label htmlFor="openclaw-key">
{t("openclaw.providerKey")}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="openclaw-key"
value={openclawForm.openclawProviderKey}
onChange={(e) =>
openclawForm.setOpenclawProviderKey(
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
)
}
placeholder={t("openclaw.providerKeyPlaceholder")}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={
(additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
) &&
!isProviderKeyLocked) ||
(openclawForm.openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
))
? "border-destructive"
: ""
}
/>
{additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
) &&
!isProviderKeyLocked && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyDuplicate")}
</p>
)}
{openclawForm.openclawProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
) && (
<p className="text-xs text-destructive">
{t("openclaw.providerKeyInvalid")}
</p>
)}
{!(
additiveExistingProviderKeys.includes(
openclawForm.openclawProviderKey,
) && !isProviderKeyLocked
) &&
(openclawForm.openclawProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
openclawForm.openclawProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{isProviderKeyLocked
? t("openclaw.providerKeyLockedHint", {
defaultValue:
"该供应商已添加到应用配置中,供应商标识不可修改",
})
: t("openclaw.providerKeyHint")}
</p>
)}
</div>
) : appId === "hermes" ? (
<div className="space-y-2">
<Label htmlFor="hermes-key">
{t("hermes.form.providerKey", {
defaultValue: "Provider Key",
})}
<span className="text-destructive ml-1">*</span>
</Label>
<Input
id="hermes-key"
value={hermesForm.hermesProviderKey}
onChange={(e) =>
hermesForm.setHermesProviderKey(
e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""),
)
}
placeholder={t("hermes.form.providerKeyPlaceholder", {
defaultValue: "my-provider",
})}
disabled={
isProviderKeyLocked || isProviderKeyLockStateLoading
}
className={
(additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) &&
!isProviderKeyLocked) ||
(hermesForm.hermesProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
))
? "border-destructive"
: ""
}
/>
{additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) &&
!isProviderKeyLocked && (
<p className="text-xs text-destructive">
{t("hermes.form.providerKeyDuplicate")}
</p>
)}
{hermesForm.hermesProviderKey.trim() !== "" &&
!/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
) && (
<p className="text-xs text-destructive">
{t("hermes.form.providerKeyInvalid")}
</p>
)}
{!(
additiveExistingProviderKeys.includes(
hermesForm.hermesProviderKey,
) && !isProviderKeyLocked
) &&
(hermesForm.hermesProviderKey.trim() === "" ||
/^[a-z0-9]+(-[a-z0-9]+)*$/.test(
hermesForm.hermesProviderKey,
)) && (
<p className="text-xs text-muted-foreground">
{isProviderKeyLocked
? t("hermes.form.providerKeyLockedHint", {
defaultValue:
"This provider is in Hermes config; key is locked.",
})
: t("hermes.form.providerKeyHint", {
defaultValue:
"Lowercase letters, numbers, and hyphens only. Used as the provider name in config.yaml.",
})}
</p>
)}
</div>
) : undefined
}
/>
{appId === "claude" && (
<ClaudeFormFields
providerId={providerId}
shouldShowApiKey={
(category !== "cloud_provider" ||
hasApiKeyField(form.getValues("settingsConfig"), "claude")) &&
shouldShowApiKey(form.getValues("settingsConfig"), isEditMode)
}
apiKey={apiKey}
onApiKeyChange={handleApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowClaudeApiKeyLink}
websiteUrl={claudeWebsiteUrl}
isPartner={isClaudePartner}
partnerPromotionKey={claudePartnerPromotionKey}
isCopilotPreset={
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com")
}
isCodexOauthPreset={
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
}
usesOAuth={
templatePreset?.requiresOAuth === true ||
templatePreset?.providerType === "github_copilot" ||
initialData?.meta?.providerType === "github_copilot" ||
baseUrl.includes("githubcopilot.com") ||
templatePreset?.providerType === "codex_oauth" ||
initialData?.meta?.providerType === "codex_oauth"
}
isCopilotAuthenticated={isCopilotAuthenticated}
selectedGitHubAccountId={selectedGitHubAccountId}
onGitHubAccountSelect={setSelectedGitHubAccountId}
isCodexOauthAuthenticated={isCodexOauthAuthenticated}
selectedCodexAccountId={selectedCodexAccountId}
onCodexAccountSelect={setSelectedCodexAccountId}
codexFastMode={codexFastMode}
onCodexFastModeChange={setCodexFastMode}
templateValueEntries={templateValueEntries}
templateValues={templateValues}
templatePresetName={templatePreset?.name || ""}
onTemplateValueChange={handleTemplateValueChange}
shouldShowSpeedTest={shouldShowSpeedTest}
baseUrl={baseUrl}
onBaseUrlChange={handleClaudeBaseUrlChange}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelSelector={category !== "official"}
claudeModel={claudeModel}
defaultHaikuModel={defaultHaikuModel}
defaultSonnetModel={defaultSonnetModel}
defaultOpusModel={defaultOpusModel}
onModelChange={handleModelChange}
speedTestEndpoints={speedTestEndpoints}
apiFormat={localApiFormat}
onApiFormatChange={handleApiFormatChange}
apiKeyField={localApiKeyField}
onApiKeyFieldChange={handleApiKeyFieldChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
/>
)}
{appId === "codex" && (
<CodexFormFields
providerId={providerId}
codexApiKey={codexApiKey}
onApiKeyChange={handleCodexApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowCodexApiKeyLink}
websiteUrl={codexWebsiteUrl}
isPartner={isCodexPartner}
partnerPromotionKey={codexPartnerPromotionKey}
shouldShowSpeedTest={shouldShowSpeedTest}
codexBaseUrl={codexBaseUrl}
onBaseUrlChange={handleCodexBaseUrlChange}
isFullUrl={localIsFullUrl}
onFullUrlChange={setLocalIsFullUrl}
isEndpointModalOpen={isCodexEndpointModalOpen}
onEndpointModalToggle={setIsCodexEndpointModalOpen}
onCustomEndpointsChange={
isEditMode ? undefined : setDraftCustomEndpoints
}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={category !== "official"}
modelName={codexModelName}
onModelNameChange={handleCodexModelNameChange}
speedTestEndpoints={speedTestEndpoints}
/>
)}
{appId === "gemini" && (
<GeminiFormFields
providerId={providerId}
shouldShowApiKey={shouldShowApiKey(
form.getValues("settingsConfig"),
isEditMode,
)}
apiKey={geminiApiKey}
onApiKeyChange={handleGeminiApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowGeminiApiKeyLink}
websiteUrl={geminiWebsiteUrl}
isPartner={isGeminiPartner}
partnerPromotionKey={geminiPartnerPromotionKey}
shouldShowSpeedTest={shouldShowSpeedTest}
baseUrl={geminiBaseUrl}
onBaseUrlChange={handleGeminiBaseUrlChange}
isEndpointModalOpen={isEndpointModalOpen}
onEndpointModalToggle={setIsEndpointModalOpen}
onCustomEndpointsChange={setDraftCustomEndpoints}
autoSelect={endpointAutoSelect}
onAutoSelectChange={setEndpointAutoSelect}
shouldShowModelField={true}
model={geminiModel}
onModelChange={handleGeminiModelChange}
speedTestEndpoints={speedTestEndpoints}
/>
)}
{appId === "opencode" && !isAnyOmoCategory && (
<OpenCodeFormFields
npm={opencodeForm.opencodeNpm}
onNpmChange={opencodeForm.handleOpencodeNpmChange}
apiKey={opencodeForm.opencodeApiKey}
onApiKeyChange={opencodeForm.handleOpencodeApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowOpencodeApiKeyLink}
websiteUrl={opencodeWebsiteUrl}
isPartner={isOpencodePartner}
partnerPromotionKey={opencodePartnerPromotionKey}
baseUrl={opencodeForm.opencodeBaseUrl}
onBaseUrlChange={opencodeForm.handleOpencodeBaseUrlChange}
models={opencodeForm.opencodeModels}
onModelsChange={opencodeForm.handleOpencodeModelsChange}
extraOptions={opencodeForm.opencodeExtraOptions}
onExtraOptionsChange={
opencodeForm.handleOpencodeExtraOptionsChange
}
/>
)}
{appId === "opencode" &&
(category === "omo" || category === "omo-slim") && (
<OmoFormFields
modelOptions={omoModelOptions}
modelVariantsMap={omoModelVariantsMap}
presetMetaMap={omoPresetMetaMap}
agents={omoDraft.omoAgents}
onAgentsChange={omoDraft.setOmoAgents}
categories={
category === "omo" ? omoDraft.omoCategories : undefined
}
onCategoriesChange={
category === "omo" ? omoDraft.setOmoCategories : undefined
}
otherFieldsStr={omoDraft.omoOtherFieldsStr}
onOtherFieldsStrChange={omoDraft.setOmoOtherFieldsStr}
isSlim={category === "omo-slim"}
/>
)}
{/* OpenClaw 专属字段 */}
{appId === "openclaw" && (
<OpenClawFormFields
baseUrl={openclawForm.openclawBaseUrl}
onBaseUrlChange={openclawForm.handleOpenclawBaseUrlChange}
apiKey={openclawForm.openclawApiKey}
onApiKeyChange={openclawForm.handleOpenclawApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowOpenclawApiKeyLink}
websiteUrl={openclawWebsiteUrl}
isPartner={isOpenclawPartner}
partnerPromotionKey={openclawPartnerPromotionKey}
api={openclawForm.openclawApi}
onApiChange={openclawForm.handleOpenclawApiChange}
models={openclawForm.openclawModels}
onModelsChange={openclawForm.handleOpenclawModelsChange}
userAgent={openclawForm.openclawUserAgent}
onUserAgentChange={openclawForm.handleOpenclawUserAgentChange}
/>
)}
{/* Hermes 专属字段 */}
{appId === "hermes" && (
<HermesFormFields
baseUrl={hermesForm.hermesBaseUrl}
onBaseUrlChange={hermesForm.handleHermesBaseUrlChange}
apiKey={hermesForm.hermesApiKey}
onApiKeyChange={hermesForm.handleHermesApiKeyChange}
category={category}
shouldShowApiKeyLink={shouldShowHermesApiKeyLink}
websiteUrl={hermesWebsiteUrl}
isPartner={isHermesPartner}
partnerPromotionKey={hermesPartnerPromotionKey}
apiMode={hermesForm.hermesApiMode}
onApiModeChange={hermesForm.handleHermesApiModeChange}
models={hermesForm.hermesModels}
onModelsChange={hermesForm.handleHermesModelsChange}
rateLimitDelay={hermesForm.hermesRateLimitDelay}
onRateLimitDelayChange={
hermesForm.handleHermesRateLimitDelayChange
}
/>
)}
{/* 配置编辑器:Codex、Claude、Gemini 分别使用不同的编辑器 */}
{appId === "codex" ? (
<>
<CodexConfigEditor
authValue={codexAuth}
configValue={codexConfig}
onAuthChange={setCodexAuth}
onConfigChange={handleCodexConfigChange}
useCommonConfig={useCodexCommonConfigFlag}
onCommonConfigToggle={handleCodexCommonConfigToggle}
commonConfigSnippet={codexCommonConfigSnippet}
onCommonConfigSnippetChange={
handleCodexCommonConfigSnippetChange
}
onCommonConfigErrorClear={clearCodexCommonConfigError}
commonConfigError={codexCommonConfigError}
authError={codexAuthError}
configError={codexConfigError}
onExtract={handleCodexExtract}
isExtracting={isCodexExtracting}
/>
{settingsConfigErrorField}
</>
) : appId === "gemini" ? (
<>
<GeminiConfigEditor
envValue={geminiEnv}
configValue={geminiConfig}
onEnvChange={handleGeminiEnvChange}
onConfigChange={handleGeminiConfigChange}
useCommonConfig={useGeminiCommonConfigFlag}
onCommonConfigToggle={handleGeminiCommonConfigToggle}
commonConfigSnippet={geminiCommonConfigSnippet}
onCommonConfigSnippetChange={
handleGeminiCommonConfigSnippetChange
}
onCommonConfigErrorClear={clearGeminiCommonConfigError}
commonConfigError={geminiCommonConfigError}
envError={envError}
configError={geminiConfigError}
onExtract={handleGeminiExtract}
isExtracting={isGeminiExtracting}
/>
{settingsConfigErrorField}
</>
) : appId === "opencode" &&
(category === "omo" || category === "omo-slim") ? (
<div className="space-y-2">
<Label>{t("provider.configJson")}</Label>
<JsonEditor
value={omoDraft.mergedOmoJsonPreview}
onChange={() => {}}
rows={14}
showValidation={false}
language="json"
/>
</div>
) : appId === "opencode" &&
category !== "omo" &&
category !== "omo-slim" ? (
<>
<div className="space-y-2">
<Label htmlFor="settingsConfig">
{t("provider.configJson")}
</Label>
<JsonEditor
value={form.getValues("settingsConfig")}
onChange={(config) => form.setValue("settingsConfig", config)}
placeholder={`{
"npm": "@ai-sdk/openai-compatible",
"options": {
"baseURL": "https://your-api-endpoint.com",
"apiKey": "your-api-key-here"
},
"models": {}
}`}
rows={14}
showValidation={true}
language="json"
/>
</div>
{settingsConfigErrorField}
</>
) : appId === "openclaw" || appId === "hermes" ? (
<>
<div className="space-y-2">
<Label htmlFor="settingsConfig">
{t("provider.configJson")}
</Label>
<JsonEditor
value={form.getValues("settingsConfig")}
onChange={(config) => form.setValue("settingsConfig", config)}
placeholder={
appId === "hermes"
? `{
"name": "my-provider",
"base_url": "https://api.example.com/v1",
"api_key": ""
}`
: `{
"baseUrl": "https://api.example.com/v1",
"apiKey": "your-api-key-here",
"api": "openai-completions",
"models": []
}`
}
rows={14}
showValidation={true}
language="json"
/>
</div>
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormMessage />
</FormItem>
)}
/>
</>
) : (
<>
<CommonConfigEditor
value={form.getValues("settingsConfig")}
onChange={(value) => form.setValue("settingsConfig", value)}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={handleCommonConfigToggle}
commonConfigSnippet={commonConfigSnippet}
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
commonConfigError={commonConfigError}
onEditClick={() => setIsCommonConfigModalOpen(true)}
isModalOpen={isCommonConfigModalOpen}
onModalClose={() => setIsCommonConfigModalOpen(false)}
onExtract={handleClaudeExtract}
isExtracting={isClaudeExtracting}
/>
{settingsConfigErrorField}
</>
)}
{!isAnyOmoCategory &&
appId !== "opencode" &&
appId !== "openclaw" &&
appId !== "hermes" && (
<ProviderAdvancedConfig
testConfig={testConfig}
pricingConfig={pricingConfig}
onTestConfigChange={setTestConfig}
onPricingConfigChange={setPricingConfig}
/>
)}
{showButtons && (
<div className="flex justify-end gap-2">
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button
type="submit"
disabled={isSubmitting || isConfirmSubmitting}
>
{submitLabel}
</Button>
</div>
)}
</form>
</Form>
<ConfirmDialog
isOpen={showCommonConfigNotice}
variant="info"
title={t("confirm.commonConfig.title")}
message={t("confirm.commonConfig.message")}
confirmText={t("confirm.commonConfig.confirm")}
onConfirm={() => void handleCommonConfigConfirm()}
onCancel={() => void handleCommonConfigConfirm()}
/>
<ConfirmDialog
isOpen={softIssues !== null && softIssues.length > 0}
variant="info"
title={t("providerForm.softValidation.title", {
defaultValue: "配置存在以下问题",
})}
message={
(softIssues ?? []).map((issue) => `${issue}`).join("\n") +
"\n\n" +
t("providerForm.softValidation.hint", {
defaultValue:
"仍要保存吗?保存后切换此供应商时可能失败,可以之后再补全。",
})
}
confirmText={t("providerForm.softValidation.saveAnyway", {
defaultValue: "仍要保存",
})}
cancelText={t("common.cancel")}
onConfirm={async () => {
if (isConfirmSubmitting) return;
const values = pendingFormValues;
if (!values) {
setSoftIssues(null);
return;
}
setIsConfirmSubmitting(true);
try {
await performSubmit(values);
setSoftIssues(null);
setPendingFormValues(null);
} catch (error) {
console.error("[ProviderForm] soft-confirm submit failed:", error);
// 保留确认框和 pending values,让用户可以重试或取消
} finally {
setIsConfirmSubmitting(false);
}
}}
onCancel={() => {
if (isConfirmSubmitting) return;
setSoftIssues(null);
setPendingFormValues(null);
}}
/>
</>
);
}
export type ProviderFormValues = ProviderFormData & {
presetId?: string;
presetCategory?: ProviderCategory;
isPartner?: boolean;
meta?: ProviderMeta;
providerKey?: string; // OpenCode/OpenClaw: user-defined provider key
suggestedDefaults?: OpenClawSuggestedDefaults; // OpenClaw: suggested default model configuration
};