feat(gemini): add config.json editor and common config functionality

Implements dual-editor pattern for Gemini providers, following the Codex architecture:
- Environment variables (.env format) editor
- Extended configuration (config.json) editor with common config support

New Components:
- GeminiConfigSections: Separate sections for env and config editing
- GeminiCommonConfigModal: Modal for editing common config snippets

New Hooks:
- useGeminiConfigState: Manages env/config separation and conversion
  - Converts between .env string format and JSON object
  - Validates JSON config structure
  - Extracts API Key and Base URL from env
- useGeminiCommonConfig: Handles common config snippets
  - Deep merge algorithm for combining configs
  - Remove common config logic for toggling off
  - localStorage persistence for snippets

Features:
- Format buttons for both env and config editors
- Common config toggle with deep merge/remove
- Error validation and display
- Auto-open modal on common config errors

Configuration Structure:
{
  "env": {
    "GOOGLE_GEMINI_BASE_URL": "https://...",
    "GEMINI_API_KEY": "sk-...",
    "GEMINI_MODEL": "gemini-2.5-pro"
  },
  "config": {
    "timeout": 30000,
    "maxRetries": 3
  }
}

This brings Gemini providers to feature parity with Claude and Codex.
This commit is contained in:
Jason
2025-11-14 08:32:30 +08:00
parent 0ea434a485
commit 146b42fb68
7 changed files with 948 additions and 131 deletions

View File

@@ -10,3 +10,5 @@ export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
export { useCodexCommonConfig } from "./useCodexCommonConfig";
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
export { useCodexTomlValidation } from "./useCodexTomlValidation";
export { useGeminiConfigState } from "./useGeminiConfigState";
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";

View File

