refactor(hooks): redesign common config hooks for runtime merge architecture

Refactor all three common config hooks (Claude, Codex, Gemini) to support
the new runtime merge architecture where:
- settingsConfig stores only custom (provider-specific) configuration
- commonConfigSnippet stores shared template configuration in database
- finalConfig is computed at runtime: merge(commonConfig, customConfig)
- Toggling common config only changes enabled state, not settingsConfig

Key changes across all hooks:
- Add finalConfig/finalEnv return value for merge preview
- Use configMerge utilities (computeFinalConfig, extractDifference)
- Remove direct config manipulation on toggle (no more inject/remove)
- Add proper TypeScript return type interfaces
- Simplify state management by removing unnecessary refs
- Improve code organization with clear section comments

This refactor enables:
- Clean separation between custom and common configuration
- Real-time merge preview in the UI
- Consistent behavior across Claude (JSON), Codex (TOML), and Gemini (env)
This commit is contained in:
YoVinchen
2026-01-26 14:09:13 +08:00
parent 5817c9aa77
commit 2ff329f6c0
4 changed files with 634 additions and 781 deletions
@@ -1,45 +1,83 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { configApi } from "@/lib/api";
import {
replaceGeminiCommonConfigSnippet,
GEMINI_COMMON_ENV_FORBIDDEN_KEYS,
type GeminiForbiddenEnvKey,
} from "@/utils/providerConfigUtils";
import {
computeFinalConfig,
extractDifference,
isPlainObject,
} from "@/utils/configMerge";
import type { ProviderMeta } from "@/types";
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
interface UseGeminiCommonConfigProps {
/**
* 当前 env 值(字符串格式,如 "KEY=VALUE\nKEY2=VALUE2"
*/
envValue: string;
/**
* env 变化回调
*/
onEnvChange: (env: string) => void;
/**
* 字符串转对象
*/
envStringToObj: (envString: string) => Record<string, string>;
/**
* 对象转字符串
*/
envObjToString: (envObj: Record<string, unknown>) => string;
/**
* 初始数据(编辑模式)
*/
initialData?: {
settingsConfig?: Record<string, unknown>;
meta?: ProviderMeta;
};
/**
* 当前选中的预设 ID
*/
selectedPresetId?: string;
/** 当前正在编辑的供应商 ID(用于同步时跳过) */
/**
* 当前正在编辑的供应商 ID
*/
currentProviderId?: string;
}
function isPlainObject(value: unknown): value is Record<string, unknown> {
return (
typeof value === "object" &&
value !== null &&
!Array.isArray(value) &&
Object.prototype.toString.call(value) === "[object Object]"
);
export interface UseGeminiCommonConfigReturn {
/** 是否启用通用配置 */
useCommonConfig: boolean;
/** 通用配置片段 (JSON 格式) */
commonConfigSnippet: string;
/** 通用配置错误信息 */
commonConfigError: string;
/** 是否正在加载 */
isLoading: boolean;
/** 是否正在提取 */
isExtracting: boolean;
/** 通用配置开关处理函数 */
handleCommonConfigToggle: (checked: boolean) => void;
/** 通用配置片段变化处理函数 */
handleCommonConfigSnippetChange: (snippet: string) => void;
/** 从当前配置提取通用配置 */
handleExtract: () => Promise<void>;
/** 最终 env 对象(运行时合并结果,只读) */
finalEnv: Record<string, string>;
}
/**
* 管理 Gemini 通用配置片段 (JSON 格式)
* 写入 Gemini 的 .env,但会排除以下敏感字段:
* - GOOGLE_GEMINI_BASE_URL
* - GEMINI_API_KEY
* 管理 Gemini 通用配置片段(重构版)
*
* 新架构:
* - envValue:传入当前 env 字符串
* - commonConfigSnippet:存储在数据库中的通用配置片段
* - finalEnv:运行时计算 = merge(commonConfig, customEnv)
* - 开启/关闭通用配置只改变 enabled 状态,不修改 envValue
*/
export function useGeminiCommonConfig({
envValue,
@@ -48,10 +86,14 @@ export function useGeminiCommonConfig({
envObjToString,
initialData,
selectedPresetId,
currentProviderId,
}: UseGeminiCommonConfigProps) {
// currentProviderId is reserved for future use
}: UseGeminiCommonConfigProps): UseGeminiCommonConfigReturn {
const { t } = useTranslation();
// 内部管理的通用配置启用状态
const [useCommonConfig, setUseCommonConfig] = useState(false);
// 通用配置片段(从数据库加载)
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
);
@@ -59,15 +101,7 @@ export function useGeminiCommonConfig({
const [isLoading, setIsLoading] = useState(true);
const [isExtracting, setIsExtracting] = useState(false);
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 用于跟踪用户是否手动切换,避免自动检测覆盖用户意图
const hasUserToggledCommonConfig = useRef(false);
// 用于跟踪新建模式是否已初始化默认勾选
const hasInitializedNewMode = useRef(false);
// 用于跟踪编辑模式是否已初始化(避免反复覆盖用户切换)
const hasInitializedEditMode = useRef(false);
// 用于避免异步保存乱序导致的过期同步
// 用于避免异步保存乱序
const saveSequenceRef = useRef(0);
const saveQueueRef = useRef<Promise<void>>(Promise.resolve());
const enqueueSave = useCallback((saveFn: () => Promise<void>) => {
@@ -76,13 +110,24 @@ export function useGeminiCommonConfig({
return next;
}, []);
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
// 用于跟踪编辑模式是否已初始化
const hasInitializedEditMode = useRef(false);
// 用于跟踪新建模式是否已初始化
const hasInitializedNewMode = useRef(false);
// 将 envValue 字符串转换为对象
const customEnv = useMemo(
() => envStringToObj(envValue),
[envValue, envStringToObj],
);
// 当预设变化时,重置初始化标记
useEffect(() => {
hasInitializedNewMode.current = false;
hasInitializedEditMode.current = false;
hasUserToggledCommonConfig.current = false;
}, [selectedPresetId]);
// 解析通用配置片段
const parseSnippetEnv = useCallback(
(
snippetString: string,
@@ -134,39 +179,29 @@ export function useGeminiCommonConfig({
[t],
);
const applySnippetToEnv = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const updated = { ...envObj };
for (const [key, value] of Object.entries(snippetEnv)) {
if (typeof value === "string") {
updated[key] = value;
}
// 获取片段应用错误
const getSnippetApplyError = useCallback(
(snippet: string) => {
const parsed = parseSnippetEnv(snippet);
if (parsed.error) {
return parsed.error;
}
return updated;
if (Object.keys(parsed.env).length === 0) {
return t("geminiConfig.noCommonConfigToApply");
}
return "";
},
[],
[parseSnippetEnv, t],
);
const removeSnippetFromEnv = useCallback(
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
const updated = { ...envObj };
for (const [key, value] of Object.entries(snippetEnv)) {
if (typeof value === "string" && updated[key] === value) {
delete updated[key];
}
}
return updated;
},
[],
);
// 初始化:从数据库加载,支持从 localStorage 迁移
// ============================================================================
// 加载通用配置片段(从数据库,支持 localStorage 迁移)
// ============================================================================
useEffect(() => {
let mounted = true;
const loadSnippet = async () => {
try {
// 使用统一 API 加载
const snippet = await configApi.getCommonConfigSnippet("gemini");
if (snippet && snippet.trim()) {
@@ -174,29 +209,26 @@ export function useGeminiCommonConfig({
setCommonConfigSnippetState(snippet);
}
} else {
// 如果数据库中没有,尝试从 localStorage 迁移
// 尝试从 localStorage 迁移
if (typeof window !== "undefined") {
try {
const legacySnippet =
window.localStorage.getItem(LEGACY_STORAGE_KEY);
if (legacySnippet && legacySnippet.trim()) {
const parsed = parseSnippetEnv(legacySnippet);
if (parsed.error) {
console.warn(
"[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移",
if (!parsed.error) {
await configApi.setCommonConfigSnippet(
"gemini",
legacySnippet,
);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Gemini 通用配置已从 localStorage 迁移到数据库",
);
return;
}
// 迁移到 config.json
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
if (mounted) {
setCommonConfigSnippetState(legacySnippet);
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log(
"[迁移] Gemini 通用配置已从 localStorage 迁移到数据库",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);
@@ -219,13 +251,13 @@ export function useGeminiCommonConfig({
};
}, [parseSnippetEnv]);
// 初始化时从 meta 读取启用状态(编辑模式)
// 优先使用 meta,若 meta 未定义则回退到内容检测
// ============================================================================
// 编辑模式初始化:从 meta 读取启用状态
// ============================================================================
useEffect(() => {
if (initialData && !isLoading && !hasInitializedEditMode.current) {
hasInitializedEditMode.current = true;
// 使用 meta 中记录的按 app 启用状态
const metaByApp = initialData.meta?.commonConfigEnabledByApp;
const resolvedMetaEnabled =
metaByApp?.gemini ?? initialData.meta?.commonConfigEnabled;
@@ -235,171 +267,107 @@ export function useGeminiCommonConfig({
setUseCommonConfig(false);
return;
}
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error || Object.keys(parsed.env).length === 0) {
setCommonConfigError(
parsed.error ?? t("geminiConfig.noCommonConfigToApply"),
);
const snippetError = getSnippetApplyError(commonConfigSnippet);
if (snippetError) {
setCommonConfigError(snippetError);
setUseCommonConfig(false);
return;
}
setCommonConfigError("");
setUseCommonConfig(true);
return;
}
// meta 未定义,回退到内容检测
// Gemini 使用 JSON 格式的 env,检测通用配置片段是否已应用
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error || Object.keys(parsed.env).length === 0) {
setUseCommonConfig(false);
} else {
// 检查当前 env 是否包含通用配置中的所有键值对
const currentEnv = envStringToObj(envValue);
const allKeysMatch = Object.entries(parsed.env).every(
([key, value]) => currentEnv[key] === value,
);
setUseCommonConfig(allKeysMatch);
setUseCommonConfig(false);
}
}
}, [
initialData,
isLoading,
commonConfigSnippet,
parseSnippetEnv,
envStringToObj,
envValue,
t,
]);
}, [initialData, isLoading, commonConfigSnippet, getSnippetApplyError]);
// 新建模式:如果通用配置片段存在且有效,默认启用
// ============================================================================
// 新建模式初始化:如果通用配置有效,默认启用
// ============================================================================
useEffect(() => {
// 仅新建模式、加载完成、尚未初始化过
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
hasInitializedNewMode.current = true;
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) return;
const hasContent = Object.keys(parsed.env).length > 0;
if (!hasContent) return;
setUseCommonConfig(true);
const currentEnv = envStringToObj(envValue);
const merged = applySnippetToEnv(currentEnv, parsed.env);
const nextEnvString = envObjToString(merged);
isUpdatingFromCommonConfig.current = true;
onEnvChange(nextEnvString);
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
if (!parsed.error && Object.keys(parsed.env).length > 0) {
setUseCommonConfig(true);
}
}
}, [
initialData,
isLoading,
commonConfigSnippet,
envValue,
envStringToObj,
envObjToString,
applySnippetToEnv,
onEnvChange,
parseSnippetEnv,
]);
}, [initialData, commonConfigSnippet, isLoading, parseSnippetEnv]);
// ============================================================================
// 计算最终 env(运行时合并)
// ============================================================================
const finalEnv = useMemo((): Record<string, string> => {
if (!useCommonConfig) {
return customEnv;
}
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error || Object.keys(parsed.env).length === 0) {
return customEnv;
}
// 通用配置作为 base,自定义 env 覆盖
const merged = computeFinalConfig(
customEnv as Record<string, unknown>,
parsed.env as Record<string, unknown>,
true,
);
// 转换回 Record<string, string>
const result: Record<string, string> = {};
for (const [key, value] of Object.entries(merged)) {
if (typeof value === "string") {
result[key] = value;
}
}
return result;
}, [customEnv, commonConfigSnippet, useCommonConfig, parseSnippetEnv]);
// ============================================================================
// 处理通用配置开关
// ============================================================================
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
hasUserToggledCommonConfig.current = true;
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error) {
setCommonConfigError(parsed.error);
setUseCommonConfig(false);
return;
if (checked) {
const snippetError = getSnippetApplyError(commonConfigSnippet);
if (snippetError) {
setCommonConfigError(snippetError);
setUseCommonConfig(false);
return;
}
}
if (Object.keys(parsed.env).length === 0) {
setCommonConfigError(t("geminiConfig.noCommonConfigToApply"));
setUseCommonConfig(false);
return;
}
const currentEnv = envStringToObj(envValue);
const updatedEnvObj = checked
? applySnippetToEnv(currentEnv, parsed.env)
: removeSnippetFromEnv(currentEnv, parsed.env);
setCommonConfigError("");
setUseCommonConfig(checked);
isUpdatingFromCommonConfig.current = true;
onEnvChange(envObjToString(updatedEnvObj));
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
// 新架构:不修改 envValue,只改变 enabled 状态
},
[
applySnippetToEnv,
commonConfigSnippet,
envObjToString,
envStringToObj,
envValue,
onEnvChange,
parseSnippetEnv,
removeSnippetFromEnv,
t,
],
[commonConfigSnippet, getSnippetApplyError],
);
// ============================================================================
// 处理通用配置片段变化
// ============================================================================
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
const saveId = ++saveSequenceRef.current;
setCommonConfigError("");
// 保存到 config.json(清空)
enqueueSave(() => configApi.setCommonConfigSnippet("gemini", ""))
.then(() => {
if (saveSequenceRef.current !== saveId) return;
// 清空时也需要同步:移除所有供应商的通用配置片段
configApi.syncCommonConfigToProviders(
"gemini",
previousSnippet,
"", // newSnippet 为空表示移除
replaceGeminiCommonConfigSnippet,
currentProviderId,
(result) => {
if (saveSequenceRef.current !== saveId) return;
if (result.error) {
toast.error(t("providerForm.commonConfigSyncFailed"));
}
},
);
})
.catch((error) => {
enqueueSave(() => configApi.setCommonConfigSnippet("gemini", "")).catch(
(error) => {
if (saveSequenceRef.current !== saveId) return;
console.error("保存 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
if (useCommonConfig) {
const parsed = parseSnippetEnv(previousSnippet);
if (!parsed.error && Object.keys(parsed.env).length > 0) {
const currentEnv = envStringToObj(envValue);
const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env);
onEnvChange(envObjToString(updatedEnv));
}
setUseCommonConfig(false);
}
},
);
return;
}
// 校验 JSON 格式
// JSON 格式校验
const parsed = parseSnippetEnv(value);
if (parsed.error) {
setCommonConfigError(parsed.error);
@@ -408,103 +376,25 @@ export function useGeminiCommonConfig({
const saveId = ++saveSequenceRef.current;
setCommonConfigError("");
enqueueSave(() => configApi.setCommonConfigSnippet("gemini", value))
.then(() => {
if (saveSequenceRef.current !== saveId) return;
// 保存成功后,同步更新所有启用了通用配置的供应商
configApi.syncCommonConfigToProviders(
"gemini",
previousSnippet,
value,
replaceGeminiCommonConfigSnippet,
currentProviderId,
(result) => {
if (saveSequenceRef.current !== saveId) return;
if (result.error) {
toast.error(t("providerForm.commonConfigSyncFailed"));
}
},
);
})
.catch((error) => {
if (saveSequenceRef.current !== saveId) return;
console.error("保存 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
enqueueSave(() =>
configApi.setCommonConfigSnippet("gemini", value),
).catch((error) => {
if (saveSequenceRef.current !== saveId) return;
console.error("保存 Gemini 通用配置失败:", error);
setCommonConfigError(
t("geminiConfig.saveFailed", { error: String(error) }),
);
});
// 若当前启用通用配置,需要替换为最新片段
if (useCommonConfig) {
const prevParsed = parseSnippetEnv(previousSnippet);
const prevEnv = prevParsed.error ? {} : prevParsed.env;
const nextEnv = parsed.env;
const currentEnv = envStringToObj(envValue);
const withoutOld =
Object.keys(prevEnv).length > 0
? removeSnippetFromEnv(currentEnv, prevEnv)
: currentEnv;
const withNew =
Object.keys(nextEnv).length > 0
? applySnippetToEnv(withoutOld, nextEnv)
: withoutOld;
isUpdatingFromCommonConfig.current = true;
onEnvChange(envObjToString(withNew));
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
}
// 注意:新架构下不再需要同步到其他供应商的 settingsConfig
// 因为 finalEnv 是运行时计算的
},
[
applySnippetToEnv,
commonConfigSnippet,
currentProviderId,
envObjToString,
envStringToObj,
envValue,
onEnvChange,
enqueueSave,
parseSnippetEnv,
removeSnippetFromEnv,
t,
useCommonConfig,
],
[enqueueSave, parseSnippetEnv, t],
);
// 当 env 变化时检查是否包含通用配置(避免通过通用配置更新时反复覆盖)
useEffect(() => {
if (isUpdatingFromCommonConfig.current || isLoading) {
return;
}
const metaByApp = initialData?.meta?.commonConfigEnabledByApp;
const hasExplicitMeta =
metaByApp?.gemini !== undefined ||
initialData?.meta?.commonConfigEnabled !== undefined;
if (hasExplicitMeta || hasUserToggledCommonConfig.current) {
return;
}
const parsed = parseSnippetEnv(commonConfigSnippet);
if (parsed.error || Object.keys(parsed.env).length === 0) {
setUseCommonConfig(false);
return;
}
const envObj = envStringToObj(envValue);
const hasCommon = Object.entries(parsed.env).every(
([key, value]) => envObj[key] === value,
);
setUseCommonConfig(hasCommon);
}, [
envValue,
commonConfigSnippet,
envStringToObj,
isLoading,
parseSnippetEnv,
initialData,
]);
// 从编辑器当前内容提取通用配置片段
// ============================================================================
// 从当前最终 env 提取通用配置片段
// ============================================================================
const handleExtract = useCallback(async () => {
setIsExtracting(true);
setCommonConfigError("");
@@ -512,7 +402,7 @@ export function useGeminiCommonConfig({
try {
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
settingsConfig: JSON.stringify({
env: envStringToObj(envValue),
env: finalEnv,
}),
});
@@ -533,6 +423,19 @@ export function useGeminiCommonConfig({
// 保存到后端
await configApi.setCommonConfigSnippet("gemini", extracted);
// 提取成功后,从 customEnv 中移除与 extracted 相同的部分
const diffResult = extractDifference(
customEnv as Record<string, unknown>,
parsed.env as Record<string, unknown>,
);
const newCustomEnv: Record<string, string> = {};
for (const [key, value] of Object.entries(diffResult.customConfig)) {
if (typeof value === "string") {
newCustomEnv[key] = value;
}
}
onEnvChange(envObjToString(newCustomEnv));
} catch (error) {
console.error("提取 Gemini 通用配置失败:", error);
setCommonConfigError(
@@ -541,7 +444,7 @@ export function useGeminiCommonConfig({
} finally {
setIsExtracting(false);
}
}, [envStringToObj, envValue, parseSnippetEnv, t]);
}, [finalEnv, customEnv, onEnvChange, envObjToString, parseSnippetEnv, t]);
return {
useCommonConfig,
@@ -552,5 +455,6 @@ export function useGeminiCommonConfig({
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
handleExtract,
finalEnv,
};
}