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

@@ -0,0 +1,122 @@
import React from "react";
import { Save, Wand2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { formatJSON } from "@/utils/formatters";
interface GeminiCommonConfigModalProps {
isOpen: boolean;
onClose: () => void;
value: string;
onChange: (value: string) => void;
error?: string;
}
/**
* GeminiCommonConfigModal - Common Gemini configuration editor modal
* Allows editing of common JSON configuration shared across Gemini providers
*/
export const GeminiCommonConfigModal: React.FC<
GeminiCommonConfigModalProps
> = ({ isOpen, onClose, value, onChange, error }) => {
const { t } = useTranslation();
const handleFormat = () => {
if (!value.trim()) return;
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent
zIndex="nested"
className="max-w-2xl max-h-[90vh] flex flex-col p-0"
>
<DialogHeader className="px-6 pt-6 pb-0">
<DialogTitle>
{t("geminiConfig.editCommonConfigTitle", {
defaultValue: "编辑 Gemini 通用配置片段",
})}
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 py-4 space-y-4">
<p className="text-sm text-gray-500 dark:text-gray-400">
{t("geminiConfig.commonConfigHint", {
defaultValue:
"通用配置片段将合并到所有启用它的 Gemini 供应商配置中",
})}
</p>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`{
"timeout": 30000,
"maxRetries": 3,
"customField": "value"
}`}
rows={12}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 focus:border-border-active transition-colors resize-y"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{error && (
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
)}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t("common.cancel")}
</Button>
<Button type="button" onClick={onClose} className="gap-2">
<Save className="w-4 h-4" />
{t("common.save")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View File

@@ -1,139 +1,76 @@
import { useTranslation } from "react-i18next";
import { Label } from "@/components/ui/label";
import { Wand2 } from "lucide-react";
import { toast } from "sonner";
import React, { useState, useEffect } from "react";
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal";
interface GeminiConfigEditorProps {
value: string;
onChange: (value: string) => void;
envValue: string;
configValue: string;
onEnvChange: (value: string) => void;
onConfigChange: (value: string) => void;
onEnvBlur?: () => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
commonConfigSnippet: string;
onCommonConfigSnippetChange: (value: string) => void;
commonConfigError: string;
envError: string;
configError: string;
}
export function GeminiConfigEditor({
value,
onChange,
}: GeminiConfigEditorProps) {
const { t } = useTranslation();
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
envValue,
configValue,
onEnvChange,
onConfigChange,
onEnvBlur,
useCommonConfig,
onCommonConfigToggle,
commonConfigSnippet,
onCommonConfigSnippetChange,
commonConfigError,
envError,
configError,
}) => {
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
// 将 JSON 格式转换为 .env 格式显示
const jsonToEnv = (jsonString: string): string => {
try {
const config = JSON.parse(jsonString);
const env = config?.env || {};
const lines: string[] = [];
if (env.GOOGLE_GEMINI_BASE_URL) {
lines.push(`GOOGLE_GEMINI_BASE_URL=${env.GOOGLE_GEMINI_BASE_URL}`);
// Auto-open common config modal if there's an error
useEffect(() => {
if (commonConfigError && !isCommonConfigModalOpen) {
setIsCommonConfigModalOpen(true);
}
if (env.GEMINI_API_KEY) {
lines.push(`GEMINI_API_KEY=${env.GEMINI_API_KEY}`);
}
if (env.GEMINI_MODEL) {
lines.push(`GEMINI_MODEL=${env.GEMINI_MODEL}`);
}
return lines.join("\n");
} catch {
return "";
}
};
// 将 .env 格式转换为 JSON 格式保存
const envToJson = (envString: string): string => {
try {
const lines = envString.split("\n");
const env: Record<string, string> = {};
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 JSON.stringify({ env }, null, 2);
} catch {
return value;
}
};
const displayValue = jsonToEnv(value);
const handleChange = (envString: string) => {
const jsonString = envToJson(envString);
onChange(jsonString);
};
const handleFormat = () => {
if (!value.trim()) return;
try {
// 重新格式化
const envString = jsonToEnv(value);
const formatted = envString
.split("\n")
.filter((l) => l.trim())
.join("\n");
const jsonString = envToJson(formatted);
onChange(jsonString);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
}, [commonConfigError, isCommonConfigModalOpen]);
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="geminiConfig">
{t("provider.geminiConfig", { defaultValue: "Gemini 配置" })}
</Label>
</div>
<textarea
id="geminiConfig"
value={displayValue}
onChange={(e) => handleChange(e.target.value)}
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
GEMINI_API_KEY=sk-your-api-key-here
GEMINI_MODEL=gemini-2.5-pro`}
rows={8}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
<div className="space-y-6">
{/* Env Section */}
<GeminiEnvSection
value={envValue}
onChange={onEnvChange}
onBlur={onEnvBlur}
error={envError}
/>
{/* Config JSON Section */}
<GeminiConfigSection
value={configValue}
onChange={onConfigChange}
useCommonConfig={useCommonConfig}
onCommonConfigToggle={onCommonConfigToggle}
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
commonConfigError={commonConfigError}
configError={configError}
/>
{/* Common Config Modal */}
<GeminiCommonConfigModal
isOpen={isCommonConfigModalOpen}
onClose={() => setIsCommonConfigModalOpen(false)}
value={commonConfigSnippet}
onChange={onCommonConfigSnippetChange}
error={commonConfigError}
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
<p className="text-xs text-muted-foreground">
{t("provider.geminiConfigHint", {
defaultValue: "使用 .env 格式配置 Gemini",
})}
</p>
</div>
</div>
);
}
};
export default GeminiConfigEditor;

View File

@@ -0,0 +1,237 @@
import React from "react";
import { useTranslation } from "react-i18next";
import { Wand2 } from "lucide-react";
import { toast } from "sonner";
import { formatJSON } from "@/utils/formatters";
interface GeminiEnvSectionProps {
value: string;
onChange: (value: string) => void;
onBlur?: () => void;
error?: string;
}
/**
* GeminiEnvSection - .env editor section for Gemini environment variables
*/
export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
value,
onChange,
onBlur,
error,
}) => {
const { t } = useTranslation();
const handleFormat = () => {
if (!value.trim()) return;
try {
// 重新格式化 .env 内容
const formatted = value
.split("\n")
.filter((line) => line.trim())
.join("\n");
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return (
<div className="space-y-2">
<label
htmlFor="geminiEnv"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
</label>
<textarea
id="geminiEnv"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={onBlur}
placeholder={`GOOGLE_GEMINI_BASE_URL=https://your-api-endpoint.com/
GEMINI_API_KEY=sk-your-api-key-here
GEMINI_MODEL=gemini-2.5-pro`}
rows={6}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[8rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{error && (
<p className="text-xs text-red-500 dark:text-red-400">{error}</p>
)}
</div>
{!error && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("geminiConfig.envFileHint", {
defaultValue: "使用 .env 格式配置 Gemini 环境变量",
})}
</p>
)}
</div>
);
};
interface GeminiConfigSectionProps {
value: string;
onChange: (value: string) => void;
useCommonConfig: boolean;
onCommonConfigToggle: (checked: boolean) => void;
onEditCommonConfig: () => void;
commonConfigError?: string;
configError?: string;
}
/**
* GeminiConfigSection - Config JSON editor section with common config support
*/
export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
value,
onChange,
useCommonConfig,
onCommonConfigToggle,
onEditCommonConfig,
commonConfigError,
configError,
}) => {
const { t } = useTranslation();
const handleFormat = () => {
if (!value.trim()) return;
try {
const formatted = formatJSON(value);
onChange(formatted);
toast.success(t("common.formatSuccess", { defaultValue: "格式化成功" }));
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
toast.error(
t("common.formatError", {
defaultValue: "格式化失败:{{error}}",
error: errorMessage,
}),
);
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<label
htmlFor="geminiConfig"
className="block text-sm font-medium text-gray-900 dark:text-gray-100"
>
{t("geminiConfig.configJson", {
defaultValue: "配置文件 (config.json)",
})}
</label>
<label className="inline-flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 cursor-pointer">
<input
type="checkbox"
checked={useCommonConfig}
onChange={(e) => onCommonConfigToggle(e.target.checked)}
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
/>
{t("geminiConfig.writeCommonConfig", {
defaultValue: "写入通用配置",
})}
</label>
</div>
<div className="flex items-center justify-end">
<button
type="button"
onClick={onEditCommonConfig}
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
>
{t("geminiConfig.editCommonConfig", {
defaultValue: "编辑通用配置",
})}
</button>
</div>
{commonConfigError && (
<p className="text-xs text-red-500 dark:text-red-400 text-right">
{commonConfigError}
</p>
)}
<textarea
id="geminiConfig"
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={`{
"timeout": 30000,
"maxRetries": 3
}`}
rows={8}
className="w-full px-3 py-2 border border-border-default dark:bg-gray-800 dark:text-gray-100 rounded-lg text-sm font-mono focus:outline-none focus:ring-2 focus:ring-blue-500/20 dark:focus:ring-blue-400/20 transition-colors resize-y min-h-[10rem]"
autoComplete="off"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
lang="en"
inputMode="text"
data-gramm="false"
data-gramm_editor="false"
data-enable-grammarly="false"
/>
<div className="flex items-center justify-between">
<button
type="button"
onClick={handleFormat}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
>
<Wand2 className="w-3.5 h-3.5" />
{t("common.format", { defaultValue: "格式化" })}
</button>
{configError && (
<p className="text-xs text-red-500 dark:text-red-400">
{configError}
</p>
)}
</div>
{!configError && (
<p className="text-xs text-gray-500 dark:text-gray-400">
{t("geminiConfig.configJsonHint", {
defaultValue: "使用 JSON 格式配置 Gemini 扩展参数(可选)",
})}
</p>
)}
</div>
);
};

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,
};
}