diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index 29ee9c618..7f4276d4f 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -219,3 +219,18 @@ pub async fn set_common_config_snippet( .map_err(|e| e.to_string())?; Ok(()) } + +/// 从当前供应商提取通用配置片段 +/// +/// 读取当前激活供应商的配置,自动排除差异化字段(API Key、模型配置、端点等), +/// 返回可复用的通用配置片段。 +#[tauri::command] +pub async fn extract_common_config_snippet( + app_type: String, + state: tauri::State<'_, crate::store::AppState>, +) -> Result { + let app = AppType::from_str(&app_type).map_err(|e| e.to_string())?; + + crate::services::provider::ProviderService::extract_common_config_snippet(&state, app) + .map_err(|e| e.to_string()) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d6475138d..307d2eafd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -377,6 +377,27 @@ pub fn run() { ) { Ok(true) => { log::info!("✓ Imported default provider for {}", app.as_str()); + + // 首次运行:自动提取通用配置片段(仅当通用配置为空时) + if app_state + .db + .get_config_snippet(app.as_str()) + .ok() + .flatten() + .is_none() + { + match crate::services::provider::ProviderService::extract_common_config_snippet(&app_state, app.clone()) { + Ok(snippet) if !snippet.is_empty() && snippet != "{}" => { + if let Err(e) = app_state.db.set_config_snippet(app.as_str(), Some(snippet)) { + log::warn!("✗ Failed to save common config snippet for {}: {e}", app.as_str()); + } else { + log::info!("✓ Extracted common config snippet for {}", app.as_str()); + } + } + Ok(_) => log::debug!("○ No common config to extract for {}", app.as_str()), + Err(e) => log::debug!("○ Failed to extract common config for {}: {e}", app.as_str()), + } + } } Ok(false) => {} // 已有供应商,静默跳过 Err(e) => { @@ -606,6 +627,7 @@ pub fn run() { commands::set_claude_common_config_snippet, commands::get_common_config_snippet, commands::set_common_config_snippet, + commands::extract_common_config_snippet, commands::read_live_provider_settings, commands::get_settings, commands::save_settings, diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 91c3ab8e1..1f8dc19eb 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -308,6 +308,151 @@ impl ProviderService { sync_current_to_live(state) } + /// Extract common config snippet from current provider + /// + /// Extracts the current provider's configuration and removes provider-specific fields + /// (API keys, model settings, endpoints) to create a reusable common config snippet. + pub fn extract_common_config_snippet( + state: &AppState, + app_type: AppType, + ) -> Result { + // Get current provider + let current_id = Self::current(state, app_type.clone())?; + if current_id.is_empty() { + return Err(AppError::Message("No current provider".to_string())); + } + + let providers = state.db.get_all_providers(app_type.as_str())?; + let provider = providers + .get(¤t_id) + .ok_or_else(|| AppError::Message(format!("Provider {} not found", current_id)))?; + + match app_type { + AppType::Claude => Self::extract_claude_common_config(&provider.settings_config), + AppType::Codex => Self::extract_codex_common_config(&provider.settings_config), + AppType::Gemini => Self::extract_gemini_common_config(&provider.settings_config), + } + } + + /// Extract common config for Claude (JSON format) + fn extract_claude_common_config(settings: &Value) -> Result { + let mut config = settings.clone(); + + // Fields to exclude from common config + const ENV_EXCLUDES: &[&str] = &[ + // Auth + "ANTHROPIC_API_KEY", + "ANTHROPIC_AUTH_TOKEN", + // Models (5 fields) + "ANTHROPIC_MODEL", + "ANTHROPIC_REASONING_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + // Endpoint + "ANTHROPIC_BASE_URL", + ]; + + const TOP_LEVEL_EXCLUDES: &[&str] = &[ + "apiBaseUrl", + // Legacy model fields + "primaryModel", + "smallFastModel", + ]; + + // Remove env fields + if let Some(env) = config.get_mut("env").and_then(|v| v.as_object_mut()) { + for key in ENV_EXCLUDES { + env.remove(*key); + } + // If env is empty after removal, remove the env object itself + if env.is_empty() { + config.as_object_mut().map(|obj| obj.remove("env")); + } + } + + // Remove top-level fields + if let Some(obj) = config.as_object_mut() { + for key in TOP_LEVEL_EXCLUDES { + obj.remove(*key); + } + } + + // Check if result is empty + if config.as_object().is_none_or(|obj| obj.is_empty()) { + return Ok("{}".to_string()); + } + + serde_json::to_string_pretty(&config) + .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) + } + + /// Extract common config for Codex (TOML format) + fn extract_codex_common_config(settings: &Value) -> Result { + // Codex config is stored as { "auth": {...}, "config": "toml string" } + let config_toml = settings + .get("config") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if config_toml.is_empty() { + return Ok(String::new()); + } + + // Lines to exclude (regex patterns for TOML) + let exclude_patterns = [ + Regex::new(r"(?m)^\s*model\s*=.*$").unwrap(), + Regex::new(r"(?m)^\s*model_provider\s*=.*$").unwrap(), + Regex::new(r"(?m)^\s*base_url\s*=.*$").unwrap(), + ]; + + let mut result = config_toml.to_string(); + for pattern in &exclude_patterns { + result = pattern.replace_all(&result, "").to_string(); + } + + // Clean up multiple empty lines + let result = Regex::new(r"\n{3,}") + .unwrap() + .replace_all(&result, "\n\n") + .trim() + .to_string(); + + Ok(result) + } + + /// Extract common config for Gemini (JSON format) + /// + /// Extracts `.env` values while excluding provider-specific credentials: + /// - GOOGLE_GEMINI_BASE_URL + /// - GEMINI_API_KEY + fn extract_gemini_common_config(settings: &Value) -> Result { + let env = settings.get("env").and_then(|v| v.as_object()); + + let mut snippet = serde_json::Map::new(); + if let Some(env) = env { + for (key, value) in env { + if key == "GOOGLE_GEMINI_BASE_URL" || key == "GEMINI_API_KEY" { + continue; + } + let Value::String(v) = value else { + continue; + }; + let trimmed = v.trim(); + if !trimmed.is_empty() { + snippet.insert(key.to_string(), Value::String(trimmed.to_string())); + } + } + } + + if snippet.is_empty() { + return Ok("{}".to_string()); + } + + serde_json::to_string_pretty(&Value::Object(snippet)) + .map_err(|e| AppError::Message(format!("Serialization failed: {e}"))) + } + /// Import default configuration from live files (re-export) /// /// Returns `Ok(true)` if imported, `Ok(false)` if skipped. diff --git a/src/components/providers/forms/CodexCommonConfigModal.tsx b/src/components/providers/forms/CodexCommonConfigModal.tsx index aeff3be80..7484b90a6 100644 --- a/src/components/providers/forms/CodexCommonConfigModal.tsx +++ b/src/components/providers/forms/CodexCommonConfigModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Save } from "lucide-react"; +import { Save, Download, Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { Button } from "@/components/ui/button"; @@ -11,6 +11,8 @@ interface CodexCommonConfigModalProps { value: string; onChange: (value: string) => void; error?: string; + onExtract?: () => void; + isExtracting?: boolean; } /** @@ -23,6 +25,8 @@ export const CodexCommonConfigModal: React.FC = ({ value, onChange, error, + onExtract, + isExtracting, }) => { const { t } = useTranslation(); const [isDarkMode, setIsDarkMode] = useState(false); @@ -49,6 +53,24 @@ export const CodexCommonConfigModal: React.FC = ({ onClose={onClose} footer={ <> + {onExtract && ( + + )} diff --git a/src/components/providers/forms/CodexConfigEditor.tsx b/src/components/providers/forms/CodexConfigEditor.tsx index 2501f96fb..42ba3cae3 100644 --- a/src/components/providers/forms/CodexConfigEditor.tsx +++ b/src/components/providers/forms/CodexConfigEditor.tsx @@ -26,6 +26,10 @@ interface CodexConfigEditorProps { authError: string; configError: string; // config.toml 错误提示 + + onExtract?: () => void; + + isExtracting?: boolean; } const CodexConfigEditor: React.FC = ({ @@ -41,6 +45,8 @@ const CodexConfigEditor: React.FC = ({ commonConfigError, authError, configError, + onExtract, + isExtracting, }) => { const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); @@ -79,6 +85,8 @@ const CodexConfigEditor: React.FC = ({ value={commonConfigSnippet} onChange={onCommonConfigSnippetChange} error={commonConfigError} + onExtract={onExtract} + isExtracting={isExtracting} /> ); diff --git a/src/components/providers/forms/CommonConfigEditor.tsx b/src/components/providers/forms/CommonConfigEditor.tsx index c954163ce..99ceb81f1 100644 --- a/src/components/providers/forms/CommonConfigEditor.tsx +++ b/src/components/providers/forms/CommonConfigEditor.tsx @@ -3,7 +3,7 @@ import { useEffect, useState } from "react"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { Label } from "@/components/ui/label"; import { Button } from "@/components/ui/button"; -import { Save } from "lucide-react"; +import { Save, Download, Loader2 } from "lucide-react"; import JsonEditor from "@/components/JsonEditor"; interface CommonConfigEditorProps { @@ -17,6 +17,8 @@ interface CommonConfigEditorProps { onEditClick: () => void; isModalOpen: boolean; onModalClose: () => void; + onExtract?: () => void; + isExtracting?: boolean; } export function CommonConfigEditor({ @@ -30,6 +32,8 @@ export function CommonConfigEditor({ onEditClick, isModalOpen, onModalClose, + onExtract, + isExtracting, }: CommonConfigEditorProps) { const { t } = useTranslation(); const [isDarkMode, setIsDarkMode] = useState(false); @@ -111,6 +115,24 @@ export function CommonConfigEditor({ onClose={onModalClose} footer={ <> + {onExtract && ( + + )} diff --git a/src/components/providers/forms/GeminiCommonConfigModal.tsx b/src/components/providers/forms/GeminiCommonConfigModal.tsx index 9846278d1..969a8cbe2 100644 --- a/src/components/providers/forms/GeminiCommonConfigModal.tsx +++ b/src/components/providers/forms/GeminiCommonConfigModal.tsx @@ -1,5 +1,5 @@ import React, { useEffect, useState } from "react"; -import { Save } from "lucide-react"; +import { Save, Download, Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { FullScreenPanel } from "@/components/common/FullScreenPanel"; import { Button } from "@/components/ui/button"; @@ -11,15 +11,17 @@ interface GeminiCommonConfigModalProps { value: string; onChange: (value: string) => void; error?: string; + onExtract?: () => void; + isExtracting?: boolean; } /** * GeminiCommonConfigModal - Common Gemini configuration editor modal - * Allows editing of common JSON configuration shared across Gemini providers + * Allows editing of common env snippet shared across Gemini providers */ export const GeminiCommonConfigModal: React.FC< GeminiCommonConfigModalProps -> = ({ isOpen, onClose, value, onChange, error }) => { +> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => { const { t } = useTranslation(); const [isDarkMode, setIsDarkMode] = useState(false); @@ -47,6 +49,24 @@ export const GeminiCommonConfigModal: React.FC< onClose={onClose} footer={ <> + {onExtract && ( + + )} @@ -61,7 +81,7 @@ export const GeminiCommonConfigModal: React.FC<

