mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-25 13:45:03 +08:00
refactor: unify common config hooks with generic base hook and adapters
- Create useCommonConfigBase generic hook (~300 lines) - Create commonConfigAdapters for Claude (JSON), Codex (TOML), Gemini (ENV/JSON) - Refactor three hooks from ~1370 lines to ~430 lines (-940 lines) - Extract useDarkMode hook from three ConfigSections components - Remove dead code: backend _str functions, frontend JSON/TOML unused exports - Deduplicate deepClone/deepMerge utilities - Fix duplicate mod tests in provider.rs
This commit is contained in:
@@ -6,9 +6,6 @@
|
||||
//!
|
||||
//! Supports JSON (Claude, Gemini) and TOML (Codex) formats.
|
||||
|
||||
// Allow dead code as this is a utility module with functions available for future use
|
||||
#![allow(dead_code)]
|
||||
|
||||
use serde_json::{Map, Value as JsonValue};
|
||||
use toml::Value as TomlValue;
|
||||
|
||||
@@ -94,55 +91,6 @@ pub fn compute_final_json_config(
|
||||
result
|
||||
}
|
||||
|
||||
/// Compute final JSON config from strings.
|
||||
///
|
||||
/// # Arguments
|
||||
/// * `custom_config_json` - Custom configuration as JSON string
|
||||
/// * `common_config_json` - Common configuration as JSON string
|
||||
/// * `enabled` - Whether common config is enabled
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (final_config_json, error_message)
|
||||
pub fn compute_final_json_config_str(
|
||||
custom_config_json: &str,
|
||||
common_config_json: &str,
|
||||
enabled: bool,
|
||||
) -> (String, Option<String>) {
|
||||
let custom_config: JsonValue = match serde_json::from_str(custom_config_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
custom_config_json.to_string(),
|
||||
Some("Failed to parse custom config JSON".to_string()),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let common_config: JsonValue = if common_config_json.trim().is_empty() {
|
||||
JsonValue::Object(Map::new())
|
||||
} else {
|
||||
match serde_json::from_str(common_config_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
custom_config_json.to_string(),
|
||||
Some("Failed to parse common config JSON".to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let final_config = compute_final_json_config(&custom_config, &common_config, enabled);
|
||||
|
||||
match serde_json::to_string_pretty(&final_config) {
|
||||
Ok(s) => (s, None),
|
||||
Err(e) => (
|
||||
custom_config_json.to_string(),
|
||||
Some(format!("Failed to serialize: {e}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if two JSON values are deeply equal.
|
||||
fn json_deep_equal(a: &JsonValue, b: &JsonValue) -> bool {
|
||||
match (a, b) {
|
||||
@@ -247,52 +195,6 @@ pub fn extract_json_difference(
|
||||
(JsonValue::Object(custom_config), has_common_keys)
|
||||
}
|
||||
|
||||
/// Extract difference from JSON strings.
|
||||
///
|
||||
/// # Returns
|
||||
/// Tuple of (custom_config_json, has_common_keys, error_message)
|
||||
pub fn extract_json_difference_str(
|
||||
live_config_json: &str,
|
||||
common_config_json: &str,
|
||||
) -> (String, bool, Option<String>) {
|
||||
let live_config: JsonValue = match serde_json::from_str(live_config_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
live_config_json.to_string(),
|
||||
false,
|
||||
Some("Failed to parse live config JSON".to_string()),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let common_config: JsonValue = if common_config_json.trim().is_empty() {
|
||||
JsonValue::Object(Map::new())
|
||||
} else {
|
||||
match serde_json::from_str(common_config_json) {
|
||||
Ok(v) => v,
|
||||
Err(_) => {
|
||||
return (
|
||||
live_config_json.to_string(),
|
||||
false,
|
||||
Some("Failed to parse common config JSON".to_string()),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let (custom_config, has_common_keys) = extract_json_difference(&live_config, &common_config);
|
||||
|
||||
match serde_json::to_string_pretty(&custom_config) {
|
||||
Ok(s) => (s, has_common_keys, None),
|
||||
Err(e) => (
|
||||
live_config_json.to_string(),
|
||||
false,
|
||||
Some(format!("Failed to serialize: {e}")),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOML Configuration Merge Functions
|
||||
// ============================================================================
|
||||
|
||||
@@ -697,7 +697,7 @@ pub struct OpenCodeModelLimit {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
mod provider_tests {
|
||||
use super::{
|
||||
ClaudeModelConfig, CodexModelConfig, GeminiModelConfig, OpenCodeProviderConfig, Provider,
|
||||
ProviderManager, ProviderMeta, UniversalProvider,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
|
||||
interface CodexAuthSectionProps {
|
||||
value: string;
|
||||
@@ -21,22 +22,7 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
||||
error,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const isDarkMode = useDarkMode();
|
||||
|
||||
const handleChange = (newValue: string) => {
|
||||
onChange(newValue);
|
||||
@@ -104,24 +90,9 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||
finalConfig,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const isDarkMode = useDarkMode();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 当启用通用配置时,自动显示预览
|
||||
useEffect(() => {
|
||||
if (useCommonConfig && finalConfig) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Label } from "@/components/ui/label";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Save, Download, Loader2, Eye, EyeOff } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
|
||||
interface CommonConfigEditorProps {
|
||||
value: string;
|
||||
@@ -39,24 +40,9 @@ export function CommonConfigEditor({
|
||||
finalConfig,
|
||||
}: CommonConfigEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const isDarkMode = useDarkMode();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 当启用通用配置时,自动显示预览
|
||||
useEffect(() => {
|
||||
if (useCommonConfig && finalConfig) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import JsonEditor from "@/components/JsonEditor";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useDarkMode } from "@/hooks/useDarkMode";
|
||||
|
||||
interface GeminiEnvSectionProps {
|
||||
value: string;
|
||||
@@ -32,24 +33,9 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
||||
finalEnv,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
const isDarkMode = useDarkMode();
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
// 当启用通用配置时,自动显示预览
|
||||
useEffect(() => {
|
||||
if (useCommonConfig && finalEnv) {
|
||||
@@ -215,22 +201,7 @@ export const GeminiConfigSection: React.FC<GeminiConfigSectionProps> = ({
|
||||
configError,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
const isDarkMode = useDarkMode();
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -1,51 +1,24 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import TOML from "smol-toml";
|
||||
import { configApi } from "@/lib/api";
|
||||
import {
|
||||
computeFinalTomlConfig,
|
||||
extractTomlDifference,
|
||||
} from "@/utils/tomlConfigMerge";
|
||||
useCommonConfigBase,
|
||||
type UseCommonConfigBaseReturn,
|
||||
} from "@/hooks/useCommonConfigBase";
|
||||
import { codexAdapter } from "@/hooks/commonConfigAdapters";
|
||||
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
||||
import type { ProviderMeta } from "@/types";
|
||||
|
||||
const LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
||||
const DEFAULT_CODEX_COMMON_CONFIG_SNIPPET = `# Common Codex config
|
||||
# Add your common TOML configuration here`;
|
||||
|
||||
/** TOML 校验错误码 */
|
||||
export type TomlValidationErrorCode =
|
||||
| "TOML_SYNTAX_ERROR"
|
||||
| "TOML_PARSE_FAILED"
|
||||
| "";
|
||||
|
||||
/**
|
||||
* 校验 TOML 格式
|
||||
* @param tomlText - 待校验的 TOML 文本
|
||||
* @returns 错误码,如果校验通过则返回空字符串
|
||||
*/
|
||||
function validateTomlFormat(tomlText: string): TomlValidationErrorCode {
|
||||
// 空字符串或仅包含注释/空行视为合法
|
||||
const lines = tomlText.split("\n");
|
||||
const hasContent = lines.some((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed && !trimmed.startsWith("#");
|
||||
});
|
||||
if (!hasContent) {
|
||||
return "";
|
||||
}
|
||||
|
||||
try {
|
||||
TOML.parse(tomlText);
|
||||
return "";
|
||||
} catch {
|
||||
return "TOML_SYNTAX_ERROR";
|
||||
}
|
||||
}
|
||||
|
||||
interface UseCodexCommonConfigProps {
|
||||
/**
|
||||
* 当前 Codex 配置(JSON 格式,包含 auth 和 config)
|
||||
* 当前 Codex 配置(可能是纯 TOML 或 JSON wrapper 格式)
|
||||
*/
|
||||
codexConfig: string;
|
||||
/**
|
||||
@@ -97,300 +70,74 @@ export interface UseCodexCommonConfigReturn {
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Codex 通用配置片段(重构版)
|
||||
* 检测 codexConfig 是否是 JSON wrapper 格式
|
||||
* @returns 如果是 JSON wrapper 返回解析后的对象,否则返回 null
|
||||
*/
|
||||
function detectJsonWrapperFormat(
|
||||
codexConfig: string,
|
||||
): { auth?: unknown; config?: string } | null {
|
||||
try {
|
||||
const parsed = JSON.parse(codexConfig);
|
||||
if (typeof parsed?.config === "string") {
|
||||
return parsed;
|
||||
}
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return parsed; // JSON 对象但没有 config 字段
|
||||
}
|
||||
} catch {
|
||||
// 不是 JSON
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Codex 通用配置片段
|
||||
*
|
||||
* 新架构:
|
||||
* - codexConfig:传入当前配置(JSON 格式,包含 auth 和 config)
|
||||
* - commonConfigSnippet:存储在数据库中的通用 TOML 配置片段
|
||||
* - finalConfig:运行时计算 = merge(commonConfig, config)
|
||||
* - 开启/关闭通用配置只改变 enabled 状态,不修改 codexConfig
|
||||
* 基于 useCommonConfigBase 泛型 Hook + Codex TOML 适配器实现。
|
||||
* 额外处理 Codex 双格式(纯 TOML / JSON wrapper)的写回逻辑。
|
||||
*/
|
||||
export function useCodexCommonConfig({
|
||||
codexConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
// currentProviderId is reserved for future use
|
||||
}: UseCodexCommonConfigProps): UseCodexCommonConfigReturn {
|
||||
const { t } = useTranslation();
|
||||
const adapter = useMemo(() => codexAdapter, []);
|
||||
|
||||
// 内部管理的通用配置启用状态
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
// 额外的 isExtracting 状态(base hook 的 handleExtract 不适用于 Codex)
|
||||
const [localIsExtracting, setLocalIsExtracting] = useState(false);
|
||||
const [localExtractError, setLocalExtractError] = useState("");
|
||||
|
||||
// 通用配置片段(从数据库加载)
|
||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||
DEFAULT_CODEX_COMMON_CONFIG_SNIPPET,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
// 是否有待保存的通用配置变更
|
||||
const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false);
|
||||
const base: UseCommonConfigBaseReturn<string> = useCommonConfigBase({
|
||||
adapter,
|
||||
inputValue: codexConfig,
|
||||
onInputChange: onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
});
|
||||
|
||||
// 用于跟踪编辑模式是否已初始化
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// 检查 TOML 片段是否有实质内容
|
||||
const hasSnippetContent = useCallback((snippet: string) => {
|
||||
const lines = snippet.split("\n");
|
||||
return lines.some((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed && !trimmed.startsWith("#");
|
||||
});
|
||||
}, []);
|
||||
|
||||
// 从 codexConfig 提取 config 字段(TOML 格式)
|
||||
// 注意:codexConfig 可能是以下两种格式之一:
|
||||
// 1. 直接的 TOML 字符串(来自 useCodexConfigState)
|
||||
// 2. JSON 字符串 { auth: {...}, config: "..." }(旧的 settingsConfig 格式)
|
||||
const extractConfigToml = useCallback((configInput: string): string => {
|
||||
if (!configInput || !configInput.trim()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 尝试解析为 JSON(旧格式)
|
||||
try {
|
||||
const parsed = JSON.parse(configInput);
|
||||
if (typeof parsed?.config === "string") {
|
||||
return parsed.config;
|
||||
}
|
||||
// 如果是 JSON 对象但没有 config 字段,返回空
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return "";
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,说明是直接的 TOML 字符串
|
||||
}
|
||||
|
||||
// 直接返回作为 TOML 字符串
|
||||
return configInput;
|
||||
}, []);
|
||||
|
||||
// 当预设变化时,重置初始化标记
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
|
||||
// ============================================================================
|
||||
// 加载通用配置片段(从数据库,支持 localStorage 迁移)
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadSnippet = async () => {
|
||||
try {
|
||||
const snippet = await configApi.getCommonConfigSnippet("codex");
|
||||
|
||||
if (snippet && snippet.trim()) {
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(snippet);
|
||||
}
|
||||
} else {
|
||||
// 尝试从 localStorage 迁移
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const legacySnippet =
|
||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (legacySnippet && legacySnippet.trim()) {
|
||||
const tomlError = validateTomlFormat(legacySnippet);
|
||||
if (!tomlError) {
|
||||
await configApi.setCommonConfigSnippet(
|
||||
"codex",
|
||||
legacySnippet,
|
||||
);
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(legacySnippet);
|
||||
}
|
||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
console.log(
|
||||
"[迁移] Codex 通用配置已从 localStorage 迁移到数据库",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载 Codex 通用配置失败:", error);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSnippet();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// 编辑模式初始化:从 meta 读取启用状态
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (initialData && !isLoading && !hasInitializedEditMode.current) {
|
||||
hasInitializedEditMode.current = true;
|
||||
|
||||
const metaByApp = initialData.meta?.commonConfigEnabledByApp;
|
||||
const resolvedMetaEnabled =
|
||||
metaByApp?.codex ?? initialData.meta?.commonConfigEnabled;
|
||||
|
||||
if (resolvedMetaEnabled !== undefined) {
|
||||
if (!resolvedMetaEnabled) {
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
if (!hasSnippetContent(commonConfigSnippet)) {
|
||||
setCommonConfigError(t("codexConfig.noCommonConfigToApply"));
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
const tomlError = validateTomlFormat(commonConfigSnippet);
|
||||
if (tomlError) {
|
||||
setCommonConfigError(
|
||||
t("codexConfig.tomlFormatError", { defaultValue: "TOML 格式错误" }),
|
||||
);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(true);
|
||||
} else {
|
||||
setUseCommonConfig(false);
|
||||
}
|
||||
}
|
||||
}, [initialData, isLoading, commonConfigSnippet, hasSnippetContent, t]);
|
||||
|
||||
// ============================================================================
|
||||
// 新建模式初始化:如果通用配置有效,默认启用
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
if (hasSnippetContent(commonConfigSnippet)) {
|
||||
const tomlError = validateTomlFormat(commonConfigSnippet);
|
||||
if (!tomlError) {
|
||||
setUseCommonConfig(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [initialData, commonConfigSnippet, isLoading, hasSnippetContent]);
|
||||
|
||||
// ============================================================================
|
||||
// 计算最终配置(运行时合并)
|
||||
// ============================================================================
|
||||
const finalConfig = useMemo((): string => {
|
||||
const customToml = extractConfigToml(codexConfig);
|
||||
|
||||
if (!useCommonConfig) {
|
||||
return customToml;
|
||||
}
|
||||
|
||||
const result = computeFinalTomlConfig(
|
||||
customToml,
|
||||
commonConfigSnippet,
|
||||
true,
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
// 合并失败时返回原配置
|
||||
return customToml;
|
||||
}
|
||||
|
||||
return result.finalConfig;
|
||||
}, [codexConfig, commonConfigSnippet, useCommonConfig, extractConfigToml]);
|
||||
|
||||
// ============================================================================
|
||||
// 处理通用配置开关
|
||||
// ============================================================================
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
if (!hasSnippetContent(commonConfigSnippet)) {
|
||||
setCommonConfigError(t("codexConfig.noCommonConfigToApply"));
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
const tomlError = validateTomlFormat(commonConfigSnippet);
|
||||
if (tomlError) {
|
||||
setCommonConfigError(
|
||||
t("codexConfig.tomlFormatError", { defaultValue: "TOML 格式错误" }),
|
||||
);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(checked);
|
||||
// 新架构:不修改 codexConfig,只改变 enabled 状态
|
||||
},
|
||||
[commonConfigSnippet, hasSnippetContent, t],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 处理通用配置片段变化(延迟保存模式:只更新本地状态,实际保存在表单提交时)
|
||||
// ============================================================================
|
||||
const handleCommonConfigSnippetChange = useCallback(
|
||||
(value: string) => {
|
||||
setCommonConfigSnippetState(value);
|
||||
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
setHasUnsavedCommonConfig(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// TOML 格式校验
|
||||
const tomlError = validateTomlFormat(value);
|
||||
if (tomlError) {
|
||||
setCommonConfigError(
|
||||
t("codexConfig.tomlSyntaxError", {
|
||||
defaultValue: "TOML 格式错误,请检查语法",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
setHasUnsavedCommonConfig(true);
|
||||
|
||||
// 注意:新架构下不再需要同步到其他供应商的 settingsConfig
|
||||
// 因为 finalConfig 是运行时计算的
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 从当前最终配置提取通用配置片段(延迟保存模式:只更新本地状态,实际保存在表单提交时)
|
||||
// ============================================================================
|
||||
// Codex 自定义 handleExtract:处理双格式写回 + 状态管理
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
setLocalIsExtracting(true);
|
||||
setLocalExtractError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("codex", {
|
||||
settingsConfig: JSON.stringify({
|
||||
config: finalConfig ?? "",
|
||||
}),
|
||||
});
|
||||
const request = adapter.buildExtractRequest(base.finalValue);
|
||||
const extracted = await configApi.extractCommonConfigSnippet(
|
||||
"codex",
|
||||
request,
|
||||
);
|
||||
|
||||
if (!extracted || !extracted.trim()) {
|
||||
setCommonConfigError(t("codexConfig.extractNoCommonConfig"));
|
||||
setLocalExtractError(t("codexConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 TOML 格式
|
||||
const tomlError = validateTomlFormat(extracted);
|
||||
if (tomlError) {
|
||||
setCommonConfigError(
|
||||
const parseResult = adapter.parseSnippet(extracted);
|
||||
if (parseResult.error || parseResult.config === null) {
|
||||
setLocalExtractError(
|
||||
t("codexConfig.extractedTomlInvalid", {
|
||||
defaultValue: "提取的配置 TOML 格式错误",
|
||||
}),
|
||||
@@ -398,37 +145,26 @@ export function useCodexCommonConfig({
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态(延迟保存:不立即调用后端 API)
|
||||
setCommonConfigSnippetState(extracted);
|
||||
setHasUnsavedCommonConfig(true);
|
||||
// 更新 snippet 状态(通过 base 的 handler)
|
||||
base.handleCommonConfigSnippetChange(extracted);
|
||||
|
||||
// 提取成功后,从 config 中移除与 extracted 相同的部分
|
||||
const customToml = extractConfigToml(codexConfig);
|
||||
// 从 config 中移除与 extracted 相同的部分
|
||||
const customToml = adapter.parseInput(codexConfig);
|
||||
const diffResult = extractTomlDifference(customToml, extracted);
|
||||
if (!diffResult.error) {
|
||||
// 更新 codexConfig
|
||||
// codexConfig 可能是两种格式:
|
||||
// 1. 纯 TOML 字符串(来自 useCodexConfigState)
|
||||
// 2. JSON 字符串 { auth: {...}, config: "..." }(旧格式)
|
||||
let updated = false;
|
||||
try {
|
||||
const parsed = JSON.parse(codexConfig);
|
||||
if (typeof parsed?.config === "string") {
|
||||
// JSON 格式,更新 config 字段
|
||||
parsed.config = diffResult.customToml;
|
||||
onConfigChange(JSON.stringify(parsed, null, 2));
|
||||
updated = true;
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,说明是纯 TOML 字符串
|
||||
}
|
||||
|
||||
// 如果是纯 TOML 字符串,直接更新
|
||||
if (!updated) {
|
||||
if (!diffResult.error) {
|
||||
// Codex 双格式写回:检测原始格式并保持一致
|
||||
const jsonWrapper = detectJsonWrapperFormat(codexConfig);
|
||||
|
||||
if (jsonWrapper && typeof jsonWrapper.config === "string") {
|
||||
// JSON wrapper 格式,更新 config 字段
|
||||
jsonWrapper.config = diffResult.customToml;
|
||||
onConfigChange(JSON.stringify(jsonWrapper, null, 2));
|
||||
} else {
|
||||
// 纯 TOML 格式
|
||||
onConfigChange(diffResult.customToml);
|
||||
}
|
||||
|
||||
// Notify user that config was modified (提示用户需要保存)
|
||||
toast.success(
|
||||
t("codexConfig.extractSuccessNeedSave", {
|
||||
defaultValue: "已提取通用配置,点击保存按钮完成保存",
|
||||
@@ -437,40 +173,32 @@ export function useCodexCommonConfig({
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("提取 Codex 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("codexConfig.extractFailed", { error: String(error) }),
|
||||
setLocalExtractError(
|
||||
t("codexConfig.extractFailed", {
|
||||
error: String(error),
|
||||
defaultValue: "提取失败",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
setLocalIsExtracting(false);
|
||||
}
|
||||
}, [finalConfig, codexConfig, onConfigChange, extractConfigToml, t]);
|
||||
}, [adapter, base, codexConfig, onConfigChange, t]);
|
||||
|
||||
// ============================================================================
|
||||
// 获取待保存的通用配置片段(用于 handleSubmit 中统一保存)
|
||||
// ============================================================================
|
||||
const getPendingCommonConfigSnippet = useCallback(() => {
|
||||
return hasUnsavedCommonConfig ? commonConfigSnippet : null;
|
||||
}, [hasUnsavedCommonConfig, commonConfigSnippet]);
|
||||
|
||||
// ============================================================================
|
||||
// 标记通用配置已保存
|
||||
// ============================================================================
|
||||
const markCommonConfigSaved = useCallback(() => {
|
||||
setHasUnsavedCommonConfig(false);
|
||||
}, []);
|
||||
// 合并 error:优先显示 extract 错误,其次是 base 的错误
|
||||
const combinedError = localExtractError || base.commonConfigError;
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
useCommonConfig: base.useCommonConfig,
|
||||
commonConfigSnippet: base.commonConfigSnippet,
|
||||
commonConfigError: combinedError,
|
||||
isLoading: base.isLoading,
|
||||
isExtracting: localIsExtracting,
|
||||
handleCommonConfigToggle: base.handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
finalConfig,
|
||||
hasUnsavedCommonConfig,
|
||||
getPendingCommonConfigSnippet,
|
||||
markCommonConfigSaved,
|
||||
finalConfig: base.finalValue,
|
||||
hasUnsavedCommonConfig: base.hasUnsavedCommonConfig,
|
||||
getPendingCommonConfigSnippet: base.getPendingCommonConfigSnippet,
|
||||
markCommonConfigSaved: base.markCommonConfigSaved,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,20 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { validateJsonConfig } from "@/utils/providerConfigUtils";
|
||||
import { configApi } from "@/lib/api";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
computeFinalConfig,
|
||||
extractDifference,
|
||||
isPlainObject,
|
||||
} from "@/utils/configMerge";
|
||||
useCommonConfigBase,
|
||||
type UseCommonConfigBaseReturn,
|
||||
} from "@/hooks/useCommonConfigBase";
|
||||
import { claudeAdapter } from "@/hooks/commonConfigAdapters";
|
||||
import type { ProviderMeta } from "@/types";
|
||||
|
||||
const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
|
||||
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
|
||||
"includeCoAuthoredBy": false
|
||||
}`;
|
||||
|
||||
interface UseCommonConfigSnippetProps {
|
||||
/**
|
||||
* 当前配置(用于显示和运行时合并)
|
||||
@@ -74,13 +65,9 @@ export interface UseCommonConfigSnippetReturn {
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Claude 通用配置片段(重构版)
|
||||
* 管理 Claude 通用配置片段
|
||||
*
|
||||
* 新架构:
|
||||
* - settingsConfig:传入自定义配置(供应商独有部分)
|
||||
* - commonConfigSnippet:存储在数据库中的通用配置片段
|
||||
* - finalConfig:运行时计算 = merge(commonConfig, settingsConfig)
|
||||
* - 开启/关闭通用配置只改变 enabled 状态,不修改 settingsConfig
|
||||
* 基于 useCommonConfigBase 泛型 Hook + Claude JSON 适配器实现。
|
||||
*/
|
||||
export function useCommonConfigSnippet({
|
||||
settingsConfig,
|
||||
@@ -88,368 +75,30 @@ export function useCommonConfigSnippet({
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
enabled = true,
|
||||
// currentProviderId is reserved for future use
|
||||
}: UseCommonConfigSnippetProps): UseCommonConfigSnippetReturn {
|
||||
const { t } = useTranslation();
|
||||
const adapter = useMemo(() => claudeAdapter, []);
|
||||
|
||||
// 内部管理的通用配置启用状态
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
|
||||
// 通用配置片段(从数据库加载)
|
||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||
DEFAULT_COMMON_CONFIG_SNIPPET,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
// 是否有待保存的通用配置变更
|
||||
const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false);
|
||||
|
||||
// 用于跟踪编辑模式是否已初始化
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// 当预设变化时,重置初始化标记
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
hasInitializedNewMode.current = false;
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId, enabled]);
|
||||
|
||||
// 解析 JSON 配置片段
|
||||
const parseSnippet = useCallback(
|
||||
(
|
||||
snippetString: string,
|
||||
): { config: Record<string, unknown>; error?: string } => {
|
||||
const trimmed = snippetString.trim();
|
||||
if (!trimmed) {
|
||||
return { config: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!isPlainObject(parsed)) {
|
||||
return { config: {}, error: t("claudeConfig.invalidJsonFormat") };
|
||||
}
|
||||
return { config: parsed };
|
||||
} catch {
|
||||
return { config: {}, error: t("claudeConfig.invalidJsonFormat") };
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// 获取片段应用错误
|
||||
const getSnippetApplyError = useCallback(
|
||||
(snippet: string) => {
|
||||
if (!snippet.trim()) {
|
||||
return t("claudeConfig.noCommonConfigToApply");
|
||||
}
|
||||
const validationError = validateJsonConfig(snippet, "通用配置片段");
|
||||
if (validationError) {
|
||||
return validationError;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(snippet) as Record<string, unknown>;
|
||||
if (Object.keys(parsed).length === 0) {
|
||||
return t("claudeConfig.noCommonConfigToApply");
|
||||
}
|
||||
} catch {
|
||||
return t("claudeConfig.noCommonConfigToApply");
|
||||
}
|
||||
return "";
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 加载通用配置片段(从数据库,支持 localStorage 迁移)
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
let mounted = true;
|
||||
|
||||
const loadSnippet = async () => {
|
||||
try {
|
||||
const snippet = await configApi.getCommonConfigSnippet("claude");
|
||||
|
||||
if (snippet && snippet.trim()) {
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(snippet);
|
||||
}
|
||||
} else {
|
||||
// 尝试从 localStorage 迁移
|
||||
if (typeof window !== "undefined") {
|
||||
try {
|
||||
const legacySnippet =
|
||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (legacySnippet && legacySnippet.trim()) {
|
||||
const parsed = parseSnippet(legacySnippet);
|
||||
if (!parsed.error) {
|
||||
await configApi.setCommonConfigSnippet(
|
||||
"claude",
|
||||
legacySnippet,
|
||||
);
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(legacySnippet);
|
||||
}
|
||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
console.log(
|
||||
"[迁移] Claude 通用配置已从 localStorage 迁移到数据库",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载通用配置失败:", error);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSnippet();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [enabled, parseSnippet]);
|
||||
|
||||
// ============================================================================
|
||||
// 编辑模式初始化:从 meta 读取启用状态
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (initialData && !isLoading && !hasInitializedEditMode.current) {
|
||||
hasInitializedEditMode.current = true;
|
||||
|
||||
const metaByApp = initialData.meta?.commonConfigEnabledByApp;
|
||||
const resolvedMetaEnabled =
|
||||
metaByApp?.claude ?? initialData.meta?.commonConfigEnabled;
|
||||
|
||||
if (resolvedMetaEnabled !== undefined) {
|
||||
if (!resolvedMetaEnabled) {
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
const snippetError = getSnippetApplyError(commonConfigSnippet);
|
||||
if (snippetError) {
|
||||
setCommonConfigError(snippetError);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(true);
|
||||
} else {
|
||||
setUseCommonConfig(false);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
enabled,
|
||||
const base: UseCommonConfigBaseReturn<string> = useCommonConfigBase({
|
||||
adapter,
|
||||
inputValue: settingsConfig,
|
||||
onInputChange: onConfigChange,
|
||||
initialData,
|
||||
isLoading,
|
||||
commonConfigSnippet,
|
||||
getSnippetApplyError,
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// 新建模式初始化:如果通用配置有效,默认启用
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
const parsed = parseSnippet(commonConfigSnippet);
|
||||
if (!parsed.error && Object.keys(parsed.config).length > 0) {
|
||||
setUseCommonConfig(true);
|
||||
}
|
||||
}
|
||||
}, [enabled, initialData, commonConfigSnippet, isLoading, parseSnippet]);
|
||||
|
||||
// ============================================================================
|
||||
// 计算最终配置(运行时合并)
|
||||
// ============================================================================
|
||||
const finalConfig = useMemo((): string => {
|
||||
if (!enabled) return settingsConfig;
|
||||
|
||||
try {
|
||||
const customParsed = settingsConfig ? JSON.parse(settingsConfig) : {};
|
||||
if (!isPlainObject(customParsed)) {
|
||||
return settingsConfig;
|
||||
}
|
||||
|
||||
if (!useCommonConfig) {
|
||||
return settingsConfig;
|
||||
}
|
||||
|
||||
const snippetParsed = parseSnippet(commonConfigSnippet);
|
||||
if (
|
||||
snippetParsed.error ||
|
||||
Object.keys(snippetParsed.config).length === 0
|
||||
) {
|
||||
return settingsConfig;
|
||||
}
|
||||
|
||||
// 通用配置作为 base,自定义配置覆盖
|
||||
const merged = computeFinalConfig(
|
||||
customParsed,
|
||||
snippetParsed.config,
|
||||
true,
|
||||
);
|
||||
|
||||
return JSON.stringify(merged, null, 2);
|
||||
} catch {
|
||||
return settingsConfig;
|
||||
}
|
||||
}, [
|
||||
selectedPresetId,
|
||||
enabled,
|
||||
settingsConfig,
|
||||
commonConfigSnippet,
|
||||
useCommonConfig,
|
||||
parseSnippet,
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// 处理通用配置开关
|
||||
// ============================================================================
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
const snippetError = getSnippetApplyError(commonConfigSnippet);
|
||||
if (snippetError) {
|
||||
setCommonConfigError(snippetError);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(checked);
|
||||
// 新架构:不修改 settingsConfig,只改变 enabled 状态
|
||||
},
|
||||
[commonConfigSnippet, getSnippetApplyError],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 处理通用配置片段变化(延迟保存模式:只更新本地状态,实际保存在表单提交时)
|
||||
// ============================================================================
|
||||
const handleCommonConfigSnippetChange = useCallback((value: string) => {
|
||||
setCommonConfigSnippetState(value);
|
||||
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
setHasUnsavedCommonConfig(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON 格式校验
|
||||
const validationError = validateJsonConfig(value, "通用配置片段");
|
||||
if (validationError) {
|
||||
setCommonConfigError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
setHasUnsavedCommonConfig(true);
|
||||
|
||||
// 注意:新架构下不再需要同步到其他供应商的 settingsConfig
|
||||
// 因为 finalConfig 是运行时计算的
|
||||
}, []);
|
||||
|
||||
// ============================================================================
|
||||
// 从当前最终配置提取通用配置片段(延迟保存模式:只更新本地状态,实际保存在表单提交时)
|
||||
// ============================================================================
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("claude", {
|
||||
settingsConfig: finalConfig,
|
||||
});
|
||||
|
||||
if (!extracted || extracted === "{}") {
|
||||
setCommonConfigError(t("claudeConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
const validationError = validateJsonConfig(extracted, "提取的配置");
|
||||
if (validationError) {
|
||||
setCommonConfigError(t("claudeConfig.extractedConfigInvalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态(延迟保存:不立即调用后端 API)
|
||||
setCommonConfigSnippetState(extracted);
|
||||
setHasUnsavedCommonConfig(true);
|
||||
|
||||
// 提取成功后,从 settingsConfig 中移除与 extracted 相同的部分
|
||||
try {
|
||||
const customParsed = settingsConfig ? JSON.parse(settingsConfig) : {};
|
||||
const extractedParsed = JSON.parse(extracted);
|
||||
|
||||
if (isPlainObject(customParsed) && isPlainObject(extractedParsed)) {
|
||||
const diffResult = extractDifference(customParsed, extractedParsed);
|
||||
onConfigChange(JSON.stringify(diffResult.customConfig, null, 2));
|
||||
// Notify user that config was modified (提示用户需要保存)
|
||||
toast.success(
|
||||
t("claudeConfig.extractSuccessNeedSave", {
|
||||
defaultValue: "已提取通用配置,点击保存按钮完成保存",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn(
|
||||
"[Extract] Failed to update settingsConfig after extract:",
|
||||
parseError,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("提取通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("claudeConfig.extractFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}, [finalConfig, settingsConfig, onConfigChange, t]);
|
||||
|
||||
// ============================================================================
|
||||
// 获取待保存的通用配置片段(用于 handleSubmit 中统一保存)
|
||||
// ============================================================================
|
||||
const getPendingCommonConfigSnippet = useCallback(() => {
|
||||
return hasUnsavedCommonConfig ? commonConfigSnippet : null;
|
||||
}, [hasUnsavedCommonConfig, commonConfigSnippet]);
|
||||
|
||||
// ============================================================================
|
||||
// 标记通用配置已保存
|
||||
// ============================================================================
|
||||
const markCommonConfigSaved = useCallback(() => {
|
||||
setHasUnsavedCommonConfig(false);
|
||||
}, []);
|
||||
});
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
finalConfig,
|
||||
hasUnsavedCommonConfig,
|
||||
getPendingCommonConfigSnippet,
|
||||
markCommonConfigSaved,
|
||||
useCommonConfig: base.useCommonConfig,
|
||||
commonConfigSnippet: base.commonConfigSnippet,
|
||||
commonConfigError: base.commonConfigError,
|
||||
isLoading: base.isLoading,
|
||||
isExtracting: base.isExtracting,
|
||||
handleCommonConfigToggle: base.handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange,
|
||||
handleExtract: base.handleExtract,
|
||||
finalConfig: base.finalValue,
|
||||
hasUnsavedCommonConfig: base.hasUnsavedCommonConfig,
|
||||
getPendingCommonConfigSnippet: base.getPendingCommonConfigSnippet,
|
||||
markCommonConfigSaved: base.markCommonConfigSaved,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { configApi } from "@/lib/api";
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
parseGeminiCommonConfigSnippet,
|
||||
GEMINI_CONFIG_ERROR_CODES,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import { computeFinalConfig, extractDifference } from "@/utils/configMerge";
|
||||
useCommonConfigBase,
|
||||
type UseCommonConfigBaseReturn,
|
||||
} from "@/hooks/useCommonConfigBase";
|
||||
import { createGeminiAdapter } from "@/hooks/commonConfigAdapters";
|
||||
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")
|
||||
@@ -74,9 +68,11 @@ export interface UseGeminiCommonConfigReturn {
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Gemini 通用配置片段(重构版)
|
||||
* 管理 Gemini 通用配置片段
|
||||
*
|
||||
* 新架构:
|
||||
* 基于 useCommonConfigBase 泛型 Hook + Gemini ENV/JSON 适配器实现。
|
||||
*
|
||||
* 架构:
|
||||
* - envValue:传入当前 env 字符串
|
||||
* - commonConfigSnippet:存储在数据库中的通用配置片段
|
||||
* - finalEnv:运行时计算 = merge(commonConfig, customEnv)
|
||||
@@ -89,355 +85,34 @@ export function useGeminiCommonConfig({
|
||||
envObjToString,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
// 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,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
// 是否有待保存的通用配置变更
|
||||
const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false);
|
||||
|
||||
// 用于跟踪编辑模式是否已初始化
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
// 用于跟踪新建模式是否已初始化
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// 将 envValue 字符串转换为对象
|
||||
const customEnv = useMemo(
|
||||
() => envStringToObj(envValue),
|
||||
[envValue, envStringToObj],
|
||||
// 创建适配器(需要转换函数)
|
||||
const adapter = useMemo(
|
||||
() => createGeminiAdapter({ envStringToObj, envObjToString }),
|
||||
[envStringToObj, envObjToString],
|
||||
);
|
||||
|
||||
// 当预设变化时,重置初始化标记
|
||||
useEffect(() => {
|
||||
hasInitializedNewMode.current = false;
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId]);
|
||||
|
||||
// 解析通用配置片段 - 使用共享解析器
|
||||
// 支持三种格式: ENV (KEY=VALUE), 扁平 JSON, 包裹 JSON {"env":{...}}
|
||||
const parseSnippetEnv = useCallback(
|
||||
(
|
||||
snippetString: string,
|
||||
): { env: Record<string, string>; error?: string } => {
|
||||
const result = parseGeminiCommonConfigSnippet(snippetString, {
|
||||
strictForbiddenKeys: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
// Map error codes to i18n keys
|
||||
if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
|
||||
const keys = result.error.split(": ")[1] ?? result.error;
|
||||
return {
|
||||
env: {},
|
||||
error: t("geminiConfig.commonConfigInvalidKeys", { keys }),
|
||||
};
|
||||
}
|
||||
if (
|
||||
result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)
|
||||
) {
|
||||
return {
|
||||
env: {},
|
||||
error: t("geminiConfig.commonConfigInvalidValues"),
|
||||
};
|
||||
}
|
||||
// Generic format error (NOT_OBJECT, ENV_NOT_OBJECT, or parse failure)
|
||||
return { env: {}, error: t("geminiConfig.invalidJsonFormat") };
|
||||
}
|
||||
|
||||
return { env: result.env };
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
// 获取片段应用错误
|
||||
const getSnippetApplyError = useCallback(
|
||||
(snippet: string) => {
|
||||
const parsed = parseSnippetEnv(snippet);
|
||||
if (parsed.error) {
|
||||
return parsed.error;
|
||||
}
|
||||
if (Object.keys(parsed.env).length === 0) {
|
||||
return t("geminiConfig.noCommonConfigToApply");
|
||||
}
|
||||
return "";
|
||||
},
|
||||
[parseSnippetEnv, t],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 加载通用配置片段(从数据库,支持 localStorage 迁移)
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
const loadSnippet = async () => {
|
||||
try {
|
||||
const snippet = await configApi.getCommonConfigSnippet("gemini");
|
||||
|
||||
if (snippet && snippet.trim()) {
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(snippet);
|
||||
}
|
||||
} else {
|
||||
// 尝试从 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) {
|
||||
await configApi.setCommonConfigSnippet(
|
||||
"gemini",
|
||||
legacySnippet,
|
||||
);
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(legacySnippet);
|
||||
}
|
||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
console.log(
|
||||
"[迁移] Gemini 通用配置已从 localStorage 迁移到数据库",
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("加载 Gemini 通用配置失败:", error);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSnippet();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [parseSnippetEnv]);
|
||||
|
||||
// ============================================================================
|
||||
// 编辑模式初始化:从 meta 读取启用状态
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (initialData && !isLoading && !hasInitializedEditMode.current) {
|
||||
hasInitializedEditMode.current = true;
|
||||
|
||||
const metaByApp = initialData.meta?.commonConfigEnabledByApp;
|
||||
const resolvedMetaEnabled =
|
||||
metaByApp?.gemini ?? initialData.meta?.commonConfigEnabled;
|
||||
|
||||
if (resolvedMetaEnabled !== undefined) {
|
||||
if (!resolvedMetaEnabled) {
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
const snippetError = getSnippetApplyError(commonConfigSnippet);
|
||||
if (snippetError) {
|
||||
setCommonConfigError(snippetError);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(true);
|
||||
} else {
|
||||
setUseCommonConfig(false);
|
||||
}
|
||||
}
|
||||
}, [initialData, isLoading, commonConfigSnippet, getSnippetApplyError]);
|
||||
|
||||
// ============================================================================
|
||||
// 新建模式初始化:如果通用配置有效,默认启用
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
||||
if (!parsed.error && Object.keys(parsed.env).length > 0) {
|
||||
setUseCommonConfig(true);
|
||||
}
|
||||
}
|
||||
}, [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) => {
|
||||
if (checked) {
|
||||
const snippetError = getSnippetApplyError(commonConfigSnippet);
|
||||
if (snippetError) {
|
||||
setCommonConfigError(snippetError);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(checked);
|
||||
// 新架构:不修改 envValue,只改变 enabled 状态
|
||||
},
|
||||
[commonConfigSnippet, getSnippetApplyError],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 处理通用配置片段变化(延迟保存模式:只更新本地状态,实际保存在表单提交时)
|
||||
// ============================================================================
|
||||
const handleCommonConfigSnippetChange = useCallback(
|
||||
(value: string) => {
|
||||
setCommonConfigSnippetState(value);
|
||||
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
setHasUnsavedCommonConfig(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// JSON 格式校验
|
||||
const parsed = parseSnippetEnv(value);
|
||||
if (parsed.error) {
|
||||
setCommonConfigError(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
setHasUnsavedCommonConfig(true);
|
||||
|
||||
// 注意:新架构下不再需要同步到其他供应商的 settingsConfig
|
||||
// 因为 finalEnv 是运行时计算的
|
||||
},
|
||||
[parseSnippetEnv],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 从当前最终 env 提取通用配置片段(延迟保存模式:只更新本地状态,实际保存在表单提交时)
|
||||
// ============================================================================
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
|
||||
try {
|
||||
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
|
||||
settingsConfig: JSON.stringify({
|
||||
env: finalEnv,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!extracted || extracted === "{}") {
|
||||
setCommonConfigError(t("geminiConfig.extractNoCommonConfig"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证 JSON 格式
|
||||
const parsed = parseSnippetEnv(extracted);
|
||||
if (parsed.error) {
|
||||
setCommonConfigError(t("geminiConfig.extractedConfigInvalid"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态(延迟保存:不立即调用后端 API)
|
||||
setCommonConfigSnippetState(extracted);
|
||||
setHasUnsavedCommonConfig(true);
|
||||
|
||||
// 提取成功后,从 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));
|
||||
// Notify user that config was modified (提示用户需要保存)
|
||||
toast.success(
|
||||
t("geminiConfig.extractSuccessNeedSave", {
|
||||
defaultValue: "已提取通用配置,点击保存按钮完成保存",
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("提取 Gemini 通用配置失败:", error);
|
||||
setCommonConfigError(
|
||||
t("geminiConfig.extractFailed", { error: String(error) }),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}, [finalEnv, customEnv, onEnvChange, envObjToString, parseSnippetEnv, t]);
|
||||
|
||||
// ============================================================================
|
||||
// 获取待保存的通用配置片段(用于 handleSubmit 中统一保存)
|
||||
// ============================================================================
|
||||
const getPendingCommonConfigSnippet = useCallback(() => {
|
||||
return hasUnsavedCommonConfig ? commonConfigSnippet : null;
|
||||
}, [hasUnsavedCommonConfig, commonConfigSnippet]);
|
||||
|
||||
// ============================================================================
|
||||
// 标记通用配置已保存
|
||||
// ============================================================================
|
||||
const markCommonConfigSaved = useCallback(() => {
|
||||
setHasUnsavedCommonConfig(false);
|
||||
}, []);
|
||||
const base: UseCommonConfigBaseReturn<Record<string, string>> =
|
||||
useCommonConfigBase({
|
||||
adapter,
|
||||
inputValue: envValue,
|
||||
onInputChange: onEnvChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
});
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
finalEnv,
|
||||
hasUnsavedCommonConfig,
|
||||
getPendingCommonConfigSnippet,
|
||||
markCommonConfigSaved,
|
||||
useCommonConfig: base.useCommonConfig,
|
||||
commonConfigSnippet: base.commonConfigSnippet,
|
||||
commonConfigError: base.commonConfigError,
|
||||
isLoading: base.isLoading,
|
||||
isExtracting: base.isExtracting,
|
||||
handleCommonConfigToggle: base.handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange: base.handleCommonConfigSnippetChange,
|
||||
handleExtract: base.handleExtract,
|
||||
finalEnv: base.finalValue,
|
||||
hasUnsavedCommonConfig: base.hasUnsavedCommonConfig,
|
||||
getPendingCommonConfigSnippet: base.getPendingCommonConfigSnippet,
|
||||
markCommonConfigSaved: base.markCommonConfigSaved,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,388 @@
|
||||
/**
|
||||
* 通用配置格式适配器
|
||||
*
|
||||
* 提供 Claude (JSON), Codex (TOML), Gemini (ENV/JSON) 三种格式的适配器实现。
|
||||
*/
|
||||
|
||||
import { validateJsonConfig } from "@/utils/providerConfigUtils";
|
||||
import {
|
||||
computeFinalConfig,
|
||||
extractDifference,
|
||||
isPlainObject,
|
||||
} from "@/utils/configMerge";
|
||||
import {
|
||||
computeFinalTomlConfig,
|
||||
extractTomlDifference,
|
||||
safeParseToml,
|
||||
} from "@/utils/tomlConfigMerge";
|
||||
import {
|
||||
parseGeminiCommonConfigSnippet,
|
||||
GEMINI_CONFIG_ERROR_CODES,
|
||||
} from "@/utils/providerConfigUtils";
|
||||
import type {
|
||||
CommonConfigAdapter,
|
||||
ParseResult,
|
||||
ExtractResult,
|
||||
} from "./useCommonConfigBase";
|
||||
|
||||
// ============================================================================
|
||||
// Claude Adapter (JSON)
|
||||
// ============================================================================
|
||||
|
||||
const CLAUDE_LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
|
||||
const CLAUDE_DEFAULT_SNIPPET = `{
|
||||
"includeCoAuthoredBy": false
|
||||
}`;
|
||||
|
||||
export const claudeAdapter: CommonConfigAdapter<
|
||||
Record<string, unknown>,
|
||||
string
|
||||
> = {
|
||||
appKey: "claude",
|
||||
defaultSnippet: CLAUDE_DEFAULT_SNIPPET,
|
||||
legacyStorageKey: CLAUDE_LEGACY_STORAGE_KEY,
|
||||
|
||||
parseSnippet: (snippet: string): ParseResult<Record<string, unknown>> => {
|
||||
const trimmed = snippet.trim();
|
||||
if (!trimmed) {
|
||||
return { config: {}, error: null };
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (!isPlainObject(parsed)) {
|
||||
return { config: null, error: "JSON 格式错误:不是对象" };
|
||||
}
|
||||
return { config: parsed, error: null };
|
||||
} catch {
|
||||
return { config: null, error: "JSON 格式错误" };
|
||||
}
|
||||
},
|
||||
|
||||
hasValidContent: (snippet: string): boolean => {
|
||||
try {
|
||||
const parsed = JSON.parse(snippet.trim());
|
||||
return isPlainObject(parsed) && Object.keys(parsed).length > 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
getApplyError: (snippet: string, t): string => {
|
||||
if (!snippet.trim()) {
|
||||
return t("claudeConfig.noCommonConfigToApply");
|
||||
}
|
||||
const validationError = validateJsonConfig(snippet, "通用配置片段");
|
||||
if (validationError) {
|
||||
return validationError;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(snippet) as Record<string, unknown>;
|
||||
if (Object.keys(parsed).length === 0) {
|
||||
return t("claudeConfig.noCommonConfigToApply");
|
||||
}
|
||||
} catch {
|
||||
return t("claudeConfig.noCommonConfigToApply");
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
parseInput: (input: string): Record<string, unknown> => {
|
||||
try {
|
||||
const parsed = JSON.parse(input || "{}");
|
||||
return isPlainObject(parsed) ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
},
|
||||
|
||||
computeFinal: (
|
||||
custom: Record<string, unknown>,
|
||||
common: Record<string, unknown>,
|
||||
enabled: boolean,
|
||||
): string => {
|
||||
if (!enabled || Object.keys(common).length === 0) {
|
||||
return JSON.stringify(custom, null, 2);
|
||||
}
|
||||
const merged = computeFinalConfig(custom, common, true);
|
||||
return JSON.stringify(merged, null, 2);
|
||||
},
|
||||
|
||||
extractDiff: (
|
||||
custom: Record<string, unknown>,
|
||||
common: Record<string, unknown>,
|
||||
): ExtractResult<Record<string, unknown>> => {
|
||||
const result = extractDifference(custom, common);
|
||||
return {
|
||||
custom: result.customConfig,
|
||||
hasCommonKeys: result.hasCommonKeys,
|
||||
};
|
||||
},
|
||||
|
||||
serializeOutput: (config: Record<string, unknown>): string => {
|
||||
return JSON.stringify(config, null, 2);
|
||||
},
|
||||
|
||||
buildExtractRequest: (finalValue: string): { settingsConfig: string } => {
|
||||
return { settingsConfig: finalValue };
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Codex Adapter (TOML)
|
||||
// ============================================================================
|
||||
|
||||
const CODEX_LEGACY_STORAGE_KEY = "cc-switch:codex-common-config-snippet";
|
||||
const CODEX_DEFAULT_SNIPPET = `# Common Codex config
|
||||
# Add your common TOML configuration here`;
|
||||
|
||||
/** 检查 TOML 是否有实质内容(非空、非纯注释) */
|
||||
function hasTomlContent(toml: string): boolean {
|
||||
const lines = toml.split("\n");
|
||||
return lines.some((line) => {
|
||||
const trimmed = line.trim();
|
||||
return trimmed && !trimmed.startsWith("#");
|
||||
});
|
||||
}
|
||||
|
||||
/** 校验 TOML 格式 */
|
||||
function validateTomlFormat(tomlText: string): string | null {
|
||||
if (!hasTomlContent(tomlText)) {
|
||||
return null; // 空或纯注释是合法的
|
||||
}
|
||||
const result = safeParseToml(tomlText);
|
||||
return result.error;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 codexConfig 提取 config 字段(TOML 格式)
|
||||
* 支持两种格式:
|
||||
* 1. 直接的 TOML 字符串
|
||||
* 2. JSON 字符串 { auth: {...}, config: "TOML" }
|
||||
*/
|
||||
function extractConfigToml(configInput: string): string {
|
||||
if (!configInput || !configInput.trim()) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// 尝试解析为 JSON(旧格式)
|
||||
try {
|
||||
const parsed = JSON.parse(configInput);
|
||||
if (typeof parsed?.config === "string") {
|
||||
return parsed.config;
|
||||
}
|
||||
// 如果是 JSON 对象但没有 config 字段,返回空
|
||||
if (typeof parsed === "object" && parsed !== null) {
|
||||
return "";
|
||||
}
|
||||
} catch {
|
||||
// JSON 解析失败,说明是直接的 TOML 字符串
|
||||
}
|
||||
|
||||
return configInput;
|
||||
}
|
||||
|
||||
export const codexAdapter: CommonConfigAdapter<string, string> = {
|
||||
appKey: "codex",
|
||||
defaultSnippet: CODEX_DEFAULT_SNIPPET,
|
||||
legacyStorageKey: CODEX_LEGACY_STORAGE_KEY,
|
||||
|
||||
parseSnippet: (snippet: string): ParseResult<string> => {
|
||||
const error = validateTomlFormat(snippet);
|
||||
if (error) {
|
||||
return { config: null, error };
|
||||
}
|
||||
return { config: snippet, error: null };
|
||||
},
|
||||
|
||||
hasValidContent: (snippet: string): boolean => {
|
||||
return hasTomlContent(snippet) && !validateTomlFormat(snippet);
|
||||
},
|
||||
|
||||
getApplyError: (snippet: string, t): string => {
|
||||
if (!hasTomlContent(snippet)) {
|
||||
return t("codexConfig.noCommonConfigToApply");
|
||||
}
|
||||
const error = validateTomlFormat(snippet);
|
||||
if (error) {
|
||||
return t("codexConfig.tomlFormatError", { defaultValue: "TOML 格式错误" });
|
||||
}
|
||||
return "";
|
||||
},
|
||||
|
||||
parseInput: (input: string): string => {
|
||||
return extractConfigToml(input);
|
||||
},
|
||||
|
||||
computeFinal: (
|
||||
custom: string,
|
||||
common: string,
|
||||
enabled: boolean,
|
||||
): string => {
|
||||
if (!enabled || !hasTomlContent(common)) {
|
||||
return custom;
|
||||
}
|
||||
const result = computeFinalTomlConfig(custom, common, true);
|
||||
return result.error ? custom : result.finalConfig;
|
||||
},
|
||||
|
||||
extractDiff: (custom: string, common: string): ExtractResult<string> => {
|
||||
const result = extractTomlDifference(custom, common);
|
||||
return {
|
||||
custom: result.customToml,
|
||||
hasCommonKeys: result.hasCommonKeys,
|
||||
error: result.error,
|
||||
};
|
||||
},
|
||||
|
||||
serializeOutput: (config: string): string => {
|
||||
return config;
|
||||
},
|
||||
|
||||
buildExtractRequest: (finalValue: string): { settingsConfig: string } => {
|
||||
return {
|
||||
settingsConfig: JSON.stringify({ config: finalValue ?? "" }),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Gemini Adapter (ENV/JSON)
|
||||
// ============================================================================
|
||||
|
||||
const GEMINI_LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
||||
const GEMINI_DEFAULT_SNIPPET = "{}";
|
||||
|
||||
export interface GeminiAdapterOptions {
|
||||
/** 字符串转对象 */
|
||||
envStringToObj: (envString: string) => Record<string, string>;
|
||||
/** 对象转字符串 */
|
||||
envObjToString: (envObj: Record<string, unknown>) => string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Gemini 适配器
|
||||
* 需要传入 env 转换函数,因为这些函数依赖于外部实现
|
||||
*/
|
||||
export function createGeminiAdapter(
|
||||
options: GeminiAdapterOptions,
|
||||
): CommonConfigAdapter<Record<string, string>, Record<string, string>> {
|
||||
const { envStringToObj, envObjToString } = options;
|
||||
|
||||
return {
|
||||
appKey: "gemini",
|
||||
defaultSnippet: GEMINI_DEFAULT_SNIPPET,
|
||||
legacyStorageKey: GEMINI_LEGACY_STORAGE_KEY,
|
||||
|
||||
parseSnippet: (
|
||||
snippet: string,
|
||||
): ParseResult<Record<string, string>> => {
|
||||
const result = parseGeminiCommonConfigSnippet(snippet, {
|
||||
strictForbiddenKeys: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
return { config: null, error: result.error };
|
||||
}
|
||||
|
||||
return { config: result.env, error: null };
|
||||
},
|
||||
|
||||
hasValidContent: (snippet: string): boolean => {
|
||||
const result = parseGeminiCommonConfigSnippet(snippet, {
|
||||
strictForbiddenKeys: true,
|
||||
});
|
||||
return !result.error && Object.keys(result.env).length > 0;
|
||||
},
|
||||
|
||||
getApplyError: (snippet: string, t): string => {
|
||||
const result = parseGeminiCommonConfigSnippet(snippet, {
|
||||
strictForbiddenKeys: true,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.FORBIDDEN_KEYS)) {
|
||||
const keys = result.error.split(": ")[1] ?? result.error;
|
||||
return t("geminiConfig.commonConfigInvalidKeys", { keys });
|
||||
}
|
||||
if (
|
||||
result.error.startsWith(GEMINI_CONFIG_ERROR_CODES.VALUE_NOT_STRING)
|
||||
) {
|
||||
return t("geminiConfig.commonConfigInvalidValues");
|
||||
}
|
||||
return t("geminiConfig.invalidJsonFormat");
|
||||
}
|
||||
|
||||
if (Object.keys(result.env).length === 0) {
|
||||
return t("geminiConfig.noCommonConfigToApply");
|
||||
}
|
||||
|
||||
return "";
|
||||
},
|
||||
|
||||
parseInput: (input: string): Record<string, string> => {
|
||||
return envStringToObj(input);
|
||||
},
|
||||
|
||||
computeFinal: (
|
||||
custom: Record<string, string>,
|
||||
common: Record<string, string>,
|
||||
enabled: boolean,
|
||||
): Record<string, string> => {
|
||||
if (!enabled || Object.keys(common).length === 0) {
|
||||
return custom;
|
||||
}
|
||||
|
||||
// 通用配置作为 base,自定义 env 覆盖
|
||||
const merged = computeFinalConfig(
|
||||
custom as Record<string, unknown>,
|
||||
common 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;
|
||||
},
|
||||
|
||||
extractDiff: (
|
||||
custom: Record<string, string>,
|
||||
common: Record<string, string>,
|
||||
): ExtractResult<Record<string, string>> => {
|
||||
const result = extractDifference(
|
||||
custom as Record<string, unknown>,
|
||||
common as Record<string, unknown>,
|
||||
);
|
||||
|
||||
// 转换回 Record<string, string>
|
||||
const customResult: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(result.customConfig)) {
|
||||
if (typeof value === "string") {
|
||||
customResult[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
custom: customResult,
|
||||
hasCommonKeys: result.hasCommonKeys,
|
||||
};
|
||||
},
|
||||
|
||||
serializeOutput: (config: Record<string, string>): string => {
|
||||
return envObjToString(config);
|
||||
},
|
||||
|
||||
buildExtractRequest: (
|
||||
finalValue: Record<string, string>,
|
||||
): { settingsConfig: string } => {
|
||||
return {
|
||||
settingsConfig: JSON.stringify({ env: finalValue }),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 通用配置管理基础 Hook
|
||||
*
|
||||
* 提供加载、切换、保存、提取等通用逻辑,通过 Adapter 注入格式特定处理。
|
||||
* 支持 Claude (JSON), Codex (TOML), Gemini (ENV/JSON) 三种格式。
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { configApi } from "@/lib/api";
|
||||
import type { ProviderMeta } from "@/types";
|
||||
|
||||
// ============================================================================
|
||||
// 类型定义
|
||||
// ============================================================================
|
||||
|
||||
/** 应用类型 */
|
||||
export type CommonConfigAppKey = "claude" | "codex" | "gemini";
|
||||
|
||||
/** 解析结果 */
|
||||
export interface ParseResult<T> {
|
||||
config: T | null;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** 合并结果 */
|
||||
export interface MergeResult<T> {
|
||||
merged: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 差异提取结果 */
|
||||
export interface ExtractResult<T> {
|
||||
custom: T;
|
||||
hasCommonKeys: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式适配器接口
|
||||
*
|
||||
* 每种格式(JSON/TOML/ENV)需要实现此接口
|
||||
*/
|
||||
export interface CommonConfigAdapter<TConfig, TFinal> {
|
||||
/** 应用标识 */
|
||||
appKey: CommonConfigAppKey;
|
||||
|
||||
/** 默认片段内容 */
|
||||
defaultSnippet: string;
|
||||
|
||||
/** localStorage 迁移 key (可选) */
|
||||
legacyStorageKey?: string;
|
||||
|
||||
/**
|
||||
* 解析片段字符串为配置对象
|
||||
* @param snippet - 原始片段字符串
|
||||
* @returns 解析结果
|
||||
*/
|
||||
parseSnippet: (snippet: string) => ParseResult<TConfig>;
|
||||
|
||||
/**
|
||||
* 验证片段是否有有效内容(非空、非纯注释)
|
||||
* @param snippet - 片段字符串
|
||||
* @returns 是否有有效内容
|
||||
*/
|
||||
hasValidContent: (snippet: string) => boolean;
|
||||
|
||||
/**
|
||||
* 获取片段应用错误(用于 toggle 时验证)
|
||||
* @param snippet - 片段字符串
|
||||
* @param t - i18n 翻译函数
|
||||
* @returns 错误信息,无错误返回空字符串
|
||||
*/
|
||||
getApplyError: (
|
||||
snippet: string,
|
||||
t: (key: string, options?: Record<string, unknown>) => string,
|
||||
) => string;
|
||||
|
||||
/**
|
||||
* 从表单输入解析当前配置
|
||||
* @param input - 表单输入值
|
||||
* @returns 解析后的配置对象
|
||||
*/
|
||||
parseInput: (input: string) => TConfig;
|
||||
|
||||
/**
|
||||
* 计算最终合并配置
|
||||
* @param custom - 自定义配置
|
||||
* @param common - 通用配置
|
||||
* @param enabled - 是否启用
|
||||
* @returns 合并后的最终配置
|
||||
*/
|
||||
computeFinal: (custom: TConfig, common: TConfig, enabled: boolean) => TFinal;
|
||||
|
||||
/**
|
||||
* 提取差异(从自定义配置中移除与通用配置相同的部分)
|
||||
* @param custom - 自定义配置
|
||||
* @param common - 通用配置
|
||||
* @returns 差异结果
|
||||
*/
|
||||
extractDiff: (custom: TConfig, common: TConfig) => ExtractResult<TConfig>;
|
||||
|
||||
/**
|
||||
* 将配置对象序列化为表单输出
|
||||
* @param config - 配置对象
|
||||
* @returns 序列化后的字符串
|
||||
*/
|
||||
serializeOutput: (config: TConfig) => string;
|
||||
|
||||
/**
|
||||
* 构建提取 API 的请求参数
|
||||
* @param finalValue - 最终合并后的值
|
||||
* @returns API 请求参数
|
||||
*/
|
||||
buildExtractRequest: (finalValue: TFinal) => { settingsConfig: string };
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Props 和 Return 类型
|
||||
// ============================================================================
|
||||
|
||||
export interface UseCommonConfigBaseProps<TConfig, TFinal> {
|
||||
/** 格式适配器 */
|
||||
adapter: CommonConfigAdapter<TConfig, TFinal>;
|
||||
/** 当前表单输入值 */
|
||||
inputValue: string;
|
||||
/** 输入变化回调 */
|
||||
onInputChange: (value: string) => void;
|
||||
/** 初始数据(编辑模式) */
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
meta?: ProviderMeta;
|
||||
};
|
||||
/** 当前选中的预设 ID */
|
||||
selectedPresetId?: string;
|
||||
/** 是否启用此 hook(默认 true) */
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseCommonConfigBaseReturn<TFinal> {
|
||||
/** 是否启用通用配置 */
|
||||
useCommonConfig: boolean;
|
||||
/** 通用配置片段 */
|
||||
commonConfigSnippet: string;
|
||||
/** 通用配置错误信息 */
|
||||
commonConfigError: string;
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 是否正在提取 */
|
||||
isExtracting: boolean;
|
||||
/** 通用配置开关处理函数 */
|
||||
handleCommonConfigToggle: (checked: boolean) => void;
|
||||
/** 通用配置片段变化处理函数 */
|
||||
handleCommonConfigSnippetChange: (snippet: string) => void;
|
||||
/** 从当前配置提取通用配置 */
|
||||
handleExtract: () => Promise<void>;
|
||||
/** 最终配置(运行时合并结果,只读) */
|
||||
finalValue: TFinal;
|
||||
/** 是否有待保存的通用配置变更 */
|
||||
hasUnsavedCommonConfig: boolean;
|
||||
/** 获取待保存的通用配置片段(用于 handleSubmit) */
|
||||
getPendingCommonConfigSnippet: () => string | null;
|
||||
/** 标记通用配置已保存 */
|
||||
markCommonConfigSaved: () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 基础 Hook 实现
|
||||
// ============================================================================
|
||||
|
||||
export function useCommonConfigBase<TConfig, TFinal>({
|
||||
adapter,
|
||||
inputValue,
|
||||
onInputChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
enabled = true,
|
||||
}: UseCommonConfigBaseProps<TConfig, TFinal>): UseCommonConfigBaseReturn<TFinal> {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// ============================================================================
|
||||
// 状态
|
||||
// ============================================================================
|
||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
||||
const [commonConfigSnippet, setCommonConfigSnippetState] = useState<string>(
|
||||
adapter.defaultSnippet,
|
||||
);
|
||||
const [commonConfigError, setCommonConfigError] = useState("");
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isExtracting, setIsExtracting] = useState(false);
|
||||
const [hasUnsavedCommonConfig, setHasUnsavedCommonConfig] = useState(false);
|
||||
|
||||
// 初始化跟踪
|
||||
const hasInitializedEditMode = useRef(false);
|
||||
const hasInitializedNewMode = useRef(false);
|
||||
|
||||
// ============================================================================
|
||||
// 预设变化时重置初始化标记
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
hasInitializedNewMode.current = false;
|
||||
hasInitializedEditMode.current = false;
|
||||
}, [selectedPresetId, enabled]);
|
||||
|
||||
// ============================================================================
|
||||
// 加载通用配置片段(从数据库,支持 localStorage 迁移)
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let mounted = true;
|
||||
|
||||
const loadSnippet = async () => {
|
||||
try {
|
||||
const snippet = await configApi.getCommonConfigSnippet(adapter.appKey);
|
||||
|
||||
if (snippet && snippet.trim()) {
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(snippet);
|
||||
}
|
||||
} else if (adapter.legacyStorageKey && typeof window !== "undefined") {
|
||||
// 尝试从 localStorage 迁移
|
||||
try {
|
||||
const legacySnippet = window.localStorage.getItem(
|
||||
adapter.legacyStorageKey,
|
||||
);
|
||||
if (legacySnippet && legacySnippet.trim()) {
|
||||
const parsed = adapter.parseSnippet(legacySnippet);
|
||||
if (!parsed.error) {
|
||||
await configApi.setCommonConfigSnippet(
|
||||
adapter.appKey,
|
||||
legacySnippet,
|
||||
);
|
||||
if (mounted) {
|
||||
setCommonConfigSnippetState(legacySnippet);
|
||||
}
|
||||
window.localStorage.removeItem(adapter.legacyStorageKey);
|
||||
console.log(
|
||||
`[迁移] ${adapter.appKey} 通用配置已从 localStorage 迁移到数据库`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`加载 ${adapter.appKey} 通用配置失败:`, error);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadSnippet();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [enabled, adapter]);
|
||||
|
||||
// ============================================================================
|
||||
// 编辑模式初始化:从 meta 读取启用状态
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (initialData && !isLoading && !hasInitializedEditMode.current) {
|
||||
hasInitializedEditMode.current = true;
|
||||
|
||||
const metaByApp = initialData.meta?.commonConfigEnabledByApp;
|
||||
const resolvedMetaEnabled =
|
||||
metaByApp?.[adapter.appKey] ?? initialData.meta?.commonConfigEnabled;
|
||||
|
||||
if (resolvedMetaEnabled !== undefined) {
|
||||
if (!resolvedMetaEnabled) {
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
const applyError = adapter.getApplyError(commonConfigSnippet, t);
|
||||
if (applyError) {
|
||||
setCommonConfigError(applyError);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(true);
|
||||
} else {
|
||||
setUseCommonConfig(false);
|
||||
}
|
||||
}
|
||||
}, [enabled, initialData, isLoading, commonConfigSnippet, adapter, t]);
|
||||
|
||||
// ============================================================================
|
||||
// 新建模式初始化:如果通用配置有效,默认启用
|
||||
// ============================================================================
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (!initialData && !isLoading && !hasInitializedNewMode.current) {
|
||||
hasInitializedNewMode.current = true;
|
||||
|
||||
if (adapter.hasValidContent(commonConfigSnippet)) {
|
||||
const parsed = adapter.parseSnippet(commonConfigSnippet);
|
||||
if (!parsed.error && parsed.config !== null) {
|
||||
setUseCommonConfig(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [enabled, initialData, commonConfigSnippet, isLoading, adapter]);
|
||||
|
||||
// ============================================================================
|
||||
// 计算最终配置(运行时合并)
|
||||
// ============================================================================
|
||||
const finalValue = useMemo((): TFinal => {
|
||||
const customConfig = adapter.parseInput(inputValue);
|
||||
|
||||
if (!enabled || !useCommonConfig) {
|
||||
return adapter.computeFinal(customConfig, customConfig, false);
|
||||
}
|
||||
|
||||
const snippetParsed = adapter.parseSnippet(commonConfigSnippet);
|
||||
if (snippetParsed.error || snippetParsed.config === null) {
|
||||
return adapter.computeFinal(customConfig, customConfig, false);
|
||||
}
|
||||
|
||||
return adapter.computeFinal(customConfig, snippetParsed.config, true);
|
||||
}, [enabled, inputValue, commonConfigSnippet, useCommonConfig, adapter]);
|
||||
|
||||
// ============================================================================
|
||||
// 处理通用配置开关
|
||||
// ============================================================================
|
||||
const handleCommonConfigToggle = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
const applyError = adapter.getApplyError(commonConfigSnippet, t);
|
||||
if (applyError) {
|
||||
setCommonConfigError(applyError);
|
||||
setUseCommonConfig(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setCommonConfigError("");
|
||||
setUseCommonConfig(checked);
|
||||
},
|
||||
[commonConfigSnippet, adapter, t],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 处理通用配置片段变化(延迟保存模式)
|
||||
// ============================================================================
|
||||
const handleCommonConfigSnippetChange = useCallback(
|
||||
(value: string) => {
|
||||
setCommonConfigSnippetState(value);
|
||||
|
||||
if (!value.trim()) {
|
||||
setCommonConfigError("");
|
||||
setHasUnsavedCommonConfig(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// 格式校验
|
||||
const parsed = adapter.parseSnippet(value);
|
||||
if (parsed.error) {
|
||||
setCommonConfigError(parsed.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setCommonConfigError("");
|
||||
setHasUnsavedCommonConfig(true);
|
||||
},
|
||||
[adapter],
|
||||
);
|
||||
|
||||
// ============================================================================
|
||||
// 从当前最终配置提取通用配置片段
|
||||
// ============================================================================
|
||||
const handleExtract = useCallback(async () => {
|
||||
setIsExtracting(true);
|
||||
setCommonConfigError("");
|
||||
|
||||
try {
|
||||
const request = adapter.buildExtractRequest(finalValue);
|
||||
const extracted = await configApi.extractCommonConfigSnippet(
|
||||
adapter.appKey,
|
||||
request,
|
||||
);
|
||||
|
||||
if (!extracted || extracted === "{}" || !extracted.trim()) {
|
||||
setCommonConfigError(
|
||||
t(`${adapter.appKey}Config.extractNoCommonConfig`, {
|
||||
defaultValue: "无法提取通用配置",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 验证提取结果格式
|
||||
const extractedParsed = adapter.parseSnippet(extracted);
|
||||
if (extractedParsed.error || extractedParsed.config === null) {
|
||||
setCommonConfigError(
|
||||
t(`${adapter.appKey}Config.extractedConfigInvalid`, {
|
||||
defaultValue: "提取的配置格式错误",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 更新片段状态
|
||||
setCommonConfigSnippetState(extracted);
|
||||
setHasUnsavedCommonConfig(true);
|
||||
|
||||
// 从自定义配置中移除与提取内容相同的部分
|
||||
const customConfig = adapter.parseInput(inputValue);
|
||||
const diffResult = adapter.extractDiff(
|
||||
customConfig,
|
||||
extractedParsed.config,
|
||||
);
|
||||
|
||||
if (!diffResult.error) {
|
||||
onInputChange(adapter.serializeOutput(diffResult.custom));
|
||||
toast.success(
|
||||
t(`${adapter.appKey}Config.extractSuccessNeedSave`, {
|
||||
defaultValue: "已提取通用配置,点击保存按钮完成保存",
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`提取 ${adapter.appKey} 通用配置失败:`, error);
|
||||
setCommonConfigError(
|
||||
t(`${adapter.appKey}Config.extractFailed`, {
|
||||
error: String(error),
|
||||
defaultValue: "提取失败",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
setIsExtracting(false);
|
||||
}
|
||||
}, [adapter, finalValue, inputValue, onInputChange, t]);
|
||||
|
||||
// ============================================================================
|
||||
// 获取待保存的通用配置片段
|
||||
// ============================================================================
|
||||
const getPendingCommonConfigSnippet = useCallback(() => {
|
||||
return hasUnsavedCommonConfig ? commonConfigSnippet : null;
|
||||
}, [hasUnsavedCommonConfig, commonConfigSnippet]);
|
||||
|
||||
// ============================================================================
|
||||
// 标记通用配置已保存
|
||||
// ============================================================================
|
||||
const markCommonConfigSaved = useCallback(() => {
|
||||
setHasUnsavedCommonConfig(false);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
commonConfigError,
|
||||
isLoading,
|
||||
isExtracting,
|
||||
handleCommonConfigToggle,
|
||||
handleCommonConfigSnippetChange,
|
||||
handleExtract,
|
||||
finalValue,
|
||||
hasUnsavedCommonConfig,
|
||||
getPendingCommonConfigSnippet,
|
||||
markCommonConfigSaved,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { useState, useEffect } from "react";
|
||||
|
||||
/**
|
||||
* Hook to track dark mode state by observing document.documentElement class changes.
|
||||
*
|
||||
* @returns boolean indicating if dark mode is currently active
|
||||
*/
|
||||
export function useDarkMode(): boolean {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDarkMode(document.documentElement.classList.contains("dark"));
|
||||
});
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ["class"],
|
||||
});
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
return isDarkMode;
|
||||
}
|
||||
@@ -208,86 +208,3 @@ export const extractDifference = (
|
||||
|
||||
return { customConfig, hasCommonKeys };
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// JSON 格式便捷函数
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* 计算最终配置(JSON 字符串版本)
|
||||
*/
|
||||
export const computeFinalConfigJson = (
|
||||
customConfigJson: string,
|
||||
commonConfigJson: string,
|
||||
enabled: boolean,
|
||||
): { finalConfig: string; error?: string } => {
|
||||
try {
|
||||
const customConfig = customConfigJson ? JSON.parse(customConfigJson) : {};
|
||||
const commonConfig = commonConfigJson ? JSON.parse(commonConfigJson) : {};
|
||||
|
||||
if (!isPlainObject(customConfig)) {
|
||||
return {
|
||||
finalConfig: customConfigJson,
|
||||
error: "自定义配置必须是 JSON 对象",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPlainObject(commonConfig)) {
|
||||
return {
|
||||
finalConfig: customConfigJson,
|
||||
error: "通用配置必须是 JSON 对象",
|
||||
};
|
||||
}
|
||||
|
||||
const final = computeFinalConfig(customConfig, commonConfig, enabled);
|
||||
return {
|
||||
finalConfig: JSON.stringify(final, null, 2),
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
finalConfig: customConfigJson,
|
||||
error: `JSON 解析失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 从 JSON 字符串中提取差异
|
||||
*/
|
||||
export const extractDifferenceJson = (
|
||||
liveConfigJson: string,
|
||||
commonConfigJson: string,
|
||||
): { customConfig: string; hasCommonKeys: boolean; error?: string } => {
|
||||
try {
|
||||
const liveConfig = liveConfigJson ? JSON.parse(liveConfigJson) : {};
|
||||
const commonConfig = commonConfigJson ? JSON.parse(commonConfigJson) : {};
|
||||
|
||||
if (!isPlainObject(liveConfig)) {
|
||||
return {
|
||||
customConfig: liveConfigJson,
|
||||
hasCommonKeys: false,
|
||||
error: "Live 配置必须是 JSON 对象",
|
||||
};
|
||||
}
|
||||
|
||||
if (!isPlainObject(commonConfig)) {
|
||||
return {
|
||||
customConfig: liveConfigJson,
|
||||
hasCommonKeys: false,
|
||||
error: "通用配置必须是 JSON 对象",
|
||||
};
|
||||
}
|
||||
|
||||
const result = extractDifference(liveConfig, commonConfig);
|
||||
return {
|
||||
customConfig: JSON.stringify(result.customConfig, null, 2),
|
||||
hasCommonKeys: result.hasCommonKeys,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
customConfig: liveConfigJson,
|
||||
hasCommonKeys: false,
|
||||
error: `JSON 解析失败: ${err instanceof Error ? err.message : String(err)}`,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||||
import { normalizeQuotes } from "@/utils/textNormalization";
|
||||
import { isPlainObject } from "@/utils/configMerge";
|
||||
import { isPlainObject, deepClone, deepMerge } from "@/utils/configMerge";
|
||||
|
||||
// Gemini 通用配置禁止的键(共享常量,供 hook 和同步逻辑复用)
|
||||
export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
||||
@@ -12,24 +12,6 @@ export const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [
|
||||
export type GeminiForbiddenEnvKey =
|
||||
(typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number];
|
||||
|
||||
const deepMerge = (
|
||||
target: Record<string, any>,
|
||||
source: Record<string, any>,
|
||||
): Record<string, any> => {
|
||||
Object.entries(source).forEach(([key, value]) => {
|
||||
if (isPlainObject(value)) {
|
||||
if (!isPlainObject(target[key])) {
|
||||
target[key] = {};
|
||||
}
|
||||
deepMerge(target[key], value);
|
||||
} else {
|
||||
// 直接覆盖非对象字段(数组/基础类型)
|
||||
target[key] = value;
|
||||
}
|
||||
});
|
||||
return target;
|
||||
};
|
||||
|
||||
const deepRemove = (
|
||||
target: Record<string, any>,
|
||||
source: Record<string, any>,
|
||||
@@ -66,23 +48,6 @@ const isSubset = (target: any, source: any): boolean => {
|
||||
return target === source;
|
||||
};
|
||||
|
||||
// 深拷贝函数
|
||||
const deepClone = <T>(obj: T): T => {
|
||||
if (obj === null || typeof obj !== "object") return obj;
|
||||
if (obj instanceof Date) return new Date(obj.getTime()) as T;
|
||||
if (obj instanceof Array) return obj.map((item) => deepClone(item)) as T;
|
||||
if (obj instanceof Object) {
|
||||
const clonedObj = {} as T;
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
}
|
||||
return obj;
|
||||
};
|
||||
|
||||
export interface UpdateCommonConfigResult {
|
||||
updatedConfig: string;
|
||||
error?: string;
|
||||
|
||||
@@ -244,118 +244,6 @@ export const extractTomlDifference = (
|
||||
};
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// TOML Section 级别处理(保留原有格式和注释的场景)
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* TOML 解析结构(用于保留格式的场景)
|
||||
*/
|
||||
export interface TomlParsedStructure {
|
||||
topLevel: string[];
|
||||
sections: Map<string, string[]>;
|
||||
sectionOrder: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 TOML 文本为段落结构(保留原始行格式)
|
||||
*/
|
||||
export const parseTomlStructure = (tomlText: string): TomlParsedStructure => {
|
||||
const lines = tomlText.split("\n");
|
||||
const topLevel: string[] = [];
|
||||
const sections = new Map<string, string[]>();
|
||||
const sectionOrder: string[] = [];
|
||||
|
||||
let currentSection: string | null = null;
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// 检查是否是 section 头
|
||||
const sectionMatch = trimmed.match(/^\[([^\]]+)\]$/);
|
||||
if (sectionMatch) {
|
||||
currentSection = sectionMatch[1];
|
||||
if (!sections.has(currentSection)) {
|
||||
sections.set(currentSection, []);
|
||||
sectionOrder.push(currentSection);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentSection === null) {
|
||||
topLevel.push(line);
|
||||
} else {
|
||||
const sectionLines = sections.get(currentSection)!;
|
||||
sectionLines.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
return { topLevel, sections, sectionOrder };
|
||||
};
|
||||
|
||||
/**
|
||||
* 从行中提取键名
|
||||
*/
|
||||
export const extractKeyFromLine = (line: string): string | null => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) {
|
||||
return null;
|
||||
}
|
||||
const match = trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*=/);
|
||||
return match ? match[1] : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 重建 TOML 文本(从结构)
|
||||
*/
|
||||
export const rebuildTomlFromStructure = (
|
||||
structure: TomlParsedStructure,
|
||||
): string => {
|
||||
const resultLines: string[] = [];
|
||||
|
||||
// 添加顶级内容
|
||||
let lastWasEmpty = true;
|
||||
for (const line of structure.topLevel) {
|
||||
const isEmpty = !line.trim();
|
||||
if (isEmpty && lastWasEmpty) {
|
||||
continue;
|
||||
}
|
||||
resultLines.push(line);
|
||||
lastWasEmpty = isEmpty;
|
||||
}
|
||||
|
||||
// 添加 sections
|
||||
for (const sectionName of structure.sectionOrder) {
|
||||
const sectionLines = structure.sections.get(sectionName)!;
|
||||
|
||||
if (resultLines.length > 0 && resultLines[resultLines.length - 1].trim()) {
|
||||
resultLines.push("");
|
||||
}
|
||||
|
||||
resultLines.push(`[${sectionName}]`);
|
||||
|
||||
lastWasEmpty = false;
|
||||
for (const line of sectionLines) {
|
||||
const isEmpty = !line.trim();
|
||||
if (isEmpty && lastWasEmpty) {
|
||||
continue;
|
||||
}
|
||||
resultLines.push(line);
|
||||
lastWasEmpty = isEmpty;
|
||||
}
|
||||
}
|
||||
|
||||
// 清理末尾空行
|
||||
while (
|
||||
resultLines.length > 0 &&
|
||||
!resultLines[resultLines.length - 1].trim()
|
||||
) {
|
||||
resultLines.pop();
|
||||
}
|
||||
|
||||
return resultLines.length > 0 ? resultLines.join("\n") + "\n" : "";
|
||||
};
|
||||
|
||||
/**
|
||||
* 检查 TOML 配置是否包含通用配置的内容
|
||||
* 使用对象级别比较,而非字符串匹配
|
||||
|
||||
Reference in New Issue
Block a user