mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-06-16 13:34:04 +08:00
refactor(common-config): consolidate hooks and migrate Gemini to ENV format
- Delete redundant wrapper hooks (useCommonConfigSnippet, useGeminiCommonConfig) - Change Gemini common config from JSON to ENV format (.env style) - Add backend validation with forbidden keys filtering (GEMINI_API_KEY, GOOGLE_GEMINI_BASE_URL) - Fix localStorage migration to skip empty parsed snippets - Add error handling for silent JSON parse failures - Clean up debug logs and unused types
This commit is contained in:
@@ -217,14 +217,30 @@ pub async fn set_common_config_snippet(
|
||||
// 验证格式(根据应用类型)
|
||||
if !snippet.trim().is_empty() {
|
||||
match app_type.as_str() {
|
||||
"claude" | "gemini" => {
|
||||
"claude" => {
|
||||
// 验证 JSON 格式
|
||||
serde_json::from_str::<serde_json::Value>(&snippet)
|
||||
.map_err(invalid_json_format_error)?;
|
||||
}
|
||||
"gemini" => {
|
||||
// 验证 ENV/JSON 格式并拒绝禁用键
|
||||
let validation = crate::config_merge::validate_gemini_common_snippet(&snippet);
|
||||
|
||||
// 如果有禁用键,返回错误
|
||||
if !validation.forbidden_keys_found.is_empty() {
|
||||
return Err(format!(
|
||||
"GEMINI_FORBIDDEN_KEYS:{}",
|
||||
validation.forbidden_keys_found.join(",")
|
||||
));
|
||||
}
|
||||
|
||||
// 如果非空但解析后无有效内容,返回错误
|
||||
if !validation.is_valid {
|
||||
return Err("GEMINI_INVALID_SNIPPET".to_string());
|
||||
}
|
||||
}
|
||||
"codex" => {
|
||||
// TOML 格式暂不验证(或可使用 toml crate)
|
||||
// 注意:TOML 验证较为复杂,暂时跳过
|
||||
// TOML 格式,暂不验证(前端验证)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -565,13 +565,24 @@ fn merge_codex_config(custom_config: &JsonValue, common_snippet: &str) -> MergeR
|
||||
///
|
||||
/// This function supports all formats for backward compatibility.
|
||||
fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> MergeResult {
|
||||
// Parse common config (try JSON first, then ENV format)
|
||||
let common_env = parse_gemini_common_snippet(common_snippet);
|
||||
// Parse and validate common config (filters forbidden keys)
|
||||
let validation = validate_gemini_common_snippet(common_snippet);
|
||||
let common_env = validation.env;
|
||||
|
||||
// Generate warning if forbidden keys were found
|
||||
let warning = if !validation.forbidden_keys_found.is_empty() {
|
||||
Some(format!(
|
||||
"GEMINI_FORBIDDEN_KEYS:{}",
|
||||
validation.forbidden_keys_found.join(",")
|
||||
))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if common_env.is_empty() {
|
||||
return MergeResult {
|
||||
config: custom_config.clone(),
|
||||
warning: None,
|
||||
warning,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -597,7 +608,7 @@ fn merge_gemini_config(custom_config: &JsonValue, common_snippet: &str) -> Merge
|
||||
|
||||
MergeResult {
|
||||
config: merged_config,
|
||||
warning: None,
|
||||
warning,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -646,6 +657,47 @@ pub(crate) fn parse_gemini_common_snippet(snippet: &str) -> serde_json::Map<Stri
|
||||
result
|
||||
}
|
||||
|
||||
/// Gemini common config forbidden keys - these should never be in common config
|
||||
/// as they are provider-specific credentials/endpoints.
|
||||
const GEMINI_FORBIDDEN_KEYS: &[&str] = &["GOOGLE_GEMINI_BASE_URL", "GEMINI_API_KEY"];
|
||||
|
||||
/// Result of validating Gemini common config snippet
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GeminiSnippetValidation {
|
||||
/// Parsed environment variables (with forbidden keys filtered out)
|
||||
pub env: serde_json::Map<String, JsonValue>,
|
||||
/// List of forbidden keys that were found and filtered
|
||||
pub forbidden_keys_found: Vec<String>,
|
||||
/// Whether the snippet is valid (parseable and non-empty after filtering)
|
||||
pub is_valid: bool,
|
||||
}
|
||||
|
||||
/// Parse and validate Gemini common config snippet.
|
||||
///
|
||||
/// This function:
|
||||
/// 1. Parses the snippet (supports ENV/JSON formats)
|
||||
/// 2. Filters out forbidden keys (GOOGLE_GEMINI_BASE_URL, GEMINI_API_KEY)
|
||||
/// 3. Returns validation result with filtered env and forbidden keys found
|
||||
pub fn validate_gemini_common_snippet(snippet: &str) -> GeminiSnippetValidation {
|
||||
let raw_env = parse_gemini_common_snippet(snippet);
|
||||
let mut filtered_env = serde_json::Map::new();
|
||||
let mut forbidden_keys_found = Vec::new();
|
||||
|
||||
for (key, value) in raw_env {
|
||||
if GEMINI_FORBIDDEN_KEYS.contains(&key.as_str()) {
|
||||
forbidden_keys_found.push(key);
|
||||
} else {
|
||||
filtered_env.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
GeminiSnippetValidation {
|
||||
is_valid: !filtered_env.is_empty() || snippet.trim().is_empty(),
|
||||
env: filtered_env,
|
||||
forbidden_keys_found,
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip surrounding quotes from ENV value.
|
||||
/// Supports both single and double quotes: "value" -> value, 'value' -> value
|
||||
fn strip_env_quotes(s: &str) -> &str {
|
||||
|
||||
@@ -654,15 +654,19 @@ impl ProviderService {
|
||||
Ok(cleaned.trim().to_string())
|
||||
}
|
||||
|
||||
/// Extract common config for Gemini (JSON format)
|
||||
/// Extract common config for Gemini (ENV format)
|
||||
///
|
||||
/// Extracts `.env` values while excluding provider-specific credentials:
|
||||
/// - GOOGLE_GEMINI_BASE_URL
|
||||
/// - GEMINI_API_KEY
|
||||
///
|
||||
/// Returns ENV format (KEY=VALUE per line) instead of JSON.
|
||||
/// Values containing newlines/carriage returns are skipped to prevent
|
||||
/// ENV format injection/truncation.
|
||||
fn extract_gemini_common_config(settings: &Value) -> Result<String, AppError> {
|
||||
let env = settings.get("env").and_then(|v| v.as_object());
|
||||
|
||||
let mut snippet = serde_json::Map::new();
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
if let Some(env) = env {
|
||||
for (key, value) in env {
|
||||
if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" {
|
||||
@@ -673,17 +677,22 @@ impl ProviderService {
|
||||
};
|
||||
let trimmed = v.trim();
|
||||
if !trimmed.is_empty() {
|
||||
snippet.insert(key.to_string(), Value::String(trimmed.to_string()));
|
||||
// Skip values containing newlines to prevent ENV format injection
|
||||
if trimmed.contains('\n') || trimmed.contains('\r') {
|
||||
continue;
|
||||
}
|
||||
lines.push(format!("{key}={trimmed}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if snippet.is_empty() {
|
||||
return Ok("{}".to_string());
|
||||
if lines.is_empty() {
|
||||
return Ok(String::new());
|
||||
}
|
||||
|
||||
serde_json::to_string_pretty(&Value::Object(snippet))
|
||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
||||
// Sort for consistent output
|
||||
lines.sort();
|
||||
Ok(lines.join("\n"))
|
||||
}
|
||||
|
||||
/// Extract common config for OpenCode (JSON format)
|
||||
|
||||
@@ -53,15 +53,12 @@ export function DeepLinkImportDialog() {
|
||||
const unlistenImport = listen<DeepLinkImportRequest>(
|
||||
"deeplink-import",
|
||||
async (event) => {
|
||||
console.log("Deep link import event received:", event.payload);
|
||||
|
||||
// If config is present, merge it to get the complete configuration
|
||||
if (event.payload.config || event.payload.configUrl) {
|
||||
try {
|
||||
const mergedRequest = await deeplinkApi.mergeDeeplinkConfig(
|
||||
event.payload,
|
||||
);
|
||||
console.log("Config merged successfully:", mergedRequest);
|
||||
setRequest(mergedRequest);
|
||||
} catch (error) {
|
||||
console.error("Failed to merge config:", error);
|
||||
|
||||
@@ -18,6 +18,7 @@ interface GeminiCommonConfigModalProps {
|
||||
/**
|
||||
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
||||
* Allows editing of common env snippet shared across Gemini providers
|
||||
* Uses ENV format (KEY=VALUE) instead of JSON
|
||||
*/
|
||||
export const GeminiCommonConfigModal: React.FC<
|
||||
GeminiCommonConfigModalProps
|
||||
@@ -88,13 +89,14 @@ export const GeminiCommonConfigModal: React.FC<
|
||||
<JsonEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={`{
|
||||
"GEMINI_MODEL": "gemini-3-pro-preview"
|
||||
}`}
|
||||
placeholder={`# Gemini 通用配置
|
||||
# 格式: KEY=VALUE
|
||||
|
||||
GEMINI_MODEL=gemini-2.5-pro`}
|
||||
darkMode={isDarkMode}
|
||||
rows={16}
|
||||
showValidation={true}
|
||||
language="json"
|
||||
showValidation={false}
|
||||
language="javascript"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -66,13 +66,16 @@ import {
|
||||
useCodexConfigState,
|
||||
useApiKeyLink,
|
||||
useTemplateValues,
|
||||
useCommonConfigSnippet,
|
||||
useCodexCommonConfig,
|
||||
useSpeedTestEndpoints,
|
||||
useCodexTomlValidation,
|
||||
useGeminiConfigState,
|
||||
useGeminiCommonConfig,
|
||||
} from "./hooks";
|
||||
import { useCommonConfigBase } from "@/hooks/useCommonConfigBase";
|
||||
import {
|
||||
claudeAdapter,
|
||||
createGeminiAdapter,
|
||||
} from "@/hooks/commonConfigAdapters";
|
||||
import { useProvidersQuery } from "@/lib/query/queries";
|
||||
|
||||
/**
|
||||
@@ -469,6 +472,17 @@ export function ProviderForm({
|
||||
});
|
||||
|
||||
// 使用通用配置片段 hook (仅 Claude 模式)
|
||||
// 直接使用 useCommonConfigBase + claudeAdapter
|
||||
const claudeCommonConfig = useCommonConfigBase({
|
||||
adapter: claudeAdapter,
|
||||
inputValue: form.getValues("settingsConfig"),
|
||||
onInputChange: (config) => form.setValue("settingsConfig", config),
|
||||
initialData: appId === "claude" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
enabled: appId === "claude",
|
||||
});
|
||||
|
||||
// 解构 Claude 通用配置
|
||||
const {
|
||||
useCommonConfig,
|
||||
commonConfigSnippet,
|
||||
@@ -478,17 +492,10 @@ export function ProviderForm({
|
||||
isExtracting: isClaudeExtracting,
|
||||
handleExtract: handleClaudeExtract,
|
||||
isLoading: isClaudeCommonConfigLoading,
|
||||
finalConfig: claudeFinalConfig,
|
||||
finalValue: claudeFinalConfig,
|
||||
getPendingCommonConfigSnippet: getPendingClaudeCommonConfigSnippet,
|
||||
markCommonConfigSaved: markClaudeCommonConfigSaved,
|
||||
} = useCommonConfigSnippet({
|
||||
settingsConfig: form.getValues("settingsConfig"),
|
||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||
initialData: appId === "claude" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
enabled: appId === "claude",
|
||||
currentProviderId: providerId,
|
||||
});
|
||||
} = claudeCommonConfig;
|
||||
|
||||
// 使用 Codex 通用配置片段 hook (仅 Codex 模式)
|
||||
const {
|
||||
@@ -537,14 +544,16 @@ export function ProviderForm({
|
||||
(key: string) => {
|
||||
originalHandleGeminiApiKeyChange(key);
|
||||
// 同步更新 settingsConfig
|
||||
let config: { env?: Record<string, unknown> };
|
||||
try {
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GEMINI_API_KEY = key.trim();
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||
} catch {
|
||||
// ignore
|
||||
// 解析失败时初始化为有效结构
|
||||
config = { env: {} };
|
||||
}
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GEMINI_API_KEY = key.trim();
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
},
|
||||
[originalHandleGeminiApiKeyChange, form],
|
||||
);
|
||||
@@ -553,14 +562,16 @@ export function ProviderForm({
|
||||
(url: string) => {
|
||||
originalHandleGeminiBaseUrlChange(url);
|
||||
// 同步更新 settingsConfig
|
||||
let config: { env?: Record<string, unknown> };
|
||||
try {
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||
} catch {
|
||||
// ignore
|
||||
// 解析失败时初始化为有效结构
|
||||
config = { env: {} };
|
||||
}
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GOOGLE_GEMINI_BASE_URL = url.trim().replace(/\/+$/, "");
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
},
|
||||
[originalHandleGeminiBaseUrlChange, form],
|
||||
);
|
||||
@@ -569,19 +580,36 @@ export function ProviderForm({
|
||||
(model: string) => {
|
||||
originalHandleGeminiModelChange(model);
|
||||
// 同步更新 settingsConfig
|
||||
let config: { env?: Record<string, unknown> };
|
||||
try {
|
||||
const config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GEMINI_MODEL = model.trim();
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
config = JSON.parse(form.getValues("settingsConfig") || "{}");
|
||||
} catch {
|
||||
// ignore
|
||||
// 解析失败时初始化为有效结构
|
||||
config = { env: {} };
|
||||
}
|
||||
if (!config.env) config.env = {};
|
||||
config.env.GEMINI_MODEL = model.trim();
|
||||
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||
},
|
||||
[originalHandleGeminiModelChange, form],
|
||||
);
|
||||
|
||||
// 使用 Gemini 通用配置 hook (仅 Gemini 模式)
|
||||
// 直接使用 useCommonConfigBase + createGeminiAdapter
|
||||
const geminiAdapter = useMemo(
|
||||
() => createGeminiAdapter({ envStringToObj, envObjToString }),
|
||||
[envStringToObj, envObjToString],
|
||||
);
|
||||
const geminiCommonConfig = useCommonConfigBase({
|
||||
adapter: geminiAdapter,
|
||||
inputValue: geminiEnv,
|
||||
onInputChange: handleGeminiEnvChange,
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
enabled: appId === "gemini",
|
||||
});
|
||||
|
||||
// 解构 Gemini 通用配置
|
||||
const {
|
||||
useCommonConfig: useGeminiCommonConfigFlag,
|
||||
commonConfigSnippet: geminiCommonConfigSnippet,
|
||||
@@ -591,18 +619,10 @@ export function ProviderForm({
|
||||
isExtracting: isGeminiExtracting,
|
||||
handleExtract: handleGeminiExtract,
|
||||
isLoading: isGeminiCommonConfigLoading,
|
||||
finalEnv: geminiFinalEnv,
|
||||
finalValue: geminiFinalEnv,
|
||||
getPendingCommonConfigSnippet: getPendingGeminiCommonConfigSnippet,
|
||||
markCommonConfigSaved: markGeminiCommonConfigSaved,
|
||||
} = useGeminiCommonConfig({
|
||||
envValue: geminiEnv,
|
||||
onEnvChange: handleGeminiEnvChange,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
initialData: appId === "gemini" ? initialData : undefined,
|
||||
selectedPresetId: selectedPresetId ?? undefined,
|
||||
currentProviderId: providerId,
|
||||
});
|
||||
} = geminiCommonConfig;
|
||||
|
||||
const supportsCommonConfig =
|
||||
appId === "claude" || appId === "codex" || appId === "gemini";
|
||||
@@ -1041,8 +1061,8 @@ export function ProviderForm({
|
||||
const configObj = geminiConfig.trim() ? JSON.parse(geminiConfig) : {};
|
||||
// 如果启用了通用配置,只保存与通用配置不同的部分(自定义配置)
|
||||
if (useGeminiCommonConfigFlag && geminiCommonConfigSnippet.trim()) {
|
||||
// Parse common config as JSON (flat {"KEY": "VALUE"} format)
|
||||
// Note: geminiCommonConfigSnippet is stored as JSON by useGeminiCommonConfig hook
|
||||
// Parse common config snippet (supports ENV/Flat JSON/Wrapped JSON formats)
|
||||
// Uses parseGeminiCommonConfigSnippet for unified parsing
|
||||
const { env: commonEnvObj, warning } = parseGeminiCommonConfig(
|
||||
geminiCommonConfigSnippet.trim(),
|
||||
);
|
||||
|
||||
@@ -11,6 +11,4 @@ export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||
export { useGeminiConfigState } from "./useGeminiConfigState";
|
||||
|
||||
// Common Config Hooks
|
||||
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
||||
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
|
||||
|
||||
@@ -10,12 +10,6 @@ import { codexAdapter } from "@/hooks/commonConfigAdapters";
|
||||
import { extractTomlDifference } from "@/utils/tomlConfigMerge";
|
||||
import type { ProviderMeta } from "@/types";
|
||||
|
||||
/** TOML 校验错误码 */
|
||||
export type TomlValidationErrorCode =
|
||||
| "TOML_SYNTAX_ERROR"
|
||||
| "TOML_PARSE_FAILED"
|
||||
| "";
|
||||
|
||||
interface UseCodexCommonConfigProps {
|
||||
/**
|
||||
* 当前 Codex 配置(可能是纯 TOML 或 JSON wrapper 格式)
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useCommonConfigBase,
|
||||
type UseCommonConfigBaseReturn,
|
||||
} from "@/hooks/useCommonConfigBase";
|
||||
import { claudeAdapter } from "@/hooks/commonConfigAdapters";
|
||||
import type { ProviderMeta } from "@/types";
|
||||
|
||||
interface UseCommonConfigSnippetProps {
|
||||
/**
|
||||
* 当前配置(用于显示和运行时合并)
|
||||
* 新架构:传入自定义配置,返回最终配置
|
||||
*/
|
||||
settingsConfig: string;
|
||||
/**
|
||||
* 配置变化回调
|
||||
*/
|
||||
onConfigChange: (config: string) => void;
|
||||
/**
|
||||
* 初始数据(编辑模式)
|
||||
*/
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
meta?: ProviderMeta;
|
||||
};
|
||||
/**
|
||||
* 当前选中的预设 ID
|
||||
*/
|
||||
selectedPresetId?: string;
|
||||
/**
|
||||
* 当 false 时跳过所有逻辑,返回禁用状态。默认:true
|
||||
*/
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* 当前正在编辑的供应商 ID
|
||||
*/
|
||||
currentProviderId?: string;
|
||||
}
|
||||
|
||||
export interface UseCommonConfigSnippetReturn {
|
||||
/** 是否启用通用配置 */
|
||||
useCommonConfig: boolean;
|
||||
/** 通用配置片段 (JSON 格式) */
|
||||
commonConfigSnippet: string;
|
||||
/** 通用配置错误信息 */
|
||||
commonConfigError: string;
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 是否正在提取 */
|
||||
isExtracting: boolean;
|
||||
/** 通用配置开关处理函数 */
|
||||
handleCommonConfigToggle: (checked: boolean) => void;
|
||||
/** 通用配置片段变化处理函数 */
|
||||
handleCommonConfigSnippetChange: (snippet: string) => void;
|
||||
/** 从当前配置提取通用配置 */
|
||||
handleExtract: () => Promise<void>;
|
||||
/** 最终配置(运行时合并结果,只读) */
|
||||
finalConfig: string;
|
||||
/** 是否有待保存的通用配置变更 */
|
||||
hasUnsavedCommonConfig: boolean;
|
||||
/** 获取待保存的通用配置片段(用于 handleSubmit) */
|
||||
getPendingCommonConfigSnippet: () => string | null;
|
||||
/** 标记通用配置已保存 */
|
||||
markCommonConfigSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Claude 通用配置片段
|
||||
*
|
||||
* 基于 useCommonConfigBase 泛型 Hook + Claude JSON 适配器实现。
|
||||
*/
|
||||
export function useCommonConfigSnippet({
|
||||
settingsConfig,
|
||||
onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
enabled = true,
|
||||
}: UseCommonConfigSnippetProps): UseCommonConfigSnippetReturn {
|
||||
const adapter = useMemo(() => claudeAdapter, []);
|
||||
|
||||
const base: UseCommonConfigBaseReturn<string> = useCommonConfigBase({
|
||||
adapter,
|
||||
inputValue: settingsConfig,
|
||||
onInputChange: onConfigChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
enabled,
|
||||
});
|
||||
|
||||
return {
|
||||
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,118 +0,0 @@
|
||||
import { useMemo } from "react";
|
||||
import {
|
||||
useCommonConfigBase,
|
||||
type UseCommonConfigBaseReturn,
|
||||
} from "@/hooks/useCommonConfigBase";
|
||||
import { createGeminiAdapter } from "@/hooks/commonConfigAdapters";
|
||||
import type { ProviderMeta } from "@/types";
|
||||
|
||||
interface UseGeminiCommonConfigProps {
|
||||
/**
|
||||
* 当前 env 值(字符串格式,如 "KEY=VALUE\nKEY2=VALUE2")
|
||||
*/
|
||||
envValue: string;
|
||||
/**
|
||||
* env 变化回调
|
||||
*/
|
||||
onEnvChange: (env: string) => void;
|
||||
/**
|
||||
* 字符串转对象
|
||||
*/
|
||||
envStringToObj: (envString: string) => Record<string, string>;
|
||||
/**
|
||||
* 对象转字符串
|
||||
*/
|
||||
envObjToString: (envObj: Record<string, unknown>) => string;
|
||||
/**
|
||||
* 初始数据(编辑模式)
|
||||
*/
|
||||
initialData?: {
|
||||
settingsConfig?: Record<string, unknown>;
|
||||
meta?: ProviderMeta;
|
||||
};
|
||||
/**
|
||||
* 当前选中的预设 ID
|
||||
*/
|
||||
selectedPresetId?: string;
|
||||
/**
|
||||
* 当前正在编辑的供应商 ID
|
||||
*/
|
||||
currentProviderId?: string;
|
||||
}
|
||||
|
||||
export interface UseGeminiCommonConfigReturn {
|
||||
/** 是否启用通用配置 */
|
||||
useCommonConfig: boolean;
|
||||
/** 通用配置片段 (JSON 格式) */
|
||||
commonConfigSnippet: string;
|
||||
/** 通用配置错误信息 */
|
||||
commonConfigError: string;
|
||||
/** 是否正在加载 */
|
||||
isLoading: boolean;
|
||||
/** 是否正在提取 */
|
||||
isExtracting: boolean;
|
||||
/** 通用配置开关处理函数 */
|
||||
handleCommonConfigToggle: (checked: boolean) => void;
|
||||
/** 通用配置片段变化处理函数 */
|
||||
handleCommonConfigSnippetChange: (snippet: string) => void;
|
||||
/** 从当前配置提取通用配置 */
|
||||
handleExtract: () => Promise<void>;
|
||||
/** 最终 env 对象(运行时合并结果,只读) */
|
||||
finalEnv: Record<string, string>;
|
||||
/** 是否有待保存的通用配置变更 */
|
||||
hasUnsavedCommonConfig: boolean;
|
||||
/** 获取待保存的通用配置片段(用于 handleSubmit) */
|
||||
getPendingCommonConfigSnippet: () => string | null;
|
||||
/** 标记通用配置已保存 */
|
||||
markCommonConfigSaved: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理 Gemini 通用配置片段
|
||||
*
|
||||
* 基于 useCommonConfigBase 泛型 Hook + Gemini ENV/JSON 适配器实现。
|
||||
*
|
||||
* 架构:
|
||||
* - envValue:传入当前 env 字符串
|
||||
* - commonConfigSnippet:存储在数据库中的通用配置片段
|
||||
* - finalEnv:运行时计算 = merge(commonConfig, customEnv)
|
||||
* - 开启/关闭通用配置只改变 enabled 状态,不修改 envValue
|
||||
*/
|
||||
export function useGeminiCommonConfig({
|
||||
envValue,
|
||||
onEnvChange,
|
||||
envStringToObj,
|
||||
envObjToString,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
}: UseGeminiCommonConfigProps): UseGeminiCommonConfigReturn {
|
||||
// 创建适配器(需要转换函数)
|
||||
const adapter = useMemo(
|
||||
() => createGeminiAdapter({ envStringToObj, envObjToString }),
|
||||
[envStringToObj, envObjToString],
|
||||
);
|
||||
|
||||
const base: UseCommonConfigBaseReturn<Record<string, string>> =
|
||||
useCommonConfigBase({
|
||||
adapter,
|
||||
inputValue: envValue,
|
||||
onInputChange: onEnvChange,
|
||||
initialData,
|
||||
selectedPresetId,
|
||||
});
|
||||
|
||||
return {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -249,7 +249,7 @@ export const codexAdapter: CommonConfigAdapter<string, string> = {
|
||||
// ============================================================================
|
||||
|
||||
const GEMINI_LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet";
|
||||
const GEMINI_DEFAULT_SNIPPET = "{}";
|
||||
const GEMINI_DEFAULT_SNIPPET = "";
|
||||
|
||||
export interface GeminiAdapterOptions {
|
||||
/** 字符串转对象 */
|
||||
@@ -306,7 +306,9 @@ export function createGeminiAdapter(
|
||||
) {
|
||||
return t("geminiConfig.commonConfigInvalidValues");
|
||||
}
|
||||
return t("geminiConfig.invalidJsonFormat");
|
||||
return t("geminiConfig.invalidEnvFormat", {
|
||||
defaultValue: "配置格式错误",
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.keys(result.env).length === 0) {
|
||||
|
||||
@@ -234,7 +234,9 @@ export function useCommonConfigBase<TConfig, TFinal>({
|
||||
);
|
||||
if (legacySnippet && legacySnippet.trim()) {
|
||||
const parsed = adapter.parseSnippet(legacySnippet);
|
||||
if (!parsed.error) {
|
||||
// 只有在解析成功且有有效内容时才迁移
|
||||
// 这避免了将 "{}" 这样的空 JSON 迁移为"有效配置"
|
||||
if (!parsed.error && adapter.hasValidContent(legacySnippet)) {
|
||||
await configApi.setCommonConfigSnippet(
|
||||
adapter.appKey,
|
||||
legacySnippet,
|
||||
@@ -246,6 +248,9 @@ export function useCommonConfigBase<TConfig, TFinal>({
|
||||
console.log(
|
||||
`[迁移] ${adapter.appKey} 通用配置已从 localStorage 迁移到数据库`,
|
||||
);
|
||||
} else {
|
||||
// 解析失败或无有效内容,清理 localStorage 不迁移
|
||||
window.localStorage.removeItem(adapter.legacyStorageKey);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -584,6 +584,7 @@
|
||||
"saveFailed": "Save failed: {{error}}",
|
||||
"extractedConfigInvalid": "Extracted config format is invalid",
|
||||
"invalidJsonFormat": "Common config snippet format error (must be valid JSON)",
|
||||
"invalidEnvFormat": "Config format error (use KEY=VALUE format, one per line)",
|
||||
"commonConfigInvalidKeys": "Common config snippet must not include GOOGLE_GEMINI_BASE_URL or GEMINI_API_KEY (found: {{keys}})",
|
||||
"commonConfigInvalidValues": "Common config snippet values must be strings",
|
||||
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
|
||||
|
||||
@@ -584,6 +584,7 @@
|
||||
"saveFailed": "保存に失敗しました: {{error}}",
|
||||
"extractedConfigInvalid": "抽出した設定のフォーマットが不正です",
|
||||
"invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)",
|
||||
"invalidEnvFormat": "設定フォーマットエラー(KEY=VALUE 形式を使用してください、各行に1つ)",
|
||||
"commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}})",
|
||||
"commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります",
|
||||
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
||||
|
||||
@@ -584,6 +584,7 @@
|
||||
"saveFailed": "保存失败: {{error}}",
|
||||
"extractedConfigInvalid": "提取的配置格式错误",
|
||||
"invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)",
|
||||
"invalidEnvFormat": "配置格式错误(请使用 KEY=VALUE 格式,每行一个)",
|
||||
"commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}})",
|
||||
"commonConfigInvalidValues": "通用配置片段的值必须是字符串",
|
||||
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
||||
|
||||
+5
-10
@@ -81,7 +81,10 @@ export async function getCommonConfigSnippet(
|
||||
* 设置通用配置片段(统一接口)
|
||||
* @param appType - 应用类型(claude/codex/gemini)
|
||||
* @param snippet - 通用配置片段(原始字符串)
|
||||
* @throws 如果格式无效(Claude/Gemini 验证 JSON,Codex 暂不验证)
|
||||
* @throws 如果格式无效
|
||||
* - Claude: 验证 JSON 格式
|
||||
* - Gemini: 验证 ENV/JSON 格式,拒绝包含禁用键(GEMINI_API_KEY/GOOGLE_GEMINI_BASE_URL)
|
||||
* - Codex: TOML 格式,暂不验证
|
||||
*/
|
||||
export async function setCommonConfigSnippet(
|
||||
appType: AppType,
|
||||
@@ -98,7 +101,7 @@ export async function setCommonConfigSnippet(
|
||||
*
|
||||
* @param appType - 应用类型(claude/codex/gemini)
|
||||
* @param options - 可选:提取来源
|
||||
* @returns 提取的通用配置片段(JSON/TOML 字符串)
|
||||
* @returns 提取的通用配置片段(Claude: JSON, Codex: TOML, Gemini: ENV)
|
||||
*/
|
||||
export type ExtractCommonConfigSnippetOptions = {
|
||||
settingsConfig?: string;
|
||||
@@ -201,10 +204,6 @@ async function executeSyncWithLock(appType: AppType): Promise<void> {
|
||||
// 输出结果
|
||||
if (result.error) {
|
||||
console.warn(`[syncCommonConfig] ${appType} 同步失败: ${result.error}`);
|
||||
} else if (result.count > 0) {
|
||||
console.log(
|
||||
`[syncCommonConfig] 共更新 ${result.count} 个 ${appType} 供应商`,
|
||||
);
|
||||
}
|
||||
|
||||
// 通知回调
|
||||
@@ -321,15 +320,11 @@ async function doSyncCommonConfigToProviders(
|
||||
},
|
||||
},
|
||||
};
|
||||
console.log(
|
||||
`[syncCommonConfig] 供应商 ${id} 检测到通用配置,已补写 meta`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await providersApi.update(providerToSave, appType);
|
||||
updatedCount++;
|
||||
console.log(`[syncCommonConfig] 已更新供应商 ${id}`);
|
||||
} catch (updateError) {
|
||||
errors.push(`供应商 ${id}: 保存失败 - ${String(updateError)}`);
|
||||
}
|
||||
|
||||
@@ -63,13 +63,26 @@ export const hasCommonConfigSnippet = (
|
||||
}
|
||||
};
|
||||
|
||||
// 检查 Gemini 配置是否已包含通用配置片段(env JSON)
|
||||
/**
|
||||
* 检查 Gemini 配置是否已包含通用配置片段
|
||||
*
|
||||
* 支持三种 snippet 格式:
|
||||
* - ENV 格式: KEY=VALUE (一行一个)
|
||||
* - Flat JSON: {"KEY": "VALUE", ...}
|
||||
* - Wrapped JSON: {"env": {"KEY": "VALUE", ...}}
|
||||
*
|
||||
* @param jsonString - Provider 的 settingsConfig (JSON 字符串)
|
||||
* @param snippetString - 通用配置片段 (ENV/JSON 格式)
|
||||
* @returns 是否已包含通用配置
|
||||
*/
|
||||
export const hasGeminiCommonConfigSnippet = (
|
||||
jsonString: string,
|
||||
snippetString: string,
|
||||
): boolean => {
|
||||
try {
|
||||
if (!snippetString.trim()) return false;
|
||||
|
||||
// 解析 provider 的 settingsConfig
|
||||
const config = jsonString ? JSON.parse(jsonString) : {};
|
||||
if (!isPlainObject(config)) return false;
|
||||
const envValue = (config as Record<string, unknown>).env;
|
||||
@@ -79,27 +92,18 @@ export const hasGeminiCommonConfigSnippet = (
|
||||
unknown
|
||||
>;
|
||||
|
||||
const parsed = JSON.parse(snippetString);
|
||||
if (!isPlainObject(parsed)) return false;
|
||||
// 使用 parseGeminiCommonConfigSnippet 解析 snippet (支持 ENV/JSON)
|
||||
const parseResult = parseGeminiCommonConfigSnippet(snippetString, {
|
||||
strictForbiddenKeys: false, // 过滤禁用键而非报错
|
||||
});
|
||||
|
||||
const entries = Object.entries(parsed).filter(
|
||||
(entry): entry is [string, string] => {
|
||||
const [key, value] = entry;
|
||||
if (
|
||||
GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(
|
||||
key as GeminiForbiddenEnvKey,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value !== "string") return false;
|
||||
return value.trim().length > 0;
|
||||
},
|
||||
);
|
||||
// 解析失败或为空
|
||||
if (parseResult.error || Object.keys(parseResult.env).length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (entries.length === 0) return false;
|
||||
|
||||
return entries.every(([key, value]) => {
|
||||
// 检查所有有效键值对是否都存在于 provider.env 中
|
||||
return Object.entries(parseResult.env).every(([key, value]) => {
|
||||
const current = env[key];
|
||||
return typeof current === "string" && current === value.trim();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user