{t("geminiConfig.commonConfigHint", { defaultValue: - "通用配置片段将合并到所有启用它的 Gemini 供应商配置中", + "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)", })}

@@ -69,9 +89,7 @@ export const GeminiCommonConfigModal: React.FC< value={value} onChange={onChange} placeholder={`{ - "timeout": 30000, - "maxRetries": 3, - "customField": "value" + "GEMINI_MODEL": "gemini-3-pro-preview" }`} darkMode={isDarkMode} rows={16} diff --git a/src/components/providers/forms/GeminiConfigEditor.tsx b/src/components/providers/forms/GeminiConfigEditor.tsx index 7d5b1bd99..2518b051f 100644 --- a/src/components/providers/forms/GeminiConfigEditor.tsx +++ b/src/components/providers/forms/GeminiConfigEditor.tsx @@ -15,6 +15,8 @@ interface GeminiConfigEditorProps { commonConfigError: string; envError: string; configError: string; + onExtract?: () => void; + isExtracting?: boolean; } const GeminiConfigEditor: React.FC = ({ @@ -30,6 +32,8 @@ const GeminiConfigEditor: React.FC = ({ commonConfigError, envError, configError, + onExtract, + isExtracting, }) => { const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false); @@ -48,16 +52,16 @@ const GeminiConfigEditor: React.FC = ({ onChange={onEnvChange} onBlur={onEnvBlur} error={envError} + useCommonConfig={useCommonConfig} + onCommonConfigToggle={onCommonConfigToggle} + onEditCommonConfig={() => setIsCommonConfigModalOpen(true)} + commonConfigError={commonConfigError} /> {/* Config JSON Section */} setIsCommonConfigModalOpen(true)} - commonConfigError={commonConfigError} configError={configError} /> @@ -68,6 +72,8 @@ const GeminiConfigEditor: React.FC = ({ value={commonConfigSnippet} onChange={onCommonConfigSnippetChange} error={commonConfigError} + onExtract={onExtract} + isExtracting={isExtracting} /> ); diff --git a/src/components/providers/forms/GeminiConfigSections.tsx b/src/components/providers/forms/GeminiConfigSections.tsx index 09132e282..999f986dc 100644 --- a/src/components/providers/forms/GeminiConfigSections.tsx +++ b/src/components/providers/forms/GeminiConfigSections.tsx @@ -7,6 +7,10 @@ interface GeminiEnvSectionProps { onChange: (value: string) => void; onBlur?: () => void; error?: string; + useCommonConfig: boolean; + onCommonConfigToggle: (checked: boolean) => void; + onEditCommonConfig: () => void; + commonConfigError?: string; } /** @@ -17,6 +21,10 @@ export const GeminiEnvSection: React.FC = ({ onChange, onBlur, error, + useCommonConfig, + onCommonConfigToggle, + onEditCommonConfig, + commonConfigError, }) => { const { t } = useTranslation(); const [isDarkMode, setIsDarkMode] = useState(false); @@ -43,92 +51,14 @@ export const GeminiEnvSection: React.FC = ({ } }; - return ( -
- - - - - {error && ( -

{error}

- )} - - {!error && ( -

- {t("geminiConfig.envFileHint", { - defaultValue: "使用 .env 格式配置 Gemini 环境变量", - })} -

- )} -
- ); -}; - -interface GeminiConfigSectionProps { - value: string; - onChange: (value: string) => void; - useCommonConfig: boolean; - onCommonConfigToggle: (checked: boolean) => void; - onEditCommonConfig: () => void; - commonConfigError?: string; - configError?: string; -} - -/** - * GeminiConfigSection - Config JSON editor section with common config support - */ -export const GeminiConfigSection: React.FC = ({ - value, - onChange, - useCommonConfig, - onCommonConfigToggle, - onEditCommonConfig, - commonConfigError, - 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(); - }, []); - return (
+ ); +}; + +interface GeminiConfigSectionProps { + value: string; + onChange: (value: string) => void; + configError?: string; +} + +/** + * GeminiConfigSection - Config JSON editor section with common config support + */ +export const GeminiConfigSection: React.FC = ({ + value, + onChange, + 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(); + }, []); + + return ( +
+ + form.setValue("settingsConfig", config), @@ -365,6 +367,8 @@ export function ProviderForm({ commonConfigError: codexCommonConfigError, handleCommonConfigToggle: handleCodexCommonConfigToggle, handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange, + isExtracting: isCodexExtracting, + handleExtract: handleCodexExtract, } = useCodexCommonConfig({ codexConfig, onConfigChange: handleCodexConfigChange, @@ -387,6 +391,7 @@ export function ProviderForm({ handleGeminiConfigChange, resetGeminiConfig, envStringToObj, + envObjToString, } = useGeminiConfigState({ initialData: appId === "gemini" ? initialData : undefined, }); @@ -447,9 +452,13 @@ export function ProviderForm({ commonConfigError: geminiCommonConfigError, handleCommonConfigToggle: handleGeminiCommonConfigToggle, handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange, + isExtracting: isGeminiExtracting, + handleExtract: handleGeminiExtract, } = useGeminiCommonConfig({ - configValue: geminiConfig, - onConfigChange: handleGeminiConfigChange, + envValue: geminiEnv, + onEnvChange: handleGeminiEnvChange, + envStringToObj, + envObjToString, initialData: appId === "gemini" ? initialData : undefined, }); @@ -927,6 +936,8 @@ export function ProviderForm({ commonConfigError={codexCommonConfigError} authError={codexAuthError} configError={codexConfigError} + onExtract={handleCodexExtract} + isExtracting={isCodexExtracting} /> {/* 配置验证错误显示 */} {/* 配置验证错误显示 */} setIsCommonConfigModalOpen(true)} isModalOpen={isCommonConfigModalOpen} onModalClose={() => setIsCommonConfigModalOpen(false)} + onExtract={handleClaudeExtract} + isExtracting={isClaudeExtracting} /> {/* 配置验证错误显示 */} ( DEFAULT_CODEX_COMMON_CONFIG_SNIPPET, ); const [commonConfigError, setCommonConfigError] = useState(""); const [isLoading, setIsLoading] = useState(true); + const [isExtracting, setIsExtracting] = useState(false); // 用于跟踪是否正在通过通用配置更新 const isUpdatingFromCommonConfig = useRef(false); + // 用于跟踪新建模式是否已初始化默认勾选 + const hasInitializedNewMode = useRef(false); // 初始化:从 config.json 加载,支持从 localStorage 迁移 useEffect(() => { @@ -100,6 +105,38 @@ export function useCodexCommonConfig({ } }, [initialData, commonConfigSnippet, isLoading]); + // 新建模式:如果通用配置片段存在且有效,默认启用 + useEffect(() => { + // 仅新建模式、加载完成、尚未初始化过 + if (!initialData && !isLoading && !hasInitializedNewMode.current) { + hasInitializedNewMode.current = true; + + // 检查 TOML 片段是否有实质内容(不只是注释和空行) + const lines = commonConfigSnippet.split("\n"); + const hasContent = lines.some((line) => { + const trimmed = line.trim(); + return trimmed && !trimmed.startsWith("#"); + }); + + if (hasContent) { + setUseCommonConfig(true); + // 合并通用配置到当前配置 + const { updatedConfig, error } = updateTomlCommonConfigSnippet( + codexConfig, + commonConfigSnippet, + true, + ); + if (!error) { + isUpdatingFromCommonConfig.current = true; + onConfigChange(updatedConfig); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); + } + } + } + }, [initialData, commonConfigSnippet, isLoading, codexConfig, onConfigChange]); + // 处理通用配置开关 const handleCommonConfigToggle = useCallback( (checked: boolean) => { @@ -140,7 +177,7 @@ export function useCodexCommonConfig({ // 保存到 config.json(清空) configApi.setCommonConfigSnippet("codex", "").catch((error) => { console.error("保存 Codex 通用配置失败:", error); - setCommonConfigError(`保存失败: ${error}`); + setCommonConfigError(t("codexConfig.saveFailed", { error: String(error) })); }); if (useCommonConfig) { @@ -160,7 +197,7 @@ export function useCodexCommonConfig({ // 保存到 config.json configApi.setCommonConfigSnippet("codex", value).catch((error) => { console.error("保存 Codex 通用配置失败:", error); - setCommonConfigError(`保存失败: ${error}`); + setCommonConfigError(t("codexConfig.saveFailed", { error: String(error) })); }); // 若当前启用通用配置,需要替换为最新片段 @@ -209,12 +246,40 @@ export function useCodexCommonConfig({ setUseCommonConfig(hasCommon); }, [codexConfig, commonConfigSnippet, isLoading]); + // 从当前供应商提取通用配置片段 + const handleExtract = useCallback(async () => { + setIsExtracting(true); + setCommonConfigError(""); + + try { + const extracted = await configApi.extractCommonConfigSnippet("codex"); + + if (!extracted || !extracted.trim()) { + setCommonConfigError(t("codexConfig.extractNoCommonConfig")); + return; + } + + // 更新片段状态 + setCommonConfigSnippetState(extracted); + + // 保存到后端 + await configApi.setCommonConfigSnippet("codex", extracted); + } catch (error) { + console.error("提取 Codex 通用配置失败:", error); + setCommonConfigError(t("codexConfig.extractFailed", { error: String(error) })); + } finally { + setIsExtracting(false); + } + }, []); + return { useCommonConfig, commonConfigSnippet, commonConfigError, isLoading, + isExtracting, handleCommonConfigToggle, handleCommonConfigSnippetChange, + handleExtract, }; } diff --git a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts index cdf59f8b9..007571431 100644 --- a/src/components/providers/forms/hooks/useCommonConfigSnippet.ts +++ b/src/components/providers/forms/hooks/useCommonConfigSnippet.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { updateCommonConfigSnippet, hasCommonConfigSnippet, @@ -28,15 +29,19 @@ export function useCommonConfigSnippet({ onConfigChange, initialData, }: UseCommonConfigSnippetProps) { + const { t } = useTranslation(); const [useCommonConfig, setUseCommonConfig] = useState(false); const [commonConfigSnippet, setCommonConfigSnippetState] = useState( DEFAULT_COMMON_CONFIG_SNIPPET, ); const [commonConfigError, setCommonConfigError] = useState(""); const [isLoading, setIsLoading] = useState(true); + const [isExtracting, setIsExtracting] = useState(false); // 用于跟踪是否正在通过通用配置更新 const isUpdatingFromCommonConfig = useRef(false); + // 用于跟踪新建模式是否已初始化默认勾选 + const hasInitializedNewMode = useRef(false); // 初始化:从 config.json 加载,支持从 localStorage 迁移 useEffect(() => { @@ -102,6 +107,38 @@ export function useCommonConfigSnippet({ } }, [initialData, commonConfigSnippet, isLoading]); + // 新建模式:如果通用配置片段存在且有效,默认启用 + useEffect(() => { + // 仅新建模式、加载完成、尚未初始化过 + if (!initialData && !isLoading && !hasInitializedNewMode.current) { + hasInitializedNewMode.current = true; + + // 检查片段是否有实质内容 + try { + const snippetObj = JSON.parse(commonConfigSnippet); + const hasContent = Object.keys(snippetObj).length > 0; + if (hasContent) { + setUseCommonConfig(true); + // 合并通用配置到当前配置 + const { updatedConfig, error } = updateCommonConfigSnippet( + settingsConfig, + commonConfigSnippet, + true, + ); + if (!error) { + isUpdatingFromCommonConfig.current = true; + onConfigChange(updatedConfig); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); + } + } + } catch { + // ignore parse error + } + } + }, [initialData, commonConfigSnippet, isLoading, settingsConfig, onConfigChange]); + // 处理通用配置开关 const handleCommonConfigToggle = useCallback( (checked: boolean) => { @@ -141,7 +178,7 @@ export function useCommonConfigSnippet({ // 保存到 config.json(清空) configApi.setCommonConfigSnippet("claude", "").catch((error) => { console.error("保存通用配置失败:", error); - setCommonConfigError(`保存失败: ${error}`); + setCommonConfigError(t("claudeConfig.saveFailed", { error: String(error) })); }); if (useCommonConfig) { @@ -165,7 +202,7 @@ export function useCommonConfigSnippet({ // 保存到 config.json configApi.setCommonConfigSnippet("claude", value).catch((error) => { console.error("保存通用配置失败:", error); - setCommonConfigError(`保存失败: ${error}`); + setCommonConfigError(t("claudeConfig.saveFailed", { error: String(error) })); }); } @@ -215,12 +252,47 @@ export function useCommonConfigSnippet({ setUseCommonConfig(hasCommon); }, [settingsConfig, commonConfigSnippet, isLoading]); + // 从当前供应商提取通用配置片段 + const handleExtract = useCallback(async () => { + setIsExtracting(true); + setCommonConfigError(""); + + try { + const extracted = await configApi.extractCommonConfigSnippet("claude"); + + if (!extracted || extracted === "{}") { + setCommonConfigError(t("claudeConfig.extractNoCommonConfig")); + return; + } + + // 验证 JSON 格式 + const validationError = validateJsonConfig(extracted, "提取的配置"); + if (validationError) { + setCommonConfigError(validationError); + return; + } + + // 更新片段状态 + setCommonConfigSnippetState(extracted); + + // 保存到后端 + await configApi.setCommonConfigSnippet("claude", extracted); + } catch (error) { + console.error("提取通用配置失败:", error); + setCommonConfigError(t("claudeConfig.extractFailed", { error: String(error) })); + } finally { + setIsExtracting(false); + } + }, []); + return { useCommonConfig, commonConfigSnippet, commonConfigError, isLoading, + isExtracting, handleCommonConfigToggle, handleCommonConfigSnippetChange, + handleExtract, }; } diff --git a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts index d27e9ae10..55f24ca28 100644 --- a/src/components/providers/forms/hooks/useGeminiCommonConfig.ts +++ b/src/components/providers/forms/hooks/useGeminiCommonConfig.ts @@ -1,126 +1,147 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { useTranslation } from "react-i18next"; import { configApi } from "@/lib/api"; const LEGACY_STORAGE_KEY = "cc-switch:gemini-common-config-snippet"; -const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = `{ - "timeout": 30000, - "maxRetries": 3 -}`; +const DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET = "{}"; + +const GEMINI_COMMON_ENV_FORBIDDEN_KEYS = [ + "GOOGLE_GEMINI_BASE_URL", + "GEMINI_API_KEY", +] as const; +type GeminiForbiddenEnvKey = (typeof GEMINI_COMMON_ENV_FORBIDDEN_KEYS)[number]; interface UseGeminiCommonConfigProps { - configValue: string; - onConfigChange: (config: string) => void; + envValue: string; + onEnvChange: (env: string) => void; + envStringToObj: (envString: string) => Record; + envObjToString: (envObj: Record) => string; initialData?: { settingsConfig?: Record; }; } -/** - * 深度合并两个对象(用于合并通用配置) - */ -function deepMerge(target: any, source: any): any { - if (typeof target !== "object" || target === null) { - return source; - } - if (typeof source !== "object" || source === null) { - return target; - } - if (Array.isArray(source)) { - return source; - } - - const result = { ...target }; - for (const key of Object.keys(source)) { - if (typeof source[key] === "object" && !Array.isArray(source[key])) { - result[key] = deepMerge(result[key], source[key]); - } else { - result[key] = source[key]; - } - } - return result; -} - -/** - * 从配置中移除通用配置片段(递归比较) - */ -function removeCommonConfig(config: any, commonConfig: any): any { - if (typeof config !== "object" || config === null) { - return config; - } - if (typeof commonConfig !== "object" || commonConfig === null) { - return config; - } - - const result = { ...config }; - for (const key of Object.keys(commonConfig)) { - if (result[key] === undefined) continue; - - // 如果值完全相等,删除该键 - if (JSON.stringify(result[key]) === JSON.stringify(commonConfig[key])) { - delete result[key]; - } else if ( - typeof result[key] === "object" && - !Array.isArray(result[key]) && - typeof commonConfig[key] === "object" && - !Array.isArray(commonConfig[key]) - ) { - // 递归移除嵌套对象 - result[key] = removeCommonConfig(result[key], commonConfig[key]); - // 如果移除后对象为空,删除该键 - if (Object.keys(result[key]).length === 0) { - delete result[key]; - } - } - } - return result; -} - -/** - * 检查配置中是否包含通用配置片段 - */ -function hasCommonConfigSnippet(config: any, commonConfig: any): boolean { - if (typeof config !== "object" || config === null) return false; - if (typeof commonConfig !== "object" || commonConfig === null) return false; - - for (const key of Object.keys(commonConfig)) { - if (config[key] === undefined) return false; - if (JSON.stringify(config[key]) !== JSON.stringify(commonConfig[key])) { - // 检查嵌套对象 - if ( - typeof config[key] === "object" && - !Array.isArray(config[key]) && - typeof commonConfig[key] === "object" && - !Array.isArray(commonConfig[key]) - ) { - if (!hasCommonConfigSnippet(config[key], commonConfig[key])) { - return false; - } - } else { - return false; - } - } - } - return true; +function isPlainObject(value: unknown): value is Record { + return ( + typeof value === "object" && + value !== null && + !Array.isArray(value) && + Object.prototype.toString.call(value) === "[object Object]" + ); } /** * 管理 Gemini 通用配置片段 (JSON 格式) - * 从 config.json 读取和保存,支持从 localStorage 平滑迁移 + * 写入 Gemini 的 .env,但会排除以下敏感字段: + * - GOOGLE_GEMINI_BASE_URL + * - GEMINI_API_KEY */ export function useGeminiCommonConfig({ - configValue, - onConfigChange, + envValue, + onEnvChange, + envStringToObj, + envObjToString, initialData, }: UseGeminiCommonConfigProps) { + const { t } = useTranslation(); const [useCommonConfig, setUseCommonConfig] = useState(false); const [commonConfigSnippet, setCommonConfigSnippetState] = useState( DEFAULT_GEMINI_COMMON_CONFIG_SNIPPET, ); const [commonConfigError, setCommonConfigError] = useState(""); const [isLoading, setIsLoading] = useState(true); + const [isExtracting, setIsExtracting] = useState(false); // 用于跟踪是否正在通过通用配置更新 const isUpdatingFromCommonConfig = useRef(false); + // 用于跟踪新建模式是否已初始化默认勾选 + const hasInitializedNewMode = useRef(false); + + const parseSnippetEnv = useCallback( + ( + snippetString: string, + ): { env: Record; error?: string } => { + const trimmed = snippetString.trim(); + if (!trimmed) { + return { env: {} }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + return { env: {}, error: t("geminiConfig.invalidJsonFormat") }; + } + + if (!isPlainObject(parsed)) { + return { env: {}, error: t("geminiConfig.invalidJsonFormat") }; + } + + const keys = Object.keys(parsed); + const forbiddenKeys = keys.filter((key) => + GEMINI_COMMON_ENV_FORBIDDEN_KEYS.includes(key as GeminiForbiddenEnvKey), + ); + if (forbiddenKeys.length > 0) { + return { + env: {}, + error: t("geminiConfig.commonConfigInvalidKeys", { + keys: forbiddenKeys.join(", "), + }), + }; + } + + const env: Record = {}; + for (const [key, value] of Object.entries(parsed)) { + if (typeof value !== "string") { + return { + env: {}, + error: t("geminiConfig.commonConfigInvalidValues"), + }; + } + const normalized = value.trim(); + if (!normalized) continue; + env[key] = normalized; + } + + return { env }; + }, + [t], + ); + + const hasEnvCommonConfigSnippet = useCallback( + (envObj: Record, snippetEnv: Record) => { + const entries = Object.entries(snippetEnv); + if (entries.length === 0) return false; + return entries.every(([key, value]) => envObj[key] === value); + }, + [], + ); + + const applySnippetToEnv = useCallback( + (envObj: Record, snippetEnv: Record) => { + const updated = { ...envObj }; + for (const [key, value] of Object.entries(snippetEnv)) { + if (typeof value === "string") { + updated[key] = value; + } + } + return updated; + }, + [], + ); + + const removeSnippetFromEnv = useCallback( + (envObj: Record, snippetEnv: Record) => { + const updated = { ...envObj }; + for (const [key, value] of Object.entries(snippetEnv)) { + if (typeof value === "string" && updated[key] === value) { + delete updated[key]; + } + } + return updated; + }, + [], + ); // 初始化:从 config.json 加载,支持从 localStorage 迁移 useEffect(() => { @@ -142,6 +163,13 @@ export function useGeminiCommonConfig({ const legacySnippet = window.localStorage.getItem(LEGACY_STORAGE_KEY); if (legacySnippet && legacySnippet.trim()) { + const parsed = parseSnippetEnv(legacySnippet); + if (parsed.error) { + console.warn( + "[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移", + ); + return; + } // 迁移到 config.json await configApi.setCommonConfigSnippet("gemini", legacySnippet); if (mounted) { @@ -172,60 +200,110 @@ export function useGeminiCommonConfig({ return () => { mounted = false; }; - }, []); + }, [parseSnippetEnv]); // 初始化时检查通用配置片段(编辑模式) useEffect(() => { if (initialData?.settingsConfig && !isLoading) { try { - const config = - typeof initialData.settingsConfig.config === "object" - ? initialData.settingsConfig.config + const env = + isPlainObject(initialData.settingsConfig.env) && + Object.keys(initialData.settingsConfig.env).length > 0 + ? (initialData.settingsConfig.env as Record) : {}; - const commonConfigObj = JSON.parse(commonConfigSnippet); - const hasCommon = hasCommonConfigSnippet(config, commonConfigObj); + const parsed = parseSnippetEnv(commonConfigSnippet); + if (parsed.error) return; + const hasCommon = hasEnvCommonConfigSnippet( + env, + parsed.env as Record, + ); setUseCommonConfig(hasCommon); } catch { // ignore parse error } } - }, [initialData, commonConfigSnippet, isLoading]); + }, [ + commonConfigSnippet, + hasEnvCommonConfigSnippet, + initialData, + isLoading, + parseSnippetEnv, + ]); + + // 新建模式:如果通用配置片段存在且有效,默认启用 + useEffect(() => { + // 仅新建模式、加载完成、尚未初始化过 + if (!initialData && !isLoading && !hasInitializedNewMode.current) { + hasInitializedNewMode.current = true; + + const parsed = parseSnippetEnv(commonConfigSnippet); + if (parsed.error) return; + const hasContent = Object.keys(parsed.env).length > 0; + if (!hasContent) return; + + setUseCommonConfig(true); + const currentEnv = envStringToObj(envValue); + const merged = applySnippetToEnv(currentEnv, parsed.env); + const nextEnvString = envObjToString(merged); + + isUpdatingFromCommonConfig.current = true; + onEnvChange(nextEnvString); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); + } + }, [ + initialData, + isLoading, + commonConfigSnippet, + envValue, + envStringToObj, + envObjToString, + applySnippetToEnv, + onEnvChange, + parseSnippetEnv, + ]); // 处理通用配置开关 const handleCommonConfigToggle = useCallback( (checked: boolean) => { - try { - const configObj = configValue.trim() ? JSON.parse(configValue) : {}; - const commonConfigObj = JSON.parse(commonConfigSnippet); - - let updatedConfig: any; - if (checked) { - // 合并通用配置 - updatedConfig = deepMerge(configObj, commonConfigObj); - } else { - // 移除通用配置 - updatedConfig = removeCommonConfig(configObj, commonConfigObj); - } - - setCommonConfigError(""); - setUseCommonConfig(checked); - - // 标记正在通过通用配置更新 - isUpdatingFromCommonConfig.current = true; - onConfigChange(JSON.stringify(updatedConfig, null, 2)); - - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - setCommonConfigError(`配置合并失败: ${errorMessage}`); + const parsed = parseSnippetEnv(commonConfigSnippet); + if (parsed.error) { + setCommonConfigError(parsed.error); setUseCommonConfig(false); + return; } + if (Object.keys(parsed.env).length === 0) { + setCommonConfigError(t("geminiConfig.noCommonConfigToApply")); + setUseCommonConfig(false); + return; + } + + const currentEnv = envStringToObj(envValue); + const updatedEnvObj = checked + ? applySnippetToEnv(currentEnv, parsed.env) + : removeSnippetFromEnv(currentEnv, parsed.env); + + setCommonConfigError(""); + setUseCommonConfig(checked); + + isUpdatingFromCommonConfig.current = true; + onEnvChange(envObjToString(updatedEnvObj)); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); }, - [configValue, commonConfigSnippet, onConfigChange], + [ + applySnippetToEnv, + commonConfigSnippet, + envObjToString, + envStringToObj, + envValue, + onEnvChange, + parseSnippetEnv, + removeSnippetFromEnv, + t, + ], ); // 处理通用配置片段变化 @@ -239,95 +317,132 @@ export function useGeminiCommonConfig({ // 保存到 config.json(清空) configApi.setCommonConfigSnippet("gemini", "").catch((error) => { console.error("保存 Gemini 通用配置失败:", error); - setCommonConfigError(`保存失败: ${error}`); + setCommonConfigError(t("geminiConfig.saveFailed", { error: String(error) })); }); if (useCommonConfig) { - // 移除旧的通用配置 - try { - const configObj = configValue.trim() ? JSON.parse(configValue) : {}; - const previousCommonConfigObj = JSON.parse(previousSnippet); - const updatedConfig = removeCommonConfig( - configObj, - previousCommonConfigObj, - ); - onConfigChange(JSON.stringify(updatedConfig, null, 2)); - setUseCommonConfig(false); - } catch { - // ignore + const parsed = parseSnippetEnv(previousSnippet); + if (!parsed.error && Object.keys(parsed.env).length > 0) { + const currentEnv = envStringToObj(envValue); + const updatedEnv = removeSnippetFromEnv(currentEnv, parsed.env); + onEnvChange(envObjToString(updatedEnv)); } + setUseCommonConfig(false); } return; } // 校验 JSON 格式 - try { - JSON.parse(value); - setCommonConfigError(""); - // 保存到 config.json - configApi.setCommonConfigSnippet("gemini", value).catch((error) => { - console.error("保存 Gemini 通用配置失败:", error); - setCommonConfigError(`保存失败: ${error}`); - }); - } catch { - setCommonConfigError("通用配置片段格式错误(必须是有效的 JSON)"); + const parsed = parseSnippetEnv(value); + if (parsed.error) { + setCommonConfigError(parsed.error); return; } + setCommonConfigError(""); + configApi.setCommonConfigSnippet("gemini", value).catch((error) => { + console.error("保存 Gemini 通用配置失败:", error); + setCommonConfigError(t("geminiConfig.saveFailed", { error: String(error) })); + }); + // 若当前启用通用配置,需要替换为最新片段 if (useCommonConfig) { - try { - const configObj = configValue.trim() ? JSON.parse(configValue) : {}; - const previousCommonConfigObj = JSON.parse(previousSnippet); - const newCommonConfigObj = JSON.parse(value); + const prevParsed = parseSnippetEnv(previousSnippet); + const prevEnv = prevParsed.error ? {} : prevParsed.env; + const nextEnv = parsed.env; + const currentEnv = envStringToObj(envValue); - // 先移除旧的通用配置 - const withoutOld = removeCommonConfig( - configObj, - previousCommonConfigObj, - ); - // 再合并新的通用配置 - const withNew = deepMerge(withoutOld, newCommonConfigObj); + const withoutOld = + Object.keys(prevEnv).length > 0 + ? removeSnippetFromEnv(currentEnv, prevEnv) + : currentEnv; + const withNew = + Object.keys(nextEnv).length > 0 + ? applySnippetToEnv(withoutOld, nextEnv) + : withoutOld; - // 标记正在通过通用配置更新,避免触发状态检查 - isUpdatingFromCommonConfig.current = true; - onConfigChange(JSON.stringify(withNew, null, 2)); - - // 在下一个事件循环中重置标记 - setTimeout(() => { - isUpdatingFromCommonConfig.current = false; - }, 0); - } catch (error) { - const errorMessage = - error instanceof Error ? error.message : String(error); - setCommonConfigError(`配置替换失败: ${errorMessage}`); - } + isUpdatingFromCommonConfig.current = true; + onEnvChange(envObjToString(withNew)); + setTimeout(() => { + isUpdatingFromCommonConfig.current = false; + }, 0); } }, - [commonConfigSnippet, configValue, useCommonConfig, onConfigChange], + [ + applySnippetToEnv, + commonConfigSnippet, + envObjToString, + envStringToObj, + envValue, + onEnvChange, + parseSnippetEnv, + removeSnippetFromEnv, + t, + useCommonConfig, + ], ); - // 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) + // 当 env 变化时检查是否包含通用配置(但避免在通过通用配置更新时检查) useEffect(() => { if (isUpdatingFromCommonConfig.current || isLoading) { return; } + const parsed = parseSnippetEnv(commonConfigSnippet); + if (parsed.error) return; + const envObj = envStringToObj(envValue); + setUseCommonConfig( + hasEnvCommonConfigSnippet(envObj, parsed.env as Record), + ); + }, [ + envValue, + commonConfigSnippet, + envStringToObj, + hasEnvCommonConfigSnippet, + isLoading, + parseSnippetEnv, + ]); + + // 从当前供应商提取通用配置片段 + const handleExtract = useCallback(async () => { + setIsExtracting(true); + setCommonConfigError(""); + try { - const configObj = configValue.trim() ? JSON.parse(configValue) : {}; - const commonConfigObj = JSON.parse(commonConfigSnippet); - const hasCommon = hasCommonConfigSnippet(configObj, commonConfigObj); - setUseCommonConfig(hasCommon); - } catch { - // ignore parse error + const extracted = await configApi.extractCommonConfigSnippet("gemini"); + + if (!extracted || extracted === "{}") { + setCommonConfigError(t("geminiConfig.extractNoCommonConfig")); + return; + } + + // 验证 JSON 格式 + const parsed = parseSnippetEnv(extracted); + if (parsed.error) { + setCommonConfigError(t("geminiConfig.extractedConfigInvalid")); + return; + } + + // 更新片段状态 + setCommonConfigSnippetState(extracted); + + // 保存到后端 + await configApi.setCommonConfigSnippet("gemini", extracted); + } catch (error) { + console.error("提取 Gemini 通用配置失败:", error); + setCommonConfigError(t("geminiConfig.extractFailed", { error: String(error) })); + } finally { + setIsExtracting(false); } - }, [configValue, commonConfigSnippet, isLoading]); + }, [parseSnippetEnv, t]); return { useCommonConfig, commonConfigSnippet, commonConfigError, isLoading, + isExtracting, handleCommonConfigToggle, handleCommonConfigSnippetChange, + handleExtract, }; } diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index c10a86f68..f34a21f64 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -53,7 +53,11 @@ "editCommonConfig": "Edit Common Config", "editCommonConfigTitle": "Edit Common Config Snippet", "commonConfigHint": "This snippet will be merged into settings.json when 'Write Common Config' is checked", - "fullSettingsHint": "Full Claude Code settings.json content" + "fullSettingsHint": "Full Claude Code settings.json content", + "extractFromCurrent": "Extract from Current Provider", + "extractNoCommonConfig": "No common config available to extract from current provider", + "extractFailed": "Extract failed: {{error}}", + "saveFailed": "Save failed: {{error}}" }, "header": { "viewOnGithub": "View on GitHub", @@ -393,7 +397,11 @@ "editCommonConfig": "Edit Common Config", "editCommonConfigTitle": "Edit Codex Common Config Snippet", "commonConfigHint": "This snippet will be appended to the end of config.toml when 'Write Common Config' is checked", - "apiUrlLabel": "API Request URL" + "apiUrlLabel": "API Request URL", + "extractFromCurrent": "Extract from Current Provider", + "extractNoCommonConfig": "No common config available to extract from current provider", + "extractFailed": "Extract failed: {{error}}", + "saveFailed": "Save failed: {{error}}" }, "geminiConfig": { "envFile": "Environment Variables (.env)", @@ -403,7 +411,18 @@ "writeCommonConfig": "Write Common Config", "editCommonConfig": "Edit Common Config", "editCommonConfigTitle": "Edit Gemini Common Config Snippet", - "commonConfigHint": "Common config snippet will be merged into all Gemini providers with it enabled" + "commonConfigHint": "This snippet writes to Gemini .env (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)", + "extractFromCurrent": "Extract from Current Provider", + "extractNoCommonConfig": "No common config available to extract from current provider", + "extractFailed": "Extract failed: {{error}}", + "saveFailed": "Save failed: {{error}}", + "extractedConfigInvalid": "Extracted config format is invalid", + "invalidJsonFormat": "Common config snippet format error (must be valid JSON)", + "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", + "configMergeFailed": "Config merge failed: {{error}}", + "configReplaceFailed": "Config replace failed: {{error}}" }, "providerPreset": { "label": "Provider Preset", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 73746946d..d79ce91f7 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -53,7 +53,11 @@ "editCommonConfig": "共通設定を編集", "editCommonConfigTitle": "共通設定スニペットを編集", "commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます", - "fullSettingsHint": "Claude Code の settings.json 全文" + "fullSettingsHint": "Claude Code の settings.json 全文", + "extractFromCurrent": "現在のプロバイダーから抽出", + "extractNoCommonConfig": "現在のプロバイダーから抽出できる共通設定がありません", + "extractFailed": "抽出に失敗しました: {{error}}", + "saveFailed": "保存に失敗しました: {{error}}" }, "header": { "viewOnGithub": "GitHub で見る", @@ -393,7 +397,11 @@ "editCommonConfig": "共通設定を編集", "editCommonConfigTitle": "Codex 共通設定スニペットを編集", "commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます", - "apiUrlLabel": "API リクエスト URL" + "apiUrlLabel": "API リクエスト URL", + "extractFromCurrent": "現在のプロバイダーから抽出", + "extractNoCommonConfig": "現在のプロバイダーから抽出できる共通設定がありません", + "extractFailed": "抽出に失敗しました: {{error}}", + "saveFailed": "保存に失敗しました: {{error}}" }, "geminiConfig": { "envFile": "環境変数 (.env)", @@ -403,7 +411,18 @@ "writeCommonConfig": "共通設定を書き込む", "editCommonConfig": "共通設定を編集", "editCommonConfigTitle": "Gemini 共通設定スニペットを編集", - "commonConfigHint": "共通設定スニペットは、この機能をオンにしたすべての Gemini プロバイダーへマージされます" + "commonConfigHint": "このスニペットは Gemini の .env に書き込みます(GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません)", + "extractFromCurrent": "現在のプロバイダーから抽出", + "extractNoCommonConfig": "現在のプロバイダーから抽出できる共通設定がありません", + "extractFailed": "抽出に失敗しました: {{error}}", + "saveFailed": "保存に失敗しました: {{error}}", + "extractedConfigInvalid": "抽出した設定のフォーマットが不正です", + "invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)", + "commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}})", + "commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります", + "noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません", + "configMergeFailed": "設定のマージに失敗しました: {{error}}", + "configReplaceFailed": "設定の置換に失敗しました: {{error}}" }, "providerPreset": { "label": "プロバイダータイプ", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index 2f4bec1c6..c6277870e 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -53,7 +53,11 @@ "editCommonConfig": "编辑通用配置", "editCommonConfigTitle": "编辑通用配置片段", "commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中", - "fullSettingsHint": "完整的 Claude Code settings.json 配置内容" + "fullSettingsHint": "完整的 Claude Code settings.json 配置内容", + "extractFromCurrent": "从当前供应商提取", + "extractNoCommonConfig": "当前供应商没有可提取的通用配置", + "extractFailed": "提取失败: {{error}}", + "saveFailed": "保存失败: {{error}}" }, "header": { "viewOnGithub": "在 GitHub 上查看", @@ -393,7 +397,11 @@ "editCommonConfig": "编辑通用配置", "editCommonConfigTitle": "编辑 Codex 通用配置片段", "commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾", - "apiUrlLabel": "API 请求地址" + "apiUrlLabel": "API 请求地址", + "extractFromCurrent": "从当前供应商提取", + "extractNoCommonConfig": "当前供应商没有可提取的通用配置", + "extractFailed": "提取失败: {{error}}", + "saveFailed": "保存失败: {{error}}" }, "geminiConfig": { "envFile": "环境变量 (.env)", @@ -403,7 +411,18 @@ "writeCommonConfig": "写入通用配置", "editCommonConfig": "编辑通用配置", "editCommonConfigTitle": "编辑 Gemini 通用配置片段", - "commonConfigHint": "通用配置片段将合并到所有启用它的 Gemini 供应商配置中" + "commonConfigHint": "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)", + "extractFromCurrent": "从当前供应商提取", + "extractNoCommonConfig": "当前供应商没有可提取的通用配置", + "extractFailed": "提取失败: {{error}}", + "saveFailed": "保存失败: {{error}}", + "extractedConfigInvalid": "提取的配置格式错误", + "invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)", + "commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}})", + "commonConfigInvalidValues": "通用配置片段的值必须是字符串", + "noCommonConfigToApply": "通用配置片段为空或没有可写入的内容", + "configMergeFailed": "配置合并失败: {{error}}", + "configReplaceFailed": "配置替换失败: {{error}}" }, "providerPreset": { "label": "预设供应商", diff --git a/src/lib/api/config.ts b/src/lib/api/config.ts index 4fe3d103a..ed252d1b2 100644 --- a/src/lib/api/config.ts +++ b/src/lib/api/config.ts @@ -47,3 +47,18 @@ export async function setCommonConfigSnippet( ): Promise { return invoke("set_common_config_snippet", { appType, snippet }); } + +/** + * 从当前供应商提取通用配置片段 + * + * 读取当前激活供应商的配置,自动排除差异化字段(API Key、模型配置、端点等), + * 返回可复用的通用配置片段。 + * + * @param appType - 应用类型(claude/codex/gemini) + * @returns 提取的通用配置片段(JSON/TOML 字符串) + */ +export async function extractCommonConfigSnippet( + appType: AppType, +): Promise { + return invoke("extract_common_config_snippet", { appType }); +}