@@ -29,8 +29,9 @@ export function useCommonConfigSnippet({
initialData,
}: UseCommonConfigSnippetProps) {
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] =
useState<string>(DEFAULT_COMMON_CONFIG_SNIPPET);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
DEFAULT_COMMON_CONFIG_SNIPPET,
);
const [commonConfigError, setCommonConfigError] = useState("");
const [isLoading, setIsLoading] = useState(true);
@@ -64,7 +65,9 @@ export function useCommonConfigSnippet({
}
// 清理 localStorage
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
console.log("[迁移] 通用配置已从 localStorage 迁移到 config.json");
console.log(
"[迁移] 通用配置已从 localStorage 迁移到 config.json",
);
}
} catch (e) {
console.warn("[迁移] 从 localStorage 迁移失败:", e);

View File

@@ -0,0 +1,299 @@
import { useState, useEffect, useCallback, useRef } from "react";
const GEMINI_COMMON_CONFIG_STORAGE_KEY =
"cc-switch:gemini-common-config-snippet";
const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = `{
"timeout": 30000,
"maxRetries": 3
}`;
interface UseGeminiCommonConfigProps {
configValue: string;
onConfigChange: (config: string) => void;
initialData?: {
settingsConfig?: Record<string, unknown>;
};
}
/**
* 深度合并两个对象(用于合并通用配置)
*/
function deepMerge(target: any, source: any): any {
if (typeof target !== "object" || target === null) {
return source;
}
if (typeof source !== "object" || source === null) {
return target;
}
if (Array.isArray(source)) {
return source;
}
const result = { ...target };
for (const key of Object.keys(source)) {
if (typeof source[key] === "object" && !Array.isArray(source[key])) {
result[key] = deepMerge(result[key], source[key]);
} else {
result[key] = source[key];
}
}
return result;
}
/**
* 从配置中移除通用配置片段(递归比较)
*/
function removeCommonConfig(config: any, commonConfig: any): any {
if (typeof config !== "object" || config === null) {
return config;
}
if (typeof commonConfig !== "object" || commonConfig === null) {
return config;
}
const result = { ...config };
for (const key of Object.keys(commonConfig)) {
if (result[key] === undefined) continue;
// 如果值完全相等,删除该键
if (JSON.stringify(result[key]) === JSON.stringify(commonConfig[key])) {
delete result[key];
} else if (
typeof result[key] === "object" &&
!Array.isArray(result[key]) &&
typeof commonConfig[key] === "object" &&
!Array.isArray(commonConfig[key])
) {
// 递归移除嵌套对象
result[key] = removeCommonConfig(result[key], commonConfig[key]);
// 如果移除后对象为空,删除该键
if (Object.keys(result[key]).length === 0) {
delete result[key];
}
}
}
return result;
}
/**
* 检查配置中是否包含通用配置片段
*/
function hasCommonConfigSnippet(config: any, commonConfig: any): boolean {
if (typeof config !== "object" || config === null) return false;
if (typeof commonConfig !== "object" || commonConfig === null) return false;
for (const key of Object.keys(commonConfig)) {
if (config[key] === undefined) return false;
if (JSON.stringify(config[key]) !== JSON.stringify(commonConfig[key])) {
// 检查嵌套对象
if (
typeof config[key] === "object" &&
!Array.isArray(config[key]) &&
typeof commonConfig[key] === "object" &&
!Array.isArray(commonConfig[key])
) {
if (!hasCommonConfigSnippet(config[key], commonConfig[key])) {
return false;
}
} else {
return false;
}
}
}
return true;
}
/**
* 管理 Gemini 通用配置片段 (JSON 格式)
*/
export function useGeminiCommonConfig({
configValue,
onConfigChange,
initialData,
}: UseGeminiCommonConfigProps) {
const [useCommonConfig, setUseCommonConfig] = useState(false);
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
() => {
if (typeof window === "undefined") {
return DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET;
}
try {
const stored = window.localStorage.getItem(
GEMINI_COMMON_CONFIG_STORAGE_KEY,
);
if (stored && stored.trim()) {
return stored;
}
} catch {
// ignore localStorage 读取失败
}
return DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET;
},
);
const [commonConfigError, setCommonConfigError] = useState("");
// 用于跟踪是否正在通过通用配置更新
const isUpdatingFromCommonConfig = useRef(false);
// 初始化时检查通用配置片段(编辑模式)
useEffect(() => {
if (initialData?.settingsConfig) {
try {
const config =
typeof initialData.settingsConfig.config === "object"
? initialData.settingsConfig.config
: {};
const commonConfigObj = JSON.parse(commonConfigSnippet);
const hasCommon = hasCommonConfigSnippet(config, commonConfigObj);
setUseCommonConfig(hasCommon);
} catch {
// ignore parse error
}
}
}, [initialData, commonConfigSnippet]);
// 同步本地存储的通用配置片段
useEffect(() => {
if (typeof window === "undefined") return;
try {
if (commonConfigSnippet.trim()) {
window.localStorage.setItem(
GEMINI_COMMON_CONFIG_STORAGE_KEY,
commonConfigSnippet,
);
} else {
window.localStorage.removeItem(GEMINI_COMMON_CONFIG_STORAGE_KEY);
}
} catch {
// ignore
}
}, [commonConfigSnippet]);
// 处理通用配置开关
const handleCommonConfigToggle = useCallback(
(checked: boolean) => {
try {
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
const commonConfigObj = JSON.parse(commonConfigSnippet);
let updatedConfig: any;
if (checked) {
// 合并通用配置
updatedConfig = deepMerge(configObj, commonConfigObj);
} else {
// 移除通用配置
updatedConfig = removeCommonConfig(configObj, commonConfigObj);
}
setCommonConfigError("");
setUseCommonConfig(checked);
// 标记正在通过通用配置更新
isUpdatingFromCommonConfig.current = true;
onConfigChange(JSON.stringify(updatedConfig, null, 2));
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
setCommonConfigError(`配置合并失败: ${errorMessage}`);
setUseCommonConfig(false);
}
},
[configValue, commonConfigSnippet, onConfigChange],
);
// 处理通用配置片段变化
const handleCommonConfigSnippetChange = useCallback(
(value: string) => {
const previousSnippet = commonConfigSnippet;
setCommonConfigSnippetState(value);
if (!value.trim()) {
setCommonConfigError("");
if (useCommonConfig) {
// 移除旧的通用配置
try {
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
const previousCommonConfigObj = JSON.parse(previousSnippet);
const updatedConfig = removeCommonConfig(
configObj,
previousCommonConfigObj,
);
onConfigChange(JSON.stringify(updatedConfig, null, 2));
setUseCommonConfig(false);
} catch {
// ignore
}
}
return;
}
// 校验 JSON 格式
try {
JSON.parse(value);
setCommonConfigError("");
} catch {
setCommonConfigError("通用配置片段格式错误(必须是有效的 JSON");
return;
}
// 若当前启用通用配置,需要替换为最新片段
if (useCommonConfig) {
try {
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
const previousCommonConfigObj = JSON.parse(previousSnippet);
const newCommonConfigObj = JSON.parse(value);
// 先移除旧的通用配置
const withoutOld = removeCommonConfig(
configObj,
previousCommonConfigObj,
);
// 再合并新的通用配置
const withNew = deepMerge(withoutOld, newCommonConfigObj);
// 标记正在通过通用配置更新,避免触发状态检查
isUpdatingFromCommonConfig.current = true;
onConfigChange(JSON.stringify(withNew, null, 2));
// 在下一个事件循环中重置标记
setTimeout(() => {
isUpdatingFromCommonConfig.current = false;
}, 0);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
setCommonConfigError(`配置替换失败: ${errorMessage}`);
}
}
},
[commonConfigSnippet, configValue, useCommonConfig, onConfigChange],
);
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
useEffect(() => {
if (isUpdatingFromCommonConfig.current) {
return;
}
try {
const configObj = configValue.trim() ? JSON.parse(configValue) : {};
const commonConfigObj = JSON.parse(commonConfigSnippet);
const hasCommon = hasCommonConfigSnippet(configObj, commonConfigObj);
setUseCommonConfig(hasCommon);
} catch {
// ignore parse error
}
}, [configValue, commonConfigSnippet]);
return {
useCommonConfig,
commonConfigSnippet,
commonConfigError,
handleCommonConfigToggle,
handleCommonConfigSnippetChange,
};
}

