From 85f0be9e1d2bab7c200006e343a9cb85f59a7538 Mon Sep 17 00:00:00 2001 From: allen_xln <18124682012@163.com> Date: Sat, 25 Apr 2026 09:28:28 +0800 Subject: [PATCH] feat(provider-form): soften validation with "save anyway" prompt (#2307) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- src-tauri/src/services/model_fetch.rs | 2 +- .../providers/forms/ProviderForm.tsx | 189 +++++++++++++----- src/i18n/locales/en.json | 5 + src/i18n/locales/ja.json | 5 + src/i18n/locales/zh.json | 5 + 5 files changed, 152 insertions(+), 54 deletions(-) diff --git a/src-tauri/src/services/model_fetch.rs b/src-tauri/src/services/model_fetch.rs index 99302defb..580ddda88 100644 --- a/src-tauri/src/services/model_fetch.rs +++ b/src-tauri/src/services/model_fetch.rs @@ -192,7 +192,7 @@ fn truncate_body(body: String) -> String { body } else { let mut s: String = body.chars().take(ERROR_BODY_MAX_CHARS).collect(); - s.push_str("…"); + s.push('…'); s } } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 87f4b79c7..836225ff1 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -297,9 +297,16 @@ export function ProviderForm({ }, ); + // 软校验:收集"业务约束"类问题(空值/缺项),由用户决定是否仍要保存 + const [softIssues, setSoftIssues] = useState(null); + const [pendingFormValues, setPendingFormValues] = + useState(null); + // 确认框走的提交路径绕过了 react-hook-form 的 isSubmitting,单独追踪 + const [isConfirmSubmitting, setIsConfirmSubmitting] = useState(false); + useEffect(() => { - onSubmittingChange?.(isSubmitting); - }, [isSubmitting, onSubmittingChange]); + onSubmittingChange?.(isSubmitting || isConfirmSubmitting); + }, [isSubmitting, isConfirmSubmitting, onSubmittingChange]); const { apiKey, @@ -766,30 +773,38 @@ export function ProviderForm({ 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) { - toast.error( + issues.push( t("providerForm.fillParameter", { label: validation.missingField.label, defaultValue: `请填写 ${validation.missingField.label}`, }), ); - return; } } + // 供应商名空:A 类 if (!values.name.trim()) { - toast.error( + issues.push( t("providerForm.fillSupplierName", { defaultValue: "请填写供应商名称", }), ); - return; } + // opencode / openclaw / hermes: providerKey 相关 + // A 类(空)归到 issues;B 类(正则不合法 / 重复 / 状态加载中)仍硬拒绝 + const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + if (appId === "opencode" && !isAnyOmoCategory) { - const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; + // providerKey 是 opencode / openclaw / hermes 的主键 ID,空或格式不合法 + // 都属于完整性约束,保留硬拒绝(mutations 层也会 throw,软化只会让错误更晦涩) if (!opencodeForm.opencodeProviderKey.trim()) { toast.error(t("opencode.providerKeyRequired")); return; @@ -814,14 +829,11 @@ export function ProviderForm({ return; } if (Object.keys(opencodeForm.opencodeModels).length === 0) { - toast.error(t("opencode.modelsRequired")); - return; + issues.push(t("opencode.modelsRequired")); } } - // OpenClaw: validate provider key if (appId === "openclaw") { - const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; if (!openclawForm.openclawProviderKey.trim()) { toast.error(t("openclaw.providerKeyRequired")); return; @@ -847,9 +859,7 @@ export function ProviderForm({ } } - // Hermes: validate provider key if (appId === "hermes") { - const keyPattern = /^[a-z0-9]+(-[a-z0-9]+)*$/; if (!hermesForm.hermesProviderKey.trim()) { toast.error(t("hermes.form.providerKeyRequired")); return; @@ -875,9 +885,7 @@ export function ProviderForm({ } } - // 非官方供应商必填校验:端点和 API Key - // cloud_provider(如 Bedrock)通过模板变量处理认证,跳过通用校验 - // GitHub Copilot 使用 OAuth 认证,不需要 API Key + // OAuth 未登录:B 类(token 根本不存在,保存了也没法建立) const isCopilotProvider = templatePreset?.providerType === "github_copilot" || initialData?.meta?.providerType === "github_copilot" || @@ -885,7 +893,6 @@ export function ProviderForm({ const isCodexOauthProvider = templatePreset?.providerType === "codex_oauth" || initialData?.meta?.providerType === "codex_oauth"; - // GitHub Copilot 必须先登录才能添加 if (isCopilotProvider && !isCopilotAuthenticated) { toast.error( t("copilot.loginRequired", { @@ -894,7 +901,6 @@ export function ProviderForm({ ); return; } - // Codex OAuth 必须先登录才能添加 if (isCodexOauthProvider && !isCodexOauthAuthenticated) { toast.error( t("codexOauth.loginRequired", { @@ -904,61 +910,108 @@ export function ProviderForm({ return; } + // OMO Other Fields JSON:B 类(格式错了保存下去数据就坏了) + 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()) { - toast.error( + issues.push( t("providerForm.endpointRequired", { defaultValue: "非官方供应商请填写 API 端点", }), ); - return; } if (!isCopilotProvider && !isCodexOauthProvider && !apiKey.trim()) { - toast.error( + issues.push( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", }), ); - return; } } else if (appId === "codex") { if (!codexBaseUrl.trim()) { - toast.error( + issues.push( t("providerForm.endpointRequired", { defaultValue: "非官方供应商请填写 API 端点", }), ); - return; } if (!codexApiKey.trim()) { - toast.error( + issues.push( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", }), ); - return; } } else if (appId === "gemini") { if (!geminiBaseUrl.trim()) { - toast.error( + issues.push( t("providerForm.endpointRequired", { defaultValue: "非官方供应商请填写 API 端点", }), ); - return; } if (!geminiApiKey.trim()) { - toast.error( + issues.push( t("providerForm.apiKeyRequired", { defaultValue: "非官方供应商请填写 API Key", }), ); - return; } } } + 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") { @@ -999,29 +1052,12 @@ export function ProviderForm({ omoConfig.categories = omoDraft.omoCategories; } if (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; - } + // 格式已在 handleSubmit 前置校验中验证过,此处可以安全解析 + const otherFields = parseOmoOtherFieldsObject( + omoDraft.omoOtherFieldsStr, + ); + if (otherFields) { omoConfig.otherFields = otherFields; - } catch { - toast.error( - t("omo.invalidJson", { - defaultValue: "Other Fields contains invalid JSON", - }), - ); - return; } } settingsConfig = JSON.stringify(omoConfig); @@ -2079,7 +2115,10 @@ export function ProviderForm({ - @@ -2096,6 +2135,50 @@ export function ProviderForm({ onConfirm={() => void handleCommonConfigConfirm()} onCancel={() => void handleCommonConfigConfirm()} /> + + 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); + }} + /> ); } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 0191a1bed..766e018b6 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -824,6 +824,11 @@ "fillTemplateValue": "Please fill in {{label}}", "endpointRequired": "API endpoint is required for non-official providers", "apiKeyRequired": "API Key is required for non-official providers", + "softValidation": { + "title": "Configuration has the following issues", + "hint": "Save anyway? Switching to this provider may fail; you can fill these in later.", + "saveAnyway": "Save anyway" + }, "configJsonError": "Config JSON format error, please check syntax", "authJsonRequired": "auth.json must be a JSON object", "authJsonError": "auth.json format error, please check JSON syntax", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 85379bce6..a4e8e412f 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -824,6 +824,11 @@ "fillTemplateValue": "{{label}} を入力してください", "endpointRequired": "公式以外は API エンドポイントが必須です", "apiKeyRequired": "公式以外は API Key が必須です", + "softValidation": { + "title": "設定に以下の問題があります", + "hint": "それでも保存しますか?保存後、このプロバイダーへの切り替えが失敗する可能性がありますが、後から補完できます。", + "saveAnyway": "それでも保存" + }, "configJsonError": "Config JSON の形式が正しくありません。構文を確認してください", "authJsonRequired": "auth.json は JSON オブジェクトで入力してください", "authJsonError": "auth.json の形式が正しくありません。JSON を確認してください", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 1c0fda06e..ae148fe8a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -824,6 +824,11 @@ "fillTemplateValue": "请填写 {{label}}", "endpointRequired": "非官方供应商请填写 API 端点", "apiKeyRequired": "非官方供应商请填写 API Key", + "softValidation": { + "title": "配置存在以下问题", + "hint": "仍要保存吗?保存后切换此供应商时可能失败,可以之后再补全。", + "saveAnyway": "仍要保存" + }, "configJsonError": "配置JSON格式错误,请检查语法", "authJsonRequired": "auth.json 必须是 JSON 对象", "authJsonError": "auth.json 格式错误,请检查JSON语法",