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

202
src/utils/tomlUtils.ts Normal file
View File

@@ -0,0 +1,202 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { McpServer } from "../types";
/**
* 验证 TOML 格式并转换为 JSON 对象
* @param text TOML 文本
* @returns 错误信息(空字符串表示成功)
*/
export const validateToml = (text: string): string => {
if (!text.trim()) return "";
try {
const parsed = parseToml(text);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "mustBeObject";
}
return "";
} catch (e: any) {
// 返回底层错误信息,由上层进行 i18n 包装
return e?.message || "parseError";
}
};
/**
* 将 McpServer 对象转换为 TOML 字符串
* 使用 @iarna/toml 的 stringify自动处理转义与嵌套表
*/
export const mcpServerToToml = (server: McpServer): string => {
const obj: any = {};
if (server.type) obj.type = server.type;
if (server.type === "stdio") {
if (server.command !== undefined) obj.command = server.command;
if (server.args && Array.isArray(server.args)) obj.args = server.args;
if (server.cwd !== undefined) obj.cwd = server.cwd;
if (server.env && typeof server.env === "object") obj.env = server.env;
} else if (server.type === "http") {
if (server.url !== undefined) obj.url = server.url;
if (server.headers && typeof server.headers === "object")
obj.headers = server.headers;
}
// 去除未定义字段,确保输出更干净
for (const k of Object.keys(obj)) {
if (obj[k] === undefined) delete obj[k];
}
// stringify 默认会带换行,做一次 trim 以适配文本框展示
return stringifyToml(obj).trim();
};
/**
* 将 TOML 文本转换为 McpServer 对象(单个服务器配置)
* 支持两种格式:
* 1. 直接的服务器配置type, command, args 等)
* 2. [mcp.servers.<id>] 或 [mcp_servers.<id>] 格式(取第一个服务器)
* @param tomlText TOML 文本
* @returns McpServer 对象
* @throws 解析或转换失败时抛出错误
*/
export const tomlToMcpServer = (tomlText: string): McpServer => {
if (!tomlText.trim()) {
throw new Error("TOML 内容不能为空");
}
const parsed = parseToml(tomlText);
// 情况 1: 直接是服务器配置(包含 type/command/url 等字段)
if (
parsed.type ||
parsed.command ||
parsed.url ||
parsed.args ||
parsed.env
) {
return normalizeServerConfig(parsed);
}
// 情况 2: [mcp.servers.<id>] 格式
if (parsed.mcp && typeof parsed.mcp === "object") {
const mcpObj = parsed.mcp as any;
if (mcpObj.servers && typeof mcpObj.servers === "object") {
const serverIds = Object.keys(mcpObj.servers);
if (serverIds.length > 0) {
const firstServer = mcpObj.servers[serverIds[0]];
return normalizeServerConfig(firstServer);
}
}
}
// 情况 3: [mcp_servers.<id>] 格式
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
const firstServer = (parsed.mcp_servers as any)[serverIds[0]];
return normalizeServerConfig(firstServer);
}
}
throw new Error(
"无法识别的 TOML 格式。请提供单个 MCP 服务器配置,或使用 [mcp.servers.<id>] 格式",
);
};
/**
* 规范化服务器配置对象为 McpServer 格式
*/
function normalizeServerConfig(config: any): McpServer {
if (!config || typeof config !== "object") {
throw new Error("服务器配置必须是对象");
}
const type = (config.type as string) || "stdio";
if (type === "stdio") {
if (!config.command || typeof config.command !== "string") {
throw new Error("stdio 类型的 MCP 服务器必须包含 command 字段");
}
const server: McpServer = {
type: "stdio",
command: config.command,
};
// 可选字段
if (config.args && Array.isArray(config.args)) {
server.args = config.args.map((arg: any) => String(arg));
}
if (config.env && typeof config.env === "object") {
const env: Record<string, string> = {};
for (const [k, v] of Object.entries(config.env)) {
env[k] = String(v);
}
server.env = env;
}
if (config.cwd && typeof config.cwd === "string") {
server.cwd = config.cwd;
}
return server;
} else if (type === "http") {
if (!config.url || typeof config.url !== "string") {
throw new Error("http 类型的 MCP 服务器必须包含 url 字段");
}
const server: McpServer = {
type: "http",
url: config.url,
};
// 可选字段
if (config.headers && typeof config.headers === "object") {
const headers: Record<string, string> = {};
for (const [k, v] of Object.entries(config.headers)) {
headers[k] = String(v);
}
server.headers = headers;
}
return server;
} else {
throw new Error(`不支持的 MCP 服务器类型: ${type}`);
}
}
/**
* 尝试从 TOML 中提取合理的服务器 ID/标题
* @param tomlText TOML 文本
* @returns 建议的 ID失败返回空字符串
*/
export const extractIdFromToml = (tomlText: string): string => {
try {
const parsed = parseToml(tomlText);
// 尝试从 [mcp.servers.<id>] 或 [mcp_servers.<id>] 中提取 ID
if (parsed.mcp && typeof parsed.mcp === "object") {
const mcpObj = parsed.mcp as any;
if (mcpObj.servers && typeof mcpObj.servers === "object") {
const serverIds = Object.keys(mcpObj.servers);
if (serverIds.length > 0) {
return serverIds[0];
}
}
}
if (parsed.mcp_servers && typeof parsed.mcp_servers === "object") {
const serverIds = Object.keys(parsed.mcp_servers);
if (serverIds.length > 0) {
return serverIds[0];
}
}
// 尝试从 command 中推断
if (parsed.command && typeof parsed.command === "string") {
const cmd = parsed.command.split(/[\\/]/).pop() || "";
return cmd.replace(/\.(exe|bat|sh|js|py)$/i, "");
}
} catch {
// 解析失败,返回空
}
return "";
};