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 { 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,36 +580,60 @@ export function ProviderForm({
|
|||||||
|
|
||||||
{/* 配置编辑器:Claude 使用通用配置编辑器,Codex 使用专用编辑器 */}
|
{/* 配置编辑器:Claude 使用通用配置编辑器,Codex 使用专用编辑器 */}
|
||||||
{appId === "codex" ? (
|
{appId === "codex" ? (
|
||||||
<CodexConfigEditor
|
<>
|
||||||
authValue={codexAuth}
|
<CodexConfigEditor
|
||||||
configValue={codexConfig}
|
authValue={codexAuth}
|
||||||
onAuthChange={setCodexAuth}
|
configValue={codexConfig}
|
||||||
onConfigChange={handleCodexConfigChange}
|
onAuthChange={setCodexAuth}
|
||||||
useCommonConfig={useCodexCommonConfigFlag}
|
onConfigChange={handleCodexConfigChange}
|
||||||
onCommonConfigToggle={handleCodexCommonConfigToggle}
|
useCommonConfig={useCodexCommonConfigFlag}
|
||||||
commonConfigSnippet={codexCommonConfigSnippet}
|
onCommonConfigToggle={handleCodexCommonConfigToggle}
|
||||||
onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange}
|
commonConfigSnippet={codexCommonConfigSnippet}
|
||||||
commonConfigError={codexCommonConfigError}
|
onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange}
|
||||||
authError={codexAuthError}
|
commonConfigError={codexCommonConfigError}
|
||||||
configError={codexConfigError}
|
authError={codexAuthError}
|
||||||
onWebsiteUrlChange={(url) => form.setValue("websiteUrl", url)}
|
configError={codexConfigError}
|
||||||
onNameChange={(name) => form.setValue("name", name)}
|
onWebsiteUrlChange={(url) => form.setValue("websiteUrl", url)}
|
||||||
isTemplateModalOpen={isCodexTemplateModalOpen}
|
onNameChange={(name) => form.setValue("name", name)}
|
||||||
setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
|
isTemplateModalOpen={isCodexTemplateModalOpen}
|
||||||
/>
|
setIsTemplateModalOpen={setIsCodexTemplateModalOpen}
|
||||||
|
/>
|
||||||
|
{/* 配置验证错误显示 */}
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="settingsConfig"
|
||||||
|
render={() => (
|
||||||
|
<FormItem className="space-y-0">
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<CommonConfigEditor
|
<>
|
||||||
value={form.watch("settingsConfig")}
|
<CommonConfigEditor
|
||||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
value={form.watch("settingsConfig")}
|
||||||
useCommonConfig={useCommonConfig}
|
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||||
onCommonConfigToggle={handleCommonConfigToggle}
|
useCommonConfig={useCommonConfig}
|
||||||
commonConfigSnippet={commonConfigSnippet}
|
onCommonConfigToggle={handleCommonConfigToggle}
|
||||||
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
|
commonConfigSnippet={commonConfigSnippet}
|
||||||
commonConfigError={commonConfigError}
|
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
|
||||||
onEditClick={() => setIsCommonConfigModalOpen(true)}
|
commonConfigError={commonConfigError}
|
||||||
isModalOpen={isCommonConfigModalOpen}
|
onEditClick={() => setIsCommonConfigModalOpen(true)}
|
||||||
onModalClose={() => setIsCommonConfigModalOpen(false)}
|
isModalOpen={isCommonConfigModalOpen}
|
||||||
/>
|
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
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,14 +1,32 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
const mcpServerSpecSchema = z.object({
|
const mcpServerSpecSchema = z
|
||||||
type: z.enum(["stdio", "http"]).optional(),
|
.object({
|
||||||
command: z.string().trim().min(1, "请输入可执行命令").optional(),
|
type: z.enum(["stdio", "http"]).optional(),
|
||||||
args: z.array(z.string()).optional(),
|
command: z.string().trim().optional(),
|
||||||
env: z.record(z.string(), z.string()).optional(),
|
args: z.array(z.string()).optional(),
|
||||||
cwd: z.string().optional(),
|
env: z.record(z.string(), z.string()).optional(),
|
||||||
url: z.string().url("请输入有效的 URL").optional(),
|
cwd: z.string().optional(),
|
||||||
headers: z.record(z.string(), 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({
|
export const mcpServerSchema = z.object({
|
||||||
id: z.string().min(1, "请输入服务器 ID"),
|
id: z.string().min(1, "请输入服务器 ID"),
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user