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:
@@ -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,6 +580,7 @@ export function ProviderForm({
|
||||
|
||||
{/* 配置编辑器:Claude 使用通用配置编辑器,Codex 使用专用编辑器 */}
|
||||
{appId === "codex" ? (
|
||||
<>
|
||||
<CodexConfigEditor
|
||||
authValue={codexAuth}
|
||||
configValue={codexConfig}
|
||||
@@ -592,7 +598,19 @@ export function ProviderForm({
|
||||
isTemplateModalOpen={isCodexTemplateModalOpen}
|
||||
setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
|
||||
/>
|
||||
{/* 配置验证错误显示 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={() => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CommonConfigEditor
|
||||
value={form.watch("settingsConfig")}
|
||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||
@@ -605,6 +623,17 @@ export function ProviderForm({
|
||||
isModalOpen={isCommonConfigModalOpen}
|
||||
onModalClose={() => setIsCommonConfigModalOpen(false)}
|
||||
/>
|
||||
{/* 配置验证错误显示 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="settingsConfig"
|
||||
render={() => (
|
||||
<FormItem className="space-y-0">
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{showButtons && (
|
||||
|
||||
96
src/lib/schemas/common.ts
Normal file
96
src/lib/schemas/common.ts
Normal 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 解析失败",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const mcpServerSpecSchema = z.object({
|
||||
const mcpServerSpecSchema = z
|
||||
.object({
|
||||
type: z.enum(["stdio", "http"]).optional(),
|
||||
command: z.string().trim().min(1, "请输入可执行命令").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().url("请输入有效的 URL").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({
|
||||
|
||||
@@ -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<typeof providerSchema>;
|
||||
|
||||
Reference in New Issue
Block a user