mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-26 23:56:02 +08:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db4a1ea907 | |||
| 58b7dc32b3 | |||
| 65ccabd5ad | |||
| a3a81806f8 | |||
| 5104045ffb | |||
| 7898096de3 | |||
| cc235c6c63 |
@@ -352,49 +352,6 @@ impl FromStr for AppType {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 通用配置片段(按应用分治)
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
|
||||||
pub struct CommonConfigSnippets {
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub claude: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub codex: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub gemini: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub opencode: Option<String>,
|
|
||||||
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub openclaw: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl CommonConfigSnippets {
|
|
||||||
/// 获取指定应用的通用配置片段
|
|
||||||
pub fn get(&self, app: &AppType) -> Option<&String> {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude.as_ref(),
|
|
||||||
AppType::Codex => self.codex.as_ref(),
|
|
||||||
AppType::Gemini => self.gemini.as_ref(),
|
|
||||||
AppType::OpenCode => self.opencode.as_ref(),
|
|
||||||
AppType::OpenClaw => self.openclaw.as_ref(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 设置指定应用的通用配置片段
|
|
||||||
pub fn set(&mut self, app: &AppType, snippet: Option<String>) {
|
|
||||||
match app {
|
|
||||||
AppType::Claude => self.claude = snippet,
|
|
||||||
AppType::Codex => self.codex = snippet,
|
|
||||||
AppType::Gemini => self.gemini = snippet,
|
|
||||||
AppType::OpenCode => self.opencode = snippet,
|
|
||||||
AppType::OpenClaw => self.openclaw = snippet,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 多应用配置结构(向后兼容)
|
/// 多应用配置结构(向后兼容)
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct MultiAppConfig {
|
pub struct MultiAppConfig {
|
||||||
@@ -412,12 +369,6 @@ pub struct MultiAppConfig {
|
|||||||
/// Claude Skills 配置
|
/// Claude Skills 配置
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub skills: SkillStore,
|
pub skills: SkillStore,
|
||||||
/// 通用配置片段(按应用分治)
|
|
||||||
#[serde(default)]
|
|
||||||
pub common_config_snippets: CommonConfigSnippets,
|
|
||||||
/// Claude 通用配置片段(旧字段,用于向后兼容迁移)
|
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
||||||
pub claude_common_config_snippet: Option<String>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_version() -> u32 {
|
fn default_version() -> u32 {
|
||||||
@@ -439,8 +390,6 @@ impl Default for MultiAppConfig {
|
|||||||
mcp: McpRoot::default(),
|
mcp: McpRoot::default(),
|
||||||
prompts: PromptRoot::default(),
|
prompts: PromptRoot::default(),
|
||||||
skills: SkillStore::default(),
|
skills: SkillStore::default(),
|
||||||
common_config_snippets: CommonConfigSnippets::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -534,15 +483,6 @@ impl MultiAppConfig {
|
|||||||
updated = true;
|
updated = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 迁移通用配置片段:claude_common_config_snippet → common_config_snippets.claude
|
|
||||||
if let Some(old_claude_snippet) = config.claude_common_config_snippet.take() {
|
|
||||||
log::info!(
|
|
||||||
"迁移通用配置:claude_common_config_snippet → common_config_snippets.claude"
|
|
||||||
);
|
|
||||||
config.common_config_snippets.claude = Some(old_claude_snippet);
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if updated {
|
if updated {
|
||||||
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
log::info!("配置结构已更新(包括 MCP 迁移或 Prompt 自动导入),保存配置...");
|
||||||
config.save()?;
|
config.save()?;
|
||||||
|
|||||||
@@ -164,38 +164,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
|
|||||||
Ok(true)
|
Ok(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn get_claude_common_config_snippet(
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<Option<String>, String> {
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.get_config_snippet("claude")
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn set_claude_common_config_snippet(
|
|
||||||
snippet: String,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<(), String> {
|
|
||||||
if !snippet.trim().is_empty() {
|
|
||||||
serde_json::from_str::<serde_json::Value>(&snippet).map_err(invalid_json_format_error)?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let value = if snippet.trim().is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(snippet)
|
|
||||||
};
|
|
||||||
|
|
||||||
state
|
|
||||||
.db
|
|
||||||
.set_config_snippet("claude", value)
|
|
||||||
.map_err(|e| e.to_string())?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn get_common_config_snippet(
|
pub async fn get_common_config_snippet(
|
||||||
app_type: String,
|
app_type: String,
|
||||||
@@ -263,26 +231,3 @@ pub async fn set_common_config_snippet(
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub async fn extract_common_config_snippet(
|
|
||||||
appType: String,
|
|
||||||
settingsConfig: Option<String>,
|
|
||||||
state: tauri::State<'_, crate::store::AppState>,
|
|
||||||
) -> Result<String, String> {
|
|
||||||
let app = AppType::from_str(&appType).map_err(|e| e.to_string())?;
|
|
||||||
|
|
||||||
if let Some(settings_config) = settingsConfig.filter(|s| !s.trim().is_empty()) {
|
|
||||||
let settings: serde_json::Value =
|
|
||||||
serde_json::from_str(&settings_config).map_err(invalid_json_format_error)?;
|
|
||||||
|
|
||||||
return crate::services::provider::ProviderService::extract_common_config_snippet_from_settings(
|
|
||||||
app,
|
|
||||||
&settings,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
crate::services::provider::ProviderService::extract_common_config_snippet(&state, app)
|
|
||||||
.map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -97,27 +97,7 @@ pub fn switch_provider(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
fn import_default_config_internal(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
|
||||||
let imported = ProviderService::import_default_config(state, app_type.clone())?;
|
let imported = ProviderService::import_default_config(state, app_type)?;
|
||||||
|
|
||||||
if imported {
|
|
||||||
// Extract common config snippet (mirrors old startup logic in lib.rs)
|
|
||||||
if state
|
|
||||||
.db
|
|
||||||
.get_config_snippet(app_type.as_str())
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.is_none()
|
|
||||||
{
|
|
||||||
match ProviderService::extract_common_config_snippet(state, app_type.clone()) {
|
|
||||||
Ok(snippet) if !snippet.is_empty() && snippet != "{}" => {
|
|
||||||
let _ = state
|
|
||||||
.db
|
|
||||||
.set_config_snippet(app_type.as_str(), Some(snippet));
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(imported)
|
Ok(imported)
|
||||||
}
|
}
|
||||||
@@ -187,6 +167,12 @@ pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, Str
|
|||||||
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn patch_claude_live_settings(patch: serde_json::Value) -> Result<bool, String> {
|
||||||
|
ProviderService::patch_claude_live(patch).map_err(|e| e.to_string())?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn test_api_endpoints(
|
pub async fn test_api_endpoints(
|
||||||
urls: Vec<String>,
|
urls: Vec<String>,
|
||||||
|
|||||||
@@ -58,9 +58,6 @@ impl Database {
|
|||||||
// 4. 迁移 Skills
|
// 4. 迁移 Skills
|
||||||
Self::migrate_skills(tx, config)?;
|
Self::migrate_skills(tx, config)?;
|
||||||
|
|
||||||
// 5. 迁移 Common Config
|
|
||||||
Self::migrate_common_config(tx, config)?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,34 +209,4 @@ impl Database {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 迁移通用配置片段
|
|
||||||
fn migrate_common_config(
|
|
||||||
tx: &rusqlite::Transaction<'_>,
|
|
||||||
config: &MultiAppConfig,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.claude {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_claude", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.codex {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_codex", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
if let Some(snippet) = &config.common_config_snippets.gemini {
|
|
||||||
tx.execute(
|
|
||||||
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
|
|
||||||
params!["common_config_gemini", snippet],
|
|
||||||
)
|
|
||||||
.map_err(|e| AppError::Database(format!("Migrate settings failed: {e}")))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -513,8 +513,6 @@ fn schema_dry_run_does_not_write_to_disk() {
|
|||||||
mcp: Default::default(),
|
mcp: Default::default(),
|
||||||
prompts: Default::default(),
|
prompts: Default::default(),
|
||||||
skills: Default::default(),
|
skills: Default::default(),
|
||||||
common_config_snippets: Default::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dry-run should succeed without any file I/O errors
|
// Dry-run should succeed without any file I/O errors
|
||||||
@@ -563,8 +561,6 @@ fn dry_run_validates_schema_compatibility() {
|
|||||||
mcp: Default::default(),
|
mcp: Default::default(),
|
||||||
prompts: Default::default(),
|
prompts: Default::default(),
|
||||||
skills: Default::default(),
|
skills: Default::default(),
|
||||||
common_config_snippets: Default::default(),
|
|
||||||
claude_common_config_snippet: None,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Dry-run should validate the full migration path
|
// Dry-run should validate the full migration path
|
||||||
|
|||||||
@@ -845,12 +845,10 @@ pub fn run() {
|
|||||||
commands::get_skills_migration_result,
|
commands::get_skills_migration_result,
|
||||||
commands::get_app_config_path,
|
commands::get_app_config_path,
|
||||||
commands::open_app_config_folder,
|
commands::open_app_config_folder,
|
||||||
commands::get_claude_common_config_snippet,
|
|
||||||
commands::set_claude_common_config_snippet,
|
|
||||||
commands::get_common_config_snippet,
|
commands::get_common_config_snippet,
|
||||||
commands::set_common_config_snippet,
|
commands::set_common_config_snippet,
|
||||||
commands::extract_common_config_snippet,
|
|
||||||
commands::read_live_provider_settings,
|
commands::read_live_provider_settings,
|
||||||
|
commands::patch_claude_live_settings,
|
||||||
commands::get_settings,
|
commands::get_settings,
|
||||||
commands::save_settings,
|
commands::save_settings,
|
||||||
commands::get_rectifier_config,
|
commands::get_rectifier_config,
|
||||||
|
|||||||
@@ -235,6 +235,11 @@ pub struct ProviderMeta {
|
|||||||
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
/// - "openai_chat": OpenAI Chat Completions 格式,需要转换
|
||||||
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
#[serde(rename = "apiFormat", skip_serializing_if = "Option::is_none")]
|
||||||
pub api_format: Option<String>,
|
pub api_format: Option<String>,
|
||||||
|
/// Claude 认证字段名(仅 Claude 供应商使用)
|
||||||
|
/// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
|
||||||
|
/// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
|
||||||
|
#[serde(rename = "apiKeyField", skip_serializing_if = "Option::is_none")]
|
||||||
|
pub api_key_field: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderManager {
|
impl ProviderManager {
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
use super::provider::{sanitize_claude_settings_for_live, ProviderService};
|
|
||||||
use crate::app_config::{AppType, MultiAppConfig};
|
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
use crate::provider::Provider;
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use serde_json::Value;
|
|
||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
@@ -82,150 +78,4 @@ impl ConfigService {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 同步当前供应商到对应的 live 配置。
|
|
||||||
pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> {
|
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Claude)?;
|
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Codex)?;
|
|
||||||
Self::sync_current_provider_for_app(config, &AppType::Gemini)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_current_provider_for_app(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
app_type: &AppType,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let (current_id, provider) = {
|
|
||||||
let manager = match config.get_manager(app_type) {
|
|
||||||
Some(manager) => manager,
|
|
||||||
None => return Ok(()),
|
|
||||||
};
|
|
||||||
|
|
||||||
if manager.current.is_empty() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
let current_id = manager.current.clone();
|
|
||||||
let provider = match manager.providers.get(¤t_id) {
|
|
||||||
Some(provider) => provider.clone(),
|
|
||||||
None => {
|
|
||||||
log::warn!(
|
|
||||||
"当前应用 {app_type:?} 的供应商 {current_id} 不存在,跳过 live 同步"
|
|
||||||
);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
};
|
|
||||||
(current_id, provider)
|
|
||||||
};
|
|
||||||
|
|
||||||
match app_type {
|
|
||||||
AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?,
|
|
||||||
AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?,
|
|
||||||
AppType::Gemini => Self::sync_gemini_live(config, ¤t_id, &provider)?,
|
|
||||||
AppType::OpenCode => {
|
|
||||||
// OpenCode uses additive mode, no live sync needed
|
|
||||||
// OpenCode providers are managed directly in the config file
|
|
||||||
}
|
|
||||||
AppType::OpenClaw => {
|
|
||||||
// OpenClaw uses additive mode, no live sync needed
|
|
||||||
// OpenClaw providers are managed directly in the config file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_codex_live(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
provider_id: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
let settings = provider.settings_config.as_object().ok_or_else(|| {
|
|
||||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置必须是对象"))
|
|
||||||
})?;
|
|
||||||
let auth = settings.get("auth").ok_or_else(|| {
|
|
||||||
AppError::Config(format!("供应商 {provider_id} 的 Codex 配置缺少 auth 字段"))
|
|
||||||
})?;
|
|
||||||
if !auth.is_object() {
|
|
||||||
return Err(AppError::Config(format!(
|
|
||||||
"供应商 {provider_id} 的 Codex auth 配置必须是 JSON 对象"
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
let cfg_text = settings.get("config").and_then(Value::as_str);
|
|
||||||
|
|
||||||
crate::codex_config::write_codex_live_atomic(auth, cfg_text)?;
|
|
||||||
// 注意:MCP 同步在 v3.7.0 中已通过 McpService 进行,不再在此调用
|
|
||||||
// sync_enabled_to_codex 使用旧的 config.mcp.codex 结构,在新架构中为空
|
|
||||||
// MCP 的启用/禁用应通过 McpService::toggle_app 进行
|
|
||||||
|
|
||||||
let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?;
|
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Codex) {
|
|
||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
|
||||||
if let Some(obj) = target.settings_config.as_object_mut() {
|
|
||||||
obj.insert(
|
|
||||||
"config".to_string(),
|
|
||||||
serde_json::Value::String(cfg_text_after),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_claude_live(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
provider_id: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
use crate::config::{read_json_file, write_json_file};
|
|
||||||
|
|
||||||
let settings_path = crate::config::get_claude_settings_path();
|
|
||||||
if let Some(parent) = settings_path.parent() {
|
|
||||||
fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?;
|
|
||||||
}
|
|
||||||
|
|
||||||
let settings = sanitize_claude_settings_for_live(&provider.settings_config);
|
|
||||||
write_json_file(&settings_path, &settings)?;
|
|
||||||
|
|
||||||
let live_after = read_json_file::<serde_json::Value>(&settings_path)?;
|
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Claude) {
|
|
||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
|
||||||
target.settings_config = live_after;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn sync_gemini_live(
|
|
||||||
config: &mut MultiAppConfig,
|
|
||||||
provider_id: &str,
|
|
||||||
provider: &Provider,
|
|
||||||
) -> Result<(), AppError> {
|
|
||||||
use crate::gemini_config::{env_to_json, read_gemini_env};
|
|
||||||
|
|
||||||
ProviderService::write_gemini_live(provider)?;
|
|
||||||
|
|
||||||
// 读回实际写入的内容并更新到配置中(包含 settings.json)
|
|
||||||
let live_after_env = read_gemini_env()?;
|
|
||||||
let settings_path = crate::gemini_config::get_gemini_settings_path();
|
|
||||||
let live_after_config = if settings_path.exists() {
|
|
||||||
crate::config::read_json_file(&settings_path)?
|
|
||||||
} else {
|
|
||||||
serde_json::json!({})
|
|
||||||
};
|
|
||||||
let mut live_after = env_to_json(&live_after_env);
|
|
||||||
if let Some(obj) = live_after.as_object_mut() {
|
|
||||||
obj.insert("config".to_string(), live_after_config);
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(manager) = config.get_manager_mut(&AppType::Gemini) {
|
|
||||||
if let Some(target) = manager.providers.get_mut(provider_id) {
|
|
||||||
target.settings_config = live_after;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -237,6 +237,439 @@ pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Re
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Key fields definitions for partial merge
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Claude env-level key fields that belong to the provider.
|
||||||
|
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||||
|
const CLAUDE_KEY_ENV_FIELDS: &[&str] = &[
|
||||||
|
// --- API auth & endpoint ---
|
||||||
|
"ANTHROPIC_BASE_URL",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"ANTHROPIC_API_KEY",
|
||||||
|
// --- Model selection ---
|
||||||
|
"ANTHROPIC_MODEL",
|
||||||
|
"ANTHROPIC_REASONING_MODEL",
|
||||||
|
"ANTHROPIC_SMALL_FAST_MODEL",
|
||||||
|
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
||||||
|
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
||||||
|
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
||||||
|
"CLAUDE_CODE_SUBAGENT_MODEL",
|
||||||
|
// --- AWS Bedrock ---
|
||||||
|
"CLAUDE_CODE_USE_BEDROCK",
|
||||||
|
"AWS_ACCESS_KEY_ID",
|
||||||
|
"AWS_SECRET_ACCESS_KEY",
|
||||||
|
"AWS_SESSION_TOKEN",
|
||||||
|
"AWS_REGION",
|
||||||
|
"AWS_PROFILE",
|
||||||
|
"ANTHROPIC_SMALL_FAST_MODEL_AWS_REGION",
|
||||||
|
// --- Google Vertex AI ---
|
||||||
|
"CLAUDE_CODE_USE_VERTEX",
|
||||||
|
"ANTHROPIC_VERTEX_PROJECT_ID",
|
||||||
|
"CLOUD_ML_REGION",
|
||||||
|
// --- Microsoft Foundry ---
|
||||||
|
"CLAUDE_CODE_USE_FOUNDRY",
|
||||||
|
// --- Provider behavior ---
|
||||||
|
"CLAUDE_CODE_MAX_OUTPUT_TOKENS",
|
||||||
|
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
|
||||||
|
"API_TIMEOUT_MS",
|
||||||
|
"DISABLE_PROMPT_CACHING",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Claude top-level key fields (legacy + modern format).
|
||||||
|
/// When adding a new field here, also update backfill_claude_key_fields().
|
||||||
|
const CLAUDE_KEY_TOP_LEVEL: &[&str] = &[
|
||||||
|
"apiBaseUrl", // legacy
|
||||||
|
"primaryModel", // legacy
|
||||||
|
"smallFastModel", // legacy
|
||||||
|
"model", // modern
|
||||||
|
"apiKey", // Bedrock API Key auth
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Codex TOML key fields.
|
||||||
|
/// When adding a new field here, also update backfill_codex_key_fields().
|
||||||
|
const CODEX_KEY_TOP_LEVEL: &[&str] = &[
|
||||||
|
"model_provider",
|
||||||
|
"model",
|
||||||
|
"model_reasoning_effort",
|
||||||
|
"review_model",
|
||||||
|
"plan_mode_reasoning_effort",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Gemini env-level key fields.
|
||||||
|
/// When adding a new field here, also update backfill_gemini_key_fields().
|
||||||
|
const GEMINI_KEY_ENV_FIELDS: &[&str] = &[
|
||||||
|
"GOOGLE_GEMINI_BASE_URL",
|
||||||
|
"GEMINI_API_KEY",
|
||||||
|
"GEMINI_MODEL",
|
||||||
|
"GOOGLE_API_KEY",
|
||||||
|
];
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Partial merge: write only key fields to live config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Write only provider-specific key fields to live configuration,
|
||||||
|
/// preserving all other user settings in the live file.
|
||||||
|
///
|
||||||
|
/// Used for switch-mode apps (Claude, Codex, Gemini) during:
|
||||||
|
/// - `switch_normal()` — switching providers
|
||||||
|
/// - `sync_current_to_live()` — startup sync
|
||||||
|
/// - `add()` / `update()` when the provider is current
|
||||||
|
pub(crate) fn write_live_partial(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => write_claude_live_partial(provider),
|
||||||
|
AppType::Codex => write_codex_live_partial(provider),
|
||||||
|
AppType::Gemini => write_gemini_live_partial(provider),
|
||||||
|
// Additive mode apps still use full snapshot
|
||||||
|
AppType::OpenCode | AppType::OpenClaw => write_live_snapshot(app_type, provider),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply a JSON merge patch (RFC 7396) directly to Claude live settings.json.
|
||||||
|
/// Used for user-level preferences (attribution, thinking, etc.) that are
|
||||||
|
/// independent of the active provider.
|
||||||
|
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||||
|
let path = get_claude_settings_path();
|
||||||
|
let mut live = if path.exists() {
|
||||||
|
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||||
|
} else {
|
||||||
|
json!({})
|
||||||
|
};
|
||||||
|
json_merge_patch(&mut live, &patch);
|
||||||
|
let settings = sanitize_claude_settings_for_live(&live);
|
||||||
|
write_json_file(&path, &settings)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// RFC 7396 JSON Merge Patch: null deletes, objects merge recursively, rest overwrites.
|
||||||
|
fn json_merge_patch(target: &mut Value, patch: &Value) {
|
||||||
|
if let Some(patch_obj) = patch.as_object() {
|
||||||
|
if !target.is_object() {
|
||||||
|
*target = json!({});
|
||||||
|
}
|
||||||
|
let target_obj = target.as_object_mut().unwrap();
|
||||||
|
for (key, value) in patch_obj {
|
||||||
|
if value.is_null() {
|
||||||
|
target_obj.remove(key);
|
||||||
|
} else if value.is_object() {
|
||||||
|
let entry = target_obj.entry(key.clone()).or_insert(json!({}));
|
||||||
|
json_merge_patch(entry, value);
|
||||||
|
// Clean up empty container objects
|
||||||
|
if entry.as_object().map_or(false, |o| o.is_empty()) {
|
||||||
|
target_obj.remove(key);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
target_obj.insert(key.clone(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Claude: merge only key env and top-level fields into live settings.json
|
||||||
|
fn write_claude_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let path = get_claude_settings_path();
|
||||||
|
|
||||||
|
// 1. Read existing live config (start from empty if file doesn't exist)
|
||||||
|
let mut live = if path.exists() {
|
||||||
|
read_json_file(&path).unwrap_or_else(|_| json!({}))
|
||||||
|
} else {
|
||||||
|
json!({})
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Ensure live.env exists as an object
|
||||||
|
if !live.get("env").is_some_and(|v| v.is_object()) {
|
||||||
|
live.as_object_mut()
|
||||||
|
.unwrap()
|
||||||
|
.insert("env".into(), json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Clear key env fields from live, then write from provider
|
||||||
|
let live_env = live.get_mut("env").unwrap().as_object_mut().unwrap();
|
||||||
|
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||||
|
live_env.remove(*key);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(provider_env) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = provider_env.get(*key) {
|
||||||
|
live_env.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle top-level legacy key fields
|
||||||
|
let live_obj = live.as_object_mut().unwrap();
|
||||||
|
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||||
|
live_obj.remove(*key);
|
||||||
|
}
|
||||||
|
if let Some(provider_obj) = provider.settings_config.as_object() {
|
||||||
|
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||||
|
if let Some(value) = provider_obj.get(*key) {
|
||||||
|
live_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Sanitize and write
|
||||||
|
let settings = sanitize_claude_settings_for_live(&live);
|
||||||
|
write_json_file(&path, &settings)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Codex: replace auth.json entirely, partially merge config.toml key fields
|
||||||
|
fn write_codex_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
let obj = provider
|
||||||
|
.settings_config
|
||||||
|
.as_object()
|
||||||
|
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
||||||
|
|
||||||
|
// auth.json is entirely provider-specific, replace it wholesale
|
||||||
|
let auth = obj
|
||||||
|
.get("auth")
|
||||||
|
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||||
|
|
||||||
|
let provider_config_str = obj.get("config").and_then(|v| v.as_str()).unwrap_or("");
|
||||||
|
|
||||||
|
// Read existing config.toml (or start from empty)
|
||||||
|
let config_path = get_codex_config_path();
|
||||||
|
let existing_toml = if config_path.exists() {
|
||||||
|
std::fs::read_to_string(&config_path).unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse both existing and provider TOML
|
||||||
|
let mut live_doc = existing_toml
|
||||||
|
.parse::<toml_edit::DocumentMut>()
|
||||||
|
.unwrap_or_else(|_| toml_edit::DocumentMut::new());
|
||||||
|
|
||||||
|
// Remove key fields from live doc
|
||||||
|
let live_root = live_doc.as_table_mut();
|
||||||
|
for key in CODEX_KEY_TOP_LEVEL {
|
||||||
|
live_root.remove(key);
|
||||||
|
}
|
||||||
|
live_root.remove("model_providers");
|
||||||
|
|
||||||
|
// Parse provider TOML and extract key fields
|
||||||
|
if !provider_config_str.is_empty() {
|
||||||
|
if let Ok(provider_doc) = provider_config_str.parse::<toml_edit::DocumentMut>() {
|
||||||
|
let provider_root = provider_doc.as_table();
|
||||||
|
|
||||||
|
// Copy key top-level fields from provider
|
||||||
|
for key in CODEX_KEY_TOP_LEVEL {
|
||||||
|
if let Some(item) = provider_root.get(key) {
|
||||||
|
live_root.insert(key, item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy model_providers table from provider
|
||||||
|
if let Some(mp) = provider_root.get("model_providers") {
|
||||||
|
live_root.insert("model_providers", mp.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write using atomic write
|
||||||
|
crate::codex_config::write_codex_live_atomic(auth, Some(&live_doc.to_string()))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gemini: merge only key env fields, preserve settings.json (MCP etc.)
|
||||||
|
fn write_gemini_live_partial(provider: &Provider) -> Result<(), AppError> {
|
||||||
|
use crate::gemini_config::{get_gemini_env_path, read_gemini_env, write_gemini_env_atomic};
|
||||||
|
|
||||||
|
let auth_type = detect_gemini_auth_type(provider);
|
||||||
|
|
||||||
|
// 1. Read existing env from live .env file
|
||||||
|
let mut env_map = if get_gemini_env_path().exists() {
|
||||||
|
read_gemini_env().unwrap_or_default()
|
||||||
|
} else {
|
||||||
|
HashMap::new()
|
||||||
|
};
|
||||||
|
|
||||||
|
// 2. Remove key fields from existing env
|
||||||
|
for key in GEMINI_KEY_ENV_FIELDS {
|
||||||
|
env_map.remove(*key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Extract key fields from provider and merge
|
||||||
|
if let Some(provider_env) = provider
|
||||||
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|v| v.as_object())
|
||||||
|
{
|
||||||
|
for key in GEMINI_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = provider_env.get(*key).and_then(|v| v.as_str()) {
|
||||||
|
if !value.is_empty() {
|
||||||
|
env_map.insert(key.to_string(), value.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Handle auth type specific behavior
|
||||||
|
match auth_type {
|
||||||
|
GeminiAuthType::GoogleOfficial => {
|
||||||
|
// Google official uses OAuth, clear all env
|
||||||
|
env_map.clear();
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
}
|
||||||
|
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||||
|
// Validate and write env
|
||||||
|
crate::gemini_config::validate_gemini_settings_strict(&provider.settings_config)?;
|
||||||
|
write_gemini_env_atomic(&env_map)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Handle settings.json (same as write_gemini_live — preserve existing MCP etc.)
|
||||||
|
use crate::gemini_config::get_gemini_settings_path;
|
||||||
|
let settings_path = get_gemini_settings_path();
|
||||||
|
|
||||||
|
if let Some(config_value) = provider.settings_config.get("config") {
|
||||||
|
if config_value.is_object() {
|
||||||
|
let mut merged = if settings_path.exists() {
|
||||||
|
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
|
||||||
|
} else {
|
||||||
|
json!({})
|
||||||
|
};
|
||||||
|
if let (Some(merged_obj), Some(config_obj)) =
|
||||||
|
(merged.as_object_mut(), config_value.as_object())
|
||||||
|
{
|
||||||
|
for (k, v) in config_obj {
|
||||||
|
merged_obj.insert(k.clone(), v.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
write_json_file(&settings_path, &merged)?;
|
||||||
|
} else if !config_value.is_null() {
|
||||||
|
return Err(AppError::localized(
|
||||||
|
"gemini.validation.invalid_config",
|
||||||
|
"Gemini 配置格式错误: config 必须是对象或 null",
|
||||||
|
"Gemini config invalid: config must be an object or null",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Set security flag based on auth type
|
||||||
|
match auth_type {
|
||||||
|
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
|
||||||
|
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
|
||||||
|
crate::gemini_config::write_packycode_settings()?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Backfill: extract only key fields from live config
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/// Extract only provider-specific key fields from a live config value.
|
||||||
|
///
|
||||||
|
/// Used during backfill to ensure the provider's `settings_config` converges
|
||||||
|
/// to containing only key fields over time.
|
||||||
|
pub(crate) fn backfill_key_fields(app_type: &AppType, live_config: &Value) -> Value {
|
||||||
|
match app_type {
|
||||||
|
AppType::Claude => backfill_claude_key_fields(live_config),
|
||||||
|
AppType::Codex => backfill_codex_key_fields(live_config),
|
||||||
|
AppType::Gemini => backfill_gemini_key_fields(live_config),
|
||||||
|
// Additive mode: return full config (no backfill needed)
|
||||||
|
_ => live_config.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_claude_key_fields(live: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// Extract key env fields
|
||||||
|
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||||
|
let mut env_obj = serde_json::Map::new();
|
||||||
|
for key in CLAUDE_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = live_env.get(*key) {
|
||||||
|
env_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !env_obj.is_empty() {
|
||||||
|
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key top-level fields
|
||||||
|
if let Some(live_obj) = live.as_object() {
|
||||||
|
for key in CLAUDE_KEY_TOP_LEVEL {
|
||||||
|
if let Some(value) = live_obj.get(*key) {
|
||||||
|
result_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_codex_key_fields(live: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// auth is entirely provider-specific — keep it as-is
|
||||||
|
if let Some(auth) = live.get("auth") {
|
||||||
|
result_obj.insert("auth".to_string(), auth.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract key TOML fields from config string
|
||||||
|
if let Some(config_str) = live.get("config").and_then(|v| v.as_str()) {
|
||||||
|
if let Ok(doc) = config_str.parse::<toml_edit::DocumentMut>() {
|
||||||
|
let mut new_doc = toml_edit::DocumentMut::new();
|
||||||
|
let new_root = new_doc.as_table_mut();
|
||||||
|
|
||||||
|
// Copy key top-level fields
|
||||||
|
for key in CODEX_KEY_TOP_LEVEL {
|
||||||
|
if let Some(item) = doc.as_table().get(key) {
|
||||||
|
new_root.insert(key, item.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy model_providers table
|
||||||
|
if let Some(mp) = doc.as_table().get("model_providers") {
|
||||||
|
new_root.insert("model_providers", mp.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let toml_str = new_doc.to_string();
|
||||||
|
if !toml_str.trim().is_empty() {
|
||||||
|
result_obj.insert("config".to_string(), Value::String(toml_str));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn backfill_gemini_key_fields(live: &Value) -> Value {
|
||||||
|
let mut result = json!({});
|
||||||
|
let result_obj = result.as_object_mut().unwrap();
|
||||||
|
|
||||||
|
// Extract key env fields
|
||||||
|
if let Some(live_env) = live.get("env").and_then(|v| v.as_object()) {
|
||||||
|
let mut env_obj = serde_json::Map::new();
|
||||||
|
for key in GEMINI_KEY_ENV_FIELDS {
|
||||||
|
if let Some(value) = live_env.get(*key) {
|
||||||
|
env_obj.insert(key.to_string(), value.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !env_obj.is_empty() {
|
||||||
|
result_obj.insert("env".to_string(), Value::Object(env_obj));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync all providers to live configuration (for additive mode apps)
|
/// Sync all providers to live configuration (for additive mode apps)
|
||||||
///
|
///
|
||||||
/// Writes all providers from the database to the live configuration file.
|
/// Writes all providers from the database to the live configuration file.
|
||||||
@@ -286,7 +719,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
|
|||||||
|
|
||||||
let providers = state.db.get_all_providers(app_type.as_str())?;
|
let providers = state.db.get_all_providers(app_type.as_str())?;
|
||||||
if let Some(provider) = providers.get(¤t_id) {
|
if let Some(provider) = providers.get(¤t_id) {
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_partial(&app_type, provider)?;
|
||||||
}
|
}
|
||||||
// Note: get_effective_current_provider already validates existence,
|
// Note: get_effective_current_provider already validates existence,
|
||||||
// so providers.get() should always succeed here
|
// so providers.get() should always succeed here
|
||||||
|
|||||||
@@ -27,11 +27,12 @@ pub use live::{
|
|||||||
|
|
||||||
// Internal re-exports (pub(crate))
|
// Internal re-exports (pub(crate))
|
||||||
pub(crate) use live::sanitize_claude_settings_for_live;
|
pub(crate) use live::sanitize_claude_settings_for_live;
|
||||||
pub(crate) use live::write_live_snapshot;
|
pub(crate) use live::write_live_partial;
|
||||||
|
|
||||||
// Internal re-exports
|
// Internal re-exports
|
||||||
use live::{
|
use live::{
|
||||||
remove_openclaw_provider_from_live, remove_opencode_provider_from_live, write_gemini_live,
|
backfill_key_fields, remove_openclaw_provider_from_live, remove_opencode_provider_from_live,
|
||||||
|
write_live_snapshot,
|
||||||
};
|
};
|
||||||
use usage::validate_usage_script;
|
use usage::validate_usage_script;
|
||||||
|
|
||||||
@@ -84,47 +85,6 @@ mod tests {
|
|||||||
assert_eq!(api_key, "token");
|
assert_eq!(api_key, "token");
|
||||||
assert_eq!(base_url, "https://claude.example");
|
assert_eq!(base_url, "https://claude.example");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn extract_codex_common_config_preserves_mcp_servers_base_url() {
|
|
||||||
let config_toml = r#"model_provider = "azure"
|
|
||||||
model = "gpt-4"
|
|
||||||
disable_response_storage = true
|
|
||||||
|
|
||||||
[model_providers.azure]
|
|
||||||
name = "Azure OpenAI"
|
|
||||||
base_url = "https://azure.example/v1"
|
|
||||||
wire_api = "responses"
|
|
||||||
|
|
||||||
[mcp_servers.my_server]
|
|
||||||
base_url = "http://localhost:8080"
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let settings = json!({ "config": config_toml });
|
|
||||||
let extracted = ProviderService::extract_codex_common_config(&settings)
|
|
||||||
.expect("extract_codex_common_config should succeed");
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
!extracted
|
|
||||||
.lines()
|
|
||||||
.any(|line| line.trim_start().starts_with("model_provider")),
|
|
||||||
"should remove top-level model_provider"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!extracted
|
|
||||||
.lines()
|
|
||||||
.any(|line| line.trim_start().starts_with("model =")),
|
|
||||||
"should remove top-level model"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!extracted.contains("[model_providers"),
|
|
||||||
"should remove entire model_providers table"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
extracted.contains("http://localhost:8080"),
|
|
||||||
"should keep mcp_servers.* base_url"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl ProviderService {
|
impl ProviderService {
|
||||||
@@ -191,7 +151,7 @@ impl ProviderService {
|
|||||||
state
|
state
|
||||||
.db
|
.db
|
||||||
.set_current_provider(app_type.as_str(), &provider.id)?;
|
.set_current_provider(app_type.as_str(), &provider.id)?;
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
write_live_partial(&app_type, &provider)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
@@ -272,7 +232,7 @@ impl ProviderService {
|
|||||||
)
|
)
|
||||||
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
.map_err(|e| AppError::Message(format!("更新 Live 备份失败: {e}")))?;
|
||||||
} else {
|
} else {
|
||||||
write_live_snapshot(&app_type, &provider)?;
|
write_live_partial(&app_type, &provider)?;
|
||||||
// Sync MCP
|
// Sync MCP
|
||||||
McpService::sync_all_enabled(state)?;
|
McpService::sync_all_enabled(state)?;
|
||||||
}
|
}
|
||||||
@@ -578,7 +538,9 @@ impl ProviderService {
|
|||||||
// Only backfill when switching to a different provider
|
// Only backfill when switching to a different provider
|
||||||
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
if let Ok(live_config) = read_live_settings(app_type.clone()) {
|
||||||
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
if let Some(mut current_provider) = providers.get(¤t_id).cloned() {
|
||||||
current_provider.settings_config = live_config;
|
// Only extract key fields from live config for backfill
|
||||||
|
current_provider.settings_config =
|
||||||
|
backfill_key_fields(&app_type, &live_config);
|
||||||
if let Err(e) =
|
if let Err(e) =
|
||||||
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
state.db.save_provider(app_type.as_str(), ¤t_provider)
|
||||||
{
|
{
|
||||||
@@ -602,11 +564,8 @@ impl ProviderService {
|
|||||||
state.db.set_current_provider(app_type.as_str(), id)?;
|
state.db.set_current_provider(app_type.as_str(), id)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync to live (write_gemini_live handles security flag internally for Gemini)
|
// Sync to live (partial merge: only key fields, preserving user settings)
|
||||||
write_live_snapshot(&app_type, provider)?;
|
write_live_partial(&app_type, provider)?;
|
||||||
|
|
||||||
// Sync MCP
|
|
||||||
McpService::sync_all_enabled(state)?;
|
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
@@ -616,222 +575,6 @@ impl ProviderService {
|
|||||||
sync_current_to_live(state)
|
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<String, AppError> {
|
|
||||||
// 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 {current_id} not found")))?;
|
|
||||||
|
|
||||||
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),
|
|
||||||
AppType::OpenCode => Self::extract_opencode_common_config(&provider.settings_config),
|
|
||||||
AppType::OpenClaw => Self::extract_openclaw_common_config(&provider.settings_config),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config snippet from a config value (e.g. editor content).
|
|
||||||
pub fn extract_common_config_snippet_from_settings(
|
|
||||||
app_type: AppType,
|
|
||||||
settings_config: &Value,
|
|
||||||
) -> Result<String, AppError> {
|
|
||||||
match app_type {
|
|
||||||
AppType::Claude => Self::extract_claude_common_config(settings_config),
|
|
||||||
AppType::Codex => Self::extract_codex_common_config(settings_config),
|
|
||||||
AppType::Gemini => Self::extract_gemini_common_config(settings_config),
|
|
||||||
AppType::OpenCode => Self::extract_opencode_common_config(settings_config),
|
|
||||||
AppType::OpenClaw => Self::extract_openclaw_common_config(settings_config),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for Claude (JSON format)
|
|
||||||
fn extract_claude_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
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<String, AppError> {
|
|
||||||
// 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());
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut doc = config_toml
|
|
||||||
.parse::<toml_edit::DocumentMut>()
|
|
||||||
.map_err(|e| AppError::Message(format!("TOML parse error: {e}")))?;
|
|
||||||
|
|
||||||
// Remove provider-specific fields.
|
|
||||||
let root = doc.as_table_mut();
|
|
||||||
root.remove("model");
|
|
||||||
root.remove("model_provider");
|
|
||||||
// Legacy/alt formats might use a top-level base_url.
|
|
||||||
root.remove("base_url");
|
|
||||||
|
|
||||||
// Remove entire model_providers table (provider-specific configuration)
|
|
||||||
root.remove("model_providers");
|
|
||||||
|
|
||||||
// Clean up multiple empty lines (keep at most one blank line).
|
|
||||||
let mut cleaned = String::new();
|
|
||||||
let mut blank_run = 0usize;
|
|
||||||
for line in doc.to_string().lines() {
|
|
||||||
if line.trim().is_empty() {
|
|
||||||
blank_run += 1;
|
|
||||||
if blank_run <= 1 {
|
|
||||||
cleaned.push('\n');
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
blank_run = 0;
|
|
||||||
cleaned.push_str(line);
|
|
||||||
cleaned.push('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(cleaned.trim().to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<String, AppError> {
|
|
||||||
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}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract common config for OpenCode (JSON format)
|
|
||||||
fn extract_opencode_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
// OpenCode uses a different config structure with npm, options, models
|
|
||||||
// For common config, we exclude provider-specific fields like apiKey
|
|
||||||
let mut config = settings.clone();
|
|
||||||
|
|
||||||
// Remove provider-specific fields
|
|
||||||
if let Some(obj) = config.as_object_mut() {
|
|
||||||
if let Some(options) = obj.get_mut("options").and_then(|v| v.as_object_mut()) {
|
|
||||||
options.remove("apiKey");
|
|
||||||
options.remove("baseURL");
|
|
||||||
}
|
|
||||||
// Keep npm and models as they might be common
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().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 OpenClaw (JSON format)
|
|
||||||
fn extract_openclaw_common_config(settings: &Value) -> Result<String, AppError> {
|
|
||||||
// OpenClaw uses a different config structure with baseUrl, apiKey, api, models
|
|
||||||
// For common config, we exclude provider-specific fields like apiKey
|
|
||||||
let mut config = settings.clone();
|
|
||||||
|
|
||||||
// Remove provider-specific fields
|
|
||||||
if let Some(obj) = config.as_object_mut() {
|
|
||||||
obj.remove("apiKey");
|
|
||||||
obj.remove("baseUrl");
|
|
||||||
// Keep api and models as they might be common
|
|
||||||
}
|
|
||||||
|
|
||||||
if config.is_null() || (config.is_object() && config.as_object().unwrap().is_empty()) {
|
|
||||||
return Ok("{}".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
serde_json::to_string_pretty(&config)
|
|
||||||
.map_err(|e| AppError::Message(format!("Serialization failed: {e}")))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Import default configuration from live files (re-export)
|
/// Import default configuration from live files (re-export)
|
||||||
///
|
///
|
||||||
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
/// Returns `Ok(true)` if imported, `Ok(false)` if skipped.
|
||||||
@@ -844,6 +587,11 @@ impl ProviderService {
|
|||||||
read_live_settings(app_type)
|
read_live_settings(app_type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Patch Claude live settings directly (user-level preferences)
|
||||||
|
pub fn patch_claude_live(patch: Value) -> Result<(), AppError> {
|
||||||
|
live::patch_claude_live(patch)
|
||||||
|
}
|
||||||
|
|
||||||
/// Get custom endpoints list (re-export)
|
/// Get custom endpoints list (re-export)
|
||||||
pub fn get_custom_endpoints(
|
pub fn get_custom_endpoints(
|
||||||
state: &AppState,
|
state: &AppState,
|
||||||
@@ -939,10 +687,6 @@ impl ProviderService {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
|
|
||||||
write_gemini_live(provider)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
|
||||||
match app_type {
|
match app_type {
|
||||||
AppType::Claude => {
|
AppType::Claude => {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ use crate::database::Database;
|
|||||||
use crate::provider::Provider;
|
use crate::provider::Provider;
|
||||||
use crate::proxy::server::ProxyServer;
|
use crate::proxy::server::ProxyServer;
|
||||||
use crate::proxy::types::*;
|
use crate::proxy::types::*;
|
||||||
use crate::services::provider::write_live_snapshot;
|
use crate::services::provider::write_live_partial;
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -1266,7 +1266,7 @@ impl ProxyService {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
write_live_snapshot(app_type, provider)
|
write_live_partial(app_type, provider)
|
||||||
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
|
||||||
|
|
||||||
Ok(true)
|
Ok(true)
|
||||||
|
|||||||
@@ -2,10 +2,7 @@ use serde_json::json;
|
|||||||
use std::fs;
|
use std::fs;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use cc_switch_lib::{
|
use cc_switch_lib::{AppError, AppType, ConfigService, MultiAppConfig, Provider};
|
||||||
get_claude_settings_path, read_json_file, AppError, AppType, ConfigService, MultiAppConfig,
|
|
||||||
Provider, ProviderMeta,
|
|
||||||
};
|
|
||||||
|
|
||||||
#[path = "support.rs"]
|
#[path = "support.rs"]
|
||||||
mod support;
|
mod support;
|
||||||
@@ -13,132 +10,6 @@ use support::{
|
|||||||
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
create_test_state, create_test_state_with_config, ensure_test_home, reset_test_fs, test_mutex,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_claude_provider_writes_live_settings() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
let home = ensure_test_home();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
let provider_config = json!({
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": "test-key",
|
|
||||||
"ANTHROPIC_BASE_URL": "https://api.test"
|
|
||||||
},
|
|
||||||
"ui": {
|
|
||||||
"displayName": "Test Provider"
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"prov-1".to_string(),
|
|
||||||
"Test Claude".to_string(),
|
|
||||||
provider_config.clone(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Claude)
|
|
||||||
.expect("claude manager");
|
|
||||||
manager.providers.insert("prov-1".to_string(), provider);
|
|
||||||
manager.current = "prov-1".to_string();
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings");
|
|
||||||
|
|
||||||
let settings_path = get_claude_settings_path();
|
|
||||||
assert!(
|
|
||||||
settings_path.exists(),
|
|
||||||
"live settings should be written to {}",
|
|
||||||
settings_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let live_value: serde_json::Value = read_json_file(&settings_path).expect("read live file");
|
|
||||||
assert_eq!(live_value, provider_config);
|
|
||||||
|
|
||||||
// 确认 SSOT 中的供应商也同步了最新内容
|
|
||||||
let updated = config
|
|
||||||
.get_manager(&AppType::Claude)
|
|
||||||
.and_then(|m| m.providers.get("prov-1"))
|
|
||||||
.expect("provider in config");
|
|
||||||
assert_eq!(updated.settings_config, provider_config);
|
|
||||||
|
|
||||||
// 额外确认写入位置位于测试 HOME 下
|
|
||||||
assert!(
|
|
||||||
settings_path.starts_with(home),
|
|
||||||
"settings path {settings_path:?} should reside under test HOME {home:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_codex_provider_writes_auth_and_config() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
|
|
||||||
// 注意:v3.7.0 后 MCP 同步由 McpService 独立处理,不再通过 provider 切换触发
|
|
||||||
// 此测试仅验证 auth.json 和 config.toml 基础配置的写入
|
|
||||||
|
|
||||||
let provider_config = json!({
|
|
||||||
"auth": {
|
|
||||||
"OPENAI_API_KEY": "codex-key"
|
|
||||||
},
|
|
||||||
"config": r#"base_url = "https://codex.test""#
|
|
||||||
});
|
|
||||||
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"codex-1".to_string(),
|
|
||||||
"Codex Test".to_string(),
|
|
||||||
provider_config.clone(),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Codex)
|
|
||||||
.expect("codex manager");
|
|
||||||
manager.providers.insert("codex-1".to_string(), provider);
|
|
||||||
manager.current = "codex-1".to_string();
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live");
|
|
||||||
|
|
||||||
let auth_path = cc_switch_lib::get_codex_auth_path();
|
|
||||||
let config_path = cc_switch_lib::get_codex_config_path();
|
|
||||||
|
|
||||||
assert!(
|
|
||||||
auth_path.exists(),
|
|
||||||
"auth.json should exist at {}",
|
|
||||||
auth_path.display()
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
config_path.exists(),
|
|
||||||
"config.toml should exist at {}",
|
|
||||||
config_path.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let auth_value: serde_json::Value = read_json_file(&auth_path).expect("read auth");
|
|
||||||
assert_eq!(
|
|
||||||
auth_value,
|
|
||||||
provider_config.get("auth").cloned().expect("auth object")
|
|
||||||
);
|
|
||||||
|
|
||||||
let toml_text = fs::read_to_string(&config_path).expect("read config.toml");
|
|
||||||
// 验证基础配置正确写入
|
|
||||||
assert!(
|
|
||||||
toml_text.contains("base_url"),
|
|
||||||
"config.toml should contain base_url from provider config"
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当前供应商应同步最新 config 文本
|
|
||||||
let manager = config.get_manager(&AppType::Codex).expect("codex manager");
|
|
||||||
let synced = manager.providers.get("codex-1").expect("codex provider");
|
|
||||||
let synced_cfg = synced
|
|
||||||
.settings_config
|
|
||||||
.get("config")
|
|
||||||
.and_then(|v| v.as_str())
|
|
||||||
.expect("config string");
|
|
||||||
assert_eq!(synced_cfg, toml_text);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sync_enabled_to_codex_writes_enabled_servers() {
|
fn sync_enabled_to_codex_writes_enabled_servers() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
@@ -338,46 +209,6 @@ fn sync_enabled_to_codex_returns_error_on_invalid_toml() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_codex_provider_missing_auth_returns_error() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
let provider = Provider::with_id(
|
|
||||||
"codex-missing-auth".to_string(),
|
|
||||||
"No Auth".to_string(),
|
|
||||||
json!({
|
|
||||||
"config": "model = \"test\""
|
|
||||||
}),
|
|
||||||
None,
|
|
||||||
);
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Codex)
|
|
||||||
.expect("codex manager");
|
|
||||||
manager.providers.insert(provider.id.clone(), provider);
|
|
||||||
manager.current = "codex-missing-auth".to_string();
|
|
||||||
|
|
||||||
let err = ConfigService::sync_current_providers_to_live(&mut config)
|
|
||||||
.expect_err("sync should fail when auth missing");
|
|
||||||
match err {
|
|
||||||
cc_switch_lib::AppError::Config(msg) => {
|
|
||||||
assert!(msg.contains("auth"), "error message should mention auth");
|
|
||||||
}
|
|
||||||
other => panic!("unexpected error variant: {other:?}"),
|
|
||||||
}
|
|
||||||
|
|
||||||
// 确认未产生任何 live 配置文件
|
|
||||||
assert!(
|
|
||||||
!cc_switch_lib::get_codex_auth_path().exists(),
|
|
||||||
"auth.json should not be created on failure"
|
|
||||||
);
|
|
||||||
assert!(
|
|
||||||
!cc_switch_lib::get_codex_config_path().exists(),
|
|
||||||
"config.toml should not be created on failure"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn write_codex_live_atomic_persists_auth_and_config() {
|
fn write_codex_live_atomic_persists_auth_and_config() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
@@ -816,107 +647,6 @@ fn create_backup_retains_only_latest_entries() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_gemini_packycode_sets_security_selected_type() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
let home = ensure_test_home();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
{
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Gemini)
|
|
||||||
.expect("gemini manager");
|
|
||||||
manager.current = "packy-1".to_string();
|
|
||||||
manager.providers.insert(
|
|
||||||
"packy-1".to_string(),
|
|
||||||
Provider::with_id(
|
|
||||||
"packy-1".to_string(),
|
|
||||||
"PackyCode".to_string(),
|
|
||||||
json!({
|
|
||||||
"env": {
|
|
||||||
"GEMINI_API_KEY": "pk-key",
|
|
||||||
"GOOGLE_GEMINI_BASE_URL": "https://api-slb.packyapi.com"
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
Some("https://www.packyapi.com".to_string()),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config)
|
|
||||||
.expect("syncing gemini live should succeed");
|
|
||||||
|
|
||||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
|
||||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
|
||||||
assert!(
|
|
||||||
gemini_settings.exists(),
|
|
||||||
"Gemini settings.json should exist at {}",
|
|
||||||
gemini_settings.display()
|
|
||||||
);
|
|
||||||
|
|
||||||
let raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings.json");
|
|
||||||
let value: serde_json::Value = serde_json::from_str(&raw).expect("parse gemini settings.json");
|
|
||||||
assert_eq!(
|
|
||||||
value
|
|
||||||
.pointer("/security/auth/selectedType")
|
|
||||||
.and_then(|v| v.as_str()),
|
|
||||||
Some("gemini-api-key"),
|
|
||||||
"syncing PackyCode Gemini should enforce security.auth.selectedType in Gemini settings"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn sync_gemini_google_official_sets_oauth_security() {
|
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
|
||||||
reset_test_fs();
|
|
||||||
let home = ensure_test_home();
|
|
||||||
|
|
||||||
let mut config = MultiAppConfig::default();
|
|
||||||
{
|
|
||||||
let manager = config
|
|
||||||
.get_manager_mut(&AppType::Gemini)
|
|
||||||
.expect("gemini manager");
|
|
||||||
manager.current = "google-official".to_string();
|
|
||||||
let mut provider = Provider::with_id(
|
|
||||||
"google-official".to_string(),
|
|
||||||
"Google".to_string(),
|
|
||||||
json!({
|
|
||||||
"env": {}
|
|
||||||
}),
|
|
||||||
Some("https://ai.google.dev".to_string()),
|
|
||||||
);
|
|
||||||
provider.meta = Some(ProviderMeta {
|
|
||||||
partner_promotion_key: Some("google-official".to_string()),
|
|
||||||
..ProviderMeta::default()
|
|
||||||
});
|
|
||||||
manager
|
|
||||||
.providers
|
|
||||||
.insert("google-official".to_string(), provider);
|
|
||||||
}
|
|
||||||
|
|
||||||
ConfigService::sync_current_providers_to_live(&mut config)
|
|
||||||
.expect("syncing google official gemini should succeed");
|
|
||||||
|
|
||||||
// security field is written to ~/.gemini/settings.json, not ~/.cc-switch/settings.json
|
|
||||||
let gemini_settings = home.join(".gemini").join("settings.json");
|
|
||||||
assert!(
|
|
||||||
gemini_settings.exists(),
|
|
||||||
"Gemini settings should exist at {}",
|
|
||||||
gemini_settings.display()
|
|
||||||
);
|
|
||||||
let gemini_raw = std::fs::read_to_string(&gemini_settings).expect("read gemini settings");
|
|
||||||
let gemini_value: serde_json::Value =
|
|
||||||
serde_json::from_str(&gemini_raw).expect("parse gemini settings json");
|
|
||||||
assert_eq!(
|
|
||||||
gemini_value
|
|
||||||
.pointer("/security/auth/selectedType")
|
|
||||||
.and_then(|v| v.as_str()),
|
|
||||||
Some("oauth-personal"),
|
|
||||||
"Gemini settings should record oauth-personal for Google Official"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn export_sql_writes_to_target_path() {
|
fn export_sql_writes_to_target_path() {
|
||||||
let _guard = test_mutex().lock().expect("acquire test mutex");
|
let _guard = test_mutex().lock().expect("acquire test mutex");
|
||||||
|
|||||||
@@ -100,9 +100,12 @@ command = "say"
|
|||||||
);
|
);
|
||||||
|
|
||||||
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
let config_text = std::fs::read_to_string(get_codex_config_path()).expect("read config.toml");
|
||||||
|
// With partial merge, only key fields (model, provider, model_providers) are
|
||||||
|
// merged into config.toml. The existing MCP section should be preserved.
|
||||||
|
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||||
assert!(
|
assert!(
|
||||||
config_text.contains("mcp_servers.echo-server"),
|
config_text.contains("mcp_servers.legacy"),
|
||||||
"config.toml should contain synced MCP servers"
|
"config.toml should preserve existing MCP servers after partial merge"
|
||||||
);
|
);
|
||||||
|
|
||||||
let current_id = app_state
|
let current_id = app_state
|
||||||
@@ -126,12 +129,9 @@ command = "say"
|
|||||||
.get("config")
|
.get("config")
|
||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
// 供应商配置应该包含在 live 文件中
|
// With partial merge, only key fields (model_provider, model, model_providers)
|
||||||
// 注意:live 文件还会包含 MCP 同步后的内容
|
// are written to the live file. MCP servers are synced separately.
|
||||||
assert!(
|
// The provider's stored config should still contain mcp_servers.latest.
|
||||||
config_text.contains("mcp_servers.latest"),
|
|
||||||
"live file should contain provider's original config"
|
|
||||||
);
|
|
||||||
assert!(
|
assert!(
|
||||||
new_config_text.contains("mcp_servers.latest"),
|
new_config_text.contains("mcp_servers.latest"),
|
||||||
"provider snapshot should contain provider's original config"
|
"provider snapshot should contain provider's original config"
|
||||||
@@ -268,11 +268,22 @@ fn switch_provider_updates_claude_live_and_state() {
|
|||||||
let legacy_provider = providers
|
let legacy_provider = providers
|
||||||
.get("old-provider")
|
.get("old-provider")
|
||||||
.expect("legacy provider still exists");
|
.expect("legacy provider still exists");
|
||||||
// 回填机制:切换前会将 live 配置回填到当前供应商
|
// Backfill mechanism: before switching, the live config's key fields are
|
||||||
// 这保护了用户在 live 文件中的手动修改
|
// backfilled to the current provider. With partial merge, only key fields
|
||||||
|
// (auth, model, endpoint) are extracted — non-key fields like workspace
|
||||||
|
// are NOT included in the backfill.
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
legacy_provider.settings_config, legacy_live,
|
legacy_provider
|
||||||
"previous provider should be backfilled with live config"
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("legacy-key"),
|
||||||
|
"previous provider should be backfilled with live auth key"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
legacy_provider.settings_config.get("workspace").is_none(),
|
||||||
|
"backfill should NOT include non-key fields like workspace"
|
||||||
);
|
);
|
||||||
|
|
||||||
let new_provider = providers.get("new-provider").expect("new provider exists");
|
let new_provider = providers.get("new-provider").expect("new provider exists");
|
||||||
|
|||||||
@@ -112,9 +112,12 @@ command = "say"
|
|||||||
|
|
||||||
let config_text =
|
let config_text =
|
||||||
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
std::fs::read_to_string(cc_switch_lib::get_codex_config_path()).expect("read config.toml");
|
||||||
|
// With partial merge, only key fields (model, provider, model_providers) are
|
||||||
|
// merged into config.toml. The existing MCP section should be preserved.
|
||||||
|
// MCP sync from DB is handled separately (at startup or explicit sync).
|
||||||
assert!(
|
assert!(
|
||||||
config_text.contains("mcp_servers.echo-server"),
|
config_text.contains("mcp_servers.legacy"),
|
||||||
"config.toml should contain synced MCP servers"
|
"config.toml should preserve existing MCP servers after partial merge"
|
||||||
);
|
);
|
||||||
|
|
||||||
let current_id = state
|
let current_id = state
|
||||||
@@ -143,11 +146,6 @@ command = "say"
|
|||||||
new_config_text.contains("mcp_servers.latest"),
|
new_config_text.contains("mcp_servers.latest"),
|
||||||
"provider config should contain original MCP servers"
|
"provider config should contain original MCP servers"
|
||||||
);
|
);
|
||||||
// live 文件额外包含同步的 MCP 服务器
|
|
||||||
assert!(
|
|
||||||
config_text.contains("mcp_servers.echo-server"),
|
|
||||||
"live config should include synced MCP servers"
|
|
||||||
);
|
|
||||||
|
|
||||||
let legacy = providers
|
let legacy = providers
|
||||||
.get("old-provider")
|
.get("old-provider")
|
||||||
@@ -414,9 +412,19 @@ fn provider_service_switch_claude_updates_live_and_state() {
|
|||||||
let legacy_provider = providers
|
let legacy_provider = providers
|
||||||
.get("old-provider")
|
.get("old-provider")
|
||||||
.expect("legacy provider still exists");
|
.expect("legacy provider still exists");
|
||||||
|
// With partial merge backfill, only key fields are extracted from live config
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
legacy_provider.settings_config, legacy_live,
|
legacy_provider
|
||||||
"previous provider should receive backfilled live config"
|
.settings_config
|
||||||
|
.get("env")
|
||||||
|
.and_then(|env| env.get("ANTHROPIC_API_KEY"))
|
||||||
|
.and_then(|key| key.as_str()),
|
||||||
|
Some("legacy-key"),
|
||||||
|
"previous provider should receive backfilled auth key"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
legacy_provider.settings_config.get("workspace").is_none(),
|
||||||
|
"backfill should NOT include non-key fields like workspace"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import EndpointSpeedTest from "./EndpointSpeedTest";
|
import EndpointSpeedTest from "./EndpointSpeedTest";
|
||||||
import { ApiKeySection, EndpointField } from "./shared";
|
import { ApiKeySection, EndpointField } from "./shared";
|
||||||
import type { ProviderCategory, ClaudeApiFormat } from "@/types";
|
import type {
|
||||||
|
ProviderCategory,
|
||||||
|
ClaudeApiFormat,
|
||||||
|
ClaudeApiKeyField,
|
||||||
|
} from "@/types";
|
||||||
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
import type { TemplateValueConfig } from "@/config/claudeProviderPresets";
|
||||||
|
|
||||||
interface EndpointCandidate {
|
interface EndpointCandidate {
|
||||||
@@ -68,6 +72,10 @@ interface ClaudeFormFieldsProps {
|
|||||||
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
// API Format (for third-party providers that use OpenAI Chat Completions format)
|
||||||
apiFormat: ClaudeApiFormat;
|
apiFormat: ClaudeApiFormat;
|
||||||
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
onApiFormatChange: (format: ClaudeApiFormat) => void;
|
||||||
|
|
||||||
|
// Auth Key Field (ANTHROPIC_AUTH_TOKEN vs ANTHROPIC_API_KEY)
|
||||||
|
apiKeyField: ClaudeApiKeyField;
|
||||||
|
onApiKeyFieldChange: (field: ClaudeApiKeyField) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClaudeFormFields({
|
export function ClaudeFormFields({
|
||||||
@@ -102,6 +110,8 @@ export function ClaudeFormFields({
|
|||||||
speedTestEndpoints,
|
speedTestEndpoints,
|
||||||
apiFormat,
|
apiFormat,
|
||||||
onApiFormatChange,
|
onApiFormatChange,
|
||||||
|
apiKeyField,
|
||||||
|
onApiKeyFieldChange,
|
||||||
}: ClaudeFormFieldsProps) {
|
}: ClaudeFormFieldsProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -219,6 +229,41 @@ export function ClaudeFormFields({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 认证字段选择(仅非官方供应商显示) */}
|
||||||
|
{shouldShowModelSelector && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<FormLabel htmlFor="apiKeyField">
|
||||||
|
{t("providerForm.authField", { defaultValue: "认证字段" })}
|
||||||
|
</FormLabel>
|
||||||
|
<Select
|
||||||
|
value={apiKeyField}
|
||||||
|
onValueChange={(v) => onApiKeyFieldChange(v as ClaudeApiKeyField)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="apiKeyField" className="w-full">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ANTHROPIC_AUTH_TOKEN">
|
||||||
|
{t("providerForm.authFieldAuthToken", {
|
||||||
|
defaultValue: "Auth Token (默认)",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ANTHROPIC_API_KEY">
|
||||||
|
{t("providerForm.authFieldApiKey", {
|
||||||
|
defaultValue: "API Key",
|
||||||
|
})}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("providerForm.authFieldHint", {
|
||||||
|
defaultValue:
|
||||||
|
"大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* 模型选择器 */}
|
{/* 模型选择器 */}
|
||||||
{shouldShowModelSelector && (
|
{shouldShowModelSelector && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
|
||||||
|
type ToggleKey = "hideAttribution" | "alwaysThinking" | "enableTeammates";
|
||||||
|
|
||||||
|
interface ClaudeQuickTogglesProps {
|
||||||
|
/** Called after a patch is applied to the live file, so the caller can mirror it in the JSON editor. */
|
||||||
|
onPatchApplied?: (patch: Record<string, unknown>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultStates: Record<ToggleKey, boolean> = {
|
||||||
|
hideAttribution: false,
|
||||||
|
alwaysThinking: false,
|
||||||
|
enableTeammates: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
function deriveStates(
|
||||||
|
cfg: Record<string, unknown>,
|
||||||
|
): Record<ToggleKey, boolean> {
|
||||||
|
const env = cfg?.env as Record<string, unknown> | undefined;
|
||||||
|
const attr = cfg?.attribution as Record<string, unknown> | undefined;
|
||||||
|
return {
|
||||||
|
hideAttribution: attr?.commit === "" && attr?.pr === "",
|
||||||
|
alwaysThinking: cfg?.alwaysThinkingEnabled === true,
|
||||||
|
enableTeammates: env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Apply RFC 7396 JSON Merge Patch in-place: null = delete, object = recurse, else overwrite. */
|
||||||
|
function jsonMergePatch(
|
||||||
|
target: Record<string, unknown>,
|
||||||
|
patch: Record<string, unknown>,
|
||||||
|
) {
|
||||||
|
for (const [key, value] of Object.entries(patch)) {
|
||||||
|
if (value === null || value === undefined) {
|
||||||
|
delete target[key];
|
||||||
|
} else if (typeof value === "object" && !Array.isArray(value)) {
|
||||||
|
if (
|
||||||
|
typeof target[key] !== "object" ||
|
||||||
|
target[key] === null ||
|
||||||
|
Array.isArray(target[key])
|
||||||
|
) {
|
||||||
|
target[key] = {};
|
||||||
|
}
|
||||||
|
jsonMergePatch(
|
||||||
|
target[key] as Record<string, unknown>,
|
||||||
|
value as Record<string, unknown>,
|
||||||
|
);
|
||||||
|
if (Object.keys(target[key] as Record<string, unknown>).length === 0) {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
target[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { jsonMergePatch };
|
||||||
|
|
||||||
|
export function ClaudeQuickToggles({
|
||||||
|
onPatchApplied,
|
||||||
|
}: ClaudeQuickTogglesProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [states, setStates] = useState(defaultStates);
|
||||||
|
|
||||||
|
const readLive = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const cfg = await invoke<Record<string, unknown>>(
|
||||||
|
"read_live_provider_settings",
|
||||||
|
{ app: "claude" },
|
||||||
|
);
|
||||||
|
setStates(deriveStates(cfg));
|
||||||
|
} catch {
|
||||||
|
// Live file missing or unreadable — show all unchecked
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
readLive();
|
||||||
|
}, [readLive]);
|
||||||
|
|
||||||
|
const toggle = useCallback(
|
||||||
|
async (key: ToggleKey) => {
|
||||||
|
let patch: Record<string, unknown>;
|
||||||
|
if (key === "hideAttribution") {
|
||||||
|
patch = states.hideAttribution
|
||||||
|
? { attribution: null }
|
||||||
|
: { attribution: { commit: "", pr: "" } };
|
||||||
|
} else if (key === "alwaysThinking") {
|
||||||
|
patch = states.alwaysThinking
|
||||||
|
? { alwaysThinkingEnabled: null }
|
||||||
|
: { alwaysThinkingEnabled: true };
|
||||||
|
} else {
|
||||||
|
patch = states.enableTeammates
|
||||||
|
? { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: null } }
|
||||||
|
: { env: { CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: "1" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
setStates((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke("patch_claude_live_settings", { patch });
|
||||||
|
onPatchApplied?.(patch);
|
||||||
|
} catch {
|
||||||
|
// Revert on failure
|
||||||
|
readLive();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[states, readLive, onPatchApplied],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-x-4 gap-y-1">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["hideAttribution", "claudeConfig.hideAttribution"],
|
||||||
|
["alwaysThinking", "claudeConfig.alwaysThinking"],
|
||||||
|
["enableTeammates", "claudeConfig.enableTeammates"],
|
||||||
|
] as const
|
||||||
|
).map(([key, i18nKey]) => (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
className="flex items-center gap-1.5 text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
<Checkbox checked={states[key]} onCheckedChange={() => toggle(key)} />
|
||||||
|
{t(i18nKey)}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,107 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "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";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
|
|
||||||
interface CodexCommonConfigModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* CodexCommonConfigModal - Common Codex configuration editor modal
|
|
||||||
* Allows editing of common TOML configuration shared across providers
|
|
||||||
*/
|
|
||||||
export const CodexCommonConfigModal: React.FC<CodexCommonConfigModalProps> = ({
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
error,
|
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}) => {
|
|
||||||
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 (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isOpen}
|
|
||||||
title={t("codexConfig.editCommonConfigTitle")}
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{onExtract && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExtract}
|
|
||||||
disabled={isExtracting}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("codexConfig.extractFromCurrent", {
|
|
||||||
defaultValue: "从编辑内容提取",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={onClose} className="gap-2">
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("codexConfig.commonConfigHint")}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<JsonEditor
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={`# Common Codex config
|
|
||||||
|
|
||||||
# Add your common TOML configuration here`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={16}
|
|
||||||
showValidation={false}
|
|
||||||
language="javascript"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
import { CodexAuthSection, CodexConfigSection } from "./CodexConfigSections";
|
||||||
import { CodexCommonConfigModal } from "./CodexCommonConfigModal";
|
|
||||||
|
|
||||||
interface CodexConfigEditorProps {
|
interface CodexConfigEditorProps {
|
||||||
authValue: string;
|
authValue: string;
|
||||||
@@ -13,23 +12,9 @@ interface CodexConfigEditorProps {
|
|||||||
|
|
||||||
onAuthBlur?: () => void;
|
onAuthBlur?: () => void;
|
||||||
|
|
||||||
useCommonConfig: boolean;
|
|
||||||
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
|
|
||||||
commonConfigError: string;
|
|
||||||
|
|
||||||
authError: string;
|
authError: string;
|
||||||
|
|
||||||
configError: string; // config.toml 错误提示
|
configError: string;
|
||||||
|
|
||||||
onExtract?: () => void;
|
|
||||||
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
||||||
@@ -38,25 +23,9 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
onAuthChange,
|
onAuthChange,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onAuthBlur,
|
onAuthBlur,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
authError,
|
authError,
|
||||||
configError,
|
configError,
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Auto-open common config modal if there's an error
|
|
||||||
useEffect(() => {
|
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
|
||||||
setIsCommonConfigModalOpen(true);
|
|
||||||
}
|
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Auth JSON Section */}
|
{/* Auth JSON Section */}
|
||||||
@@ -71,23 +40,8 @@ const CodexConfigEditor: React.FC<CodexConfigEditorProps> = ({
|
|||||||
<CodexConfigSection
|
<CodexConfigSection
|
||||||
value={configValue}
|
value={configValue}
|
||||||
onChange={onConfigChange}
|
onChange={onConfigChange}
|
||||||
useCommonConfig={useCommonConfig}
|
|
||||||
onCommonConfigToggle={onCommonConfigToggle}
|
|
||||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
|
||||||
commonConfigError={commonConfigError}
|
|
||||||
configError={configError}
|
configError={configError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Common Config Modal */}
|
|
||||||
<CodexCommonConfigModal
|
|
||||||
isOpen={isCommonConfigModalOpen}
|
|
||||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
error={commonConfigError}
|
|
||||||
onExtract={onExtract}
|
|
||||||
isExtracting={isExtracting}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -78,10 +78,6 @@ export const CodexAuthSection: React.FC<CodexAuthSectionProps> = ({
|
|||||||
interface CodexConfigSectionProps {
|
interface CodexConfigSectionProps {
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
onEditCommonConfig: () => void;
|
|
||||||
commonConfigError?: string;
|
|
||||||
configError?: string;
|
configError?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,10 +87,6 @@ interface CodexConfigSectionProps {
|
|||||||
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
onEditCommonConfig,
|
|
||||||
commonConfigError,
|
|
||||||
configError,
|
configError,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -117,40 +109,12 @@ export const CodexConfigSection: React.FC<CodexConfigSectionProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<label
|
||||||
<label
|
htmlFor="codexConfig"
|
||||||
htmlFor="codexConfig"
|
className="block text-sm font-medium text-foreground"
|
||||||
className="block text-sm font-medium text-foreground"
|
>
|
||||||
>
|
{t("codexConfig.configToml")}
|
||||||
{t("codexConfig.configToml")}
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
{t("codexConfig.writeCommonConfig")}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEditCommonConfig}
|
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
{t("codexConfig.editCommonConfig")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -1,280 +0,0 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useEffect, useState, useCallback, useMemo } from "react";
|
|
||||||
import { FullScreenPanel } from "@/components/common/FullScreenPanel";
|
|
||||||
import { Label } from "@/components/ui/label";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Save, Download, Loader2 } from "lucide-react";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
|
|
||||||
interface CommonConfigEditorProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
commonConfigError: string;
|
|
||||||
onEditClick: () => void;
|
|
||||||
isModalOpen: boolean;
|
|
||||||
onModalClose: () => void;
|
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CommonConfigEditor({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
onEditClick,
|
|
||||||
isModalOpen,
|
|
||||||
onModalClose,
|
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}: CommonConfigEditorProps) {
|
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Mirror value prop to local state so checkbox toggles and JsonEditor stay in sync
|
|
||||||
// (parent uses form.getValues which doesn't trigger re-renders)
|
|
||||||
const [localValue, setLocalValue] = useState(value);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setLocalValue(value);
|
|
||||||
}, [value]);
|
|
||||||
|
|
||||||
const handleLocalChange = useCallback(
|
|
||||||
(newValue: string) => {
|
|
||||||
setLocalValue(newValue);
|
|
||||||
onChange(newValue);
|
|
||||||
},
|
|
||||||
[onChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
const toggleStates = useMemo(() => {
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(localValue);
|
|
||||||
return {
|
|
||||||
hideAttribution:
|
|
||||||
config?.attribution?.commit === "" && config?.attribution?.pr === "",
|
|
||||||
alwaysThinking: config?.alwaysThinkingEnabled === true,
|
|
||||||
teammates:
|
|
||||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === "1" ||
|
|
||||||
config?.env?.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS === 1,
|
|
||||||
};
|
|
||||||
} catch {
|
|
||||||
return {
|
|
||||||
hideAttribution: false,
|
|
||||||
alwaysThinking: false,
|
|
||||||
teammates: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}, [localValue]);
|
|
||||||
|
|
||||||
const handleToggle = useCallback(
|
|
||||||
(toggleKey: string, checked: boolean) => {
|
|
||||||
try {
|
|
||||||
const config = JSON.parse(localValue || "{}");
|
|
||||||
|
|
||||||
switch (toggleKey) {
|
|
||||||
case "hideAttribution":
|
|
||||||
if (checked) {
|
|
||||||
config.attribution = { commit: "", pr: "" };
|
|
||||||
} else {
|
|
||||||
delete config.attribution;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "alwaysThinking":
|
|
||||||
if (checked) {
|
|
||||||
config.alwaysThinkingEnabled = true;
|
|
||||||
} else {
|
|
||||||
delete config.alwaysThinkingEnabled;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case "teammates":
|
|
||||||
if (!config.env) config.env = {};
|
|
||||||
if (checked) {
|
|
||||||
config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS = "1";
|
|
||||||
} else {
|
|
||||||
delete config.env.CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS;
|
|
||||||
if (Object.keys(config.env).length === 0) delete config.env;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleLocalChange(JSON.stringify(config, null, 2));
|
|
||||||
} catch {
|
|
||||||
// Don't modify if JSON is invalid
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[localValue, handleLocalChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Label htmlFor="settingsConfig">{t("provider.configJson")}</Label>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="useCommonConfig"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
{t("claudeConfig.writeCommonConfig", {
|
|
||||||
defaultValue: "写入通用配置",
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEditClick}
|
|
||||||
className="text-xs text-blue-400 dark:text-blue-500 hover:text-blue-500 dark:hover:text-blue-400 transition-colors"
|
|
||||||
>
|
|
||||||
{t("claudeConfig.editCommonConfig", {
|
|
||||||
defaultValue: "编辑通用配置",
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{commonConfigError && !isModalOpen && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1">
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={toggleStates.hideAttribution}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleToggle("hideAttribution", e.target.checked)
|
|
||||||
}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span>{t("claudeConfig.hideAttribution")}</span>
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={toggleStates.alwaysThinking}
|
|
||||||
onChange={(e) => handleToggle("alwaysThinking", e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span>{t("claudeConfig.alwaysThinking")}</span>
|
|
||||||
</label>
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={toggleStates.teammates}
|
|
||||||
onChange={(e) => handleToggle("teammates", e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
<span>{t("claudeConfig.enableTeammates")}</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<JsonEditor
|
|
||||||
value={localValue}
|
|
||||||
onChange={handleLocalChange}
|
|
||||||
placeholder={`{
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com",
|
|
||||||
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={14}
|
|
||||||
showValidation={true}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isModalOpen}
|
|
||||||
title={t("claudeConfig.editCommonConfigTitle", {
|
|
||||||
defaultValue: "编辑通用配置片段",
|
|
||||||
})}
|
|
||||||
onClose={onModalClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{onExtract && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExtract}
|
|
||||||
disabled={isExtracting}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("claudeConfig.extractFromCurrent", {
|
|
||||||
defaultValue: "从编辑内容提取",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onModalClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={onModalClose} className="gap-2">
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("claudeConfig.commonConfigHint", {
|
|
||||||
defaultValue: "通用配置片段将合并到所有启用它的供应商配置中",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
<JsonEditor
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
placeholder={`{
|
|
||||||
"env": {
|
|
||||||
"ANTHROPIC_BASE_URL": "https://your-api-endpoint.com"
|
|
||||||
}
|
|
||||||
}`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={16}
|
|
||||||
showValidation={true}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
import React, { useEffect, useState } from "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";
|
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
|
||||||
|
|
||||||
interface GeminiCommonConfigModalProps {
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
error?: string;
|
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GeminiCommonConfigModal - Common Gemini configuration editor modal
|
|
||||||
* Allows editing of common env snippet shared across Gemini providers
|
|
||||||
*/
|
|
||||||
export const GeminiCommonConfigModal: React.FC<
|
|
||||||
GeminiCommonConfigModalProps
|
|
||||||
> = ({ isOpen, onClose, value, onChange, error, onExtract, isExtracting }) => {
|
|
||||||
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 (
|
|
||||||
<FullScreenPanel
|
|
||||||
isOpen={isOpen}
|
|
||||||
title={t("geminiConfig.editCommonConfigTitle", {
|
|
||||||
defaultValue: "编辑 Gemini 通用配置片段",
|
|
||||||
})}
|
|
||||||
onClose={onClose}
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
{onExtract && (
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
onClick={onExtract}
|
|
||||||
disabled={isExtracting}
|
|
||||||
className="gap-2"
|
|
||||||
>
|
|
||||||
{isExtracting ? (
|
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
)}
|
|
||||||
{t("geminiConfig.extractFromCurrent", {
|
|
||||||
defaultValue: "从编辑内容提取",
|
|
||||||
})}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button type="button" variant="outline" onClick={onClose}>
|
|
||||||
{t("common.cancel")}
|
|
||||||
</Button>
|
|
||||||
<Button type="button" onClick={onClose} className="gap-2">
|
|
||||||
<Save className="w-4 h-4" />
|
|
||||||
{t("common.save")}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="space-y-4">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t("geminiConfig.commonConfigHint", {
|
|
||||||
defaultValue:
|
|
||||||
"该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<JsonEditor
|
|
||||||
value={value}
|
|
||||||
onChange={onChange}
|
|
||||||
placeholder={`{
|
|
||||||
"GEMINI_MODEL": "gemini-3-pro-preview"
|
|
||||||
}`}
|
|
||||||
darkMode={isDarkMode}
|
|
||||||
rows={16}
|
|
||||||
showValidation={true}
|
|
||||||
language="json"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-red-500 dark:text-red-400">{error}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</FullScreenPanel>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { useState, useEffect } from "react";
|
import React from "react";
|
||||||
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
|
import { GeminiEnvSection, GeminiConfigSection } from "./GeminiConfigSections";
|
||||||
import { GeminiCommonConfigModal } from "./GeminiCommonConfigModal";
|
|
||||||
|
|
||||||
interface GeminiConfigEditorProps {
|
interface GeminiConfigEditorProps {
|
||||||
envValue: string;
|
envValue: string;
|
||||||
@@ -8,15 +7,8 @@ interface GeminiConfigEditorProps {
|
|||||||
onEnvChange: (value: string) => void;
|
onEnvChange: (value: string) => void;
|
||||||
onConfigChange: (value: string) => void;
|
onConfigChange: (value: string) => void;
|
||||||
onEnvBlur?: () => void;
|
onEnvBlur?: () => void;
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
commonConfigSnippet: string;
|
|
||||||
onCommonConfigSnippetChange: (value: string) => void;
|
|
||||||
commonConfigError: string;
|
|
||||||
envError: string;
|
envError: string;
|
||||||
configError: string;
|
configError: string;
|
||||||
onExtract?: () => void;
|
|
||||||
isExtracting?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
||||||
@@ -25,25 +17,9 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onEnvChange,
|
onEnvChange,
|
||||||
onConfigChange,
|
onConfigChange,
|
||||||
onEnvBlur,
|
onEnvBlur,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
commonConfigSnippet,
|
|
||||||
onCommonConfigSnippetChange,
|
|
||||||
commonConfigError,
|
|
||||||
envError,
|
envError,
|
||||||
configError,
|
configError,
|
||||||
onExtract,
|
|
||||||
isExtracting,
|
|
||||||
}) => {
|
}) => {
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Auto-open common config modal if there's an error
|
|
||||||
useEffect(() => {
|
|
||||||
if (commonConfigError && !isCommonConfigModalOpen) {
|
|
||||||
setIsCommonConfigModalOpen(true);
|
|
||||||
}
|
|
||||||
}, [commonConfigError, isCommonConfigModalOpen]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Env Section */}
|
{/* Env Section */}
|
||||||
@@ -52,10 +28,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onChange={onEnvChange}
|
onChange={onEnvChange}
|
||||||
onBlur={onEnvBlur}
|
onBlur={onEnvBlur}
|
||||||
error={envError}
|
error={envError}
|
||||||
useCommonConfig={useCommonConfig}
|
|
||||||
onCommonConfigToggle={onCommonConfigToggle}
|
|
||||||
onEditCommonConfig={() => setIsCommonConfigModalOpen(true)}
|
|
||||||
commonConfigError={commonConfigError}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Config JSON Section */}
|
{/* Config JSON Section */}
|
||||||
@@ -64,17 +36,6 @@ const GeminiConfigEditor: React.FC<GeminiConfigEditorProps> = ({
|
|||||||
onChange={onConfigChange}
|
onChange={onConfigChange}
|
||||||
configError={configError}
|
configError={configError}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Common Config Modal */}
|
|
||||||
<GeminiCommonConfigModal
|
|
||||||
isOpen={isCommonConfigModalOpen}
|
|
||||||
onClose={() => setIsCommonConfigModalOpen(false)}
|
|
||||||
value={commonConfigSnippet}
|
|
||||||
onChange={onCommonConfigSnippetChange}
|
|
||||||
error={commonConfigError}
|
|
||||||
onExtract={onExtract}
|
|
||||||
isExtracting={isExtracting}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ interface GeminiEnvSectionProps {
|
|||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
onBlur?: () => void;
|
onBlur?: () => void;
|
||||||
error?: string;
|
error?: string;
|
||||||
useCommonConfig: boolean;
|
|
||||||
onCommonConfigToggle: (checked: boolean) => void;
|
|
||||||
onEditCommonConfig: () => void;
|
|
||||||
commonConfigError?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,10 +17,6 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
onChange,
|
onChange,
|
||||||
onBlur,
|
onBlur,
|
||||||
error,
|
error,
|
||||||
useCommonConfig,
|
|
||||||
onCommonConfigToggle,
|
|
||||||
onEditCommonConfig,
|
|
||||||
commonConfigError,
|
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isDarkMode, setIsDarkMode] = useState(false);
|
const [isDarkMode, setIsDarkMode] = useState(false);
|
||||||
@@ -53,44 +45,12 @@ export const GeminiEnvSection: React.FC<GeminiEnvSectionProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<label
|
||||||
<label
|
htmlFor="geminiEnv"
|
||||||
htmlFor="geminiEnv"
|
className="block text-sm font-medium text-foreground"
|
||||||
className="block text-sm font-medium text-foreground"
|
>
|
||||||
>
|
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
||||||
{t("geminiConfig.envFile", { defaultValue: "环境变量 (.env)" })}
|
</label>
|
||||||
</label>
|
|
||||||
|
|
||||||
<label className="inline-flex items-center gap-2 text-sm text-muted-foreground cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={useCommonConfig}
|
|
||||||
onChange={(e) => onCommonConfigToggle(e.target.checked)}
|
|
||||||
className="w-4 h-4 text-blue-500 bg-white dark:bg-gray-800 border-border-default rounded focus:ring-blue-500 dark:focus:ring-blue-400 focus:ring-2"
|
|
||||||
/>
|
|
||||||
{t("geminiConfig.writeCommonConfig", {
|
|
||||||
defaultValue: "写入通用配置",
|
|
||||||
})}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onEditCommonConfig}
|
|
||||||
className="text-xs text-blue-500 dark:text-blue-400 hover:underline"
|
|
||||||
>
|
|
||||||
{t("geminiConfig.editCommonConfig", {
|
|
||||||
defaultValue: "编辑通用配置",
|
|
||||||
})}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{commonConfigError && (
|
|
||||||
<p className="text-xs text-red-500 dark:text-red-400 text-right">
|
|
||||||
{commonConfigError}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<JsonEditor
|
<JsonEditor
|
||||||
value={value}
|
value={value}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import type {
|
|||||||
ProviderTestConfig,
|
ProviderTestConfig,
|
||||||
ProviderProxyConfig,
|
ProviderProxyConfig,
|
||||||
ClaudeApiFormat,
|
ClaudeApiFormat,
|
||||||
|
ClaudeApiKeyField,
|
||||||
} from "@/types";
|
} from "@/types";
|
||||||
import {
|
import {
|
||||||
providerPresets,
|
providerPresets,
|
||||||
@@ -43,9 +44,9 @@ import { applyTemplateValues } from "@/utils/providerConfigUtils";
|
|||||||
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
import { mergeProviderMeta } from "@/utils/providerMetaUtils";
|
||||||
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
import { getCodexCustomTemplate } from "@/config/codexTemplates";
|
||||||
import CodexConfigEditor from "./CodexConfigEditor";
|
import CodexConfigEditor from "./CodexConfigEditor";
|
||||||
import { CommonConfigEditor } from "./CommonConfigEditor";
|
|
||||||
import GeminiConfigEditor from "./GeminiConfigEditor";
|
import GeminiConfigEditor from "./GeminiConfigEditor";
|
||||||
import JsonEditor from "@/components/JsonEditor";
|
import JsonEditor from "@/components/JsonEditor";
|
||||||
|
import { ClaudeQuickToggles, jsonMergePatch } from "./ClaudeQuickToggles";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
import { ProviderPresetSelector } from "./ProviderPresetSelector";
|
||||||
import { BasicFormFields } from "./BasicFormFields";
|
import { BasicFormFields } from "./BasicFormFields";
|
||||||
@@ -67,12 +68,9 @@ import {
|
|||||||
useCodexConfigState,
|
useCodexConfigState,
|
||||||
useApiKeyLink,
|
useApiKeyLink,
|
||||||
useTemplateValues,
|
useTemplateValues,
|
||||||
useCommonConfigSnippet,
|
|
||||||
useCodexCommonConfig,
|
|
||||||
useSpeedTestEndpoints,
|
useSpeedTestEndpoints,
|
||||||
useCodexTomlValidation,
|
useCodexTomlValidation,
|
||||||
useGeminiConfigState,
|
useGeminiConfigState,
|
||||||
useGeminiCommonConfig,
|
|
||||||
useOmoModelSource,
|
useOmoModelSource,
|
||||||
useOpencodeFormState,
|
useOpencodeFormState,
|
||||||
useOmoDraftState,
|
useOmoDraftState,
|
||||||
@@ -243,6 +241,55 @@ export function ProviderForm({
|
|||||||
mode: "onSubmit",
|
mode: "onSubmit",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
||||||
|
if (appId !== "claude") return "anthropic";
|
||||||
|
return initialData?.meta?.apiFormat ?? "anthropic";
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
||||||
|
setLocalApiFormat(format);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [localApiKeyField, setLocalApiKeyField] = useState<ClaudeApiKeyField>(
|
||||||
|
() => {
|
||||||
|
if (appId !== "claude") return "ANTHROPIC_AUTH_TOKEN";
|
||||||
|
if (initialData?.meta?.apiKeyField) return initialData.meta.apiKeyField;
|
||||||
|
try {
|
||||||
|
const config = initialData?.settingsConfig;
|
||||||
|
if (
|
||||||
|
config?.env &&
|
||||||
|
(config.env as Record<string, unknown>).ANTHROPIC_API_KEY !==
|
||||||
|
undefined
|
||||||
|
)
|
||||||
|
return "ANTHROPIC_API_KEY";
|
||||||
|
} catch {}
|
||||||
|
return "ANTHROPIC_AUTH_TOKEN";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleApiKeyFieldChange = useCallback(
|
||||||
|
(field: ClaudeApiKeyField) => {
|
||||||
|
setLocalApiKeyField(field);
|
||||||
|
try {
|
||||||
|
const config = JSON.parse(form.getValues("settingsConfig") || "{}") as {
|
||||||
|
env?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
const env = (config.env ?? {}) as Record<string, unknown>;
|
||||||
|
const oldField =
|
||||||
|
field === "ANTHROPIC_API_KEY"
|
||||||
|
? "ANTHROPIC_AUTH_TOKEN"
|
||||||
|
: "ANTHROPIC_API_KEY";
|
||||||
|
if (oldField in env) {
|
||||||
|
env[field] = env[oldField];
|
||||||
|
delete env[oldField];
|
||||||
|
config.env = env;
|
||||||
|
form.setValue("settingsConfig", JSON.stringify(config, null, 2));
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
},
|
||||||
|
[form],
|
||||||
|
);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
apiKey,
|
apiKey,
|
||||||
handleApiKeyChange,
|
handleApiKeyChange,
|
||||||
@@ -253,6 +300,7 @@ export function ProviderForm({
|
|||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
category,
|
category,
|
||||||
appType: appId,
|
appType: appId,
|
||||||
|
apiKeyField: appId === "claude" ? localApiKeyField : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
const { baseUrl, handleClaudeBaseUrlChange } = useBaseUrlState({
|
||||||
@@ -276,15 +324,6 @@ export function ProviderForm({
|
|||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [localApiFormat, setLocalApiFormat] = useState<ClaudeApiFormat>(() => {
|
|
||||||
if (appId !== "claude") return "anthropic";
|
|
||||||
return initialData?.meta?.apiFormat ?? "anthropic";
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleApiFormatChange = useCallback((format: ClaudeApiFormat) => {
|
|
||||||
setLocalApiFormat(format);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
codexAuth,
|
codexAuth,
|
||||||
codexConfig,
|
codexConfig,
|
||||||
@@ -382,37 +421,6 @@ export function ProviderForm({
|
|||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
isExtracting: isClaudeExtracting,
|
|
||||||
handleExtract: handleClaudeExtract,
|
|
||||||
} = useCommonConfigSnippet({
|
|
||||||
settingsConfig: form.getValues("settingsConfig"),
|
|
||||||
onConfigChange: (config) => form.setValue("settingsConfig", config),
|
|
||||||
initialData: appId === "claude" ? initialData : undefined,
|
|
||||||
selectedPresetId: selectedPresetId ?? undefined,
|
|
||||||
enabled: appId === "claude",
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
|
||||||
useCommonConfig: useCodexCommonConfigFlag,
|
|
||||||
commonConfigSnippet: codexCommonConfigSnippet,
|
|
||||||
commonConfigError: codexCommonConfigError,
|
|
||||||
handleCommonConfigToggle: handleCodexCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange: handleCodexCommonConfigSnippetChange,
|
|
||||||
isExtracting: isCodexExtracting,
|
|
||||||
handleExtract: handleCodexExtract,
|
|
||||||
} = useCodexCommonConfig({
|
|
||||||
codexConfig,
|
|
||||||
onConfigChange: handleCodexConfigChange,
|
|
||||||
initialData: appId === "codex" ? initialData : undefined,
|
|
||||||
selectedPresetId: selectedPresetId ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
geminiEnv,
|
geminiEnv,
|
||||||
geminiConfig,
|
geminiConfig,
|
||||||
@@ -428,7 +436,6 @@ export function ProviderForm({
|
|||||||
handleGeminiConfigChange,
|
handleGeminiConfigChange,
|
||||||
resetGeminiConfig,
|
resetGeminiConfig,
|
||||||
envStringToObj,
|
envStringToObj,
|
||||||
envObjToString,
|
|
||||||
} = useGeminiConfigState({
|
} = useGeminiConfigState({
|
||||||
initialData: appId === "gemini" ? initialData : undefined,
|
initialData: appId === "gemini" ? initialData : undefined,
|
||||||
});
|
});
|
||||||
@@ -479,23 +486,6 @@ export function ProviderForm({
|
|||||||
[originalHandleGeminiModelChange, updateGeminiEnvField],
|
[originalHandleGeminiModelChange, updateGeminiEnvField],
|
||||||
);
|
);
|
||||||
|
|
||||||
const {
|
|
||||||
useCommonConfig: useGeminiCommonConfigFlag,
|
|
||||||
commonConfigSnippet: geminiCommonConfigSnippet,
|
|
||||||
commonConfigError: geminiCommonConfigError,
|
|
||||||
handleCommonConfigToggle: handleGeminiCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange: handleGeminiCommonConfigSnippetChange,
|
|
||||||
isExtracting: isGeminiExtracting,
|
|
||||||
handleExtract: handleGeminiExtract,
|
|
||||||
} = useGeminiCommonConfig({
|
|
||||||
envValue: geminiEnv,
|
|
||||||
onEnvChange: handleGeminiEnvChange,
|
|
||||||
envStringToObj,
|
|
||||||
envObjToString,
|
|
||||||
initialData: appId === "gemini" ? initialData : undefined,
|
|
||||||
selectedPresetId: selectedPresetId ?? undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Extracted hooks: OpenCode / OMO / OpenClaw ─────────────────────
|
// ── Extracted hooks: OpenCode / OMO / OpenClaw ─────────────────────
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -535,8 +525,6 @@ export function ProviderForm({
|
|||||||
getSettingsConfig: () => form.getValues("settingsConfig"),
|
getSettingsConfig: () => form.getValues("settingsConfig"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [isCommonConfigModalOpen, setIsCommonConfigModalOpen] = useState(false);
|
|
||||||
|
|
||||||
const handleSubmit = (values: ProviderFormData) => {
|
const handleSubmit = (values: ProviderFormData) => {
|
||||||
if (appId === "claude" && templateValueEntries.length > 0) {
|
if (appId === "claude" && templateValueEntries.length > 0) {
|
||||||
const validation = validateTemplateValues();
|
const validation = validateTemplateValues();
|
||||||
@@ -829,6 +817,10 @@ export function ProviderForm({
|
|||||||
appId === "claude" && category !== "official"
|
appId === "claude" && category !== "official"
|
||||||
? localApiFormat
|
? localApiFormat
|
||||||
: undefined,
|
: undefined,
|
||||||
|
apiKeyField:
|
||||||
|
appId === "claude" && category !== "official"
|
||||||
|
? localApiKeyField
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
onSubmit(payload);
|
onSubmit(payload);
|
||||||
@@ -1066,6 +1058,12 @@ export function ProviderForm({
|
|||||||
setLocalApiFormat("anthropic");
|
setLocalApiFormat("anthropic");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (preset.apiKeyField) {
|
||||||
|
setLocalApiKeyField(preset.apiKeyField);
|
||||||
|
} else {
|
||||||
|
setLocalApiKeyField("ANTHROPIC_AUTH_TOKEN");
|
||||||
|
}
|
||||||
|
|
||||||
form.reset({
|
form.reset({
|
||||||
name: preset.name,
|
name: preset.name,
|
||||||
websiteUrl: preset.websiteUrl ?? "",
|
websiteUrl: preset.websiteUrl ?? "",
|
||||||
@@ -1270,6 +1268,8 @@ export function ProviderForm({
|
|||||||
speedTestEndpoints={speedTestEndpoints}
|
speedTestEndpoints={speedTestEndpoints}
|
||||||
apiFormat={localApiFormat}
|
apiFormat={localApiFormat}
|
||||||
onApiFormatChange={handleApiFormatChange}
|
onApiFormatChange={handleApiFormatChange}
|
||||||
|
apiKeyField={localApiKeyField}
|
||||||
|
onApiKeyFieldChange={handleApiKeyFieldChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -1396,15 +1396,8 @@ export function ProviderForm({
|
|||||||
configValue={codexConfig}
|
configValue={codexConfig}
|
||||||
onAuthChange={setCodexAuth}
|
onAuthChange={setCodexAuth}
|
||||||
onConfigChange={handleCodexConfigChange}
|
onConfigChange={handleCodexConfigChange}
|
||||||
useCommonConfig={useCodexCommonConfigFlag}
|
|
||||||
onCommonConfigToggle={handleCodexCommonConfigToggle}
|
|
||||||
commonConfigSnippet={codexCommonConfigSnippet}
|
|
||||||
onCommonConfigSnippetChange={handleCodexCommonConfigSnippetChange}
|
|
||||||
commonConfigError={codexCommonConfigError}
|
|
||||||
authError={codexAuthError}
|
authError={codexAuthError}
|
||||||
configError={codexConfigError}
|
configError={codexConfigError}
|
||||||
onExtract={handleCodexExtract}
|
|
||||||
isExtracting={isCodexExtracting}
|
|
||||||
/>
|
/>
|
||||||
{settingsConfigErrorField}
|
{settingsConfigErrorField}
|
||||||
</>
|
</>
|
||||||
@@ -1415,17 +1408,8 @@ export function ProviderForm({
|
|||||||
configValue={geminiConfig}
|
configValue={geminiConfig}
|
||||||
onEnvChange={handleGeminiEnvChange}
|
onEnvChange={handleGeminiEnvChange}
|
||||||
onConfigChange={handleGeminiConfigChange}
|
onConfigChange={handleGeminiConfigChange}
|
||||||
useCommonConfig={useGeminiCommonConfigFlag}
|
|
||||||
onCommonConfigToggle={handleGeminiCommonConfigToggle}
|
|
||||||
commonConfigSnippet={geminiCommonConfigSnippet}
|
|
||||||
onCommonConfigSnippetChange={
|
|
||||||
handleGeminiCommonConfigSnippetChange
|
|
||||||
}
|
|
||||||
commonConfigError={geminiCommonConfigError}
|
|
||||||
envError={envError}
|
envError={envError}
|
||||||
configError={geminiConfigError}
|
configError={geminiConfigError}
|
||||||
onExtract={handleGeminiExtract}
|
|
||||||
isExtracting={isGeminiExtracting}
|
|
||||||
/>
|
/>
|
||||||
{settingsConfigErrorField}
|
{settingsConfigErrorField}
|
||||||
</>
|
</>
|
||||||
@@ -1499,20 +1483,42 @@ export function ProviderForm({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<CommonConfigEditor
|
<div className="space-y-2">
|
||||||
value={form.getValues("settingsConfig")}
|
<Label htmlFor="settingsConfig">
|
||||||
onChange={(value) => form.setValue("settingsConfig", value)}
|
{t("claudeConfig.configLabel")}
|
||||||
useCommonConfig={useCommonConfig}
|
</Label>
|
||||||
onCommonConfigToggle={handleCommonConfigToggle}
|
<ClaudeQuickToggles
|
||||||
commonConfigSnippet={commonConfigSnippet}
|
onPatchApplied={(patch) => {
|
||||||
onCommonConfigSnippetChange={handleCommonConfigSnippetChange}
|
try {
|
||||||
commonConfigError={commonConfigError}
|
const cfg = JSON.parse(
|
||||||
onEditClick={() => setIsCommonConfigModalOpen(true)}
|
form.getValues("settingsConfig") || "{}",
|
||||||
isModalOpen={isCommonConfigModalOpen}
|
);
|
||||||
onModalClose={() => setIsCommonConfigModalOpen(false)}
|
jsonMergePatch(cfg, patch);
|
||||||
onExtract={handleClaudeExtract}
|
form.setValue(
|
||||||
isExtracting={isClaudeExtracting}
|
"settingsConfig",
|
||||||
/>
|
JSON.stringify(cfg, null, 2),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// invalid JSON in editor — skip mirror
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<JsonEditor
|
||||||
|
value={form.watch("settingsConfig")}
|
||||||
|
onChange={(value) => form.setValue("settingsConfig", value)}
|
||||||
|
placeholder={`{
|
||||||
|
"env": {
|
||||||
|
"ANTHROPIC_AUTH_TOKEN": "your-api-key-here"
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
rows={14}
|
||||||
|
showValidation={true}
|
||||||
|
language="json"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("claudeConfig.fullSettingsHint")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
{settingsConfigErrorField}
|
{settingsConfigErrorField}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ export { useCodexConfigState } from "./useCodexConfigState";
|
|||||||
export { useApiKeyLink } from "./useApiKeyLink";
|
export { useApiKeyLink } from "./useApiKeyLink";
|
||||||
export { useCustomEndpoints } from "./useCustomEndpoints";
|
export { useCustomEndpoints } from "./useCustomEndpoints";
|
||||||
export { useTemplateValues } from "./useTemplateValues";
|
export { useTemplateValues } from "./useTemplateValues";
|
||||||
export { useCommonConfigSnippet } from "./useCommonConfigSnippet";
|
|
||||||
export { useCodexCommonConfig } from "./useCodexCommonConfig";
|
|
||||||
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
export { useSpeedTestEndpoints } from "./useSpeedTestEndpoints";
|
||||||
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
export { useCodexTomlValidation } from "./useCodexTomlValidation";
|
||||||
export { useGeminiConfigState } from "./useGeminiConfigState";
|
export { useGeminiConfigState } from "./useGeminiConfigState";
|
||||||
export { useGeminiCommonConfig } from "./useGeminiCommonConfig";
|
|
||||||
export { useOmoModelSource } from "./useOmoModelSource";
|
export { useOmoModelSource } from "./useOmoModelSource";
|
||||||
export { useOpencodeFormState } from "./useOpencodeFormState";
|
export { useOpencodeFormState } from "./useOpencodeFormState";
|
||||||
export { useOmoDraftState } from "./useOmoDraftState";
|
export { useOmoDraftState } from "./useOmoDraftState";
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface UseApiKeyStateProps {
|
|||||||
selectedPresetId: string | null;
|
selectedPresetId: string | null;
|
||||||
category?: ProviderCategory;
|
category?: ProviderCategory;
|
||||||
appType?: string;
|
appType?: string;
|
||||||
|
apiKeyField?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -24,6 +25,7 @@ export function useApiKeyState({
|
|||||||
selectedPresetId,
|
selectedPresetId,
|
||||||
category,
|
category,
|
||||||
appType,
|
appType,
|
||||||
|
apiKeyField,
|
||||||
}: UseApiKeyStateProps) {
|
}: UseApiKeyStateProps) {
|
||||||
const [apiKey, setApiKey] = useState(() => {
|
const [apiKey, setApiKey] = useState(() => {
|
||||||
if (initialConfig) {
|
if (initialConfig) {
|
||||||
@@ -58,7 +60,7 @@ export function useApiKeyState({
|
|||||||
initialConfig || "{}",
|
initialConfig || "{}",
|
||||||
key.trim(),
|
key.trim(),
|
||||||
{
|
{
|
||||||
// 最佳实践:仅在“新增模式”且“非官方类别”时补齐缺失字段
|
// 最佳实践:仅在"新增模式"且"非官方类别"时补齐缺失字段
|
||||||
// - 新增模式:selectedPresetId !== null
|
// - 新增模式:selectedPresetId !== null
|
||||||
// - 非官方类别:category !== undefined && category !== "official"
|
// - 非官方类别:category !== undefined && category !== "official"
|
||||||
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
// - 官方类别:不创建字段(UI 也会禁用输入框)
|
||||||
@@ -68,12 +70,20 @@ export function useApiKeyState({
|
|||||||
category !== undefined &&
|
category !== undefined &&
|
||||||
category !== "official",
|
category !== "official",
|
||||||
appType,
|
appType,
|
||||||
|
apiKeyField,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
onConfigChange(configString);
|
onConfigChange(configString);
|
||||||
},
|
},
|
||||||
[initialConfig, selectedPresetId, category, appType, onConfigChange],
|
[
|
||||||
|
initialConfig,
|
||||||
|
selectedPresetId,
|
||||||
|
category,
|
||||||
|
appType,
|
||||||
|
apiKeyField,
|
||||||
|
onConfigChange,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showApiKey = useCallback(
|
const showApiKey = useCallback(
|
||||||
|
|||||||
@@ -1,308 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
updateTomlCommonConfigSnippet,
|
|
||||||
hasTomlCommonConfigSnippet,
|
|
||||||
} from "@/utils/providerConfigUtils";
|
|
||||||
import { configApi } from "@/lib/api";
|
|
||||||
|
|
||||||
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`;
|
|
||||||
|
|
||||||
interface UseCodexCommonConfigProps {
|
|
||||||
codexConfig: string;
|
|
||||||
onConfigChange: (config: string) => void;
|
|
||||||
initialData?: {
|
|
||||||
settingsConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
selectedPresetId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理 Codex 通用配置片段 (TOML 格式)
|
|
||||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
|
||||||
*/
|
|
||||||
export function useCodexCommonConfig({
|
|
||||||
codexConfig,
|
|
||||||
onConfigChange,
|
|
||||||
initialData,
|
|
||||||
selectedPresetId,
|
|
||||||
}: UseCodexCommonConfigProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [useCommonConfig, setUseCommonConfig] = useState(false);
|
|
||||||
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 isUpdatingFromCommonConfig = useRef(false);
|
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
hasInitializedNewMode.current = false;
|
|
||||||
}, [selectedPresetId]);
|
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
||||||
useEffect(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("codex");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const legacySnippet =
|
|
||||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacySnippet && legacySnippet.trim()) {
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("codex", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Codex 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载 Codex 通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData?.settingsConfig && !isLoading) {
|
|
||||||
const config =
|
|
||||||
typeof initialData.settingsConfig.config === "string"
|
|
||||||
? initialData.settingsConfig.config
|
|
||||||
: "";
|
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(config, commonConfigSnippet);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}
|
|
||||||
}, [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) => {
|
|
||||||
const { updatedConfig, error: snippetError } =
|
|
||||||
updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
checked,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snippetError) {
|
|
||||||
setCommonConfigError(snippetError);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
setUseCommonConfig(checked);
|
|
||||||
// 标记正在通过通用配置更新
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[codexConfig, commonConfigSnippet, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("codex", "").catch((error) => {
|
|
||||||
console.error("保存 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const { updatedConfig } = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TOML 格式校验较为复杂,暂时不做校验,直接清空错误
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json
|
|
||||||
configApi.setCommonConfigSnippet("codex", value).catch((error) => {
|
|
||||||
console.error("保存 Codex 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("codexConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 若当前启用通用配置,需要替换为最新片段
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const removeResult = updateTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (removeResult.error) {
|
|
||||||
setCommonConfigError(removeResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const addResult = updateTomlCommonConfigSnippet(
|
|
||||||
removeResult.updatedConfig,
|
|
||||||
value,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (addResult.error) {
|
|
||||||
setCommonConfigError(addResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记正在通过通用配置更新,避免触发状态检查
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(addResult.updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[commonConfigSnippet, codexConfig, useCommonConfig, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
||||||
useEffect(() => {
|
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasCommon = hasTomlCommonConfigSnippet(
|
|
||||||
codexConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}, [codexConfig, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setIsExtracting(true);
|
|
||||||
setCommonConfigError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("codex", {
|
|
||||||
settingsConfig: JSON.stringify({
|
|
||||||
config: codexConfig ?? "",
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [codexConfig, t]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
isLoading,
|
|
||||||
isExtracting,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
handleExtract,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,331 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import {
|
|
||||||
updateCommonConfigSnippet,
|
|
||||||
hasCommonConfigSnippet,
|
|
||||||
validateJsonConfig,
|
|
||||||
} from "@/utils/providerConfigUtils";
|
|
||||||
import { configApi } from "@/lib/api";
|
|
||||||
|
|
||||||
const LEGACY_STORAGE_KEY = "cc-switch:common-config-snippet";
|
|
||||||
const DEFAULT_COMMON_CONFIG_SNIPPET = `{
|
|
||||||
"includeCoAuthoredBy": false
|
|
||||||
}`;
|
|
||||||
|
|
||||||
interface UseCommonConfigSnippetProps {
|
|
||||||
settingsConfig: string;
|
|
||||||
onConfigChange: (config: string) => void;
|
|
||||||
initialData?: {
|
|
||||||
settingsConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
selectedPresetId?: string;
|
|
||||||
/** When false, the hook skips all logic and returns disabled state. Default: true */
|
|
||||||
enabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理 Claude 通用配置片段
|
|
||||||
* 从 config.json 读取和保存,支持从 localStorage 平滑迁移
|
|
||||||
*/
|
|
||||||
export function useCommonConfigSnippet({
|
|
||||||
settingsConfig,
|
|
||||||
onConfigChange,
|
|
||||||
initialData,
|
|
||||||
selectedPresetId,
|
|
||||||
enabled = true,
|
|
||||||
}: UseCommonConfigSnippetProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
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 isUpdatingFromCommonConfig = useRef(false);
|
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
hasInitializedNewMode.current = false;
|
|
||||||
}, [selectedPresetId, enabled]);
|
|
||||||
|
|
||||||
// 初始化:从 config.json 加载,支持从 localStorage 迁移
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) {
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("claude");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 localStorage 迁移
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
try {
|
|
||||||
const legacySnippet =
|
|
||||||
window.localStorage.getItem(LEGACY_STORAGE_KEY);
|
|
||||||
if (legacySnippet && legacySnippet.trim()) {
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("claude", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Claude 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [enabled]);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (initialData && !isLoading) {
|
|
||||||
const configString = JSON.stringify(initialData.settingsConfig, null, 2);
|
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
|
||||||
configString,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}
|
|
||||||
}, [enabled, initialData, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 新建模式:如果通用配置片段存在且有效,默认启用
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
// 仅新建模式、加载完成、尚未初始化过
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
enabled,
|
|
||||||
initialData,
|
|
||||||
commonConfigSnippet,
|
|
||||||
isLoading,
|
|
||||||
settingsConfig,
|
|
||||||
onConfigChange,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 处理通用配置开关
|
|
||||||
const handleCommonConfigToggle = useCallback(
|
|
||||||
(checked: boolean) => {
|
|
||||||
const { updatedConfig, error: snippetError } = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
checked,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (snippetError) {
|
|
||||||
setCommonConfigError(snippetError);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCommonConfigError("");
|
|
||||||
setUseCommonConfig(checked);
|
|
||||||
// 标记正在通过通用配置更新
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
},
|
|
||||||
[settingsConfig, commonConfigSnippet, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("claude", "").catch((error) => {
|
|
||||||
console.error("保存通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
const { updatedConfig } = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
onConfigChange(updatedConfig);
|
|
||||||
setUseCommonConfig(false);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证JSON格式
|
|
||||||
const validationError = validateJsonConfig(value, "通用配置片段");
|
|
||||||
if (validationError) {
|
|
||||||
setCommonConfigError(validationError);
|
|
||||||
} else {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json
|
|
||||||
configApi.setCommonConfigSnippet("claude", value).catch((error) => {
|
|
||||||
console.error("保存通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("claudeConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 若当前启用通用配置且格式正确,需要替换为最新片段
|
|
||||||
if (useCommonConfig && !validationError) {
|
|
||||||
const removeResult = updateCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
previousSnippet,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (removeResult.error) {
|
|
||||||
setCommonConfigError(removeResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const addResult = updateCommonConfigSnippet(
|
|
||||||
removeResult.updatedConfig,
|
|
||||||
value,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (addResult.error) {
|
|
||||||
setCommonConfigError(addResult.error);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 标记正在通过通用配置更新,避免触发状态检查
|
|
||||||
isUpdatingFromCommonConfig.current = true;
|
|
||||||
onConfigChange(addResult.updatedConfig);
|
|
||||||
// 在下一个事件循环中重置标记
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[commonConfigSnippet, settingsConfig, useCommonConfig, onConfigChange],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 当配置变化时检查是否包含通用配置(但避免在通过通用配置更新时检查)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!enabled) return;
|
|
||||||
if (isUpdatingFromCommonConfig.current || isLoading) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hasCommon = hasCommonConfigSnippet(
|
|
||||||
settingsConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
}, [enabled, settingsConfig, commonConfigSnippet, isLoading]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setIsExtracting(true);
|
|
||||||
setCommonConfigError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("claude", {
|
|
||||||
settingsConfig,
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [settingsConfig, t]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
isLoading,
|
|
||||||
isExtracting,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
handleExtract,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,465 +0,0 @@
|
|||||||
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 = "{}";
|
|
||||||
|
|
||||||
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 {
|
|
||||||
envValue: string;
|
|
||||||
onEnvChange: (env: string) => void;
|
|
||||||
envStringToObj: (envString: string) => Record<string, string>;
|
|
||||||
envObjToString: (envObj: Record<string, unknown>) => string;
|
|
||||||
initialData?: {
|
|
||||||
settingsConfig?: Record<string, unknown>;
|
|
||||||
};
|
|
||||||
selectedPresetId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
||||||
return (
|
|
||||||
typeof value === "object" &&
|
|
||||||
value !== null &&
|
|
||||||
!Array.isArray(value) &&
|
|
||||||
Object.prototype.toString.call(value) === "[object Object]"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 管理 Gemini 通用配置片段 (JSON 格式)
|
|
||||||
* 写入 Gemini 的 .env,但会排除以下敏感字段:
|
|
||||||
* - GOOGLE_GEMINI_BASE_URL
|
|
||||||
* - GEMINI_API_KEY
|
|
||||||
*/
|
|
||||||
export function useGeminiCommonConfig({
|
|
||||||
envValue,
|
|
||||||
onEnvChange,
|
|
||||||
envStringToObj,
|
|
||||||
envObjToString,
|
|
||||||
initialData,
|
|
||||||
selectedPresetId,
|
|
||||||
}: UseGeminiCommonConfigProps) {
|
|
||||||
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 isUpdatingFromCommonConfig = useRef(false);
|
|
||||||
// 用于跟踪新建模式是否已初始化默认勾选
|
|
||||||
const hasInitializedNewMode = useRef(false);
|
|
||||||
|
|
||||||
// 当预设变化时,重置初始化标记,使新预设能够重新触发初始化逻辑
|
|
||||||
useEffect(() => {
|
|
||||||
hasInitializedNewMode.current = false;
|
|
||||||
}, [selectedPresetId]);
|
|
||||||
|
|
||||||
const parseSnippetEnv = useCallback(
|
|
||||||
(
|
|
||||||
snippetString: string,
|
|
||||||
): { env: Record<string, string>; 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<string, string> = {};
|
|
||||||
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<string, string>, snippetEnv: Record<string, string>) => {
|
|
||||||
const entries = Object.entries(snippetEnv);
|
|
||||||
if (entries.length === 0) return false;
|
|
||||||
return entries.every(([key, value]) => envObj[key] === value);
|
|
||||||
},
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
const applySnippetToEnv = useCallback(
|
|
||||||
(envObj: Record<string, string>, snippetEnv: Record<string, string>) => {
|
|
||||||
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<string, string>, snippetEnv: Record<string, string>) => {
|
|
||||||
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(() => {
|
|
||||||
let mounted = true;
|
|
||||||
|
|
||||||
const loadSnippet = async () => {
|
|
||||||
try {
|
|
||||||
// 使用统一 API 加载
|
|
||||||
const snippet = await configApi.getCommonConfigSnippet("gemini");
|
|
||||||
|
|
||||||
if (snippet && snippet.trim()) {
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(snippet);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// 如果 config.json 中没有,尝试从 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) {
|
|
||||||
console.warn(
|
|
||||||
"[迁移] legacy Gemini 通用配置片段格式不符合当前规则,跳过迁移",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// 迁移到 config.json
|
|
||||||
await configApi.setCommonConfigSnippet("gemini", legacySnippet);
|
|
||||||
if (mounted) {
|
|
||||||
setCommonConfigSnippetState(legacySnippet);
|
|
||||||
}
|
|
||||||
// 清理 localStorage
|
|
||||||
window.localStorage.removeItem(LEGACY_STORAGE_KEY);
|
|
||||||
console.log(
|
|
||||||
"[迁移] Gemini 通用配置已从 localStorage 迁移到 config.json",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("[迁移] 从 localStorage 迁移失败:", e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载 Gemini 通用配置失败:", error);
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setIsLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
loadSnippet();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
mounted = false;
|
|
||||||
};
|
|
||||||
}, [parseSnippetEnv]);
|
|
||||||
|
|
||||||
// 初始化时检查通用配置片段(编辑模式)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialData?.settingsConfig && !isLoading) {
|
|
||||||
try {
|
|
||||||
const env =
|
|
||||||
isPlainObject(initialData.settingsConfig.env) &&
|
|
||||||
Object.keys(initialData.settingsConfig.env).length > 0
|
|
||||||
? (initialData.settingsConfig.env as Record<string, string>)
|
|
||||||
: {};
|
|
||||||
const parsed = parseSnippetEnv(commonConfigSnippet);
|
|
||||||
if (parsed.error) return;
|
|
||||||
const hasCommon = hasEnvCommonConfigSnippet(
|
|
||||||
env,
|
|
||||||
parsed.env as Record<string, string>,
|
|
||||||
);
|
|
||||||
setUseCommonConfig(hasCommon);
|
|
||||||
} catch {
|
|
||||||
// ignore parse error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [
|
|
||||||
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) => {
|
|
||||||
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);
|
|
||||||
},
|
|
||||||
[
|
|
||||||
applySnippetToEnv,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envObjToString,
|
|
||||||
envStringToObj,
|
|
||||||
envValue,
|
|
||||||
onEnvChange,
|
|
||||||
parseSnippetEnv,
|
|
||||||
removeSnippetFromEnv,
|
|
||||||
t,
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 处理通用配置片段变化
|
|
||||||
const handleCommonConfigSnippetChange = useCallback(
|
|
||||||
(value: string) => {
|
|
||||||
const previousSnippet = commonConfigSnippet;
|
|
||||||
setCommonConfigSnippetState(value);
|
|
||||||
|
|
||||||
if (!value.trim()) {
|
|
||||||
setCommonConfigError("");
|
|
||||||
// 保存到 config.json(清空)
|
|
||||||
configApi.setCommonConfigSnippet("gemini", "").catch((error) => {
|
|
||||||
console.error("保存 Gemini 通用配置失败:", error);
|
|
||||||
setCommonConfigError(
|
|
||||||
t("geminiConfig.saveFailed", { error: String(error) }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (useCommonConfig) {
|
|
||||||
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 格式
|
|
||||||
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) {
|
|
||||||
const prevParsed = parseSnippetEnv(previousSnippet);
|
|
||||||
const prevEnv = prevParsed.error ? {} : prevParsed.env;
|
|
||||||
const nextEnv = parsed.env;
|
|
||||||
const currentEnv = envStringToObj(envValue);
|
|
||||||
|
|
||||||
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;
|
|
||||||
onEnvChange(envObjToString(withNew));
|
|
||||||
setTimeout(() => {
|
|
||||||
isUpdatingFromCommonConfig.current = false;
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[
|
|
||||||
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<string, string>),
|
|
||||||
);
|
|
||||||
}, [
|
|
||||||
envValue,
|
|
||||||
commonConfigSnippet,
|
|
||||||
envStringToObj,
|
|
||||||
hasEnvCommonConfigSnippet,
|
|
||||||
isLoading,
|
|
||||||
parseSnippetEnv,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 从编辑器当前内容提取通用配置片段
|
|
||||||
const handleExtract = useCallback(async () => {
|
|
||||||
setIsExtracting(true);
|
|
||||||
setCommonConfigError("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const extracted = await configApi.extractCommonConfigSnippet("gemini", {
|
|
||||||
settingsConfig: JSON.stringify({
|
|
||||||
env: envStringToObj(envValue),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}, [envStringToObj, envValue, parseSnippetEnv, t]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
useCommonConfig,
|
|
||||||
commonConfigSnippet,
|
|
||||||
commonConfigError,
|
|
||||||
isLoading,
|
|
||||||
isExtracting,
|
|
||||||
handleCommonConfigToggle,
|
|
||||||
handleCommonConfigSnippetChange,
|
|
||||||
handleExtract,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -316,12 +316,10 @@ export const providerPresets: ProviderPreset[] = [
|
|||||||
name: "AiHubMix",
|
name: "AiHubMix",
|
||||||
websiteUrl: "https://aihubmix.com",
|
websiteUrl: "https://aihubmix.com",
|
||||||
apiKeyUrl: "https://aihubmix.com",
|
apiKeyUrl: "https://aihubmix.com",
|
||||||
// 说明:该供应商使用 ANTHROPIC_API_KEY(而非 ANTHROPIC_AUTH_TOKEN)
|
|
||||||
apiKeyField: "ANTHROPIC_API_KEY",
|
|
||||||
settingsConfig: {
|
settingsConfig: {
|
||||||
env: {
|
env: {
|
||||||
ANTHROPIC_BASE_URL: "https://aihubmix.com",
|
ANTHROPIC_BASE_URL: "https://aihubmix.com",
|
||||||
ANTHROPIC_API_KEY: "",
|
ANTHROPIC_AUTH_TOKEN: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
// 请求地址候选(用于地址管理/测速),用户可自行选择/覆盖
|
||||||
|
|||||||
@@ -51,15 +51,7 @@
|
|||||||
},
|
},
|
||||||
"claudeConfig": {
|
"claudeConfig": {
|
||||||
"configLabel": "Claude Code settings.json (JSON) *",
|
"configLabel": "Claude Code settings.json (JSON) *",
|
||||||
"writeCommonConfig": "Write Common Config",
|
|
||||||
"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 Editor",
|
|
||||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
|
||||||
"extractFailed": "Extract failed: {{error}}",
|
|
||||||
"saveFailed": "Save failed: {{error}}",
|
|
||||||
"hideAttribution": "Hide AI Attribution",
|
"hideAttribution": "Hide AI Attribution",
|
||||||
"alwaysThinking": "Extended Thinking",
|
"alwaysThinking": "Extended Thinking",
|
||||||
"enableTeammates": "Teammates Mode"
|
"enableTeammates": "Teammates Mode"
|
||||||
@@ -125,11 +117,7 @@
|
|||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
"notesPlaceholder": "e.g., Company dedicated account",
|
"notesPlaceholder": "e.g., Company dedicated account",
|
||||||
"configJson": "Config JSON",
|
"configJson": "Config JSON",
|
||||||
"writeCommonConfig": "Write common config",
|
|
||||||
"editCommonConfigButton": "Edit common config",
|
|
||||||
"configJsonHint": "Please fill in complete Claude Code configuration",
|
"configJsonHint": "Please fill in complete Claude Code configuration",
|
||||||
"editCommonConfigTitle": "Edit common config snippet",
|
|
||||||
"editCommonConfigHint": "Common config snippet will be merged into all providers that enable it",
|
|
||||||
"addProvider": "Add Provider",
|
"addProvider": "Add Provider",
|
||||||
"sortUpdated": "Sort order updated",
|
"sortUpdated": "Sort order updated",
|
||||||
"usageSaved": "Usage query configuration saved",
|
"usageSaved": "Usage query configuration saved",
|
||||||
@@ -667,6 +655,10 @@
|
|||||||
"apiFormatHint": "Select the input format for the provider's API",
|
"apiFormatHint": "Select the input format for the provider's API",
|
||||||
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
"apiFormatAnthropic": "Anthropic Messages (Native)",
|
||||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
|
"apiFormatOpenAIChat": "OpenAI Chat Completions (Requires proxy)",
|
||||||
|
"authField": "Auth Field",
|
||||||
|
"authFieldAuthToken": "Auth Token (Default)",
|
||||||
|
"authFieldApiKey": "API Key",
|
||||||
|
"authFieldHint": "Most third-party providers use Auth Token; a few require API Key",
|
||||||
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
"anthropicDefaultHaikuModel": "Default Haiku Model",
|
||||||
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
"anthropicDefaultSonnetModel": "Default Sonnet Model",
|
||||||
"anthropicDefaultOpusModel": "Default Opus Model",
|
"anthropicDefaultOpusModel": "Default Opus Model",
|
||||||
@@ -738,33 +730,15 @@
|
|||||||
"authJsonHint": "Codex auth.json configuration content",
|
"authJsonHint": "Codex auth.json configuration content",
|
||||||
"configToml": "config.toml (TOML)",
|
"configToml": "config.toml (TOML)",
|
||||||
"configTomlHint": "Codex config.toml configuration content",
|
"configTomlHint": "Codex config.toml configuration content",
|
||||||
"writeCommonConfig": "Write Common Config",
|
"apiUrlLabel": "API Request URL"
|
||||||
"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",
|
|
||||||
"extractFromCurrent": "Extract from Editor",
|
|
||||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
|
||||||
"extractFailed": "Extract failed: {{error}}",
|
|
||||||
"saveFailed": "Save failed: {{error}}"
|
|
||||||
},
|
},
|
||||||
"geminiConfig": {
|
"geminiConfig": {
|
||||||
"envFile": "Environment Variables (.env)",
|
"envFile": "Environment Variables (.env)",
|
||||||
"envFileHint": "Configure Gemini environment variables in .env format",
|
"envFileHint": "Configure Gemini environment variables in .env format",
|
||||||
"configJson": "Configuration File (config.json)",
|
"configJson": "Configuration File (config.json)",
|
||||||
"configJsonHint": "Configure Gemini extended parameters in JSON format (optional)",
|
"configJsonHint": "Configure Gemini extended parameters in JSON format (optional)",
|
||||||
"writeCommonConfig": "Write Common Config",
|
|
||||||
"editCommonConfig": "Edit Common Config",
|
|
||||||
"editCommonConfigTitle": "Edit Gemini Common Config Snippet",
|
|
||||||
"commonConfigHint": "This snippet writes to Gemini .env (GOOGLE_GEMINI_BASE_URL and GEMINI_API_KEY are not allowed)",
|
|
||||||
"extractFromCurrent": "Extract from Editor",
|
|
||||||
"extractNoCommonConfig": "No common config available to extract from editor",
|
|
||||||
"extractFailed": "Extract failed: {{error}}",
|
|
||||||
"saveFailed": "Save failed: {{error}}",
|
|
||||||
"extractedConfigInvalid": "Extracted config format is invalid",
|
"extractedConfigInvalid": "Extracted config format is invalid",
|
||||||
"invalidJsonFormat": "Common config snippet format error (must be valid JSON)",
|
"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",
|
"noCommonConfigToApply": "Common config snippet is empty or has no applicable entries",
|
||||||
"configMergeFailed": "Config merge failed: {{error}}",
|
"configMergeFailed": "Config merge failed: {{error}}",
|
||||||
"configReplaceFailed": "Config replace failed: {{error}}"
|
"configReplaceFailed": "Config replace failed: {{error}}"
|
||||||
|
|||||||
@@ -51,15 +51,7 @@
|
|||||||
},
|
},
|
||||||
"claudeConfig": {
|
"claudeConfig": {
|
||||||
"configLabel": "Claude Code settings.json (JSON) *",
|
"configLabel": "Claude Code settings.json (JSON) *",
|
||||||
"writeCommonConfig": "共通設定を書き込む",
|
|
||||||
"editCommonConfig": "共通設定を編集",
|
|
||||||
"editCommonConfigTitle": "共通設定スニペットを編集",
|
|
||||||
"commonConfigHint": "「共通設定を書き込む」がオンのとき settings.json にマージされます",
|
|
||||||
"fullSettingsHint": "Claude Code の settings.json 全文",
|
"fullSettingsHint": "Claude Code の settings.json 全文",
|
||||||
"extractFromCurrent": "編集内容から抽出",
|
|
||||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
|
||||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
|
||||||
"saveFailed": "保存に失敗しました: {{error}}",
|
|
||||||
"hideAttribution": "AI署名を非表示",
|
"hideAttribution": "AI署名を非表示",
|
||||||
"alwaysThinking": "拡張思考",
|
"alwaysThinking": "拡張思考",
|
||||||
"enableTeammates": "Teammates モード"
|
"enableTeammates": "Teammates モード"
|
||||||
@@ -125,11 +117,7 @@
|
|||||||
"notes": "メモ",
|
"notes": "メモ",
|
||||||
"notesPlaceholder": "例: 会社用アカウント",
|
"notesPlaceholder": "例: 会社用アカウント",
|
||||||
"configJson": "Config JSON",
|
"configJson": "Config JSON",
|
||||||
"writeCommonConfig": "共通設定を書き込む",
|
|
||||||
"editCommonConfigButton": "共通設定を編集",
|
|
||||||
"configJsonHint": "Claude Code の設定をすべて入力してください",
|
"configJsonHint": "Claude Code の設定をすべて入力してください",
|
||||||
"editCommonConfigTitle": "共通設定スニペットを編集",
|
|
||||||
"editCommonConfigHint": "共通設定スニペットは、この機能をオンにしたすべてのプロバイダーへマージされます",
|
|
||||||
"addProvider": "プロバイダーを追加",
|
"addProvider": "プロバイダーを追加",
|
||||||
"sortUpdated": "並び順を更新しました",
|
"sortUpdated": "並び順を更新しました",
|
||||||
"usageSaved": "利用状況の設定を保存しました",
|
"usageSaved": "利用状況の設定を保存しました",
|
||||||
@@ -667,6 +655,10 @@
|
|||||||
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
"apiFormatHint": "プロバイダー API の入力フォーマットを選択",
|
||||||
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
"apiFormatAnthropic": "Anthropic Messages(ネイティブ)",
|
||||||
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
|
"apiFormatOpenAIChat": "OpenAI Chat Completions(プロキシが必要)",
|
||||||
|
"authField": "認証フィールド",
|
||||||
|
"authFieldAuthToken": "Auth Token(デフォルト)",
|
||||||
|
"authFieldApiKey": "API Key",
|
||||||
|
"authFieldHint": "ほとんどのサードパーティプロバイダーは Auth Token を使用します。一部は API Key が必要です",
|
||||||
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
"anthropicDefaultHaikuModel": "既定 Haiku モデル",
|
||||||
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
|
"anthropicDefaultSonnetModel": "既定 Sonnet モデル",
|
||||||
"anthropicDefaultOpusModel": "既定 Opus モデル",
|
"anthropicDefaultOpusModel": "既定 Opus モデル",
|
||||||
@@ -738,33 +730,15 @@
|
|||||||
"authJsonHint": "Codex の auth.json 設定内容",
|
"authJsonHint": "Codex の auth.json 設定内容",
|
||||||
"configToml": "config.toml (TOML)",
|
"configToml": "config.toml (TOML)",
|
||||||
"configTomlHint": "Codex の config.toml 設定内容",
|
"configTomlHint": "Codex の config.toml 設定内容",
|
||||||
"writeCommonConfig": "共通設定を書き込む",
|
"apiUrlLabel": "API リクエスト URL"
|
||||||
"editCommonConfig": "共通設定を編集",
|
|
||||||
"editCommonConfigTitle": "Codex 共通設定スニペットを編集",
|
|
||||||
"commonConfigHint": "「共通設定を書き込む」がオンの場合、config.toml の末尾に追記されます",
|
|
||||||
"apiUrlLabel": "API リクエスト URL",
|
|
||||||
"extractFromCurrent": "編集内容から抽出",
|
|
||||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
|
||||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
|
||||||
"saveFailed": "保存に失敗しました: {{error}}"
|
|
||||||
},
|
},
|
||||||
"geminiConfig": {
|
"geminiConfig": {
|
||||||
"envFile": "環境変数 (.env)",
|
"envFile": "環境変数 (.env)",
|
||||||
"envFileHint": ".env 形式で Gemini の環境変数を設定",
|
"envFileHint": ".env 形式で Gemini の環境変数を設定",
|
||||||
"configJson": "設定ファイル (config.json)",
|
"configJson": "設定ファイル (config.json)",
|
||||||
"configJsonHint": "Gemini 拡張パラメーターを JSON 形式で設定(任意)",
|
"configJsonHint": "Gemini 拡張パラメーターを JSON 形式で設定(任意)",
|
||||||
"writeCommonConfig": "共通設定を書き込む",
|
|
||||||
"editCommonConfig": "共通設定を編集",
|
|
||||||
"editCommonConfigTitle": "Gemini 共通設定スニペットを編集",
|
|
||||||
"commonConfigHint": "このスニペットは Gemini の .env に書き込みます(GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY は使用できません)",
|
|
||||||
"extractFromCurrent": "編集内容から抽出",
|
|
||||||
"extractNoCommonConfig": "編集内容から抽出できる共通設定がありません",
|
|
||||||
"extractFailed": "抽出に失敗しました: {{error}}",
|
|
||||||
"saveFailed": "保存に失敗しました: {{error}}",
|
|
||||||
"extractedConfigInvalid": "抽出した設定のフォーマットが不正です",
|
"extractedConfigInvalid": "抽出した設定のフォーマットが不正です",
|
||||||
"invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)",
|
"invalidJsonFormat": "共通設定スニペットの形式が不正です(有効な JSON でなければなりません)",
|
||||||
"commonConfigInvalidKeys": "共通設定スニペットに GOOGLE_GEMINI_BASE_URL または GEMINI_API_KEY を含めることはできません(検出: {{keys}})",
|
|
||||||
"commonConfigInvalidValues": "共通設定スニペットの値は文字列である必要があります",
|
|
||||||
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
"noCommonConfigToApply": "共通設定スニペットが空、または適用できる項目がありません",
|
||||||
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
|
"configMergeFailed": "設定のマージに失敗しました: {{error}}",
|
||||||
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
|
"configReplaceFailed": "設定の置換に失敗しました: {{error}}"
|
||||||
|
|||||||
@@ -51,15 +51,7 @@
|
|||||||
},
|
},
|
||||||
"claudeConfig": {
|
"claudeConfig": {
|
||||||
"configLabel": "Claude Code 配置 (JSON) *",
|
"configLabel": "Claude Code 配置 (JSON) *",
|
||||||
"writeCommonConfig": "写入通用配置",
|
|
||||||
"editCommonConfig": "编辑通用配置",
|
|
||||||
"editCommonConfigTitle": "编辑通用配置片段",
|
|
||||||
"commonConfigHint": "该片段会在勾选\"写入通用配置\"时合并到 settings.json 中",
|
|
||||||
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
|
"fullSettingsHint": "完整的 Claude Code settings.json 配置内容",
|
||||||
"extractFromCurrent": "从编辑内容提取",
|
|
||||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
|
||||||
"extractFailed": "提取失败: {{error}}",
|
|
||||||
"saveFailed": "保存失败: {{error}}",
|
|
||||||
"hideAttribution": "隐藏 AI 署名",
|
"hideAttribution": "隐藏 AI 署名",
|
||||||
"alwaysThinking": "扩展思考",
|
"alwaysThinking": "扩展思考",
|
||||||
"enableTeammates": "Teammates 模式"
|
"enableTeammates": "Teammates 模式"
|
||||||
@@ -125,11 +117,7 @@
|
|||||||
"notes": "备注",
|
"notes": "备注",
|
||||||
"notesPlaceholder": "例如:公司专用账号",
|
"notesPlaceholder": "例如:公司专用账号",
|
||||||
"configJson": "配置 JSON",
|
"configJson": "配置 JSON",
|
||||||
"writeCommonConfig": "写入通用配置",
|
|
||||||
"editCommonConfigButton": "编辑通用配置",
|
|
||||||
"configJsonHint": "请填写完整的 Claude Code 配置",
|
"configJsonHint": "请填写完整的 Claude Code 配置",
|
||||||
"editCommonConfigTitle": "编辑通用配置片段",
|
|
||||||
"editCommonConfigHint": "通用配置片段将合并到所有启用它的供应商配置中",
|
|
||||||
"addProvider": "添加供应商",
|
"addProvider": "添加供应商",
|
||||||
"sortUpdated": "排序已更新",
|
"sortUpdated": "排序已更新",
|
||||||
"usageSaved": "用量查询配置已保存",
|
"usageSaved": "用量查询配置已保存",
|
||||||
@@ -667,6 +655,10 @@
|
|||||||
"apiFormatHint": "选择供应商 API 的输入格式",
|
"apiFormatHint": "选择供应商 API 的输入格式",
|
||||||
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
"apiFormatAnthropic": "Anthropic Messages (原生)",
|
||||||
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
|
"apiFormatOpenAIChat": "OpenAI Chat Completions (需开启代理)",
|
||||||
|
"authField": "认证字段",
|
||||||
|
"authFieldAuthToken": "Auth Token (默认)",
|
||||||
|
"authFieldApiKey": "API Key",
|
||||||
|
"authFieldHint": "大多数第三方供应商使用 Auth Token;少数供应商需要 API Key",
|
||||||
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
"anthropicDefaultHaikuModel": "Haiku 默认模型",
|
||||||
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
"anthropicDefaultSonnetModel": "Sonnet 默认模型",
|
||||||
"anthropicDefaultOpusModel": "Opus 默认模型",
|
"anthropicDefaultOpusModel": "Opus 默认模型",
|
||||||
@@ -738,33 +730,15 @@
|
|||||||
"authJsonHint": "Codex auth.json 配置内容",
|
"authJsonHint": "Codex auth.json 配置内容",
|
||||||
"configToml": "config.toml (TOML)",
|
"configToml": "config.toml (TOML)",
|
||||||
"configTomlHint": "Codex config.toml 配置内容",
|
"configTomlHint": "Codex config.toml 配置内容",
|
||||||
"writeCommonConfig": "写入通用配置",
|
"apiUrlLabel": "API 请求地址"
|
||||||
"editCommonConfig": "编辑通用配置",
|
|
||||||
"editCommonConfigTitle": "编辑 Codex 通用配置片段",
|
|
||||||
"commonConfigHint": "该片段会在勾选'写入通用配置'时追加到 config.toml 末尾",
|
|
||||||
"apiUrlLabel": "API 请求地址",
|
|
||||||
"extractFromCurrent": "从编辑内容提取",
|
|
||||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
|
||||||
"extractFailed": "提取失败: {{error}}",
|
|
||||||
"saveFailed": "保存失败: {{error}}"
|
|
||||||
},
|
},
|
||||||
"geminiConfig": {
|
"geminiConfig": {
|
||||||
"envFile": "环境变量 (.env)",
|
"envFile": "环境变量 (.env)",
|
||||||
"envFileHint": "使用 .env 格式配置 Gemini 环境变量",
|
"envFileHint": "使用 .env 格式配置 Gemini 环境变量",
|
||||||
"configJson": "配置文件 (config.json)",
|
"configJson": "配置文件 (config.json)",
|
||||||
"configJsonHint": "使用 JSON 格式配置 Gemini 扩展参数(可选)",
|
"configJsonHint": "使用 JSON 格式配置 Gemini 扩展参数(可选)",
|
||||||
"writeCommonConfig": "写入通用配置",
|
|
||||||
"editCommonConfig": "编辑通用配置",
|
|
||||||
"editCommonConfigTitle": "编辑 Gemini 通用配置片段",
|
|
||||||
"commonConfigHint": "该片段会写入 Gemini 的 .env(不允许包含 GOOGLE_GEMINI_BASE_URL、GEMINI_API_KEY)",
|
|
||||||
"extractFromCurrent": "从编辑内容提取",
|
|
||||||
"extractNoCommonConfig": "当前编辑内容没有可提取的通用配置",
|
|
||||||
"extractFailed": "提取失败: {{error}}",
|
|
||||||
"saveFailed": "保存失败: {{error}}",
|
|
||||||
"extractedConfigInvalid": "提取的配置格式错误",
|
"extractedConfigInvalid": "提取的配置格式错误",
|
||||||
"invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)",
|
"invalidJsonFormat": "通用配置片段格式错误(必须是有效的 JSON)",
|
||||||
"commonConfigInvalidKeys": "通用配置片段不能包含 GOOGLE_GEMINI_BASE_URL 或 GEMINI_API_KEY(发现:{{keys}})",
|
|
||||||
"commonConfigInvalidValues": "通用配置片段的值必须是字符串",
|
|
||||||
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
"noCommonConfigToApply": "通用配置片段为空或没有可写入的内容",
|
||||||
"configMergeFailed": "配置合并失败: {{error}}",
|
"configMergeFailed": "配置合并失败: {{error}}",
|
||||||
"configReplaceFailed": "配置替换失败: {{error}}"
|
"configReplaceFailed": "配置替换失败: {{error}}"
|
||||||
|
|||||||
@@ -3,27 +3,6 @@ import { invoke } from "@tauri-apps/api/core";
|
|||||||
|
|
||||||
export type AppType = "claude" | "codex" | "gemini" | "omo" | "omo_slim";
|
export type AppType = "claude" | "codex" | "gemini" | "omo" | "omo_slim";
|
||||||
|
|
||||||
/**
|
|
||||||
* 获取 Claude 通用配置片段(已废弃,使用 getCommonConfigSnippet)
|
|
||||||
* @returns 通用配置片段(JSON 字符串),如果不存在则返回 null
|
|
||||||
* @deprecated 使用 getCommonConfigSnippet('claude') 替代
|
|
||||||
*/
|
|
||||||
export async function getClaudeCommonConfigSnippet(): Promise<string | null> {
|
|
||||||
return invoke<string | null>("get_claude_common_config_snippet");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 设置 Claude 通用配置片段(已废弃,使用 setCommonConfigSnippet)
|
|
||||||
* @param snippet - 通用配置片段(JSON 字符串)
|
|
||||||
* @throws 如果 JSON 格式无效
|
|
||||||
* @deprecated 使用 setCommonConfigSnippet('claude', snippet) 替代
|
|
||||||
*/
|
|
||||||
export async function setClaudeCommonConfigSnippet(
|
|
||||||
snippet: string,
|
|
||||||
): Promise<void> {
|
|
||||||
return invoke("set_claude_common_config_snippet", { snippet });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取通用配置片段(统一接口)
|
* 获取通用配置片段(统一接口)
|
||||||
* @param appType - 应用类型(claude/codex/gemini)
|
* @param appType - 应用类型(claude/codex/gemini)
|
||||||
@@ -47,31 +26,3 @@ export async function setCommonConfigSnippet(
|
|||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
return invoke("set_common_config_snippet", { appType, snippet });
|
return invoke("set_common_config_snippet", { appType, snippet });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 提取通用配置片段
|
|
||||||
*
|
|
||||||
* 默认读取当前激活供应商的配置;若传入 `options.settingsConfig`,则从编辑器当前内容提取。
|
|
||||||
* 会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。
|
|
||||||
*
|
|
||||||
* @param appType - 应用类型(claude/codex/gemini)
|
|
||||||
* @param options - 可选:提取来源
|
|
||||||
* @returns 提取的通用配置片段(JSON/TOML 字符串)
|
|
||||||
*/
|
|
||||||
export type ExtractCommonConfigSnippetOptions = {
|
|
||||||
settingsConfig?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function extractCommonConfigSnippet(
|
|
||||||
appType: Exclude<AppType, "omo">,
|
|
||||||
options?: ExtractCommonConfigSnippetOptions,
|
|
||||||
): Promise<string> {
|
|
||||||
const args: Record<string, unknown> = { appType };
|
|
||||||
const settingsConfig = options?.settingsConfig;
|
|
||||||
|
|
||||||
if (typeof settingsConfig === "string" && settingsConfig.trim()) {
|
|
||||||
args.settingsConfig = settingsConfig;
|
|
||||||
}
|
|
||||||
|
|
||||||
return invoke<string>("extract_common_config_snippet", args);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -145,6 +145,10 @@ export interface ProviderMeta {
|
|||||||
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
// - "anthropic": 原生 Anthropic Messages API 格式,直接透传
|
||||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||||
apiFormat?: "anthropic" | "openai_chat";
|
apiFormat?: "anthropic" | "openai_chat";
|
||||||
|
// Claude 认证字段名(仅 Claude 供应商使用)
|
||||||
|
// - "ANTHROPIC_AUTH_TOKEN" (默认): 大多数第三方/聚合供应商
|
||||||
|
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
|
||||||
|
apiKeyField?: "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
||||||
}
|
}
|
||||||
|
|
||||||
// Skill 同步方式
|
// Skill 同步方式
|
||||||
@@ -155,6 +159,11 @@ export type SkillSyncMethod = "auto" | "symlink" | "copy";
|
|||||||
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
// - "openai_chat": OpenAI Chat Completions 格式,需要格式转换
|
||||||
export type ClaudeApiFormat = "anthropic" | "openai_chat";
|
export type ClaudeApiFormat = "anthropic" | "openai_chat";
|
||||||
|
|
||||||
|
// Claude 认证字段类型
|
||||||
|
// - "ANTHROPIC_AUTH_TOKEN": 大多数第三方/聚合供应商使用(默认)
|
||||||
|
// - "ANTHROPIC_API_KEY": 少数供应商需要原生 API Key
|
||||||
|
export type ClaudeApiKeyField = "ANTHROPIC_AUTH_TOKEN" | "ANTHROPIC_API_KEY";
|
||||||
|
|
||||||
// 主页面显示的应用配置
|
// 主页面显示的应用配置
|
||||||
export interface VisibleApps {
|
export interface VisibleApps {
|
||||||
claude: boolean;
|
claude: boolean;
|
||||||
|
|||||||
@@ -3,86 +3,6 @@
|
|||||||
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
import type { TemplateValueConfig } from "../config/claudeProviderPresets";
|
||||||
import { normalizeQuotes } from "@/utils/textNormalization";
|
import { normalizeQuotes } from "@/utils/textNormalization";
|
||||||
|
|
||||||
const isPlainObject = (value: unknown): value is Record<string, any> => {
|
|
||||||
return Object.prototype.toString.call(value) === "[object Object]";
|
|
||||||
};
|
|
||||||
|
|
||||||
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>,
|
|
||||||
) => {
|
|
||||||
Object.entries(source).forEach(([key, value]) => {
|
|
||||||
if (!(key in target)) return;
|
|
||||||
|
|
||||||
if (isPlainObject(value) && isPlainObject(target[key])) {
|
|
||||||
// 只移除完全匹配的嵌套属性
|
|
||||||
deepRemove(target[key], value);
|
|
||||||
if (Object.keys(target[key]).length === 0) {
|
|
||||||
delete target[key];
|
|
||||||
}
|
|
||||||
} else if (isSubset(target[key], value)) {
|
|
||||||
// 只有当值完全匹配时才删除
|
|
||||||
delete target[key];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isSubset = (target: any, source: any): boolean => {
|
|
||||||
if (isPlainObject(source)) {
|
|
||||||
if (!isPlainObject(target)) return false;
|
|
||||||
return Object.entries(source).every(([key, value]) =>
|
|
||||||
isSubset(target[key], value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(source)) {
|
|
||||||
if (!Array.isArray(target) || target.length !== source.length) return false;
|
|
||||||
return source.every((item, index) => isSubset(target[index], item));
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证JSON配置格式
|
// 验证JSON配置格式
|
||||||
export const validateJsonConfig = (
|
export const validateJsonConfig = (
|
||||||
value: string,
|
value: string,
|
||||||
@@ -102,69 +22,6 @@ export const validateJsonConfig = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 将通用配置片段写入/移除 settingsConfig
|
|
||||||
export const updateCommonConfigSnippet = (
|
|
||||||
jsonString: string,
|
|
||||||
snippetString: string,
|
|
||||||
enabled: boolean,
|
|
||||||
): UpdateCommonConfigResult => {
|
|
||||||
let config: Record<string, any>;
|
|
||||||
try {
|
|
||||||
config = jsonString ? JSON.parse(jsonString) : {};
|
|
||||||
} catch (err) {
|
|
||||||
return {
|
|
||||||
updatedConfig: jsonString,
|
|
||||||
error: "配置 JSON 解析失败,无法写入通用配置",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!snippetString.trim()) {
|
|
||||||
return {
|
|
||||||
updatedConfig: JSON.stringify(config, null, 2),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 使用统一的验证函数
|
|
||||||
const snippetError = validateJsonConfig(snippetString, "通用配置片段");
|
|
||||||
if (snippetError) {
|
|
||||||
return {
|
|
||||||
updatedConfig: JSON.stringify(config, null, 2),
|
|
||||||
error: snippetError,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const snippet = JSON.parse(snippetString) as Record<string, any>;
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
const merged = deepMerge(deepClone(config), snippet);
|
|
||||||
return {
|
|
||||||
updatedConfig: JSON.stringify(merged, null, 2),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const cloned = deepClone(config);
|
|
||||||
deepRemove(cloned, snippet);
|
|
||||||
return {
|
|
||||||
updatedConfig: JSON.stringify(cloned, null, 2),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查当前配置是否已包含通用配置片段
|
|
||||||
export const hasCommonConfigSnippet = (
|
|
||||||
jsonString: string,
|
|
||||||
snippetString: string,
|
|
||||||
): boolean => {
|
|
||||||
try {
|
|
||||||
if (!snippetString.trim()) return false;
|
|
||||||
const config = jsonString ? JSON.parse(jsonString) : {};
|
|
||||||
const snippet = JSON.parse(snippetString);
|
|
||||||
if (!isPlainObject(snippet)) return false;
|
|
||||||
return isSubset(config, snippet);
|
|
||||||
} catch (err) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 读取配置中的 API Key(支持 Claude, Codex, Gemini)
|
// 读取配置中的 API Key(支持 Claude, Codex, Gemini)
|
||||||
export const getApiKeyFromConfig = (
|
export const getApiKeyFromConfig = (
|
||||||
jsonString: string,
|
jsonString: string,
|
||||||
@@ -278,9 +135,13 @@ export const hasApiKeyField = (
|
|||||||
export const setApiKeyInConfig = (
|
export const setApiKeyInConfig = (
|
||||||
jsonString: string,
|
jsonString: string,
|
||||||
apiKey: string,
|
apiKey: string,
|
||||||
options: { createIfMissing?: boolean; appType?: string } = {},
|
options: {
|
||||||
|
createIfMissing?: boolean;
|
||||||
|
appType?: string;
|
||||||
|
apiKeyField?: string;
|
||||||
|
} = {},
|
||||||
): string => {
|
): string => {
|
||||||
const { createIfMissing = false, appType } = options;
|
const { createIfMissing = false, appType, apiKeyField } = options;
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(jsonString);
|
const config = JSON.parse(jsonString);
|
||||||
if (!config.env) {
|
if (!config.env) {
|
||||||
@@ -313,13 +174,13 @@ export const setApiKeyInConfig = (
|
|||||||
return JSON.stringify(config, null, 2);
|
return JSON.stringify(config, null, 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则默认创建 AUTH_TOKEN 字段)
|
// Claude API Key (优先写入已存在的字段;若两者均不存在且允许创建,则使用 apiKeyField 指定的字段名)
|
||||||
if ("ANTHROPIC_AUTH_TOKEN" in env) {
|
if ("ANTHROPIC_AUTH_TOKEN" in env) {
|
||||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
||||||
} else if ("ANTHROPIC_API_KEY" in env) {
|
} else if ("ANTHROPIC_API_KEY" in env) {
|
||||||
env.ANTHROPIC_API_KEY = apiKey;
|
env.ANTHROPIC_API_KEY = apiKey;
|
||||||
} else if (createIfMissing) {
|
} else if (createIfMissing) {
|
||||||
env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
env[apiKeyField ?? "ANTHROPIC_AUTH_TOKEN"] = apiKey;
|
||||||
} else {
|
} else {
|
||||||
return jsonString;
|
return jsonString;
|
||||||
}
|
}
|
||||||
@@ -329,85 +190,6 @@ export const setApiKeyInConfig = (
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// ========== TOML Config Utilities ==========
|
|
||||||
|
|
||||||
export interface UpdateTomlCommonConfigResult {
|
|
||||||
updatedConfig: string;
|
|
||||||
error?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存之前的通用配置片段,用于替换操作
|
|
||||||
let previousCommonSnippet = "";
|
|
||||||
|
|
||||||
// 将通用配置片段写入/移除 TOML 配置
|
|
||||||
export const updateTomlCommonConfigSnippet = (
|
|
||||||
tomlString: string,
|
|
||||||
snippetString: string,
|
|
||||||
enabled: boolean,
|
|
||||||
): UpdateTomlCommonConfigResult => {
|
|
||||||
if (!snippetString.trim()) {
|
|
||||||
// 如果片段为空,直接返回原始配置
|
|
||||||
return {
|
|
||||||
updatedConfig: tomlString,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
if (enabled) {
|
|
||||||
// 添加通用配置
|
|
||||||
// 先移除旧的通用配置(如果有)
|
|
||||||
let updatedConfig = tomlString;
|
|
||||||
if (previousCommonSnippet && tomlString.includes(previousCommonSnippet)) {
|
|
||||||
updatedConfig = tomlString.replace(previousCommonSnippet, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 在文件末尾添加新的通用配置
|
|
||||||
// 确保有适当的换行
|
|
||||||
const needsNewline = updatedConfig && !updatedConfig.endsWith("\n");
|
|
||||||
updatedConfig =
|
|
||||||
updatedConfig + (needsNewline ? "\n\n" : "\n") + snippetString;
|
|
||||||
|
|
||||||
// 保存当前通用配置片段
|
|
||||||
previousCommonSnippet = snippetString;
|
|
||||||
|
|
||||||
return {
|
|
||||||
updatedConfig: updatedConfig.trim() + "\n",
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
// 移除通用配置
|
|
||||||
if (tomlString.includes(snippetString)) {
|
|
||||||
const updatedConfig = tomlString.replace(snippetString, "");
|
|
||||||
// 清理多余的空行
|
|
||||||
const cleaned = updatedConfig.replace(/\n{3,}/g, "\n\n").trim();
|
|
||||||
|
|
||||||
// 清空保存的状态
|
|
||||||
previousCommonSnippet = "";
|
|
||||||
|
|
||||||
return {
|
|
||||||
updatedConfig: cleaned ? cleaned + "\n" : "",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
updatedConfig: tomlString,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 检查 TOML 配置是否已包含通用配置片段
|
|
||||||
export const hasTomlCommonConfigSnippet = (
|
|
||||||
tomlString: string,
|
|
||||||
snippetString: string,
|
|
||||||
): boolean => {
|
|
||||||
if (!snippetString.trim()) return false;
|
|
||||||
|
|
||||||
// 简单检查配置是否包含片段内容
|
|
||||||
// 去除空白字符后比较,避免格式差异影响
|
|
||||||
const normalizeWhitespace = (str: string) => str.replace(/\s+/g, " ").trim();
|
|
||||||
|
|
||||||
return normalizeWhitespace(tomlString).includes(
|
|
||||||
normalizeWhitespace(snippetString),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ========== Codex base_url utils ==========
|
// ========== Codex base_url utils ==========
|
||||||
|
|
||||||
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
// 从 Codex 的 TOML 配置文本中提取 base_url(支持单/双引号)
|
||||||
|
|||||||
Reference in New Issue
Block a user