mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-27 16:26:16 +08:00
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:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
/** 更新供应商配置的结果 */
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// 配置合并函数
|
||||
// ============================================================================
|
||||
|
||||
@@ -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(支持单/双引号)
|
||||
|
||||
Reference in New Issue
Block a user