View File

@@ -0,0 +1,217 @@
import { useState, useCallback, useEffect } from "react";
interface UseGeminiConfigStateProps {
initialData?: {
settingsConfig?: Record<string, unknown>;
};
}
/**
* 管理 Gemini 配置状态
* Gemini 配置包含两部分env (环境变量) 和 config (扩展配置 JSON)
*/
export function useGeminiConfigState({
initialData,
}: UseGeminiConfigStateProps) {
const [geminiEnv, setGeminiEnvState] = useState("");
const [geminiConfig, setGeminiConfigState] = useState("");
const [geminiApiKey, setGeminiApiKey] = useState("");
const [geminiBaseUrl, setGeminiBaseUrl] = useState("");
const [envError, setEnvError] = useState("");
const [configError, setConfigError] = useState("");
// 将 JSON env 对象转换为 .env 格式字符串
const envObjToString = useCallback(
(envObj: Record<string, unknown>): string => {
const lines: string[] = [];
if (typeof envObj.GOOGLE_GEMINI_BASE_URL === "string") {
lines.push(`GOOGLE_GEMINI_BASE_URL=${envObj.GOOGLE_GEMINI_BASE_URL}`);
}
if (typeof envObj.GEMINI_API_KEY === "string") {
lines.push(`GEMINI_API_KEY=${envObj.GEMINI_API_KEY}`);
}
if (typeof envObj.GEMINI_MODEL === "string") {
lines.push(`GEMINI_MODEL=${envObj.GEMINI_MODEL}`);
}
return lines.join("\n");
},
[],
);
// 将 .env 格式字符串转换为 JSON env 对象
const envStringToObj = useCallback(
(envString: string): Record<string, string> => {
const env: Record<string, string> = {};
const lines = envString.split("\n");
lines.forEach((line) => {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) return;
const equalIndex = trimmed.indexOf("=");
if (equalIndex > 0) {
const key = trimmed.substring(0, equalIndex).trim();
const value = trimmed.substring(equalIndex + 1).trim();
env[key] = value;
}
});
return env;
},
[],
);
// 初始化 Gemini 配置(编辑模式)
useEffect(() => {
if (!initialData) return;
const config = initialData.settingsConfig;
if (typeof config === "object" && config !== null) {
// 设置 env
const env = (config as any).env || {};
setGeminiEnvState(envObjToString(env));
// 设置 config
const configObj = (config as any).config || {};
setGeminiConfigState(JSON.stringify(configObj, null, 2));
// 提取 API Key 和 Base URL
if (typeof env.GEMINI_API_KEY === "string") {
setGeminiApiKey(env.GEMINI_API_KEY);
}
if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") {
setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);
}
}
}, [initialData, envObjToString]);
// 从 geminiEnv 中提取并同步 API Key 和 Base URL
useEffect(() => {
const envObj = envStringToObj(geminiEnv);
const extractedKey = envObj.GEMINI_API_KEY || "";
const extractedBaseUrl = envObj.GOOGLE_GEMINI_BASE_URL || "";
if (extractedKey !== geminiApiKey) {
setGeminiApiKey(extractedKey);
}
if (extractedBaseUrl !== geminiBaseUrl) {
setGeminiBaseUrl(extractedBaseUrl);
}
}, [geminiEnv, envStringToObj]);
// 验证 Gemini Config JSON
const validateGeminiConfig = useCallback((value: string): string => {
if (!value.trim()) return ""; // 空值允许
try {
const parsed = JSON.parse(value);
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return "";
}
return "Config must be a JSON object";
} catch {
return "Invalid JSON format";
}
}, []);
// 设置 env
const setGeminiEnv = useCallback((value: string) => {
setGeminiEnvState(value);
// .env 格式较宽松,不做严格校验
setEnvError("");
}, []);
// 设置 config (支持函数更新)
const setGeminiConfig = useCallback(
(value: string | ((prev: string) => string)) => {
const newValue =
typeof value === "function" ? value(geminiConfig) : value;
setGeminiConfigState(newValue);
setConfigError(validateGeminiConfig(newValue));
},
[geminiConfig, validateGeminiConfig],
);
// 处理 Gemini API Key 输入并写回 env
const handleGeminiApiKeyChange = useCallback(
(key: string) => {
const trimmed = key.trim();
setGeminiApiKey(trimmed);
const envObj = envStringToObj(geminiEnv);
envObj.GEMINI_API_KEY = trimmed;
const newEnv = envObjToString(envObj);
setGeminiEnv(newEnv);
},
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
);
// 处理 Gemini Base URL 变化
const handleGeminiBaseUrlChange = useCallback(
(url: string) => {
const sanitized = url.trim().replace(/\/+$/, "");
setGeminiBaseUrl(sanitized);
const envObj = envStringToObj(geminiEnv);
envObj.GOOGLE_GEMINI_BASE_URL = sanitized;
const newEnv = envObjToString(envObj);
setGeminiEnv(newEnv);
},
[geminiEnv, envStringToObj, envObjToString, setGeminiEnv],
);
// 处理 env 变化
const handleGeminiEnvChange = useCallback(
(value: string) => {
setGeminiEnv(value);
},
[setGeminiEnv],
);
// 处理 config 变化
const handleGeminiConfigChange = useCallback(
(value: string) => {
setGeminiConfig(value);
},
[setGeminiConfig],
);
// 重置配置(用于预设切换)
const resetGeminiConfig = useCallback(
(env: Record<string, unknown>, config: Record<string, unknown>) => {
const envString = envObjToString(env);
const configString = JSON.stringify(config, null, 2);
setGeminiEnv(envString);
setGeminiConfig(configString);
// 提取 API Key 和 Base URL
if (typeof env.GEMINI_API_KEY === "string") {
setGeminiApiKey(env.GEMINI_API_KEY);
} else {
setGeminiApiKey("");
}
if (typeof env.GOOGLE_GEMINI_BASE_URL === "string") {
setGeminiBaseUrl(env.GOOGLE_GEMINI_BASE_URL);
} else {
setGeminiBaseUrl("");
}
},
[envObjToString, setGeminiEnv, setGeminiConfig],
);
return {
geminiEnv,
geminiConfig,
geminiApiKey,
geminiBaseUrl,
envError,
configError,
setGeminiEnv,
setGeminiConfig,
handleGeminiApiKeyChange,
handleGeminiBaseUrlChange,
handleGeminiEnvChange,
handleGeminiConfigChange,
resetGeminiConfig,
envStringToObj,
envObjToString,
};
}