mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-19 03:29:45 +08:00
6d078e7f33
Common config snippets are now dynamically overlaid when writing live files, rather than being pre-merged into provider snapshots at edit time. This ensures that updating a snippet immediately takes effect for the current provider and automatically propagates to other providers on their next switch. Key changes: - Add write_live_with_common_config() overlay pipeline - Strip common config from live before backfilling provider snapshots - Normalize provider snapshots on save to keep them snippet-free - Add explicit commonConfigEnabled flag in ProviderMeta (Option<bool>) - Migrate legacy providers on snippet save (infer flag from subset check) - Add Codex TOML snippet validation in set_common_config_snippet - Stabilize onConfigChange callbacks with useCallback in ProviderForm
501 lines
15 KiB
TypeScript
501 lines
15 KiB
TypeScript
import { useState, useEffect, useCallback, useRef } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { configApi } from "@/lib/api";
|
|
|
|
const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
|
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}";
|
|
|
|
const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
|
"GOOGLE_GEMINI_BASE_URL",
|
|
"GEMINI_API_KEY",
|
|
] as const;
|
|
type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
|
|
|
interface UseGeminiCommonConfigProps {
|
|
envValue: string;
|
|
onEnvChange: (env: string) => void;
|
|
envStringToObj: (envString: string) => Record<string, string>;
|
|
envObjToString: (envObj: Record<string, unknown>) => string;
|
|
initialData?: {
|
|
settingsConfig?: Record<string, unknown>;
|
|
};
|
|
initialEnabled?: boolean;
|
|
selectedPresetId?: 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]"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 管理 Gemini 通用配置片段 (JSON 格式)
|
|
* 写入 Gemini 的 .env,但会排除以下敏感字段:
|
|
* - GOOGLE_GEMINI_BASE_URL
|
|
* - GEMINI_API_KEY
|
|
*/
|
|
export function useGeminiCommonConfig({
|
|
envValue,
|
|
onEnvChange,
|
|
envStringToObj,
|
|
envObjToString,
|
|
initialData,
|
|
initialEnabled,
|
|
selectedPresetId,
|
|
}: UseGeminiCommonConfigProps) {
|
|
const { t } = useTranslation();
|
|
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
|
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
|
DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET,
|
|
);
|
|
const [commonConfigError, setCommonConfigError] = useState("");
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [isExtracting, setIsExtracting] = useState(false);
|
|
|
|
// 用于跟踪是否正在通过通用配置更新
|
|
const isUpdatingFromCommonConfig = useRef(false);
|
|
// 用于跟踪新建模式是否已初始化默认勾选
|
|
const hasInitializedNewMode = useRef(false);
|
|
// 用于跟踪编辑模式是否已初始化显式开关/预览
|
|
const hasInitializedEditMode = useRef(false);
|
|
|
|
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
useEffect(() => {
|
|
hasInitializedNewMode.current = false;
|
|
hasInitializedEditMode.current = false;
|
|
}, [selectedPresetId, initialEnabled]);
|
|
|
|
const parseSnippetEnv = useCallback(
|
|
(
|
|
snippetString: string,
|
|
): { env: Record<string, string>; error?: string } => {
|
|
const trimmed = snippetString.trim();
|
|
if (!trimmed) {
|
|
return { env: {} };
|
|
}
|
|
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(trimmed);
|
|
} catch {
|
|
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
|
}
|
|
|
|
if (!isPlainObject(parsed)) {
|
|
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
|
}
|
|
|
|
const keys = Object.keys(parsed);
|
|
const forbiddenKeys = keys.filter((key) =>
|
|
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey),
|
|
);
|
|
if (forbiddenKeys.length > 0) {
|
|
return {
|
|
env: {},
|
|
error: t("geminiConfig.commonConfigInvalidKeys", {
|
|
keys: forbiddenKeys.join(", "),
|
|
}),
|
|
};
|
|
}
|
|
|
|
const env: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(parsed)) {
|
|
if (typeof value !== "string") {
|
|
return {
|
|
env: {},
|
|
error: t("geminiConfig.commonConfigInvalidValues"),
|
|
};
|
|
}
|
|
const normalized = value.trim();
|
|
if (!normalized) continue;
|
|
env[key] = normalized;
|
|
}
|
|
|
|
return { env };
|
|
},
|
|
[t],
|
|
);
|
|
|
|
const hasEnvCommonConfigSnippet = useCallback(
|
|
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
|
const entries = Object.entries(snippetEnv);
|
|
if (entries.length === 0) return false;
|
|
return entries.every(([key, value]) => envObj[key] === value);
|
|
},
|
|
[],
|
|
);
|
|
|
|
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;
|
|
}
|
|
}
|
|
return updated;
|
|
},
|
|
[],
|
|
);
|
|
|
|
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;
|
|
},
|
|
[],
|
|
);
|
|
|
|
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
useEffect(() => {
|
|
let mounted = true;
|
|
|
|
const loadSnippet = async () => {
|
|
try {
|
|
// 使用统一 API 加载
|
|
const snippet = await configApi.getCommonConfigSnippet("gemini");
|
|
|
|
if (snippet && snippet.trim()) {
|
|
if (mounted) {
|
|
setCommonConfigSnippetState(snippet);
|
|
}
|
|
} else {
|
|
// 如果 config.json 中没有,尝试从 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 通用配置片段格式不符合当前规则,跳过迁移",
|
|
);
|
|
return;
|
|
}
|
|
// 迁移到 config.json
|
|
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
|
|
if (mounted) {
|
|
setCommonConfigSnippetState(legacySnippet);
|
|
}
|
|
// 清理 localStorage
|
|
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
console.log(
|
|
"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json",
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
}
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error("加载 Gemini 通用配置失败:", error);
|
|
} finally {
|
|
if (mounted) {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
loadSnippet();
|
|
|
|
return () => {
|
|
mounted = false;
|
|
};
|
|
}, [parseSnippetEnv]);
|
|
|
|
// 初始化时检查通用配置片段(编辑模式)
|
|
useEffect(() => {
|
|
if (initialData?.settingsConfig && !isLoading) {
|
|
try {
|
|
const env =
|
|
isPlainObject(initialData.settingsConfig.env) &&
|
|
Object.keys(initialData.settingsConfig.env).length > 0
|
|
? (initialData.settingsConfig.env as Record<string, string>)
|
|
: {};
|
|
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
if (parsed.error) return;
|
|
const inferredHasCommon = hasEnvCommonConfigSnippet(
|
|
env,
|
|
parsed.env as Record<string, string>,
|
|
);
|
|
const hasCommon = initialEnabled ?? inferredHasCommon;
|
|
setUseCommonConfig(hasCommon);
|
|
|
|
if (
|
|
hasCommon &&
|
|
!inferredHasCommon &&
|
|
!hasInitializedEditMode.current
|
|
) {
|
|
hasInitializedEditMode.current = 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);
|
|
} else {
|
|
hasInitializedEditMode.current = true;
|
|
}
|
|
} catch {
|
|
// ignore parse error
|
|
}
|
|
}
|
|
}, [
|
|
applySnippetToEnv,
|
|
commonConfigSnippet,
|
|
envObjToString,
|
|
envStringToObj,
|
|
envValue,
|
|
hasEnvCommonConfigSnippet,
|
|
initialData,
|
|
initialEnabled,
|
|
isLoading,
|
|
onEnvChange,
|
|
parseSnippetEnv,
|
|
]);
|
|
|
|
// 新建模式:如果通用配置片段存在且有效,默认启用
|
|
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);
|
|
}
|
|
}, [
|
|
initialData,
|
|
isLoading,
|
|
commonConfigSnippet,
|
|
envValue,
|
|
envStringToObj,
|
|
envObjToString,
|
|
applySnippetToEnv,
|
|
onEnvChange,
|
|
parseSnippetEnv,
|
|
]);
|
|
|
|
// 处理通用配置开关
|
|
const handleCommonConfigToggle = useCallback(
|
|
(checked: boolean) => {
|
|
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
if (parsed.error) {
|
|
setCommonConfigError(parsed.error);
|
|
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);
|
|
},
|
|
[
|
|
applySnippetToEnv,
|
|
commonConfigSnippet,
|
|
envObjToString,
|
|
envStringToObj,
|
|
envValue,
|
|
onEnvChange,
|
|
parseSnippetEnv,
|
|
removeSnippetFromEnv,
|
|
t,
|
|
],
|
|
);
|
|
|
|
// 处理通用配置片段变化
|
|
const handleCommonConfigSnippetChange = useCallback(
|
|
(value: string) => {
|
|
const previousSnippet = commonConfigSnippet;
|
|
setCommonConfigSnippetState(value);
|
|
|
|
if (!value.trim()) {
|
|
setCommonConfigError("");
|
|
// 保存到 config.json(清空)
|
|
configApi
|
|
.setCommonConfigSnippet("gemini", "")
|
|
.catch((error: unknown) => {
|
|
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 格式
|
|
const parsed = parseSnippetEnv(value);
|
|
if (parsed.error) {
|
|
setCommonConfigError(parsed.error);
|
|
return;
|
|
}
|
|
|
|
setCommonConfigError("");
|
|
configApi
|
|
.setCommonConfigSnippet("gemini", value)
|
|
.catch((error: unknown) => {
|
|
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);
|
|
}
|
|
},
|
|
[
|
|
applySnippetToEnv,
|
|
commonConfigSnippet,
|
|
envObjToString,
|
|
envStringToObj,
|
|
envValue,
|
|
onEnvChange,
|
|
parseSnippetEnv,
|
|
removeSnippetFromEnv,
|
|
t,
|
|
useCommonConfig,
|
|
],
|
|
);
|
|
|
|
// 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
useEffect(() => {
|
|
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
return;
|
|
}
|
|
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
if (parsed.error) return;
|
|
const envObj = envStringToObj(envValue);
|
|
setUseCommonConfig(
|
|
hasEnvCommonConfigSnippet(envObj, parsed.env as Record<string, string>),
|
|
);
|
|
}, [
|
|
envValue,
|
|
commonConfigSnippet,
|
|
envStringToObj,
|
|
hasEnvCommonConfigSnippet,
|
|
isLoading,
|
|
parseSnippetEnv,
|
|
]);
|
|
|
|
// 从编辑器当前内容提取通用配置片段
|
|
const handleExtract = useCallback(async () => {
|
|
setIsExtracting(true);
|
|
setCommonConfigError("");
|
|
|
|
try {
|
|
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
|
|
settingsConfig: JSON.stringify({
|
|
env: envStringToObj(envValue),
|
|
}),
|
|
});
|
|
|
|
if (!extracted || extracted === "{}") {
|
|
setCommonConfigError(t("geminiConfig.extractNoCommonConfig"));
|
|
return;
|
|
}
|
|
|
|
// 验证 JSON 格式
|
|
const parsed = parseSnippetEnv(extracted);
|
|
if (parsed.error) {
|
|
setCommonConfigError(t("geminiConfig.extractedConfigInvalid"));
|
|
return;
|
|
}
|
|
|
|
// 更新片段状态
|
|
setCommonConfigSnippetState(extracted);
|
|
|
|
// 保存到后端
|
|
await configApi.setCommonConfigSnippet("gemini", extracted);
|
|
} catch (error) {
|
|
console.error("提取 Gemini 通用配置失败:", error);
|
|
setCommonConfigError(
|
|
t("geminiConfig.extractFailed", { error: String(error) }),
|
|
);
|
|
} finally {
|
|
setIsExtracting(false);
|
|
}
|
|
}, [envStringToObj, envValue, parseSnippetEnv, t]);
|
|
|
|
return {
|
|
useCommonConfig,
|
|
commonConfigSnippet,
|
|
commonConfigError,
|
|
isLoading,
|
|
isExtracting,
|
|
handleCommonConfigToggle,
|
|
handleCommonConfigSnippetChange,
|
|
handleExtract,
|
|
};
|
|
}
|