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)
This commit is contained in:
YoVinchen
2026-01-31 15:23:49 +08:00
parent 580b32dd67
commit d66f196378
6 changed files with 196 additions and 173 deletions
@@ -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<string> = 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;
+148 -15
View File
@@ -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<string, unknown>;
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<string, string> = {
appKey: "codex",
defaultSnippet: CODEX_DEFAULT_SNIPPET,
@@ -199,6 +248,18 @@ export const codexAdapter: CommonConfigAdapter<string, string> = {
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<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;
}
}
+9
View File
@@ -66,6 +66,15 @@ export interface CommonConfigAdapter<TConfig, TFinal> {
*/
hasValidContent: (snippet: string) => boolean;
/**
* 检查配置是否包含指定的片段内容
* 用于检测供应商配置是否已应用通用配置
* @param configStr - 供应商的配置字符串 (settingsConfig)
* @param snippetStr - 通用配置片段字符串
* @returns 是否包含片段内容
*/
hasContent: (configStr: string, snippetStr: string) => boolean;
/**
* 获取片段应用错误(用于 toggle 时验证)
* @param snippet - 片段字符串
+5 -21
View File
@@ -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),
);
}
/** 更新供应商配置的结果 */
+20
View File
@@ -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;
};
// ============================================================================
// 配置合并函数
// ============================================================================
-97
View File
@@ -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<string, unknown>).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(支持单/双引号)