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:
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
299
src/components/providers/forms/hooks/useGeminiCommonConfig.ts
Normal file
299
src/components/providers/forms/hooks/useGeminiCommonConfig.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
217
src/components/providers/forms/hooks/useGeminiConfigState.ts
Normal file
217
src/components/providers/forms/hooks/useGeminiConfigState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user