From 7b52c44a9d59c90c09b815c5f68e4c28c5aac471 Mon Sep 17 00:00:00 2001 From: Jason Date: Sun, 9 Nov 2025 20:42:25 +0800 Subject: [PATCH] feat(schema): add common JSON/TOML validators and enforce MCP conditional fields - Add src/lib/schemas/common.ts with jsonConfigSchema and tomlConfigSchema - Enhance src/lib/schemas/mcp.ts to require command for stdio and url for http via superRefine - Keep ProviderForm as-is; future steps will wire new schemas into RHF flows - Verified: pnpm typecheck passes --- .../providers/forms/ProviderForm.tsx | 89 +++++++++++------ src/lib/schemas/common.ts | 96 +++++++++++++++++++ src/lib/schemas/mcp.ts | 36 +++++-- src/lib/schemas/provider.ts | 47 ++++++++- 4 files changed, 224 insertions(+), 44 deletions(-) create mode 100644 src/lib/schemas/common.ts diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index 1c43c11..4922be5 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -3,7 +3,12 @@ import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { useTranslation } from "react-i18next"; import { Button } from "@/components/ui/button"; -import { Form } from "@/components/ui/form"; +import { + Form, + FormField, + FormItem, + FormMessage, +} from "@/components/ui/form"; import { providerSchema, type ProviderFormData } from "@/lib/schemas/provider"; import type { AppId } from "@/lib/api"; import type { ProviderCategory, ProviderMeta } from "@/types"; @@ -575,36 +580,60 @@ export function ProviderForm({ {/* 配置编辑器:Claude 使用通用配置编辑器,Codex 使用专用编辑器 */} {appId === "codex" ? ( - form.setValue("websiteUrl", url)} - onNameChange={(name) => form.setValue("name", name)} - isTemplateModalOpen={isCodexTemplateModalOpen} - setIsTemplateModalOpen={setIsCodexTemplateModalOpen} - /> + <> + form.setValue("websiteUrl", url)} + onNameChange={(name) => form.setValue("name", name)} + isTemplateModalOpen={isCodexTemplateModalOpen} + setIsTemplateModalOpen={setIsCodexTemplateModalOpen} + /> + {/* 配置验证错误显示 */} + ( + + + + )} + /> + ) : ( - form.setValue("settingsConfig", value)} - useCommonConfig={useCommonConfig} - onCommonConfigToggle={handleCommonConfigToggle} - commonConfigSnippet={commonConfigSnippet} - onCommonConfigSnippetChange={handleCommonConfigSnippetChange} - commonConfigError={commonConfigError} - onEditClick={() => setIsCommonConfigModalOpen(true)} - isModalOpen={isCommonConfigModalOpen} - onModalClose={() => setIsCommonConfigModalOpen(false)} - /> + <> + form.setValue("settingsConfig", value)} + useCommonConfig={useCommonConfig} + onCommonConfigToggle={handleCommonConfigToggle} + commonConfigSnippet={commonConfigSnippet} + onCommonConfigSnippetChange={handleCommonConfigSnippetChange} + commonConfigError={commonConfigError} + onEditClick={() => setIsCommonConfigModalOpen(true)} + isModalOpen={isCommonConfigModalOpen} + onModalClose={() => setIsCommonConfigModalOpen(false)} + /> + {/* 配置验证错误显示 */} + ( + + + + )} + /> + )} {showButtons && ( diff --git a/src/lib/schemas/common.ts b/src/lib/schemas/common.ts new file mode 100644 index 0000000..d197bb2 --- /dev/null +++ b/src/lib/schemas/common.ts @@ -0,0 +1,96 @@ +import { z } from "zod"; +import { validateToml, tomlToMcpServer } from "@/utils/tomlUtils"; + +/** + * 解析 JSON 语法错误,返回更友好的位置信息。 + */ +function parseJsonError(error: unknown): string { + if (!(error instanceof SyntaxError)) { + return "JSON 格式错误"; + } + + const message = error.message || "JSON 解析失败"; + + // Chrome/V8: "Unexpected token ... in JSON at position 123" + const positionMatch = message.match(/at position (\d+)/i); + if (positionMatch) { + const position = parseInt(positionMatch[1], 10); + return `JSON 格式错误(位置:${position})`; + } + + // Firefox: "JSON.parse: unexpected character at line 1 column 23" + const lineColumnMatch = message.match(/line (\d+) column (\d+)/i); + if (lineColumnMatch) { + const line = lineColumnMatch[1]; + const column = lineColumnMatch[2]; + return `JSON 格式错误:第 ${line} 行,第 ${column} 列`; + } + + return `JSON 格式错误:${message}`; +} + +/** + * 通用的 JSON 配置文本校验: + * - 非空 + * - 可解析且为对象(非数组) + */ +export const jsonConfigSchema = z + .string() + .min(1, "配置不能为空") + .superRefine((value, ctx) => { + try { + const obj = JSON.parse(value); + if (!obj || typeof obj !== "object" || Array.isArray(obj)) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "需为单个对象配置", + }); + } + } catch (e) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: parseJsonError(e), + }); + } + }); + +/** + * 通用的 TOML 配置文本校验: + * - 允许为空(由上层业务决定是否必填) + * - 语法与结构有效 + * - 针对 stdio/http 的必填字段(command/url)进行提示 + */ +export const tomlConfigSchema = z.string().superRefine((value, ctx) => { + const err = validateToml(value); + if (err) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: `TOML 无效:${err}`, + }); + return; + } + + if (!value.trim()) return; + + try { + const server = tomlToMcpServer(value); + if (server.type === "stdio" && !server.command?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "stdio 类型需填写 command", + }); + } + if (server.type === "http" && !server.url?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "http 类型需填写 url", + }); + } + } catch (e: any) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: e?.message || "TOML 解析失败", + }); + } +}); + diff --git a/src/lib/schemas/mcp.ts b/src/lib/schemas/mcp.ts index 3d14f54..2159609 100644 --- a/src/lib/schemas/mcp.ts +++ b/src/lib/schemas/mcp.ts @@ -1,14 +1,32 @@ import { z } from "zod"; -const mcpServerSpecSchema = z.object({ - type: z.enum(["stdio", "http"]).optional(), - command: z.string().trim().min(1, "请输入可执行命令").optional(), - args: z.array(z.string()).optional(), - env: z.record(z.string(), z.string()).optional(), - cwd: z.string().optional(), - url: z.string().url("请输入有效的 URL").optional(), - headers: z.record(z.string(), z.string()).optional(), -}); +const mcpServerSpecSchema = z + .object({ + type: z.enum(["stdio", "http"]).optional(), + command: z.string().trim().optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + cwd: z.string().optional(), + url: z.string().trim().url("请输入有效的 URL").optional(), + headers: z.record(z.string(), z.string()).optional(), + }) + .superRefine((server, ctx) => { + const type = server.type ?? "stdio"; + if (type === "stdio" && !server.command?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "stdio 类型需填写 command", + path: ["command"], + }); + } + if (type === "http" && !server.url?.trim()) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "http 类型需填写 url", + path: ["url"], + }); + } + }); export const mcpServerSchema = z.object({ id: z.string().min(1, "请输入服务器 ID"), diff --git a/src/lib/schemas/provider.ts b/src/lib/schemas/provider.ts index 3d3da56..62b203a 100644 --- a/src/lib/schemas/provider.ts +++ b/src/lib/schemas/provider.ts @@ -1,19 +1,56 @@ import { z } from "zod"; +/** + * 解析 JSON 语法错误,提取位置信息 + */ +function parseJsonError(error: unknown): string { + if (!(error instanceof SyntaxError)) { + return "配置 JSON 格式错误"; + } + + const message = error.message; + + // 提取位置信息:Chrome/V8: "Unexpected token ... in JSON at position 123" + const positionMatch = message.match(/at position (\d+)/i); + if (positionMatch) { + const position = parseInt(positionMatch[1], 10); + return `JSON 格式错误:${message.split(" in JSON")[0]}(位置:${position})`; + } + + // Firefox: "JSON.parse: unexpected character at line 1 column 23" + const lineColumnMatch = message.match(/line (\d+) column (\d+)/i); + if (lineColumnMatch) { + const line = lineColumnMatch[1]; + const column = lineColumnMatch[2]; + return `JSON 格式错误:第 ${line} 行,第 ${column} 列`; + } + + // 通用情况:提取关键错误信息 + const cleanMessage = message + .replace(/^JSON\.parse:\s*/i, "") + .replace(/^Unexpected\s+/i, "意外的 ") + .replace(/token/gi, "符号") + .replace(/Expected/gi, "预期"); + + return `JSON 格式错误:${cleanMessage}`; +} + export const providerSchema = z.object({ name: z.string().min(1, "请填写供应商名称"), websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")), settingsConfig: z .string() .min(1, "请填写配置内容") - .refine((value) => { + .superRefine((value, ctx) => { try { JSON.parse(value); - return true; - } catch { - return false; + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: parseJsonError(error), + }); } - }, "配置 JSON 格式错误"), + }), }); export type ProviderFormData = z.infer;