diff --git a/package.json b/package.json index 79e4ec2..20e25b6 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "@codemirror/state": "^6.5.2", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.38.2", + "smol-toml": "^1.4.2", "@tailwindcss/vite": "^4.1.13", "@tauri-apps/api": "^2.8.0", "@tauri-apps/plugin-dialog": "^2.4.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7c2b881..c01abf3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -59,6 +59,9 @@ importers: react-i18next: specifier: ^16.0.0 version: 16.0.0(i18next@25.5.2(typescript@5.9.2))(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.2) + smol-toml: + specifier: ^1.4.2 + version: 1.4.2 tailwindcss: specifier: ^4.1.13 version: 4.1.13 @@ -421,56 +424,67 @@ packages: resolution: {integrity: sha512-EtP8aquZ0xQg0ETFcxUbU71MZlHaw9MChwrQzatiE8U/bvi5uv/oChExXC4mWhjiqK7azGJBqU0tt5H123SzVA==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.46.2': resolution: {integrity: sha512-qO7F7U3u1nfxYRPM8HqFtLd+raev2K137dsV08q/LRKRLEc7RsiDWihUnrINdsWQxPR9jqZ8DIIZ1zJJAm5PjQ==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.46.2': resolution: {integrity: sha512-3dRaqLfcOXYsfvw5xMrxAk9Lb1f395gkoBYzSFcc/scgRFptRXL9DOaDpMiehf9CO8ZDRJW2z45b6fpU5nwjng==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.46.2': resolution: {integrity: sha512-fhHFTutA7SM+IrR6lIfiHskxmpmPTJUXpWIsBXpeEwNgZzZZSg/q4i6FU4J8qOGyJ0TR+wXBwx/L7Ho9z0+uDg==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loongarch64-gnu@4.46.2': resolution: {integrity: sha512-i7wfGFXu8x4+FRqPymzjD+Hyav8l95UIZ773j7J7zRYc3Xsxy2wIn4x+llpunexXe6laaO72iEjeeGyUFmjKeA==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.46.2': resolution: {integrity: sha512-B/l0dFcHVUnqcGZWKcWBSV2PF01YUt0Rvlurci5P+neqY/yMKchGU8ullZvIv5e8Y1C6wOn+U03mrDylP5q9Yw==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.46.2': resolution: {integrity: sha512-32k4ENb5ygtkMwPMucAb8MtV8olkPT03oiTxJbgkJa7lJ7dZMr0GCFJlyvy+K8iq7F/iuOr41ZdUHaOiqyR3iQ==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.46.2': resolution: {integrity: sha512-t5B2loThlFEauloaQkZg9gxV05BYeITLvLkWOkRXogP4qHXLkWSbSHKM9S6H1schf/0YGP/qNKtiISlxvfmmZw==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.46.2': resolution: {integrity: sha512-YKjekwTEKgbB7n17gmODSmJVUIvj8CX7q5442/CK80L8nqOUbMtf8b01QkG3jOqyr1rotrAnW6B/qiHwfcuWQA==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.46.2': resolution: {integrity: sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.46.2': resolution: {integrity: sha512-7kX69DIrBeD7yNp4A5b81izs8BqoZkCIaxQaOpumcJ1S/kmqNFjPhDu1LHeVXv0SexfHQv5cqHsxLOjETuqDuA==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-win32-arm64-msvc@4.46.2': resolution: {integrity: sha512-wiJWMIpeaak/jsbaq2HMh/rzZxHVW1rU6coyeNNpMwk5isiPjSTx0a4YLSlYDwBH/WBvLz+EtsNqQScZTLJy3g==} @@ -525,24 +539,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.1.13': resolution: {integrity: sha512-hZQrmtLdhyqzXHB7mkXfq0IYbxegaqTmfa1p9MBj72WPoDD3oNOh1Lnxf6xZLY9C3OV6qiCYkO1i/LrzEdW2mg==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.1.13': resolution: {integrity: sha512-uaZTYWxSXyMWDJZNY1Ul7XkJTCBRFZ5Fo6wtjrgBKzZLoJNrG+WderJwAjPzuNZOnmdrVg260DKwXCFtJ/hWRQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.1.13': resolution: {integrity: sha512-oXiPj5mi4Hdn50v5RdnuuIms0PVPI/EG4fxAfFiIKQh5TgQgX7oSuDWntHW7WNIi/yVLAiS+CRGW4RkoGSSgVQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.1.13': resolution: {integrity: sha512-+LC2nNtPovtrDwBc/nqnIKYh/W2+R69FA0hgoeOn64BdCX522u19ryLh3Vf3F8W49XBcMIxSe665kwy21FkhvA==} @@ -603,30 +621,35 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-arm64-musl@2.8.1': resolution: {integrity: sha512-VK/zwBzQY9SfyK7RSrxlIRQLJyhyssoByYWPK/FJMre8SV/y8zZ071cTQNG9dPWM1f+onI1WPTleG+TBUq/0Gw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@tauri-apps/cli-linux-riscv64-gnu@2.8.1': resolution: {integrity: sha512-bFw3zK6xkyurDR5kw2QgiU6YFlFNrfgtli3wRdTRv8zSVLZMQ2iZ8keYnd57vpvsbZ9PusFPYAMS7Fkzkf9I4g==} engines: {node: '>= 10'} cpu: [riscv64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-gnu@2.8.1': resolution: {integrity: sha512-zOnFX+Rppuz0UVVSeCi67lMet8le+yT4UIiQ6t/QYGtpoWO/D4GpMoVYehJlR14klNXrC2CRxT9b3BUWTCEBwA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@tauri-apps/cli-linux-x64-musl@2.8.1': resolution: {integrity: sha512-gLy6eisaeOTC6NQirs3a0XZNCVT/i7JPYHkXx6ArH6+Kb9IU8ogthTY4MQoYbkWmdOp3ijKX+RT1dD3IZURrEg==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@tauri-apps/cli-win32-arm64-msvc@2.8.1': resolution: {integrity: sha512-ciZ93Dm847zFDqRyc1e0YRiu/cdWne1bMhvifcZOibbyqSKB9o+b95Y5axMtXqR4Wsd2mHiC5TE+MVF3NDsdEw==} @@ -820,24 +843,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.30.1: resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.30.1: resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.30.1: resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.30.1: resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==} @@ -947,6 +974,10 @@ packages: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true + smol-toml@1.4.2: + resolution: {integrity: sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==} + engines: {node: '>= 18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -1793,6 +1824,8 @@ snapshots: semver@6.3.1: {} + smol-toml@1.4.2: {} + source-map-js@1.2.1: {} style-mod@4.1.2: {} diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index f41e8df..445ea63 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -7,6 +7,12 @@ import { buttonStyles, inputStyles } from "../../lib/styles"; import McpWizardModal from "./McpWizardModal"; import { extractErrorMessage } from "../../utils/errorUtils"; import { AppType } from "../../lib/tauri-api"; +import { + validateToml, + tomlToMcpServer, + extractIdFromToml, + mcpServerToToml, +} from "../../utils/tomlUtils"; interface McpFormModalProps { appType: AppType; @@ -22,25 +28,10 @@ interface McpFormModalProps { ) => void; } -/** - * 验证 JSON 格式 - */ -const validateJson = (text: string): string => { - if (!text.trim()) return ""; - try { - const parsed = JSON.parse(text); - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { - return "JSON 必须是对象"; - } - return ""; - } catch { - return "JSON 格式错误"; - } -}; - /** * MCP 表单模态框组件(简化版) - * 仅包含:标题(必填)、描述(可选)、JSON 配置(可选,带格式校验) + * Claude: 使用 JSON 格式 + * Codex: 使用 TOML 格式 */ const McpFormModal: React.FC = ({ appType, @@ -52,14 +43,45 @@ const McpFormModal: React.FC = ({ onNotify, }) => { const { t } = useTranslation(); + + // JSON 基本校验(返回 i18n 文案) + const validateJson = (text: string): string => { + if (!text.trim()) return ""; + try { + const parsed = JSON.parse(text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + return t("mcp.error.jsonInvalid"); + } + return ""; + } catch { + return t("mcp.error.jsonInvalid"); + } + }; + + // 统一格式化 TOML 错误(本地化 + 详情) + const formatTomlError = (err: string): string => { + if (!err) return ""; + if (err === "mustBeObject" || err === "parseError") { + return t("mcp.error.tomlInvalid"); + } + return `${t("mcp.error.tomlInvalid")}: ${err}`; + }; const [formId, setFormId] = useState(editingId || ""); const [formDescription, setFormDescription] = useState( (initialData as any)?.description || "", ); - const [formJson, setFormJson] = useState( - initialData ? JSON.stringify(initialData, null, 2) : "", - ); - const [jsonError, setJsonError] = useState(""); + + // 根据 appType 决定初始格式 + const [formConfig, setFormConfig] = useState(() => { + if (!initialData) return ""; + if (appType === "codex") { + return mcpServerToToml(initialData); + } else { + return JSON.stringify(initialData, null, 2); + } + }); + + const [configError, setConfigError] = useState(""); const [saving, setSaving] = useState(false); const [isWizardOpen, setIsWizardOpen] = useState(false); const [idError, setIdError] = useState(""); @@ -67,6 +89,9 @@ const McpFormModal: React.FC = ({ // 编辑模式下禁止修改 ID const isEditing = !!editingId; + // 判断是否使用 TOML 格式 + const useToml = appType === "codex"; + // 预设选择状态(仅新增模式显示;-1 表示自定义) const [selectedPreset, setSelectedPreset] = useState( isEditing ? null : -1, @@ -96,10 +121,20 @@ const McpFormModal: React.FC = ({ const id = ensureUniqueId(p.id); setFormId(id); setFormDescription(p.description || ""); - const json = JSON.stringify(p.server, null, 2); - setFormJson(json); - // 触发一次校验 - setJsonError(validateJson(json)); + + // 根据格式转换配置 + if (useToml) { + const toml = mcpServerToToml(p.server); + setFormConfig(toml); + { + const err = validateToml(toml); + setConfigError(formatTomlError(err)); + } + } else { + const json = JSON.stringify(p.server, null, 2); + setFormConfig(json); + setConfigError(validateJson(json)); + } setSelectedPreset(index); }; @@ -109,53 +144,101 @@ const McpFormModal: React.FC = ({ // 恢复到空白模板 setFormId(""); setFormDescription(""); - setFormJson(""); - setJsonError(""); + setFormConfig(""); + setConfigError(""); }; - const handleJsonChange = (value: string) => { - setFormJson(value); + const handleConfigChange = (value: string) => { + setFormConfig(value); - // 基础 JSON 校验 - const baseErr = validateJson(value); - if (baseErr) { - setJsonError(baseErr); - return; - } + if (useToml) { + // TOML 校验 + const err = validateToml(value); + if (err) { + setConfigError(formatTomlError(err)); + return; + } - // 进一步结构校验:仅允许单个服务器对象,禁止整份配置 - if (value.trim()) { - try { - const obj = JSON.parse(value); - if (obj && typeof obj === "object") { - if (Object.prototype.hasOwnProperty.call(obj, "mcpServers")) { - setJsonError(t("mcp.error.singleServerObjectRequired")); + // 尝试解析并做必填字段提示 + if (value.trim()) { + try { + const server = tomlToMcpServer(value); + if (server.type === "stdio" && !server.command?.trim()) { + setConfigError(t("mcp.error.commandRequired")); + return; + } + if (server.type === "http" && !server.url?.trim()) { + setConfigError(t("mcp.wizard.urlRequired")); return; } - // 若带有类型,做必填字段提示(不阻止输入,仅给出即时反馈) - const typ = (obj as any)?.type; - if (typ === "stdio" && !(obj as any)?.command?.trim()) { - setJsonError(t("mcp.error.commandRequired")); - return; - } - if (typ === "http" && !(obj as any)?.url?.trim()) { - setJsonError(t("mcp.wizard.urlRequired")); - return; + // 尝试提取 ID(如果用户还没有填写) + if (!formId.trim()) { + const extractedId = extractIdFromToml(value); + if (extractedId) { + setFormId(extractedId); + } } + } catch (e: any) { + const msg = e?.message || String(e); + setConfigError(formatTomlError(msg)); + return; + } + } + } else { + // JSON 校验 + const baseErr = validateJson(value); + if (baseErr) { + setConfigError(baseErr); + return; + } + + // 进一步结构校验 + if (value.trim()) { + try { + const obj = JSON.parse(value); + if (obj && typeof obj === "object") { + if (Object.prototype.hasOwnProperty.call(obj, "mcpServers")) { + setConfigError(t("mcp.error.singleServerObjectRequired")); + return; + } + + const typ = (obj as any)?.type; + if (typ === "stdio" && !(obj as any)?.command?.trim()) { + setConfigError(t("mcp.error.commandRequired")); + return; + } + if (typ === "http" && !(obj as any)?.url?.trim()) { + setConfigError(t("mcp.wizard.urlRequired")); + return; + } + } + } catch { + // 解析异常已在基础校验覆盖 } - } catch { - // 解析异常已在基础校验覆盖 } } - setJsonError(""); + setConfigError(""); }; const handleWizardApply = (title: string, json: string) => { setFormId(title); - setFormJson(json); - setJsonError(validateJson(json)); + // Wizard 返回的是 JSON,根据格式决定是否需要转换 + if (useToml) { + try { + const server = JSON.parse(json) as McpServer; + const toml = mcpServerToToml(server); + setFormConfig(toml); + const err = validateToml(toml); + setConfigError(formatTomlError(err)); + } catch (e: any) { + setConfigError(t("mcp.error.jsonInvalid")); + } + } else { + setFormConfig(json); + setConfigError(validateJson(json)); + } }; const handleSubmit = async () => { @@ -170,39 +253,74 @@ const McpFormModal: React.FC = ({ return; } - // 验证 JSON - const currentJsonError = validateJson(formJson); - setJsonError(currentJsonError); - if (currentJsonError) { - onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000); - return; - } + // 验证配置格式 + let server: McpServer; - setSaving(true); - try { - let server: McpServer; - if (formJson.trim()) { - // 解析 JSON 配置 - server = JSON.parse(formJson) as McpServer; + if (useToml) { + // TOML 模式 + const tomlError = validateToml(formConfig); + setConfigError(formatTomlError(tomlError)); + if (tomlError) { + onNotify?.(t("mcp.error.tomlInvalid"), "error", 3000); + return; + } - // 前置必填校验,避免后端拒绝后才提示 - if (server?.type === "stdio" && !server?.command?.trim()) { - onNotify?.(t("mcp.error.commandRequired"), "error", 3000); - return; - } - if (server?.type === "http" && !server?.url?.trim()) { - onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000); - return; - } - } else { - // 空 JSON 时提供默认值(注意:后端会校验 stdio 需要非空 command / http 需要 url) + if (!formConfig.trim()) { + // 空配置 server = { type: "stdio", command: "", args: [], }; + } else { + try { + server = tomlToMcpServer(formConfig); + } catch (e: any) { + const msg = e?.message || String(e); + setConfigError(formatTomlError(msg)); + onNotify?.(t("mcp.error.tomlInvalid"), "error", 4000); + return; + } + } + } else { + // JSON 模式 + const jsonError = validateJson(formConfig); + setConfigError(jsonError); + if (jsonError) { + onNotify?.(t("mcp.error.jsonInvalid"), "error", 3000); + return; } + if (!formConfig.trim()) { + // 空配置 + server = { + type: "stdio", + command: "", + args: [], + }; + } else { + try { + server = JSON.parse(formConfig) as McpServer; + } catch (e: any) { + setConfigError(t("mcp.error.jsonInvalid")); + onNotify?.(t("mcp.error.jsonInvalid"), "error", 4000); + return; + } + } + } + + // 前置必填校验 + if (server?.type === "stdio" && !server?.command?.trim()) { + onNotify?.(t("mcp.error.commandRequired"), "error", 3000); + return; + } + if (server?.type === "http" && !server?.url?.trim()) { + onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000); + return; + } + + setSaving(true); + try { // 保留原有的 enabled 状态 if (initialData?.enabled !== undefined) { server.enabled = initialData.enabled; @@ -213,10 +331,9 @@ const McpFormModal: React.FC = ({ (server as any).description = formDescription.trim(); } - // 显式等待父组件保存流程,以便正确处理成功/失败 + // 显式等待父组件保存流程 await onSave(formId.trim(), server); } catch (error: any) { - // 提取后端错误信息(支持 string / {message} / tauri payload) const detail = extractErrorMessage(error); const msg = detail || t("mcp.error.saveFailed"); onNotify?.(msg, "error", detail ? 6000 : 4000); @@ -328,11 +445,11 @@ const McpFormModal: React.FC = ({ /> - {/* JSON 配置 */} + {/* 配置输入框(根据格式显示 JSON 或 TOML) */}
{(isEditing || selectedPreset === -1) && (