fix(toml): normalize CJK quotes to prevent parsing errors

Add quote normalization to handle Chinese/fullwidth quotes automatically
converted by IME. This fixes TOML parsing failures when users input
configuration with non-ASCII quotes (" " ' ' etc.).

Changes:
- Add textNormalization utility for quote normalization
- Apply normalization in TOML input handlers (MCP form, Codex config)
- Disable browser auto-correction in Textarea component
- Add defensive normalization in TOML parsing layer
This commit is contained in:
Jason
2025-11-10 14:35:55 +08:00
parent 3210202132
commit 23d06515ad
6 changed files with 52 additions and 13 deletions

View File

@@ -32,6 +32,7 @@ import {
extractIdFromToml, extractIdFromToml,
mcpServerToToml, mcpServerToToml,
} from "@/utils/tomlUtils"; } from "@/utils/tomlUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
import { useMcpValidation } from "./useMcpValidation"; import { useMcpValidation } from "./useMcpValidation";
interface McpFormModalProps { interface McpFormModalProps {
@@ -228,19 +229,21 @@ const McpFormModal: React.FC<McpFormModalProps> = ({
}; };
const handleConfigChange = (value: string) => { const handleConfigChange = (value: string) => {
setFormConfig(value); // 若为 TOML 模式,先做引号归一化,避免中文输入法导致的格式错误
const nextValue = useToml ? normalizeTomlText(value) : value;
setFormConfig(nextValue);
if (useToml) { if (useToml) {
// TOML validation (use hook's complete validation) // TOML validation (use hook's complete validation)
const err = validateTomlConfig(value); const err = validateTomlConfig(nextValue);
if (err) { if (err) {
setConfigError(err); setConfigError(err);
return; return;
} }
// Try to extract ID (if user hasn't filled it yet) // Try to extract ID (if user hasn't filled it yet)
if (value.trim() && !formId.trim()) { if (nextValue.trim() && !formId.trim()) {
const extractedId = extractIdFromToml(value); const extractedId = extractIdFromToml(nextValue);
if (extractedId) { if (extractedId) {
setFormId(extractedId); setFormId(extractedId);
} }

View File

@@ -3,6 +3,7 @@ import {
extractCodexBaseUrl, extractCodexBaseUrl,
setCodexBaseUrl as setCodexBaseUrlInConfig, setCodexBaseUrl as setCodexBaseUrlInConfig,
} from "@/utils/providerConfigUtils"; } from "@/utils/providerConfigUtils";
import { normalizeTomlText } from "@/utils/textNormalization";
interface UseCodexConfigStateProps { interface UseCodexConfigStateProps {
initialData?: { initialData?: {
@@ -159,10 +160,12 @@ export function useCodexConfigState({ initialData }: UseCodexConfigStateProps) {
// 处理 config 变化(同步 Base URL // 处理 config 变化(同步 Base URL
const handleCodexConfigChange = useCallback( const handleCodexConfigChange = useCallback(
(value: string) => { (value: string) => {
setCodexConfig(value); // 归一化中文/全角/弯引号,避免 TOML 解析报错
const normalized = normalizeTomlText(value);
setCodexConfig(normalized);
if (!isUpdatingCodexBaseUrlRef.current) { if (!isUpdatingCodexBaseUrlRef.current) {
const extracted = extractCodexBaseUrl(value) || ""; const extracted = extractCodexBaseUrl(normalized) || "";
if (extracted !== codexBaseUrl) { if (extracted !== codexBaseUrl) {
setCodexBaseUrl(extracted); setCodexBaseUrl(extracted);
} }

View File

@@ -11,6 +11,10 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
"flex min-h-[80px] w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-[80px] w-full rounded-md border border-border-default bg-background px-3 py-2 text-sm shadow-sm placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 disabled:cursor-not-allowed disabled:opacity-50",
className, className,
)} )}
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
ref={ref} ref={ref}
{...props} {...props}
/> />

View File

@@ -1,6 +1,7 @@
// 供应商配置处理工具函数 // 供应商配置处理工具函数
import type { TemplateValueConfig } from "../config/claudeProviderPresets"; import type { TemplateValueConfig } from "../config/claudeProviderPresets";
import { normalizeQuotes } from "@/utils/textNormalization";
const isPlainObject = (value: unknown): value is Record<string, any> => { const isPlainObject = (value: unknown): value is Record<string, any> => {
return Object.prototype.toString.call(value) === "[object Object]"; return Object.prototype.toString.call(value) === "[object Object]";
@@ -357,7 +358,9 @@ export const extractCodexBaseUrl = (
configText: string | undefined | null, configText: string | undefined | null,
): string | undefined => { ): string | undefined => {
try { try {
const text = typeof configText === "string" ? configText : ""; const raw = typeof configText === "string" ? configText : "";
// 归一化中文/全角引号,避免正则提取失败
const text = normalizeQuotes(raw);
if (!text) return undefined; if (!text) return undefined;
const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/); const m = text.match(/base_url\s*=\s*(['"])([^'\"]+)\1/);
return m && m[2] ? m[2] : undefined; return m && m[2] ? m[2] : undefined;
@@ -390,16 +393,20 @@ export const setCodexBaseUrl = (
if (!trimmed) { if (!trimmed) {
return configText; return configText;
} }
// 归一化原文本中的引号(既能匹配,也能输出稳定格式)
const normalizedText = normalizeQuotes(configText);
const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, ""); const normalizedUrl = trimmed.replace(/\s+/g, "").replace(/\/+$/, "");
const replacementLine = `base_url = "${normalizedUrl}"`; const replacementLine = `base_url = "${normalizedUrl}"`;
const pattern = /base_url\s*=\s*(["'])([^"']+)\1/; const pattern = /base_url\s*=\s*(["'])([^"']+)\1/;
if (pattern.test(configText)) { if (pattern.test(normalizedText)) {
return configText.replace(pattern, replacementLine); return normalizedText.replace(pattern, replacementLine);
} }
const prefix = const prefix =
configText && !configText.endsWith("\n") ? `${configText}\n` : configText; normalizedText && !normalizedText.endsWith("\n")
? `${normalizedText}\n`
: normalizedText;
return `${prefix}${replacementLine}\n`; return `${prefix}${replacementLine}\n`;
}; };

View File

@@ -0,0 +1,20 @@
/**
* 将常见的中文/全角/弯引号统一为 ASCII 引号,以避免 TOML 解析失败。
* - 双引号:” “ „ ‟ → "
* - 单引号:’ → '
* 保守起见,不替换书名号/角引号(《》、「」等),避免误伤内容语义。
*/
export const normalizeQuotes = (text: string): string => {
if (!text) return text;
return text
// 双引号族 → "
.replace(/[“”„‟"]/g, '"')
// 单引号族 → '
.replace(/[]/g, "'");
};
/**
* 专用于 TOML 文本的归一化;目前等同于 normalizeQuotes后续可扩展如空白、行尾等
*/
export const normalizeTomlText = (text: string): string => normalizeQuotes(text);

View File

@@ -1,4 +1,5 @@
import { parse as parseToml, stringify as stringifyToml } from "smol-toml"; import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
import { normalizeTomlText } from "@/utils/textNormalization";
import { McpServerSpec } from "../types"; import { McpServerSpec } from "../types";
/** /**
@@ -9,7 +10,8 @@ import { McpServerSpec } from "../types";
export const validateToml = (text: string): string => { export const validateToml = (text: string): string => {
if (!text.trim()) return ""; if (!text.trim()) return "";
try { try {
const parsed = parseToml(text); const normalized = normalizeTomlText(text);
const parsed = parseToml(normalized);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "mustBeObject"; return "mustBeObject";
} }
@@ -52,7 +54,7 @@ export const tomlToMcpServer = (tomlText: string): McpServerSpec => {
throw new Error("TOML 内容不能为空"); throw new Error("TOML 内容不能为空");
} }
const parsed = parseToml(tomlText); const parsed = parseToml(normalizeTomlText(tomlText));
// 情况 1: 直接是服务器配置(包含 type/command/url 等字段) // 情况 1: 直接是服务器配置(包含 type/command/url 等字段)
if ( if (
@@ -185,7 +187,7 @@ function normalizeServerConfig(config: any): McpServerSpec {
*/ */
export const extractIdFromToml = (tomlText: string): string => { export const extractIdFromToml = (tomlText: string): string => {
try { try {
const parsed = parseToml(tomlText); const parsed = parseToml(normalizeTomlText(tomlText));
// 尝试从 [mcp.servers.<id>] 或 [mcp_servers.<id>] 中提取 ID // 尝试从 [mcp.servers.<id>] 或 [mcp_servers.<id>] 中提取 ID
if (parsed.mcp && typeof parsed.mcp === "object") { if (parsed.mcp && typeof parsed.mcp === "object") {