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
This commit is contained in:
Jason
2025-11-09 20:42:25 +08:00
parent 772081312e
commit 7b52c44a9d
4 changed files with 224 additions and 44 deletions

View File

@@ -3,7 +3,12 @@ import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; 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 { providerSchema, type ProviderFormData } from "@/lib/schemas/provider";
import type { AppId } from "@/lib/api"; import type { AppId } from "@/lib/api";
import type { ProviderCategory, ProviderMeta } from "@/types"; import type { ProviderCategory, ProviderMeta } from "@/types";
@@ -575,6 +580,7 @@ export function ProviderForm({
{/* 配置编辑器Claude 使用通用配置编辑器Codex 使用专用编辑器 */} {/* 配置编辑器Claude 使用通用配置编辑器Codex 使用专用编辑器 */}
{appId === "codex" ? ( {appId === "codex" ? (
<>
<CodexConfigEditor <CodexConfigEditor
authValue={codexAuth} authValue={codexAuth}
configValue={codexConfig} configValue={codexConfig}
@@ -592,7 +598,19 @@ export function ProviderForm({
isTemplateModalOpen={isCodexTemplateModalOpen} isTemplateModalOpen={isCodexTemplateModalOpen}
setIsTemplateModalOpen={setIsCodexTemplateModalOpen} setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
/> />
{/* 配置验证错误显示 */}
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormMessage />
</FormItem>
)}
/>
</>
) : ( ) : (
<>
<CommonConfigEditor <CommonConfigEditor
value={form.watch("settingsConfig")} value={form.watch("settingsConfig")}
onChange={(value) => form.setValue("settingsConfig", value)} onChange={(value) => form.setValue("settingsConfig", value)}
@@ -605,6 +623,17 @@ export function ProviderForm({
isModalOpen={isCommonConfigModalOpen} isModalOpen={isCommonConfigModalOpen}
onModalClose={() => setIsCommonConfigModalOpen(false)} onModalClose={() => setIsCommonConfigModalOpen(false)}
/> />
{/* 配置验证错误显示 */}
<FormField
control={form.control}
name="settingsConfig"
render={() => (
<FormItem className="space-y-0">
<FormMessage />
</FormItem>
)}
/>
</>
)} )}
{showButtons && ( {showButtons && (

96
src/lib/schemas/common.ts Normal file
View File

@@ -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 解析失败",
});
}
});

View File

@@ -1,13 +1,31 @@
import { z } from "zod"; import { z } from "zod";
const mcpServerSpecSchema = z.object({ const mcpServerSpecSchema = z
.object({
type: z.enum(["stdio", "http"]).optional(), type: z.enum(["stdio", "http"]).optional(),
command: z.string().trim().min(1, "请输入可执行命令").optional(), command: z.string().trim().optional(),
args: z.array(z.string()).optional(), args: z.array(z.string()).optional(),
env: z.record(z.string(), z.string()).optional(), env: z.record(z.string(), z.string()).optional(),
cwd: z.string().optional(), cwd: z.string().optional(),
url: z.string().url("请输入有效的 URL").optional(), url: z.string().trim().url("请输入有效的 URL").optional(),
headers: z.record(z.string(), z.string()).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({ export const mcpServerSchema = z.object({

View File

@@ -1,19 +1,56 @@
import { z } from "zod"; 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({ export const providerSchema = z.object({
name: z.string().min(1, "请填写供应商名称"), name: z.string().min(1, "请填写供应商名称"),
websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")), websiteUrl: z.string().url("请输入有效的网址").optional().or(z.literal("")),
settingsConfig: z settingsConfig: z
.string() .string()
.min(1, "请填写配置内容") .min(1, "请填写配置内容")
.refine((value) => { .superRefine((value, ctx) => {
try { try {
JSON.parse(value); JSON.parse(value);
return true; } catch (error) {
} catch { ctx.addIssue({
return false; code: z.ZodIssueCode.custom,
message: parseJsonError(error),
});
} }
}, "配置 JSON 格式错误"), }),
}); });
export type ProviderFormData = z.infer<typeof providerSchema>; export type ProviderFormData = z.infer<typeof providerSchema>;