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>
This commit is contained in:
allen_xln
2026-04-25 09:28:28 +08:00
committed by GitHub
parent 08e2b29b8a
commit 85f0be9e1d
5 changed files with 152 additions and 54 deletions
+1 -1
View File
@@ -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
}
}
+136 -53
View File
@@ -297,9 +297,16 @@ export function ProviderForm({
},
);
// 软校验:收集"业务约束"类问题(空值/缺项),由用户决定是否仍要保存
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);
}, [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 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()) {
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({
<Button variant="outline" type="button" onClick={onCancel}>
{t("common.cancel")}
</Button>
<Button type="submit" disabled={isSubmitting}>
<Button
type="submit"
disabled={isSubmitting || isConfirmSubmitting}
>
{submitLabel}
</Button>
</div>
@@ -2096,6 +2135,50 @@ export function ProviderForm({
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);
}}
/>
</>
);
}
+5
View File
@@ -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",
+5
View File
@@ -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 を確認してください",
+5
View File
@@ -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语法",