From d66f196378e820af9f34c175146c3fa5f837768d Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Sat, 31 Jan 2026 15:23:49 +0800 Subject: [PATCH] refactor(common-config): consolidate hasContent methods into adapters - Add hasContent method to CommonConfigAdapter interface - Move isSubset utility to configMerge.ts for reuse - Export preserveCodexConfigFormat from adapters for hook use - Add hasContentByAppType dispatcher for unified content detection - Remove dead code from providerConfigUtils (hasCommonConfigSnippet, hasTomlCommonConfigSnippet, hasGeminiCommonConfigSnippet) --- .../forms/hooks/useCodexCommonConfig.ts | 54 ++---- src/hooks/commonConfigAdapters.ts | 163 ++++++++++++++++-- src/hooks/useCommonConfigBase.ts | 9 + src/lib/api/config.ts | 26 +-- src/utils/configMerge.ts | 20 +++ src/utils/providerConfigUtils.ts | 97 ----------- 6 files changed, 196 insertions(+), 173 deletions(-) diff --git a/src/components/providers/forms/hooks/useCodexCommonConfig.ts b/src/components/providers/forms/hooks/useCodexCommonConfig.ts index 6380165fa..0e7c0d763 100644 --- a/src/components/providers/forms/hooks/useCodexCommonConfig.ts +++ b/src/components/providers/forms/hooks/useCodexCommonConfig.ts @@ -1,4 +1,4 @@ -import { useState, useMemo, useCallback } from "react"; +import { useState, useCallback } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { configApi } from "@/lib/api"; @@ -6,7 +6,10 @@ import { useCommonConfigBase, type UseCommonConfigBaseReturn, } from "@/hooks/useCommonConfigBase"; -import { codexAdapter } from "@/hooks/commonConfigAdapters"; +import { + codexAdapter, + preserveCodexConfigFormat, +} from "@/hooks/commonConfigAdapters"; import { extractTomlDifference } from "@/utils/tomlConfigMerge"; import type { ProviderMeta } from "@/types"; @@ -63,27 +66,6 @@ export interface UseCodexCommonConfigReturn { markCommonConfigSaved: () => void; } -/** - * 检测 codexConfig 是否是 JSON wrapper 格式 - * @returns 如果是 JSON wrapper 返回解析后的对象,否则返回 null - */ -function detectJsonWrapperFormat( - codexConfig: string, -): { auth?: unknown; config?: string } | null { - try { - const parsed = JSON.parse(codexConfig); - if (typeof parsed?.config === "string") { - return parsed; - } - if (typeof parsed === "object" && parsed !== null) { - return parsed; // JSON 对象但没有 config 字段 - } - } catch { - // 不是 JSON - } - return null; -} - /** * 管理 Codex 通用配置片段 * @@ -97,14 +79,13 @@ export function useCodexCommonConfig({ selectedPresetId, }: UseCodexCommonConfigProps): UseCodexCommonConfigReturn { const { t } = useTranslation(); - const adapter = useMemo(() => codexAdapter, []); // 额外的 isExtracting 状态(base hook 的 handleExtract 不适用于 Codex) const [localIsExtracting, setLocalIsExtracting] = useState(false); const [localExtractError, setLocalExtractError] = useState(""); const base: UseCommonConfigBaseReturn = useCommonConfigBase({ - adapter, + adapter: codexAdapter, inputValue: codexConfig, onInputChange: onConfigChange, initialData, @@ -117,7 +98,7 @@ export function useCodexCommonConfig({ setLocalExtractError(""); try { - const request = adapter.buildExtractRequest(base.finalValue); + const request = codexAdapter.buildExtractRequest(base.finalValue); const extracted = await configApi.extractCommonConfigSnippet( "codex", request, @@ -129,7 +110,7 @@ export function useCodexCommonConfig({ } // 验证 TOML 格式 - const parseResult = adapter.parseSnippet(extracted); + const parseResult = codexAdapter.parseSnippet(extracted); if (parseResult.error || parseResult.config === null) { setLocalExtractError( t("codexConfig.extractedTomlInvalid", { @@ -143,21 +124,14 @@ export function useCodexCommonConfig({ base.handleCommonConfigSnippetChange(extracted); // 从 config 中移除与 extracted 相同的部分 - const customToml = adapter.parseInput(codexConfig); + const customToml = codexAdapter.parseInput(codexConfig); const diffResult = extractTomlDifference(customToml, extracted); if (!diffResult.error) { - // Codex 双格式写回:检测原始格式并保持一致 - const jsonWrapper = detectJsonWrapperFormat(codexConfig); - - if (jsonWrapper && typeof jsonWrapper.config === "string") { - // JSON wrapper 格式,更新 config 字段 - jsonWrapper.config = diffResult.customToml; - onConfigChange(JSON.stringify(jsonWrapper, null, 2)); - } else { - // 纯 TOML 格式 - onConfigChange(diffResult.customToml); - } + // 使用共享的格式保留函数写回 + onConfigChange( + preserveCodexConfigFormat(codexConfig, diffResult.customToml), + ); toast.success( t("codexConfig.extractSuccessNeedSave", { @@ -176,7 +150,7 @@ export function useCodexCommonConfig({ } finally { setLocalIsExtracting(false); } - }, [adapter, base, codexConfig, onConfigChange, t]); + }, [base, codexConfig, onConfigChange, t]); // 合并 error:优先显示 extract 错误,其次是 base 的错误 const combinedError = localExtractError || base.commonConfigError; diff --git a/src/hooks/commonConfigAdapters.ts b/src/hooks/commonConfigAdapters.ts index 8968a6508..9e65b7f81 100644 --- a/src/hooks/commonConfigAdapters.ts +++ b/src/hooks/commonConfigAdapters.ts @@ -4,11 +4,11 @@ * 提供 Claude (JSON), Codex (TOML), Gemini (ENV/JSON) 三种格式的适配器实现。 */ -import { validateJsonConfig } from "@/utils/providerConfigUtils"; import { computeFinalConfig, extractDifference, isPlainObject, + isSubset, } from "@/utils/configMerge"; import { computeFinalTomlConfig, @@ -60,28 +60,32 @@ export const claudeAdapter: CommonConfigAdapter< }, hasValidContent: (snippet: string): boolean => { + const result = claudeAdapter.parseSnippet(snippet); + return ( + !result.error && + result.config !== null && + Object.keys(result.config).length > 0 + ); + }, + + hasContent: (configStr: string, snippetStr: string): boolean => { try { - const parsed = JSON.parse(snippet.trim()); - return isPlainObject(parsed) && Object.keys(parsed).length > 0; + if (!snippetStr.trim()) return false; + const config = configStr ? JSON.parse(configStr) : {}; + const snippet = JSON.parse(snippetStr); + if (!isPlainObject(snippet)) return false; + return isSubset(config, snippet); } catch { return false; } }, getApplyError: (snippet: string, t): string => { - if (!snippet.trim()) { - return t("claudeConfig.noCommonConfigToApply"); + const result = claudeAdapter.parseSnippet(snippet); + if (result.error) { + return result.error; } - const validationError = validateJsonConfig(snippet, "通用配置片段"); - if (validationError) { - return validationError; - } - try { - const parsed = JSON.parse(snippet) as Record; - if (Object.keys(parsed).length === 0) { - return t("claudeConfig.noCommonConfigToApply"); - } - } catch { + if (result.config === null || Object.keys(result.config).length === 0) { return t("claudeConfig.noCommonConfigToApply"); } return ""; @@ -182,6 +186,51 @@ function extractConfigToml(configInput: string): string { return configInput; } +/** + * 检测 Codex 配置是否是 JSON wrapper 格式 + * @returns 如果是 JSON wrapper 返回解析后的对象,否则返回 null + */ +function detectJsonWrapperFormat( + codexConfig: string, +): { auth?: unknown; config?: string } | null { + try { + const parsed = JSON.parse(codexConfig); + if (typeof parsed?.config === "string") { + return parsed; + } + if (typeof parsed === "object" && parsed !== null) { + return parsed; + } + } catch { + // 不是 JSON + } + return null; +} + +/** + * 保留原始格式写回 Codex 配置 + * + * 如果原始配置是 JSON wrapper 格式,则更新 config 字段并返回 JSON + * 如果原始配置是纯 TOML 格式,则直接返回 TOML + * + * @param originalConfig - 原始配置字符串 + * @param updatedToml - 更新后的 TOML 内容 + * @returns 格式化后的配置字符串 + */ +export function preserveCodexConfigFormat( + originalConfig: string, + updatedToml: string, +): string { + const jsonWrapper = detectJsonWrapperFormat(originalConfig); + if (jsonWrapper && typeof jsonWrapper.config === "string") { + // JSON wrapper 格式,更新 config 字段 + jsonWrapper.config = updatedToml; + return JSON.stringify(jsonWrapper, null, 2); + } + // 纯 TOML 格式 + return updatedToml; +} + export const codexAdapter: CommonConfigAdapter = { appKey: "codex", defaultSnippet: CODEX_DEFAULT_SNIPPET, @@ -199,6 +248,18 @@ export const codexAdapter: CommonConfigAdapter = { return hasTomlContent(snippet) && !validateTomlFormat(snippet); }, + hasContent: (configStr: string, snippetStr: string): boolean => { + if (!snippetStr.trim()) return false; + // 解析配置(可能是纯 TOML 或 JSON wrapper 格式) + const configToml = extractConfigToml(configStr); + const configParsed = safeParseToml(configToml); + const snippetParsed = safeParseToml(snippetStr); + if (configParsed.error || !configParsed.config) return false; + if (snippetParsed.error || !snippetParsed.config) return false; + // 使用 isSubset 检查 snippet 是否是 config 的子集 + return isSubset(configParsed.config, snippetParsed.config); + }, + getApplyError: (snippet: string, t): string => { if (!hasTomlContent(snippet)) { return t("codexConfig.noCommonConfigToApply"); @@ -291,6 +352,10 @@ export function createGeminiAdapter( return !result.error && Object.keys(result.env).length > 0; }, + hasContent: (configStr: string, snippetStr: string): boolean => { + return geminiHasContentStatic(configStr, snippetStr); + }, + getApplyError: (snippet: string, t): string => { const result = parseGeminiCommonConfigSnippet(snippet, { strictForbiddenKeys: true, @@ -384,3 +449,71 @@ 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).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; + } +} diff --git a/src/hooks/useCommonConfigBase.ts b/src/hooks/useCommonConfigBase.ts index 4f78d52ed..415b96678 100644 --- a/src/hooks/useCommonConfigBase.ts +++ b/src/hooks/useCommonConfigBase.ts @@ -66,6 +66,15 @@ export interface CommonConfigAdapter { */ hasValidContent: (snippet: string) => boolean; + /** + * 检查配置是否包含指定的片段内容 + * 用于检测供应商配置是否已应用通用配置 + * @param configStr - 供应商的配置字符串 (settingsConfig) + * @param snippetStr - 通用配置片段字符串 + * @returns 是否包含片段内容 + */ + hasContent: (configStr: string, snippetStr: string) => boolean; + /** * 获取片段应用错误(用于 toggle 时验证) * @param snippet - 片段字符串 diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 0129588ad..48f7f1bdb 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -2,11 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import type { Provider } from "@/types"; import { providersApi } from "./providers"; -import { - hasCommonConfigSnippet, - hasTomlCommonConfigSnippet, - hasGeminiCommonConfigSnippet, -} from "@/utils/providerConfigUtils"; +import { hasContentByAppType } from "@/hooks/commonConfigAdapters"; export type AppType = "claude" | "codex" | "gemini"; @@ -358,22 +354,10 @@ function detectCommonConfigEnabledByContent( ); if (candidates.length === 0) return false; - switch (appType) { - case "codex": - return candidates.some((snippet) => - hasTomlCommonConfigSnippet(settingsConfigStr, snippet), - ); - case "gemini": - return candidates.some((snippet) => - hasGeminiCommonConfigSnippet(settingsConfigStr, snippet), - ); - case "claude": - return candidates.some((snippet) => - hasCommonConfigSnippet(settingsConfigStr, snippet), - ); - default: - return false; - } + // 使用统一的 adapter-based hasContent 检查 + return candidates.some((snippet) => + hasContentByAppType(appType, settingsConfigStr, snippet), + ); } /** 更新供应商配置的结果 */ diff --git a/src/utils/configMerge.ts b/src/utils/configMerge.ts index d4d9c41bf..ba0431473 100644 --- a/src/utils/configMerge.ts +++ b/src/utils/configMerge.ts @@ -62,6 +62,26 @@ export const deepEqual = (a: unknown, b: unknown): boolean => { return false; }; +/** + * 检查 source 是否是 target 的子集 + * 即 source 中的所有键值对都存在于 target 中 + */ +export const isSubset = (target: unknown, source: unknown): boolean => { + if (isPlainObject(source)) { + if (!isPlainObject(target)) return false; + return Object.entries(source).every(([key, value]) => + isSubset(target[key], value), + ); + } + + if (Array.isArray(source)) { + if (!Array.isArray(target) || target.length !== source.length) return false; + return source.every((item, index) => isSubset(target[index], item)); + } + + return target === source; +}; + // ============================================================================ // 配置合并函数 // ============================================================================ diff --git a/src/utils/providerConfigUtils.ts b/src/utils/providerConfigUtils.ts index e04afeb72..a40d6e988 100644 --- a/src/utils/providerConfigUtils.ts +++ b/src/utils/providerConfigUtils.ts @@ -12,22 +12,6 @@ export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [ export type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number]; -const isSubset = (target: any, source: any): boolean => { - if (isPlainObject(source)) { - if (!isPlainObject(target)) return false; - return Object.entries(source).every(([key, value]) => - isSubset(target[key], value), - ); - } - - if (Array.isArray(source)) { - if (!Array.isArray(target) || target.length !== source.length) return false; - return source.every((item, index) => isSubset(target[index], item)); - } - - return target === source; -}; - // 验证JSON配置格式 export const validateJsonConfig = ( value: string, @@ -47,71 +31,6 @@ export const validateJsonConfig = ( } }; -// 检查当前配置是否已包含通用配置片段 -export const hasCommonConfigSnippet = ( - jsonString: string, - snippetString: string, -): boolean => { - try { - if (!snippetString.trim()) return false; - const config = jsonString ? JSON.parse(jsonString) : {}; - const snippet = JSON.parse(snippetString); - if (!isPlainObject(snippet)) return false; - return isSubset(config, snippet); - } catch (err) { - return false; - } -}; - -/** - * 检查 Gemini 配置是否已包含通用配置片段 - * - * 支持三种 snippet 格式: - * - ENV 格式: KEY=VALUE (一行一个) - * - Flat JSON: {"KEY": "VALUE", ...} - * - Wrapped JSON: {"env": {"KEY": "VALUE", ...}} - * - * @param jsonString - Provider 的 settingsConfig (JSON 字符串) - * @param snippetString - 通用配置片段 (ENV/JSON 格式) - * @returns 是否已包含通用配置 - */ -export const hasGeminiCommonConfigSnippet = ( - jsonString: string, - snippetString: string, -): boolean => { - try { - if (!snippetString.trim()) return false; - - // 解析 provider 的 settingsConfig - const config = jsonString ? JSON.parse(jsonString) : {}; - if (!isPlainObject(config)) return false; - const envValue = (config as Record).env; - if (envValue !== undefined && !isPlainObject(envValue)) return false; - const env = (isPlainObject(envValue) ? envValue : {}) as Record< - string, - unknown - >; - - // 使用 parseGeminiCommonConfigSnippet 解析 snippet (支持 ENV/JSON) - const parseResult = parseGeminiCommonConfigSnippet(snippetString, { - strictForbiddenKeys: false, // 过滤禁用键而非报错 - }); - - // 解析失败或为空 - if (parseResult.error || Object.keys(parseResult.env).length === 0) { - return false; - } - - // 检查所有有效键值对是否都存在于 provider.env 中 - return Object.entries(parseResult.env).every(([key, value]) => { - const current = env[key]; - return typeof current === "string" && current === value.trim(); - }); - } catch { - return false; - } -}; - // 读取配置中的 API Key(支持 Claude, Codex, Gemini) export const getApiKeyFromConfig = ( jsonString: string, @@ -276,22 +195,6 @@ export const setApiKeyInConfig = ( } }; -// 检查 TOML 配置是否已包含通用配置片段 -export const hasTomlCommonConfigSnippet = ( - tomlString: string, - snippetString: string, -): boolean => { - if (!snippetString.trim()) return false; - - // 简单检查配置是否包含片段内容 - // 去除空白字符后比较,避免格式差异影响 - const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim(); - - return normalizeWhitespace(tomlString).includes( - normalizeWhitespace(snippetString), - ); -}; - // ========== Codex base_url utils ========== // 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)