fix(app): eliminate startup white screen by replacing @iarna/toml with browser-friendly smol-toml

- chore(deps): switch TOML dependency from @iarna/toml to smol-toml and update lockfile
- feat(mcp): add TOML editing/validation for Codex while keeping JSON for Claude; support auto ID extraction from TOML and JSON->TOML conversion for wizard output; add pre-submit required checks (stdio.command / http.url)
- refactor(mcp): unify JSON/TOML validation errors via i18n; add formatTomlError for consistent, localized messages; consolidate state into formConfig/configError
- feat(i18n): add TOML labels/placeholders and error keys (tomlConfig, tomlPlaceholder, tomlInvalid)
- feat(utils): introduce tomlUtils with parse/stringify/validate/convert helpers using smol-toml; provide tomlToMcpServer, mcpServerToToml, extractIdFromToml, validateToml
- build: confirm Vite no longer externalizes Node builtins during build; renderer builds without 'Module 'stream' has been externalized' warning
This commit is contained in:
Jason
2025-10-11 15:34:58 +08:00
parent a6ee3ba35f
commit c2b27a4949
6 changed files with 452 additions and 89 deletions

View File

@@ -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<McpFormModalProps> = ({
appType,
@@ -52,14 +43,45 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
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<McpFormModalProps> = ({
// 编辑模式下禁止修改 ID
const isEditing = !!editingId;
// 判断是否使用 TOML 格式
const useToml = appType === "codex";
// 预设选择状态(仅新增模式显示;-1 表示自定义)
const [selectedPreset, setSelectedPreset] = useState<number | null>(
isEditing ? null : -1,
@@ -96,10 +121,20 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
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<McpFormModalProps> = ({
// 恢复到空白模板
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<McpFormModalProps> = ({
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<McpFormModalProps> = ({
(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<McpFormModalProps> = ({
/>
</div>
{/* JSON 配置 */}
{/* 配置输入框(根据格式显示 JSON 或 TOML */}
<div>
<div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
{t("mcp.form.jsonConfig")}
{useToml ? t("mcp.form.tomlConfig") : t("mcp.form.jsonConfig")}
</label>
{(isEditing || selectedPreset === -1) && (
<button
@@ -346,14 +463,18 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
</div>
<textarea
className={`${inputStyles.text} h-48 resize-none font-mono text-xs`}
placeholder={t("mcp.form.jsonPlaceholder")}
value={formJson}
onChange={(e) => handleJsonChange(e.target.value)}
placeholder={
useToml
? t("mcp.form.tomlPlaceholder")
: t("mcp.form.jsonPlaceholder")
}
value={formConfig}
onChange={(e) => handleConfigChange(e.target.value)}
/>
{jsonError && (
{configError && (
<div className="flex items-center gap-2 mt-2 text-red-500 dark:text-red-400 text-sm">
<AlertCircle size={16} />
<span>{jsonError}</span>
<span>{configError}</span>
</div>
)}
</div>