mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
fix(common-config): improve error handling, layer separation, and reliability across sync system
P0 Fixes:
- Enable toml `preserve_order` feature for reliable key ordering
- Extract pure detection functions to utils/commonConfigDetection.ts (fix layer inversion)
- Return structured result from preserveCodexConfigFormat to prevent data loss
P1 Fixes:
- Handle pure TOML strings in Codex config instead of silent skip
- Surface extractDiff errors in useCodexCommonConfig instead of swallowing
- Log parse errors in detectCommonConfigEnabledByContent for debugging
P2 Fixes:
- Use parsing-based assertions in Rust TOML ordering tests
- Add schema warning when Codex config field is wrong type
- Add depth/node limits to isSubset function (prevent stack overflow)
- Replace hardcoded "{}" check with adapter.hasValidContent()
P3 Fixes:
- Refactor Gemini errors to structured GeminiConfigErrorInfo format
- Add mapGeminiErrorToI18n for type-safe error-to-i18n mapping
This commit is contained in:
Generated
+1
@@ -5633,6 +5633,7 @@ version = "0.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d"
|
||||
dependencies = [
|
||||
"indexmap 2.11.4",
|
||||
"serde",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
|
||||
@@ -35,7 +35,7 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-store = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
dirs = "5.0"
|
||||
toml = "0.8"
|
||||
toml = { version = "0.8", features = ["preserve_order"] }
|
||||
toml_edit = "0.22"
|
||||
reqwest = { version = "0.12", features = ["rustls-tls", "json", "stream", "socks"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time", "sync"] }
|
||||
|
||||
@@ -837,17 +837,32 @@ shared_key = "shared"
|
||||
assert!(result.contains("custom-model")); // custom wins
|
||||
assert!(result.contains("shared_key")); // from common
|
||||
|
||||
// Verify ordering: custom keys appear before common-only keys
|
||||
let model_pos = result.find("model").unwrap();
|
||||
let custom_section_pos = result.find("custom_section").unwrap();
|
||||
let shared_key_pos = result.find("shared_key").unwrap();
|
||||
// With preserve_order feature enabled, verify key ordering via parsing
|
||||
// instead of relying on string position (which is fragile)
|
||||
let parsed: TomlValue = result.parse().expect("result should be valid TOML");
|
||||
let table = parsed.as_table().expect("result should be a table");
|
||||
let keys: Vec<&String> = table.keys().collect();
|
||||
|
||||
// Custom keys should appear before common-only keys
|
||||
let model_idx = keys.iter().position(|k| *k == "model");
|
||||
let custom_section_idx = keys.iter().position(|k| *k == "custom_section");
|
||||
let shared_key_idx = keys.iter().position(|k| *k == "shared_key");
|
||||
|
||||
assert!(model_idx.is_some(), "model key should exist in result");
|
||||
assert!(
|
||||
model_pos < shared_key_pos,
|
||||
"custom 'model' should appear before common-only 'shared_key'"
|
||||
custom_section_idx.is_some(),
|
||||
"custom_section key should exist in result"
|
||||
);
|
||||
assert!(
|
||||
custom_section_pos < shared_key_pos || model_pos < shared_key_pos,
|
||||
"custom keys should appear before common-only keys"
|
||||
shared_key_idx.is_some(),
|
||||
"shared_key key should exist in result"
|
||||
);
|
||||
|
||||
// With preserve_order, custom keys (model, custom_section) should come before common-only keys (shared_key)
|
||||
assert!(
|
||||
model_idx.unwrap() < shared_key_idx.unwrap(),
|
||||
"custom 'model' should appear before common-only 'shared_key' (got model_idx={:?}, shared_key_idx={:?})",
|
||||
model_idx, shared_key_idx
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,25 +120,48 @@ export function useCodexCommonConfig({
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新 snippet 状态(通过 base 的 handler)
|
||||
base.handleCommonConfigSnippetChange(extracted);
|
||||
|
||||
// 从 config 中移除与 extracted 相同的部分
|
||||
const customToml = codexAdapter.parseInput(codexConfig);
|
||||
const diffResult = extractTomlDifference(customToml, extracted);
|
||||
|
||||
if (!diffResult.error) {
|
||||
// 使用共享的格式保留函数写回
|
||||
onConfigChange(
|
||||
preserveCodexConfigFormat(codexConfig, diffResult.customToml),
|
||||
);
|
||||
|
||||
toast.success(
|
||||
t("codexConfig.extractSuccessNeedSave", {
|
||||
defaultValue: "已提取通用配置,点击保存按钮完成保存",
|
||||
if (diffResult.error) {
|
||||
// 差异提取失败,显示错误而不是静默吞掉
|
||||
setLocalExtractError(
|
||||
t("codexConfig.extractDiffFailed", {
|
||||
error: diffResult.error,
|
||||
defaultValue: `差异提取失败: ${diffResult.error}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新 snippet 状态(在差异提取成功后再更新,避免状态不一致)
|
||||
base.handleCommonConfigSnippetChange(extracted);
|
||||
|
||||
// 使用共享的格式保留函数写回
|
||||
const preserveResult = preserveCodexConfigFormat(
|
||||
codexConfig,
|
||||
diffResult.customToml,
|
||||
);
|
||||
|
||||
if (preserveResult.error) {
|
||||
// 格式保留失败,显示错误
|
||||
setLocalExtractError(
|
||||
t("codexConfig.preserveFormatFailed", {
|
||||
error: preserveResult.error,
|
||||
defaultValue: `配置格式保留失败: ${preserveResult.error}`,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
onConfigChange(preserveResult.config);
|
||||
|
||||
toast.success(
|
||||
t("codexConfig.extractSuccessNeedSave", {
|
||||
defaultValue: "已提取通用配置,点击保存按钮完成保存",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("提取 Codex 通用配置失败:", error);
|
||||
setLocalExtractError(
|
||||
|
||||
@@ -25,6 +25,10 @@ import type {
|
||||
ExtractResult,
|
||||
} from "./useCommonConfigBase";
|
||||
|
||||
// Re-export from utils for backward compatibility
|
||||
export { hasContentByAppType } from "@/utils/commonConfigDetection";
|
||||
import { hasGeminiContent } from "@/utils/commonConfigDetection";
|
||||
|
||||
// ============================================================================
|
||||
// Claude Adapter (JSON)
|
||||
// ============================================================================
|
||||
@@ -163,20 +167,30 @@ function validateTomlFormat(tomlText: string): string | null {
|
||||
* 支持两种格式:
|
||||
* 1. 直接的 TOML 字符串
|
||||
* 2. JSON 字符串 { auth: {...}, config: "TOML" }
|
||||
*
|
||||
* 注意:如果 config 字段存在但不是 string,这表示 schema 异常
|
||||
* 此时返回空字符串并打印警告,避免静默处理错误数据
|
||||
*/
|
||||
function extractConfigToml(configInput: string): string {
|
||||
if (!configInput || !configInput.trim()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 尝试解析为 JSON(旧格式)
|
||||
// 尝试解析为 JSON(wrapper 格式)
|
||||
try {
|
||||
const parsed = JSON.parse(configInput);
|
||||
if (typeof parsed?.config === "string") {
|
||||
return parsed.config;
|
||||
}
|
||||
// 如果是 JSON 对象但没有 config 字段,返回空
|
||||
// 如果是 JSON 对象
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
// config 字段存在但不是 string,这是 schema 异常
|
||||
if ("config" in parsed && typeof parsed.config !== "string") {
|
||||
console.warn(
|
||||
`[extractConfigToml] config field is ${typeof parsed.config}, expected string`,
|
||||
);
|
||||
}
|
||||
// JSON 对象没有有效的 config 字段,返回空(无 TOML 可提取)
|
||||
return "";
|
||||
}
|
||||
} catch {
|
||||
@@ -192,13 +206,15 @@ function extractConfigToml(configInput: string): string {
|
||||
*/
|
||||
function detectJsonWrapperFormat(
|
||||
codexConfig: string,
|
||||
): { auth?: unknown; config?: string } | null {
|
||||
): { auth?: unknown; config?: unknown; [key: string]: unknown } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(codexConfig);
|
||||
if (typeof parsed?.config === "string") {
|
||||
return parsed;
|
||||
}
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
// 只有当解析结果是对象时才认为是 JSON wrapper
|
||||
if (
|
||||
typeof parsed === "object" &&
|
||||
parsed !== null &&
|
||||
!Array.isArray(parsed)
|
||||
) {
|
||||
return parsed;
|
||||
}
|
||||
} catch {
|
||||
@@ -207,6 +223,16 @@ function detectJsonWrapperFormat(
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Codex 配置格式保留结果
|
||||
*/
|
||||
export interface PreserveCodexConfigResult {
|
||||
/** 格式化后的配置字符串 */
|
||||
config: string;
|
||||
/** 错误信息(如果有) */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 保留原始格式写回 Codex 配置
|
||||
*
|
||||
@@ -215,20 +241,39 @@ function detectJsonWrapperFormat(
|
||||
*
|
||||
* @param originalConfig - 原始配置字符串
|
||||
* @param updatedToml - 更新后的 TOML 内容
|
||||
* @returns 格式化后的配置字符串
|
||||
* @returns 格式化后的配置字符串和可能的错误
|
||||
*/
|
||||
export function preserveCodexConfigFormat(
|
||||
originalConfig: string,
|
||||
updatedToml: string,
|
||||
): string {
|
||||
): PreserveCodexConfigResult {
|
||||
const jsonWrapper = detectJsonWrapperFormat(originalConfig);
|
||||
if (jsonWrapper && typeof jsonWrapper.config === "string") {
|
||||
// JSON wrapper 格式,更新 config 字段
|
||||
jsonWrapper.config = updatedToml;
|
||||
return JSON.stringify(jsonWrapper, null, 2);
|
||||
|
||||
if (jsonWrapper) {
|
||||
// 是 JSON wrapper 格式
|
||||
if ("config" in jsonWrapper) {
|
||||
// 存在 config 字段
|
||||
if (typeof jsonWrapper.config !== "string") {
|
||||
// config 字段不是 string,这是意外的 schema
|
||||
// 返回原配置并报错,避免数据丢失
|
||||
return {
|
||||
config: originalConfig,
|
||||
error: `Codex config field is ${typeof jsonWrapper.config}, expected string. Cannot update safely.`,
|
||||
};
|
||||
}
|
||||
// 正常更新 config 字段
|
||||
jsonWrapper.config = updatedToml;
|
||||
return { config: JSON.stringify(jsonWrapper, null, 2) };
|
||||
} else {
|
||||
// 没有 config 字段,但是是 JSON 对象(可能包含 auth 等其他字段)
|
||||
// 添加 config 字段
|
||||
jsonWrapper.config = updatedToml;
|
||||
return { config: JSON.stringify(jsonWrapper, null, 2) };
|
||||
}
|
||||
}
|
||||
|
||||
// 纯 TOML 格式
|
||||
return updatedToml;
|
||||
return { config: updatedToml };
|
||||
}
|
||||
|
||||
export const codexAdapter: CommonConfigAdapter<string, string> = {
|
||||
@@ -353,7 +398,7 @@ export function createGeminiAdapter(
|
||||
},
|
||||
|
||||
hasContent: (configStr: string, snippetStr: string): boolean => {
|
||||
return geminiHasContentStatic(configStr, snippetStr);
|
||||
return hasGeminiContent(configStr, snippetStr).hasContent;
|
||||
},
|
||||
|
||||
getApplyError: (snippet: string, t): string => {
|
||||
@@ -361,16 +406,24 @@ export function createGeminiAdapter(
|
||||
strictForbiddenKeys: true,
|
||||
});
|
||||
|
||||
if (result.errorInfo) {
|
||||
// Use structured error info for type-safe handling
|
||||
switch (result.errorInfo.code) {
|
||||
case GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS:
|
||||
return t("geminiConfig.commonConfigInvalidKeys", {
|
||||
keys: result.errorInfo.keys?.join(", ") ?? "",
|
||||
});
|
||||
case GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING:
|
||||
return t("geminiConfig.commonConfigInvalidValues");
|
||||
default:
|
||||
return t("geminiConfig.invalidEnvFormat", {
|
||||
defaultValue: "配置格式错误",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for legacy error string (backward compatibility)
|
||||
if (result.error) {
|
||||
if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
|
||||
const keys = result.error.split(": ")[1] ?? result.error;
|
||||
return t("geminiConfig.commonConfigInvalidKeys", { keys });
|
||||
}
|
||||
if (
|
||||
result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)
|
||||
) {
|
||||
return t("geminiConfig.commonConfigInvalidValues");
|
||||
}
|
||||
return t("geminiConfig.invalidEnvFormat", {
|
||||
defaultValue: "配置格式错误",
|
||||
});
|
||||
@@ -449,71 +502,3 @@ export function createGeminiAdapter(
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 静态 hasContent 检查(用于 config.ts 同步检测)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 检查配置是否包含通用配置片段(按 appType 分发)
|
||||
*
|
||||
* 用于 config.ts 中的 detectCommonConfigEnabledByContent,
|
||||
* 替代原有的三个独立函数(hasCommonConfigSnippet, hasTomlCommonConfigSnippet, hasGeminiCommonConfigSnippet)
|
||||
*
|
||||
* @param appType - 应用类型
|
||||
* @param configStr - 供应商的 settingsConfig 字符串
|
||||
* @param snippetStr - 通用配置片段字符串
|
||||
* @returns 是否包含片段内容
|
||||
*/
|
||||
export function hasContentByAppType(
|
||||
appType: "claude" | "codex" | "gemini",
|
||||
configStr: string,
|
||||
snippetStr: string,
|
||||
): boolean {
|
||||
switch (appType) {
|
||||
case "claude":
|
||||
return claudeAdapter.hasContent(configStr, snippetStr);
|
||||
case "codex":
|
||||
return codexAdapter.hasContent(configStr, snippetStr);
|
||||
case "gemini":
|
||||
// Gemini 使用静态实现(不需要 envStringToObj/envObjToString)
|
||||
return geminiHasContentStatic(configStr, snippetStr);
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gemini hasContent 的静态实现(不依赖 adapter options)
|
||||
*/
|
||||
function geminiHasContentStatic(
|
||||
configStr: string,
|
||||
snippetStr: string,
|
||||
): boolean {
|
||||
try {
|
||||
if (!snippetStr.trim()) return false;
|
||||
|
||||
const config = configStr ? JSON.parse(configStr) : {};
|
||||
if (!isPlainObject(config)) return false;
|
||||
const envValue = (config as Record<string, unknown>).env;
|
||||
if (envValue !== undefined && !isPlainObject(envValue)) return false;
|
||||
const env = (isPlainObject(envValue) ? envValue : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const parseResult = parseGeminiCommonConfigSnippet(snippetStr, {
|
||||
strictForbiddenKeys: false,
|
||||
});
|
||||
if (parseResult.error || Object.keys(parseResult.env).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.entries(parseResult.env).every(([key, value]) => {
|
||||
const current = env[key];
|
||||
return typeof current === "string" && current === value.trim();
|
||||
});
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,7 +407,11 @@ export function useCommonConfigBase<TConfig, TFinal>({
|
||||
request,
|
||||
);
|
||||
|
||||
if (!extracted || extracted === "{}" || !extracted.trim()) {
|
||||
if (
|
||||
!extracted ||
|
||||
!extracted.trim() ||
|
||||
!adapter.hasValidContent(extracted)
|
||||
) {
|
||||
setCommonConfigError(
|
||||
t(`${adapter.appKey}Config.extractNoCommonConfig`, {
|
||||
defaultValue: "无法提取通用配置",
|
||||
|
||||
+27
-11
@@ -2,7 +2,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type { Provider } from "@/types";
|
||||
import { providersApi } from "./providers";
|
||||
import { hasContentByAppType } from "@/hooks/commonConfigAdapters";
|
||||
import { detectContent } from "@/utils/commonConfigDetection";
|
||||
|
||||
export type AppType = "claude" | "codex" | "gemini";
|
||||
|
||||
@@ -354,10 +354,21 @@ function detectCommonConfigEnabledByContent(
|
||||
);
|
||||
if (candidates.length === 0) return false;
|
||||
|
||||
// 使用统一的 adapter-based hasContent 检查
|
||||
return candidates.some((snippet) =>
|
||||
hasContentByAppType(appType, settingsConfigStr, snippet),
|
||||
);
|
||||
// 使用 detectContent 获取完整结果(包含解析错误信息)
|
||||
for (const snippet of candidates) {
|
||||
const result = detectContent(appType, settingsConfigStr, snippet);
|
||||
if (result.hasContent) {
|
||||
return true;
|
||||
}
|
||||
// 如果解析失败,记录错误(便于排障)
|
||||
if (result.parseError) {
|
||||
console.warn(
|
||||
`[detectCommonConfigEnabledByContent] Parse error for ${appType}: ${result.parseError}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/** 更新供应商配置的结果 */
|
||||
@@ -373,7 +384,8 @@ function getSettingsConfigString(
|
||||
provider: Provider,
|
||||
appType: AppType,
|
||||
): string | null {
|
||||
const config = provider.settingsConfig;
|
||||
// Runtime type may be string (JSON-serialized) despite Record<string, any> declaration
|
||||
const config = provider.settingsConfig as Record<string, unknown> | string;
|
||||
if (!config) return null;
|
||||
|
||||
switch (appType) {
|
||||
@@ -385,8 +397,7 @@ function getSettingsConfigString(
|
||||
// Codex: settingsConfig.config 是 TOML 字符串
|
||||
// 先校验 settingsConfig 是对象类型
|
||||
if (typeof config === "string") {
|
||||
// settingsConfig 是字符串(可能是之前保存失败的情况)
|
||||
// 尝试解析为 JSON
|
||||
// settingsConfig 是字符串,尝试解析为 JSON
|
||||
try {
|
||||
const parsed = JSON.parse(config);
|
||||
if (
|
||||
@@ -396,10 +407,15 @@ function getSettingsConfigString(
|
||||
) {
|
||||
return parsed.config;
|
||||
}
|
||||
// JSON 对象但没有 string config 字段
|
||||
// 可能是历史数据或异常保存的情况
|
||||
return null;
|
||||
} catch {
|
||||
console.warn(
|
||||
`[getSettingsConfigString] Codex provider settingsConfig 是无效字符串,跳过`,
|
||||
);
|
||||
// JSON 解析失败,可能是纯 TOML 字符串
|
||||
// 返回原字符串当 TOML 处理,而不是静默跳过
|
||||
if (config.trim()) {
|
||||
return config;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
/**
|
||||
* 通用配置内容检测工具
|
||||
*
|
||||
* 这些是纯函数,不依赖 React/hooks,可以安全地被 lib/api 层使用。
|
||||
* 解决层级反转问题:lib/api 不应依赖 hooks 层。
|
||||
*/
|
||||
|
||||
import { isPlainObject, isSubset } from "@/utils/configMerge";
|
||||
import { safeParseToml } from "@/utils/tomlConfigMerge";
|
||||
import {
|
||||
parseGeminiCommonConfigSnippet,
|
||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
export type CommonConfigAppType = "claude" | "codex" | "gemini";
|
||||
|
||||
/**
|
||||
* 内容检测结果
|
||||
*/
|
||||
export interface ContentDetectionResult {
|
||||
/** 是否包含通用配置内容 */
|
||||
hasContent: boolean;
|
||||
/** 检测失败原因(如果有) */
|
||||
parseError?: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Claude 内容检测
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 检查 Claude 配置是否包含通用配置片段
|
||||
*/
|
||||
export function hasClaudeContent(
|
||||
configStr: string,
|
||||
snippetStr: string,
|
||||
): ContentDetectionResult {
|
||||
try {
|
||||
if (!snippetStr.trim()) {
|
||||
return { hasContent: false };
|
||||
}
|
||||
const config = configStr ? JSON.parse(configStr) : {};
|
||||
const snippet = JSON.parse(snippetStr);
|
||||
if (!isPlainObject(snippet)) {
|
||||
return { hasContent: false, parseError: "snippet is not a plain object" };
|
||||
}
|
||||
return { hasContent: isSubset(config, snippet) };
|
||||
} catch (e) {
|
||||
return {
|
||||
hasContent: false,
|
||||
parseError: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Codex 内容检测
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 从 Codex 配置中提取 TOML 配置字符串
|
||||
* 支持两种格式:纯 TOML 或 JSON wrapper { config: "TOML" }
|
||||
*/
|
||||
export function extractCodexConfigToml(configInput: string): {
|
||||
toml: string;
|
||||
parseError?: string;
|
||||
} {
|
||||
if (!configInput || !configInput.trim()) {
|
||||
return { toml: "" };
|
||||
}
|
||||
|
||||
// 尝试解析为 JSON(wrapper 格式)
|
||||
try {
|
||||
const parsed = JSON.parse(configInput);
|
||||
if (typeof parsed?.config === "string") {
|
||||
return { toml: parsed.config };
|
||||
}
|
||||
// 是 JSON 对象但没有 string config 字段
|
||||
// 这可能是未知 schema,返回原字符串当 TOML 处理
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
// 如果有 config 但不是 string,返回错误
|
||||
if ("config" in parsed && typeof parsed.config !== "string") {
|
||||
return {
|
||||
toml: "",
|
||||
parseError: `config field exists but is ${typeof parsed.config}, expected string`,
|
||||
};
|
||||
}
|
||||
// 没有 config 字段的 JSON 对象,可能是纯 JSON 配置
|
||||
// 返回空,因为无法当 TOML 处理
|
||||
return { toml: "" };
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,说明是直接的 TOML 字符串
|
||||
}
|
||||
|
||||
return { toml: configInput };
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查 Codex 配置是否包含通用配置片段
|
||||
*/
|
||||
export function hasCodexContent(
|
||||
configStr: string,
|
||||
snippetStr: string,
|
||||
): ContentDetectionResult {
|
||||
if (!snippetStr.trim()) {
|
||||
return { hasContent: false };
|
||||
}
|
||||
|
||||
// 解析配置(可能是纯 TOML 或 JSON wrapper 格式)
|
||||
const { toml: configToml, parseError: configError } =
|
||||
extractCodexConfigToml(configStr);
|
||||
if (configError) {
|
||||
return { hasContent: false, parseError: configError };
|
||||
}
|
||||
|
||||
const configParsed = safeParseToml(configToml);
|
||||
const snippetParsed = safeParseToml(snippetStr);
|
||||
|
||||
if (configParsed.error || !configParsed.config) {
|
||||
return {
|
||||
hasContent: false,
|
||||
parseError: configParsed.error || "failed to parse config TOML",
|
||||
};
|
||||
}
|
||||
if (snippetParsed.error || !snippetParsed.config) {
|
||||
return {
|
||||
hasContent: false,
|
||||
parseError: snippetParsed.error || "failed to parse snippet TOML",
|
||||
};
|
||||
}
|
||||
|
||||
// 使用 isSubset 检查 snippet 是否是 config 的子集
|
||||
return { hasContent: isSubset(configParsed.config, snippetParsed.config) };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Gemini 内容检测
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 检查 Gemini 配置是否包含通用配置片段
|
||||
*/
|
||||
export function hasGeminiContent(
|
||||
configStr: string,
|
||||
snippetStr: string,
|
||||
): ContentDetectionResult {
|
||||
try {
|
||||
if (!snippetStr.trim()) {
|
||||
return { hasContent: false };
|
||||
}
|
||||
|
||||
const config = configStr ? JSON.parse(configStr) : {};
|
||||
if (!isPlainObject(config)) {
|
||||
return { hasContent: false, parseError: "config is not a plain object" };
|
||||
}
|
||||
const envValue = (config as Record<string, unknown>).env;
|
||||
if (envValue !== undefined && !isPlainObject(envValue)) {
|
||||
return {
|
||||
hasContent: false,
|
||||
parseError: "env field is not a plain object",
|
||||
};
|
||||
}
|
||||
const env = (isPlainObject(envValue) ? envValue : {}) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
const parseResult = parseGeminiCommonConfigSnippet(snippetStr, {
|
||||
strictForbiddenKeys: false,
|
||||
});
|
||||
if (parseResult.error) {
|
||||
return { hasContent: false, parseError: parseResult.error };
|
||||
}
|
||||
if (Object.keys(parseResult.env).length === 0) {
|
||||
return { hasContent: false };
|
||||
}
|
||||
|
||||
const hasContent = Object.entries(parseResult.env).every(([key, value]) => {
|
||||
// 跳过禁用的键
|
||||
if (GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as any)) {
|
||||
return true;
|
||||
}
|
||||
const current = env[key];
|
||||
return typeof current === "string" && current === value.trim();
|
||||
});
|
||||
|
||||
return { hasContent };
|
||||
} catch (e) {
|
||||
return {
|
||||
hasContent: false,
|
||||
parseError: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 统一入口
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 检查配置是否包含通用配置片段(按 appType 分发)
|
||||
*
|
||||
* 这是给 lib/api/config.ts 使用的入口函数。
|
||||
* 返回结构化结果,包含检测结果和可能的错误信息。
|
||||
*
|
||||
* @param appType - 应用类型
|
||||
* @param configStr - 供应商的 settingsConfig 字符串
|
||||
* @param snippetStr - 通用配置片段字符串
|
||||
* @returns 检测结果
|
||||
*/
|
||||
export function detectContent(
|
||||
appType: CommonConfigAppType,
|
||||
configStr: string,
|
||||
snippetStr: string,
|
||||
): ContentDetectionResult {
|
||||
switch (appType) {
|
||||
case "claude":
|
||||
return hasClaudeContent(configStr, snippetStr);
|
||||
case "codex":
|
||||
return hasCodexContent(configStr, snippetStr);
|
||||
case "gemini":
|
||||
return hasGeminiContent(configStr, snippetStr);
|
||||
default:
|
||||
return { hasContent: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化版:仅返回 boolean(向后兼容)
|
||||
*
|
||||
* 注意:此函数会吞掉解析错误,仅用于向后兼容。
|
||||
* 新代码应使用 detectContent 获取完整结果。
|
||||
*/
|
||||
export function hasContentByAppType(
|
||||
appType: CommonConfigAppType,
|
||||
configStr: string,
|
||||
snippetStr: string,
|
||||
): boolean {
|
||||
return detectContent(appType, configStr, snippetStr).hasContent;
|
||||
}
|
||||
@@ -65,18 +65,46 @@ export const deepEqual = (a: unknown, b: unknown): boolean => {
|
||||
/**
|
||||
* 检查 source 是否是 target 的子集
|
||||
* 即 source 中的所有键值对都存在于 target 中
|
||||
*
|
||||
* 注意:此函数有深度和节点数限制,避免大对象导致的性能问题或栈溢出
|
||||
*/
|
||||
export const isSubset = (target: unknown, source: unknown): boolean => {
|
||||
const IS_SUBSET_MAX_DEPTH = 20;
|
||||
const IS_SUBSET_MAX_NODES = 10000;
|
||||
|
||||
export const isSubset = (
|
||||
target: unknown,
|
||||
source: unknown,
|
||||
_depth: number = 0,
|
||||
_nodeCounter: { count: number } = { count: 0 },
|
||||
): boolean => {
|
||||
// 超过最大深度或节点数限制,返回 false(保守判断)
|
||||
if (_depth > IS_SUBSET_MAX_DEPTH) {
|
||||
console.warn(
|
||||
`[isSubset] Max depth (${IS_SUBSET_MAX_DEPTH}) exceeded, returning false`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
_nodeCounter.count++;
|
||||
if (_nodeCounter.count > IS_SUBSET_MAX_NODES) {
|
||||
console.warn(
|
||||
`[isSubset] Max nodes (${IS_SUBSET_MAX_NODES}) exceeded, returning false`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isPlainObject(source)) {
|
||||
if (!isPlainObject(target)) return false;
|
||||
return Object.entries(source).every(([key, value]) =>
|
||||
isSubset(target[key], value),
|
||||
isSubset(target[key], value, _depth + 1, _nodeCounter),
|
||||
);
|
||||
}
|
||||
|
||||
if (Array.isArray(source)) {
|
||||
if (!Array.isArray(target) || target.length !== source.length) return false;
|
||||
return source.every((item, index) => isSubset(target[index], item));
|
||||
return source.every((item, index) =>
|
||||
isSubset(target[index], item, _depth + 1, _nodeCounter),
|
||||
);
|
||||
}
|
||||
|
||||
return target === source;
|
||||
|
||||
@@ -327,22 +327,49 @@ export const setCodexModelName = (
|
||||
* These codes are used for consistent error handling and i18n mapping.
|
||||
*/
|
||||
export const GEMINI_CONFIG_ERROR_CODES = {
|
||||
NOT_OBJECT: "GEMINI_CONFIG_NOT_OBJECT",
|
||||
ENV_NOT_OBJECT: "GEMINI_CONFIG_ENV_NOT_OBJECT",
|
||||
VALUE_NOT_STRING: "GEMINI_CONFIG_VALUE_NOT_STRING",
|
||||
FORBIDDEN_KEYS: "GEMINI_CONFIG_FORBIDDEN_KEYS",
|
||||
NOT_OBJECT: "NOT_OBJECT",
|
||||
ENV_NOT_OBJECT: "ENV_NOT_OBJECT",
|
||||
VALUE_NOT_STRING: "VALUE_NOT_STRING",
|
||||
FORBIDDEN_KEYS: "FORBIDDEN_KEYS",
|
||||
} as const;
|
||||
|
||||
export type GeminiConfigErrorCode =
|
||||
(typeof GEMINI_CONFIG_ERROR_CODES)[keyof typeof GEMINI_CONFIG_ERROR_CODES];
|
||||
|
||||
/**
|
||||
* Structured error info for Gemini common config parsing.
|
||||
* Replaces fragile string prefix matching with type-safe error handling.
|
||||
*/
|
||||
export interface GeminiConfigErrorInfo {
|
||||
/** Error code for programmatic handling */
|
||||
code: GeminiConfigErrorCode;
|
||||
/** Human-readable error message */
|
||||
message: string;
|
||||
/** For FORBIDDEN_KEYS: the list of forbidden keys found */
|
||||
keys?: string[];
|
||||
/** For VALUE_NOT_STRING: the key with invalid value */
|
||||
key?: string;
|
||||
/** For VALUE_NOT_STRING: the actual type of the value */
|
||||
valueType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of parsing Gemini common config snippet
|
||||
*/
|
||||
export interface GeminiCommonConfigParseResult {
|
||||
/** Parsed env key-value pairs (empty if invalid) */
|
||||
env: Record<string, string>;
|
||||
/** Error message if parsing/validation failed (starts with error code) */
|
||||
/**
|
||||
* Error message if parsing/validation failed.
|
||||
* @deprecated Use errorInfo for type-safe error handling
|
||||
*/
|
||||
error?: string;
|
||||
/** Structured error info for type-safe handling */
|
||||
errorInfo?: GeminiConfigErrorInfo;
|
||||
/** Warning message (non-fatal, config still usable) */
|
||||
warning?: string;
|
||||
/** Structured warning info */
|
||||
warningInfo?: GeminiConfigErrorInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,9 +412,14 @@ export function parseGeminiCommonConfigSnippet(
|
||||
|
||||
// Must be a plain object (not array, null, etc.)
|
||||
if (!isPlainObject(parsed)) {
|
||||
const errorInfo: GeminiConfigErrorInfo = {
|
||||
code: GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT,
|
||||
message: "must be a JSON object, not array or primitive",
|
||||
};
|
||||
return {
|
||||
env: {},
|
||||
error: `${GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT}: must be a JSON object, not array or primitive`,
|
||||
error: `${errorInfo.code}: ${errorInfo.message}`,
|
||||
errorInfo,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -397,9 +429,14 @@ export function parseGeminiCommonConfigSnippet(
|
||||
if ("env" in parsed) {
|
||||
const envField = parsed.env;
|
||||
if (!isPlainObject(envField)) {
|
||||
const errorInfo: GeminiConfigErrorInfo = {
|
||||
code: GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT,
|
||||
message: "'env' field must be a plain object",
|
||||
};
|
||||
return {
|
||||
env: {},
|
||||
error: `${GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT}: 'env' field must be a plain object`,
|
||||
error: `${errorInfo.code}: ${errorInfo.message}`,
|
||||
errorInfo,
|
||||
};
|
||||
}
|
||||
rawEnv = envField as Record<string, unknown>;
|
||||
@@ -429,7 +466,6 @@ export function parseGeminiCommonConfigSnippet(
|
||||
|
||||
// Validate and filter entries
|
||||
const env: Record<string, string> = {};
|
||||
const warnings: string[] = [];
|
||||
const forbiddenKeysFound: string[] = [];
|
||||
|
||||
for (const [key, value] of Object.entries(rawEnv)) {
|
||||
@@ -444,9 +480,16 @@ export function parseGeminiCommonConfigSnippet(
|
||||
// Must be string
|
||||
if (typeof value !== "string") {
|
||||
if (isJson) {
|
||||
const errorInfo: GeminiConfigErrorInfo = {
|
||||
code: GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING,
|
||||
message: `value for '${key}' must be a string, got ${typeof value}`,
|
||||
key,
|
||||
valueType: typeof value,
|
||||
};
|
||||
return {
|
||||
env: {},
|
||||
error: `${GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING}: value for '${key}' must be a string, got ${typeof value}`,
|
||||
error: `${errorInfo.code}: ${errorInfo.message}`,
|
||||
errorInfo,
|
||||
};
|
||||
}
|
||||
// For ENV format, skip non-strings silently (shouldn't happen)
|
||||
@@ -464,21 +507,92 @@ export function parseGeminiCommonConfigSnippet(
|
||||
|
||||
// Handle forbidden keys
|
||||
if (forbiddenKeysFound.length > 0) {
|
||||
const msg = `${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: ${forbiddenKeysFound.join(", ")}`;
|
||||
const errorInfo: GeminiConfigErrorInfo = {
|
||||
code: GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS,
|
||||
message: forbiddenKeysFound.join(", "),
|
||||
keys: forbiddenKeysFound,
|
||||
};
|
||||
const msg = `${errorInfo.code}: ${errorInfo.message}`;
|
||||
if (strictForbiddenKeys) {
|
||||
return { env: {}, error: msg };
|
||||
return { env: {}, error: msg, errorInfo };
|
||||
}
|
||||
warnings.push(msg);
|
||||
// Return as warning instead of error
|
||||
return {
|
||||
env,
|
||||
warning: msg,
|
||||
warningInfo: errorInfo,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
env,
|
||||
warning: warnings.length > 0 ? warnings.join("; ") : undefined,
|
||||
};
|
||||
return { env };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Gemini common config error/warning info to i18n-friendly message.
|
||||
* Prefers structured errorInfo, falls back to string parsing for backward compatibility.
|
||||
*
|
||||
* @param info - Structured error info or raw warning string
|
||||
* @param t - The i18n translation function
|
||||
* @returns Translated message
|
||||
*/
|
||||
export function mapGeminiErrorToI18n(
|
||||
info: GeminiConfigErrorInfo | string,
|
||||
t: (
|
||||
key: string,
|
||||
options?: { keys?: string; key?: string; defaultValue?: string },
|
||||
) => string,
|
||||
): string {
|
||||
// Handle structured error info (preferred)
|
||||
if (typeof info === "object") {
|
||||
switch (info.code) {
|
||||
case GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS:
|
||||
return t("geminiConfig.forbiddenKeysWarning", {
|
||||
keys: info.keys?.join(", ") ?? info.message,
|
||||
});
|
||||
case GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING:
|
||||
return t("geminiConfig.commonConfigInvalidValues", {
|
||||
key: info.key,
|
||||
defaultValue: info.message,
|
||||
});
|
||||
case GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT:
|
||||
case GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT:
|
||||
return t("geminiConfig.invalidEnvFormat", {
|
||||
defaultValue: info.message,
|
||||
});
|
||||
default:
|
||||
return info.message;
|
||||
}
|
||||
}
|
||||
|
||||
// Backward compatibility: parse string format
|
||||
if (info.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
|
||||
const keys = info.replace(
|
||||
`${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: `,
|
||||
"",
|
||||
);
|
||||
return t("geminiConfig.forbiddenKeysWarning", { keys });
|
||||
}
|
||||
if (info.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)) {
|
||||
return t("geminiConfig.commonConfigInvalidValues", {
|
||||
defaultValue: "Invalid value type",
|
||||
});
|
||||
}
|
||||
if (
|
||||
info.startsWith(GEMINI_CONFIG_ERROR_CODES.NOT_OBJECT) ||
|
||||
info.startsWith(GEMINI_CONFIG_ERROR_CODES.ENV_NOT_OBJECT)
|
||||
) {
|
||||
return t("geminiConfig.invalidEnvFormat", {
|
||||
defaultValue: "Invalid format",
|
||||
});
|
||||
}
|
||||
|
||||
// Unknown format, return as-is
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map Gemini common config warning to i18n-friendly message.
|
||||
* @deprecated Use mapGeminiErrorToI18n with structured errorInfo/warningInfo instead
|
||||
*
|
||||
* @param warning - The raw warning string from parseGeminiCommonConfigSnippet
|
||||
* @param t - The i18n translation function
|
||||
@@ -491,14 +605,5 @@ export function mapGeminiWarningToI18n(
|
||||
options?: { keys?: string; defaultValue?: string },
|
||||
) => string,
|
||||
): string {
|
||||
if (warning.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
|
||||
// Extract key list: "GEMINI_CONFIG_FORBIDDEN_KEYS: KEY1, KEY2" -> "KEY1, KEY2"
|
||||
const keys = warning.replace(
|
||||
`${GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS}: `,
|
||||
"",
|
||||
);
|
||||
return t("geminiConfig.forbiddenKeysWarning", { keys });
|
||||
}
|
||||
// Other warnings: return as-is
|
||||
return warning;
|
||||
return mapGeminiErrorToI18n(warning, t);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user