diff --git a/src-tauri/src/commands/config.rs b/src-tauri/src/commands/config.rs index f000a4d29..c70939e04 100644 --- a/src-tauri/src/commands/config.rs +++ b/src-tauri/src/commands/config.rs @@ -9,7 +9,6 @@ use crate::codex_config; use crate::config::{self, get_claude_settings_path, ConfigStatus}; use crate::settings; -/// 获取 Claude Code 配置状态 #[tauri::command] pub async fn get_claude_config_status() -> Result { Ok(config::get_claude_config_status()) @@ -63,13 +62,11 @@ pub async fn get_config_status(app: String) -> Result { } } -/// 获取 Claude Code 配置文件路径 #[tauri::command] pub async fn get_claude_code_config_path() -> Result { Ok(get_claude_settings_path().to_string_lossy().to_string()) } -/// 获取当前生效的配置目录 #[tauri::command] pub async fn get_config_dir(app: String) -> Result { let dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { @@ -82,7 +79,6 @@ pub async fn get_config_dir(app: String) -> Result { Ok(dir.to_string_lossy().to_string()) } -/// 打开配置文件夹 #[tauri::command] pub async fn open_config_folder(handle: AppHandle, app: String) -> Result { let config_dir = match AppType::from_str(&app).map_err(|e| e.to_string())? { @@ -104,7 +100,6 @@ pub async fn open_config_folder(handle: AppHandle, app: String) -> Result Result { let config_path = config::get_app_config_path(); Ok(config_path.to_string_lossy().to_string()) } -/// 打开应用配置文件夹 #[tauri::command] pub async fn open_app_config_folder(handle: AppHandle) -> Result { let config_dir = config::get_app_config_dir(); @@ -160,7 +153,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result { Ok(true) } -/// 获取 Claude 通用配置片段(已废弃,使用 get_common_config_snippet) #[tauri::command] pub async fn get_claude_common_config_snippet( state: tauri::State<'_, crate::store::AppState>, @@ -171,13 +163,11 @@ pub async fn get_claude_common_config_snippet( .map_err(|e| e.to_string()) } -/// 设置 Claude 通用配置片段(已废弃,使用 set_common_config_snippet) #[tauri::command] pub async fn set_claude_common_config_snippet( snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { - // 验证是否为有效的 JSON(如果不为空) if !snippet.trim().is_empty() { serde_json::from_str::(&snippet).map_err(invalid_json_format_error)?; } @@ -195,7 +185,6 @@ pub async fn set_claude_common_config_snippet( Ok(()) } -/// 获取通用配置片段(统一接口) #[tauri::command] pub async fn get_common_config_snippet( app_type: String, @@ -207,25 +196,19 @@ pub async fn get_common_config_snippet( .map_err(|e| e.to_string()) } -/// 设置通用配置片段(统一接口) #[tauri::command] pub async fn set_common_config_snippet( app_type: String, snippet: String, state: tauri::State<'_, crate::store::AppState>, ) -> Result<(), String> { - // 验证格式(根据应用类型) if !snippet.trim().is_empty() { match app_type.as_str() { - "claude" | "gemini" => { - // 验证 JSON 格式 + "claude" | "gemini" | "omo" => { serde_json::from_str::(&snippet) .map_err(invalid_json_format_error)?; } - "codex" => { - // TOML 格式暂不验证(或可使用 toml crate) - // 注意:TOML 验证较为复杂,暂时跳过 - } + "codex" => {} _ => {} } } @@ -240,14 +223,20 @@ pub async fn set_common_config_snippet( .db .set_config_snippet(&app_type, value) .map_err(|e| e.to_string())?; + + if app_type == "omo" + && state + .db + .get_current_omo_provider("opencode") + .map_err(|e| e.to_string())? + .is_some() + { + crate::services::OmoService::write_config_to_file(state.inner()) + .map_err(|e| e.to_string())?; + } Ok(()) } -/// 提取通用配置片段 -/// -/// 优先从 `settingsConfig`(编辑器当前内容)提取;若未提供,则从当前激活供应商提取。 -/// -/// 提取时会自动排除差异化字段(API Key、模型配置、端点等),返回可复用的通用配置片段。 #[tauri::command] pub async fn extract_common_config_snippet( appType: String, diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index ff81fde2e..7976c4316 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ mod global_proxy; mod import_export; mod mcp; mod misc; +mod omo; mod plugin; mod prompt; mod provider; @@ -26,6 +27,7 @@ pub use global_proxy::*; pub use import_export::*; pub use mcp::*; pub use misc::*; +pub use omo::*; pub use plugin::*; pub use prompt::*; pub use provider::*; diff --git a/src-tauri/src/commands/omo.rs b/src-tauri/src/commands/omo.rs new file mode 100644 index 000000000..9b3b4be47 --- /dev/null +++ b/src-tauri/src/commands/omo.rs @@ -0,0 +1,50 @@ +use tauri::State; + +use crate::services::omo::OmoLocalFileData; +use crate::services::OmoService; +use crate::store::AppState; + +#[tauri::command] +pub async fn read_omo_local_file() -> Result { + OmoService::read_local_file().map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result { + let provider = state + .db + .get_current_omo_provider("opencode") + .map_err(|e| e.to_string())?; + Ok(provider.map(|p| p.id).unwrap_or_default()) +} + +#[tauri::command] +pub async fn disable_current_omo(state: State<'_, AppState>) -> Result<(), String> { + let providers = state + .db + .get_all_providers("opencode") + .map_err(|e| e.to_string())?; + for (id, p) in &providers { + if p.category.as_deref() == Some("omo") { + state + .db + .clear_omo_provider_current("opencode", id) + .map_err(|e| e.to_string())?; + } + } + OmoService::delete_config_file().map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +pub async fn get_omo_provider_count(state: State<'_, AppState>) -> Result { + let providers = state + .db + .get_all_providers("opencode") + .map_err(|e| e.to_string())?; + let count = providers + .values() + .filter(|p| p.category.as_deref() == Some("omo")) + .count(); + Ok(count) +} diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index b1b94a742..47de928f5 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -8,7 +8,6 @@ use crate::services::{EndpointLatency, ProviderService, ProviderSortUpdate, Spee use crate::store::AppState; use std::str::FromStr; -/// 获取所有供应商 #[tauri::command] pub fn get_providers( state: State<'_, AppState>, @@ -18,14 +17,12 @@ pub fn get_providers( ProviderService::list(state.inner(), app_type).map_err(|e| e.to_string()) } -/// 获取当前供应商ID #[tauri::command] pub fn get_current_provider(state: State<'_, AppState>, app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::current(state.inner(), app_type).map_err(|e| e.to_string()) } -/// 添加供应商 #[tauri::command] pub fn add_provider( state: State<'_, AppState>, @@ -36,7 +33,6 @@ pub fn add_provider( ProviderService::add(state.inner(), app_type, provider).map_err(|e| e.to_string()) } -/// 更新供应商 #[tauri::command] pub fn update_provider( state: State<'_, AppState>, @@ -47,7 +43,6 @@ pub fn update_provider( ProviderService::update(state.inner(), app_type, provider).map_err(|e| e.to_string()) } -/// 删除供应商 #[tauri::command] pub fn delete_provider( state: State<'_, AppState>, @@ -60,17 +55,18 @@ pub fn delete_provider( .map_err(|e| e.to_string()) } -/// Remove provider from live config only (for additive mode apps like OpenCode) -/// Does NOT delete from database - provider remains in the list #[tauri::command] -pub fn remove_provider_from_live_config(app: String, id: String) -> Result { +pub fn remove_provider_from_live_config( + state: tauri::State<'_, AppState>, + app: String, + id: String, +) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; - ProviderService::remove_from_live_config(app_type, &id) + ProviderService::remove_from_live_config(state.inner(), app_type, &id) .map(|_| true) .map_err(|e| e.to_string()) } -/// 切换供应商 fn switch_provider_internal(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { ProviderService::switch(state, app_type, id) } @@ -108,14 +104,12 @@ pub fn import_default_config_test_hook( import_default_config_internal(state, app_type) } -/// 导入当前配置为默认供应商 #[tauri::command] pub fn import_default_config(state: State<'_, AppState>, app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; import_default_config_internal(&state, app_type).map_err(Into::into) } -/// 查询供应商用量 #[allow(non_snake_case)] #[tauri::command] pub async fn queryProviderUsage( @@ -129,7 +123,6 @@ pub async fn queryProviderUsage( .map_err(|e| e.to_string()) } -/// 测试用量脚本(使用当前编辑器中的脚本,不保存) #[allow(non_snake_case)] #[allow(clippy::too_many_arguments)] #[tauri::command] @@ -162,14 +155,12 @@ pub async fn testUsageScript( .map_err(|e| e.to_string()) } -/// 读取当前生效的配置内容 #[tauri::command] pub fn read_live_provider_settings(app: String) -> Result { let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?; ProviderService::read_live_settings(app_type).map_err(|e| e.to_string()) } -/// 测试第三方/自定义供应商端点的网络延迟 #[tauri::command] pub async fn test_api_endpoints( urls: Vec, @@ -180,7 +171,6 @@ pub async fn test_api_endpoints( .map_err(|e| e.to_string()) } -/// 获取自定义端点列表 #[tauri::command] pub fn get_custom_endpoints( state: State<'_, AppState>, @@ -192,7 +182,6 @@ pub fn get_custom_endpoints( .map_err(|e| e.to_string()) } -/// 添加自定义端点 #[tauri::command] pub fn add_custom_endpoint( state: State<'_, AppState>, @@ -205,7 +194,6 @@ pub fn add_custom_endpoint( .map_err(|e| e.to_string()) } -/// 删除自定义端点 #[tauri::command] pub fn remove_custom_endpoint( state: State<'_, AppState>, @@ -218,7 +206,6 @@ pub fn remove_custom_endpoint( .map_err(|e| e.to_string()) } -/// 更新端点最后使用时间 #[tauri::command] pub fn update_endpoint_last_used( state: State<'_, AppState>, @@ -231,7 +218,6 @@ pub fn update_endpoint_last_used( .map_err(|e| e.to_string()) } -/// 更新多个供应商的排序 #[tauri::command] pub fn update_providers_sort_order( state: State<'_, AppState>, @@ -242,24 +228,16 @@ pub fn update_providers_sort_order( ProviderService::update_sort_order(state.inner(), app_type, updates).map_err(|e| e.to_string()) } -// ============================================================================ -// 统一供应商(Universal Provider)命令 -// ============================================================================ - use crate::provider::UniversalProvider; use std::collections::HashMap; use tauri::{AppHandle, Emitter}; -/// 统一供应商同步完成事件的 payload #[derive(Clone, serde::Serialize)] pub struct UniversalProviderSyncedEvent { - /// 操作类型: "upsert" | "delete" | "sync" pub action: String, - /// 统一供应商 ID pub id: String, } -/// 发送统一供应商同步事件,通知前端刷新供应商列表 fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) { let _ = app.emit( "universal-provider-synced", @@ -270,7 +248,6 @@ fn emit_universal_provider_synced(app: &AppHandle, action: &str, id: &str) { ); } -/// 获取所有统一供应商 #[tauri::command] pub fn get_universal_providers( state: State<'_, AppState>, @@ -278,7 +255,6 @@ pub fn get_universal_providers( ProviderService::list_universal(state.inner()).map_err(|e| e.to_string()) } -/// 获取单个统一供应商 #[tauri::command] pub fn get_universal_provider( state: State<'_, AppState>, @@ -287,7 +263,6 @@ pub fn get_universal_provider( ProviderService::get_universal(state.inner(), &id).map_err(|e| e.to_string()) } -/// 添加或更新统一供应商 #[tauri::command] pub fn upsert_universal_provider( app: AppHandle, @@ -298,13 +273,11 @@ pub fn upsert_universal_provider( let result = ProviderService::upsert_universal(state.inner(), provider).map_err(|e| e.to_string())?; - // 发送事件通知前端刷新 emit_universal_provider_synced(&app, "upsert", &id); Ok(result) } -/// 删除统一供应商 #[tauri::command] pub fn delete_universal_provider( app: AppHandle, @@ -314,13 +287,11 @@ pub fn delete_universal_provider( let result = ProviderService::delete_universal(state.inner(), &id).map_err(|e| e.to_string())?; - // 发送事件通知前端刷新 emit_universal_provider_synced(&app, "delete", &id); Ok(result) } -/// 同步统一供应商到各应用(手动触发) #[tauri::command] pub fn sync_universal_provider( app: AppHandle, @@ -330,29 +301,17 @@ pub fn sync_universal_provider( let result = ProviderService::sync_universal_to_apps(state.inner(), &id).map_err(|e| e.to_string())?; - // 发送事件通知前端刷新 emit_universal_provider_synced(&app, "sync", &id); Ok(result) } -// ============================================================================ -// OpenCode 专属命令 -// ============================================================================ - -/// 从 OpenCode live 配置导入供应商到数据库 -/// -/// 这是 OpenCode 特有的功能,因为 OpenCode 使用累加模式, -/// 用户可能已经在 opencode.json 中配置了供应商。 #[tauri::command] pub fn import_opencode_providers_from_live(state: State<'_, AppState>) -> Result { crate::services::provider::import_opencode_providers_from_live(state.inner()) .map_err(|e| e.to_string()) } -/// 获取 OpenCode live 配置中的供应商 ID 列表 -/// -/// 用于前端判断供应商是否已添加到 opencode.json #[tauri::command] pub fn get_opencode_live_provider_ids() -> Result, String> { crate::opencode_config::get_providers() diff --git a/src-tauri/src/database/dao/mod.rs b/src-tauri/src/database/dao/mod.rs index 81ac6a6c1..53ea93dfa 100644 --- a/src-tauri/src/database/dao/mod.rs +++ b/src-tauri/src/database/dao/mod.rs @@ -4,6 +4,7 @@ pub mod failover; pub mod mcp; +pub mod omo; pub mod prompts; pub mod providers; pub mod proxy; @@ -15,3 +16,4 @@ pub mod universal_providers; // 所有 DAO 方法都通过 Database impl 提供,无需单独导出 // 导出 FailoverQueueItem 供外部使用 pub use failover::FailoverQueueItem; +pub use omo::OmoGlobalConfig; diff --git a/src-tauri/src/database/dao/omo.rs b/src-tauri/src/database/dao/omo.rs new file mode 100644 index 000000000..00f4fdb34 --- /dev/null +++ b/src-tauri/src/database/dao/omo.rs @@ -0,0 +1,73 @@ +use crate::database::Database; +use crate::error::AppError; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OmoGlobalConfig { + pub id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub schema_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub sisyphus_agent: Option, + #[serde(default)] + pub disabled_agents: Vec, + #[serde(default)] + pub disabled_mcps: Vec, + #[serde(default)] + pub disabled_hooks: Vec, + #[serde(default)] + pub disabled_skills: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub lsp: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub experimental: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub background_task: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub browser_automation_engine: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub claude_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub other_fields: Option, + pub updated_at: String, +} + +impl Default for OmoGlobalConfig { + fn default() -> Self { + Self { + id: "global".to_string(), + schema_url: None, + sisyphus_agent: None, + disabled_agents: vec![], + disabled_mcps: vec![], + disabled_hooks: vec![], + disabled_skills: vec![], + lsp: None, + experimental: None, + background_task: None, + browser_automation_engine: None, + claude_code: None, + other_fields: None, + updated_at: chrono::Utc::now().to_rfc3339(), + } + } +} + +impl Database { + pub fn get_omo_global_config(&self) -> Result { + let json_str = self.get_setting("common_config_omo")?; + match json_str { + Some(s) => serde_json::from_str::(&s) + .map_err(|e| AppError::Config(format!("Failed to parse common_config_omo: {e}"))), + None => Ok(OmoGlobalConfig::default()), + } + } + + pub fn save_omo_global_config(&self, config: &OmoGlobalConfig) -> Result<(), AppError> { + let json_str = serde_json::to_string(config) + .map_err(|e| AppError::Config(format!("JSON serialization failed: {e}")))?; + self.set_setting("common_config_omo", &json_str)?; + Ok(()) + } +} diff --git a/src-tauri/src/database/dao/providers.rs b/src-tauri/src/database/dao/providers.rs index 71872799a..0bf9961d2 100644 --- a/src-tauri/src/database/dao/providers.rs +++ b/src-tauri/src/database/dao/providers.rs @@ -1,7 +1,3 @@ -//! 供应商数据访问对象 -//! -//! 提供供应商(Provider)的 CRUD 操作。 - use crate::database::{lock_conn, Database}; use crate::error::AppError; use crate::provider::{Provider, ProviderMeta}; @@ -9,8 +5,18 @@ use indexmap::IndexMap; use rusqlite::params; use std::collections::HashMap; +type OmoProviderRow = ( + String, + String, + String, + Option, + Option, + Option, + Option, + String, +); + impl Database { - /// 获取指定应用类型的所有供应商 pub fn get_all_providers( &self, app_type: &str, @@ -66,7 +72,6 @@ impl Database { let (id, mut provider) = provider_res.map_err(|e| AppError::Database(e.to_string()))?; provider.id = id.clone(); - // 加载 endpoints let mut stmt_endpoints = conn.prepare( "SELECT url, added_at FROM provider_endpoints WHERE provider_id = ?1 AND app_type = ?2 ORDER BY added_at ASC, url ASC" ).map_err(|e| AppError::Database(e.to_string()))?; @@ -103,7 +108,6 @@ impl Database { Ok(providers) } - /// 获取当前激活的供应商 ID pub fn get_current_provider(&self, app_type: &str) -> Result, AppError> { let conn = lock_conn!(self.conn); let mut stmt = conn @@ -123,7 +127,6 @@ impl Database { } } - /// 根据 ID 获取单个供应商 pub fn get_provider_by_id( &self, id: &str, @@ -174,21 +177,15 @@ impl Database { } } - /// 保存供应商(新增或更新) - /// - /// 注意:更新模式下不同步 endpoints,因为编辑模式下端点通过单独的 API 管理 - /// (add_custom_endpoint / remove_custom_endpoint),避免覆盖用户的修改。 pub fn save_provider(&self, app_type: &str, provider: &Provider) -> Result<(), AppError> { let mut conn = lock_conn!(self.conn); let tx = conn .transaction() .map_err(|e| AppError::Database(e.to_string()))?; - // 处理 meta:取出 endpoints 以便单独处理 let mut meta_clone = provider.meta.clone().unwrap_or_default(); let endpoints = std::mem::take(&mut meta_clone.custom_endpoints); - // 检查是否存在(用于判断新增/更新,以及保留 is_current 和 in_failover_queue) let existing: Option<(bool, bool)> = tx .query_row( "SELECT is_current, in_failover_queue FROM providers WHERE id = ?1 AND app_type = ?2", @@ -202,7 +199,6 @@ impl Database { existing.unwrap_or((false, provider.in_failover_queue)); if is_update { - // 更新模式:使用 UPDATE 避免触发 ON DELETE CASCADE tx.execute( "UPDATE providers SET name = ?1, @@ -241,7 +237,6 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; } else { - // 新增模式:使用 INSERT tx.execute( "INSERT INTO providers ( id, app_type, name, settings_config, website_url, category, @@ -268,7 +263,6 @@ impl Database { ) .map_err(|e| AppError::Database(e.to_string()))?; - // 只有新增时才同步 endpoints for (url, endpoint) in endpoints { tx.execute( "INSERT INTO provider_endpoints (provider_id, app_type, url, added_at) @@ -283,7 +277,6 @@ impl Database { Ok(()) } - /// 删除供应商 pub fn delete_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { let conn = lock_conn!(self.conn); conn.execute( @@ -294,21 +287,18 @@ impl Database { Ok(()) } - /// 设置当前供应商 pub fn set_current_provider(&self, app_type: &str, id: &str) -> Result<(), AppError> { let mut conn = lock_conn!(self.conn); let tx = conn .transaction() .map_err(|e| AppError::Database(e.to_string()))?; - // 重置所有为 0 tx.execute( "UPDATE providers SET is_current = 0 WHERE app_type = ?1", params![app_type], ) .map_err(|e| AppError::Database(e.to_string()))?; - // 设置新的当前供应商 tx.execute( "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2", params![id, app_type], @@ -319,7 +309,6 @@ impl Database { Ok(()) } - /// 更新供应商的 settings_config(仅更新配置,不改变其他字段) pub fn update_provider_settings_config( &self, app_type: &str, @@ -341,7 +330,6 @@ impl Database { Ok(()) } - /// 添加自定义端点 pub fn add_custom_endpoint( &self, app_type: &str, @@ -357,7 +345,6 @@ impl Database { Ok(()) } - /// 移除自定义端点 pub fn remove_custom_endpoint( &self, app_type: &str, @@ -372,4 +359,126 @@ impl Database { .map_err(|e| AppError::Database(e.to_string()))?; Ok(()) } + + pub fn set_omo_provider_current( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let mut conn = lock_conn!(self.conn); + let tx = conn + .transaction() + .map_err(|e| AppError::Database(e.to_string()))?; + tx.execute( + "UPDATE providers SET is_current = 0 WHERE app_type = ?1 AND category = 'omo'", + params![app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + let updated = tx + .execute( + "UPDATE providers SET is_current = 1 WHERE id = ?1 AND app_type = ?2 AND category = 'omo'", + params![provider_id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + if updated != 1 { + return Err(AppError::Database(format!( + "Failed to set OMO provider current: provider '{provider_id}' not found in app '{app_type}'" + ))); + } + tx.commit().map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn is_omo_provider_current( + &self, + app_type: &str, + provider_id: &str, + ) -> Result { + let conn = lock_conn!(self.conn); + match conn.query_row( + "SELECT is_current FROM providers + WHERE id = ?1 AND app_type = ?2 AND category = 'omo'", + params![provider_id, app_type], + |row| row.get(0), + ) { + Ok(is_current) => Ok(is_current), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(false), + Err(e) => Err(AppError::Database(e.to_string())), + } + } + + pub fn clear_omo_provider_current( + &self, + app_type: &str, + provider_id: &str, + ) -> Result<(), AppError> { + let conn = lock_conn!(self.conn); + conn.execute( + "UPDATE providers SET is_current = 0 + WHERE id = ?1 AND app_type = ?2 AND category = 'omo'", + params![provider_id, app_type], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + + pub fn get_current_omo_provider(&self, app_type: &str) -> Result, AppError> { + let conn = lock_conn!(self.conn); + let row_data: Result = conn.query_row( + "SELECT id, name, settings_config, category, created_at, sort_index, notes, meta + FROM providers + WHERE app_type = ?1 AND category = 'omo' AND is_current = 1 + LIMIT 1", + params![app_type], + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + )) + }, + ); + + let (id, name, settings_config_str, category, created_at, sort_index, notes, meta_str) = + match row_data { + Ok(v) => v, + Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None), + Err(e) => return Err(AppError::Database(e.to_string())), + }; + + let settings_config = serde_json::from_str(&settings_config_str).map_err(|e| { + AppError::Database(format!( + "Failed to parse OMO provider settings_config (provider_id={id}): {e}" + )) + })?; + let meta: crate::provider::ProviderMeta = if meta_str.trim().is_empty() { + crate::provider::ProviderMeta::default() + } else { + serde_json::from_str(&meta_str).map_err(|e| { + AppError::Database(format!( + "Failed to parse OMO provider meta (provider_id={id}): {e}" + )) + })? + }; + + Ok(Some(Provider { + id, + name, + settings_config, + website_url: None, + category, + created_at, + sort_index, + notes, + meta: Some(meta), + icon: None, + icon_color: None, + in_failover_queue: false, + })) + } } diff --git a/src-tauri/src/database/mod.rs b/src-tauri/src/database/mod.rs index 2ba3ed591..41c26e978 100644 --- a/src-tauri/src/database/mod.rs +++ b/src-tauri/src/database/mod.rs @@ -33,6 +33,7 @@ mod tests; // DAO 类型导出供外部使用 pub use dao::FailoverQueueItem; +pub use dao::OmoGlobalConfig; use crate::config::get_app_config_dir; use crate::error::AppError; diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 9d8c622f1..4c5790654 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -52,6 +52,8 @@ pub enum AppError { }, #[error("数据库错误: {0}")] Database(String), + #[error("OMO 配置文件不存在")] + OmoConfigNotFound, #[error("所有供应商已熔断,无可用渠道")] AllProvidersCircuitOpen, #[error("未配置供应商")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 5dd10a152..9c246ff90 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -503,6 +503,28 @@ pub fn run() { Err(e) => log::debug!("○ Failed to import OpenCode providers: {e}"), } + // 2.2 OMO 配置导入(当数据库中无 OMO provider 时,从本地文件导入) + { + let has_omo = app_state + .db + .get_all_providers("opencode") + .map(|providers| providers.values().any(|p| p.category.as_deref() == Some("omo"))) + .unwrap_or(false); + if !has_omo { + match crate::services::OmoService::import_from_local(&app_state) { + Ok(provider) => { + log::info!("✓ Imported OMO config from local as provider '{}'", provider.name); + } + Err(AppError::OmoConfigNotFound) => { + log::debug!("○ No OMO config to import"); + } + Err(e) => { + log::warn!("✗ Failed to import OMO config from local: {e}"); + } + } + } + } + // 3. 导入 MCP 服务器配置(表空时触发) if app_state.db.is_mcp_table_empty().unwrap_or(false) { log::info!("MCP table empty, importing from live configurations..."); @@ -958,6 +980,10 @@ pub fn run() { commands::scan_local_proxies, // Window theme control commands::set_window_theme, + commands::read_omo_local_file, + commands::get_current_omo_provider_id, + commands::get_omo_provider_count, + commands::disable_current_omo, ]); let app = builder diff --git a/src-tauri/src/opencode_config.rs b/src-tauri/src/opencode_config.rs index d14daf9b5..f79fcbc4b 100644 --- a/src-tauri/src/opencode_config.rs +++ b/src-tauri/src/opencode_config.rs @@ -1,26 +1,3 @@ -//! OpenCode 配置文件读写模块 -//! -//! 处理 `~/.config/opencode/opencode.json` 配置文件的读写操作。 -//! OpenCode 使用累加式供应商管理,所有供应商配置共存于同一配置文件中。 -//! -//! ## 配置文件格式 -//! -//! ```json -//! { -//! "$schema": "https://opencode.ai/config.json", -//! "provider": { -//! "my-provider": { -//! "npm": "@ai-sdk/openai-compatible", -//! "options": { "baseURL": "...", "apiKey": "{env:API_KEY}" }, -//! "models": { "gpt-4o": { "name": "GPT-4o" } } -//! } -//! }, -//! "mcp": { -//! "my-server": { "type": "local", "command": ["..."] } -//! } -//! } -//! ``` - use crate::config::write_json_file; use crate::error::AppError; use crate::provider::OpenCodeProviderConfig; @@ -29,52 +6,29 @@ use indexmap::IndexMap; use serde_json::{json, Map, Value}; use std::path::PathBuf; -// ============================================================================ -// Path Functions -// ============================================================================ - -/// 获取 OpenCode 配置目录 -/// -/// 默认路径: `~/.config/opencode/` -/// 可通过 settings.opencode_config_dir 覆盖 pub fn get_opencode_dir() -> PathBuf { if let Some(override_dir) = get_opencode_override_dir() { return override_dir; } - // 所有平台统一使用 ~/.config/opencode dirs::home_dir() .map(|h| h.join(".config").join("opencode")) .unwrap_or_else(|| PathBuf::from(".config").join("opencode")) } -/// 获取 OpenCode 配置文件路径 -/// -/// 返回 `~/.config/opencode/opencode.json` pub fn get_opencode_config_path() -> PathBuf { get_opencode_dir().join("opencode.json") } -/// 获取 OpenCode 环境变量文件路径(如果存在) -/// -/// 返回 `~/.config/opencode/.env` #[allow(dead_code)] pub fn get_opencode_env_path() -> PathBuf { get_opencode_dir().join(".env") } -// ============================================================================ -// Core Read/Write Functions -// ============================================================================ - -/// 读取 OpenCode 配置文件 -/// -/// 返回完整的配置 JSON 对象 pub fn read_opencode_config() -> Result { let path = get_opencode_config_path(); if !path.exists() { - // Return empty config with schema return Ok(json!({ "$schema": "https://opencode.ai/config.json" })); @@ -84,23 +38,14 @@ pub fn read_opencode_config() -> Result { serde_json::from_str(&content).map_err(|e| AppError::json(&path, e)) } -/// 写入 OpenCode 配置文件(原子写入) -/// -/// 使用临时文件 + 重命名确保原子性 pub fn write_opencode_config(config: &Value) -> Result<(), AppError> { let path = get_opencode_config_path(); - // 复用统一的原子写入逻辑(兼容 Windows 上目标文件已存在的情况) write_json_file(&path, config)?; log::debug!("OpenCode config written to {path:?}"); Ok(()) } -// ============================================================================ -// Provider Functions (Untyped - for raw JSON operations) -// ============================================================================ - -/// 获取所有供应商配置(原始 JSON) pub fn get_providers() -> Result, AppError> { let config = read_opencode_config()?; Ok(config @@ -110,7 +55,6 @@ pub fn get_providers() -> Result, AppError> { .unwrap_or_default()) } -/// 设置供应商配置(原始 JSON) pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> { let mut full_config = read_opencode_config()?; @@ -128,7 +72,6 @@ pub fn set_provider(id: &str, config: Value) -> Result<(), AppError> { write_opencode_config(&full_config) } -/// 删除供应商配置 pub fn remove_provider(id: &str) -> Result<(), AppError> { let mut config = read_opencode_config()?; @@ -139,11 +82,6 @@ pub fn remove_provider(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } -// ============================================================================ -// Provider Functions (Typed - using OpenCodeProviderConfig) -// ============================================================================ - -/// 获取所有供应商配置(类型化) pub fn get_typed_providers() -> Result, AppError> { let providers = get_providers()?; let mut result = IndexMap::new(); @@ -155,7 +93,6 @@ pub fn get_typed_providers() -> Result, } Err(e) => { log::warn!("Failed to parse provider '{id}': {e}"); - // Skip invalid providers but continue } } } @@ -163,17 +100,11 @@ pub fn get_typed_providers() -> Result, Ok(result) } -/// 设置供应商配置(类型化) pub fn set_typed_provider(id: &str, config: &OpenCodeProviderConfig) -> Result<(), AppError> { let value = serde_json::to_value(config).map_err(|e| AppError::JsonSerialize { source: e })?; set_provider(id, value) } -// ============================================================================ -// MCP Functions -// ============================================================================ - -/// 获取所有 MCP 服务器配置 pub fn get_mcp_servers() -> Result, AppError> { let config = read_opencode_config()?; Ok(config @@ -183,7 +114,6 @@ pub fn get_mcp_servers() -> Result, AppError> { .unwrap_or_default()) } -/// 设置 MCP 服务器配置 pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> { let mut full_config = read_opencode_config()?; @@ -198,7 +128,6 @@ pub fn set_mcp_server(id: &str, config: Value) -> Result<(), AppError> { write_opencode_config(&full_config) } -/// 删除 MCP 服务器配置 pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { let mut config = read_opencode_config()?; @@ -208,3 +137,57 @@ pub fn remove_mcp_server(id: &str) -> Result<(), AppError> { write_opencode_config(&config) } + +pub fn add_plugin(plugin_name: &str) -> Result<(), AppError> { + let mut config = read_opencode_config()?; + + let plugins = config.get_mut("plugin").and_then(|v| v.as_array_mut()); + + match plugins { + Some(arr) => { + if plugin_name.starts_with("oh-my-opencode") + && !plugin_name.starts_with("oh-my-opencode-slim") + { + arr.retain(|v| { + v.as_str() + .map(|s| !s.starts_with("oh-my-opencode-slim")) + .unwrap_or(true) + }); + } + + let already_exists = arr.iter().any(|v| v.as_str() == Some(plugin_name)); + if !already_exists { + arr.push(Value::String(plugin_name.to_string())); + } + } + None => { + config["plugin"] = json!([plugin_name]); + } + } + + write_opencode_config(&config) +} + +pub fn remove_plugin_by_prefix(prefix: &str) -> Result<(), AppError> { + let mut config = read_opencode_config()?; + + if let Some(arr) = config.get_mut("plugin").and_then(|v| v.as_array_mut()) { + arr.retain(|v| { + v.as_str() + .map(|s| { + if !s.starts_with(prefix) { + return true; // Keep: doesn't match prefix at all + } + let rest = &s[prefix.len()..]; + rest.starts_with('-') + }) + .unwrap_or(true) + }); + + if arr.is_empty() { + config.as_object_mut().map(|obj| obj.remove("plugin")); + } + } + + write_opencode_config(&config) +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 07abe16e4..0875fdeb3 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -2,6 +2,7 @@ pub mod config; pub mod env_checker; pub mod env_manager; pub mod mcp; +pub mod omo; pub mod prompt; pub mod provider; pub mod proxy; @@ -12,6 +13,7 @@ pub mod usage_stats; pub use config::ConfigService; pub use mcp::McpService; +pub use omo::OmoService; pub use prompt::PromptService; pub use provider::{ProviderService, ProviderSortUpdate}; pub use proxy::ProxyService; diff --git a/src-tauri/src/services/omo.rs b/src-tauri/src/services/omo.rs new file mode 100644 index 000000000..45ed9479b --- /dev/null +++ b/src-tauri/src/services/omo.rs @@ -0,0 +1,437 @@ +use crate::config::write_json_file; +use crate::database::OmoGlobalConfig; +use crate::error::AppError; +use crate::opencode_config::get_opencode_dir; +use crate::store::AppState; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OmoLocalFileData { + pub agents: Option, + pub categories: Option, + pub other_fields: Option, + pub global: OmoGlobalConfig, + pub file_path: String, + pub last_modified: Option, +} + +type OmoProfileData = (Option, Option, Option, bool); + +pub struct OmoService; + +impl OmoService { + fn config_path() -> PathBuf { + get_opencode_dir().join("oh-my-opencode.jsonc") + } + + fn resolve_local_config_path() -> Result { + let config_path = Self::config_path(); + if config_path.exists() { + return Ok(config_path); + } + + let json_path = config_path.with_extension("json"); + if json_path.exists() { + return Ok(json_path); + } + + Err(AppError::OmoConfigNotFound) + } + + fn read_jsonc_object(path: &Path) -> Result, AppError> { + let content = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?; + let cleaned = Self::strip_jsonc_comments(&content); + let parsed: Value = serde_json::from_str(&cleaned) + .map_err(|e| AppError::Config(format!("Failed to parse oh-my-opencode config: {e}")))?; + parsed + .as_object() + .cloned() + .ok_or_else(|| AppError::Config("Expected JSON object".to_string())) + } + + fn extract_other_fields(obj: &Map) -> Map { + const KNOWN_KEYS: [&str; 13] = [ + "$schema", + "agents", + "categories", + "sisyphus_agent", + "disabled_agents", + "disabled_mcps", + "disabled_hooks", + "disabled_skills", + "lsp", + "experimental", + "background_task", + "browser_automation_engine", + "claude_code", + ]; + + let mut other = Map::new(); + for (k, v) in obj { + if !KNOWN_KEYS.contains(&k.as_str()) { + other.insert(k.clone(), v.clone()); + } + } + other + } + + fn extract_string_array(val: &Value) -> Vec { + val.as_array() + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) + .unwrap_or_default() + } + + fn merge_global_from_obj(obj: &Map, global: &mut OmoGlobalConfig) { + if let Some(v) = obj.get("$schema") { + global.schema_url = v.as_str().map(|s| s.to_string()); + } + for (key, target) in [ + ("disabled_agents", &mut global.disabled_agents), + ("disabled_mcps", &mut global.disabled_mcps), + ("disabled_hooks", &mut global.disabled_hooks), + ("disabled_skills", &mut global.disabled_skills), + ] { + if let Some(v) = obj.get(key) { + *target = Self::extract_string_array(v); + } + } + for (key, target) in [ + ("sisyphus_agent", &mut global.sisyphus_agent), + ("lsp", &mut global.lsp), + ("experimental", &mut global.experimental), + ("background_task", &mut global.background_task), + ( + "browser_automation_engine", + &mut global.browser_automation_engine, + ), + ("claude_code", &mut global.claude_code), + ] { + if let Some(v) = obj.get(key) { + *target = Some(v.clone()); + } + } + } + + fn insert_opt_value(result: &mut Map, key: &str, value: &Option) { + if let Some(v) = value { + result.insert(key.to_string(), v.clone()); + } + } + + fn insert_string_array(result: &mut Map, key: &str, values: &[String]) { + if !values.is_empty() { + result.insert( + key.to_string(), + serde_json::to_value(values).unwrap_or(Value::Array(vec![])), + ); + } + } + + fn insert_object_entries(result: &mut Map, value: Option<&Value>) { + if let Some(Value::Object(map)) = value { + for (k, v) in map { + result.insert(k.clone(), v.clone()); + } + } + } + + pub fn delete_config_file() -> Result<(), AppError> { + let config_path = Self::config_path(); + if config_path.exists() { + std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?; + log::info!("OMO config file deleted: {config_path:?}"); + } + crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode")?; + Ok(()) + } + + pub fn write_config_to_file(state: &AppState) -> Result<(), AppError> { + let global = state.db.get_omo_global_config()?; + let current_omo = state.db.get_current_omo_provider("opencode")?; + + let profile_data = current_omo.as_ref().map(|p| { + let agents = p.settings_config.get("agents").cloned(); + let categories = p.settings_config.get("categories").cloned(); + let other_fields = p.settings_config.get("otherFields").cloned(); + let use_common_config = p + .settings_config + .get("useCommonConfig") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + (agents, categories, other_fields, use_common_config) + }); + + let merged = Self::merge_config(&global, profile_data.as_ref()); + let config_path = Self::config_path(); + + if let Some(parent) = config_path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + write_json_file(&config_path, &merged)?; + + crate::opencode_config::add_plugin("oh-my-opencode@latest")?; + + log::info!("OMO config written to {config_path:?}"); + Ok(()) + } + + fn merge_config(global: &OmoGlobalConfig, profile_data: Option<&OmoProfileData>) -> Value { + let mut result = Map::new(); + let use_common_config = profile_data.map(|(_, _, _, v)| *v).unwrap_or(true); + + if use_common_config { + if let Some(url) = &global.schema_url { + result.insert("$schema".to_string(), Value::String(url.clone())); + } + + Self::insert_opt_value(&mut result, "sisyphus_agent", &global.sisyphus_agent); + Self::insert_string_array(&mut result, "disabled_agents", &global.disabled_agents); + Self::insert_string_array(&mut result, "disabled_mcps", &global.disabled_mcps); + Self::insert_string_array(&mut result, "disabled_hooks", &global.disabled_hooks); + Self::insert_string_array(&mut result, "disabled_skills", &global.disabled_skills); + Self::insert_opt_value(&mut result, "lsp", &global.lsp); + Self::insert_opt_value(&mut result, "experimental", &global.experimental); + Self::insert_opt_value(&mut result, "background_task", &global.background_task); + Self::insert_opt_value( + &mut result, + "browser_automation_engine", + &global.browser_automation_engine, + ); + Self::insert_opt_value(&mut result, "claude_code", &global.claude_code); + + Self::insert_object_entries(&mut result, global.other_fields.as_ref()); + } + + if let Some((agents, categories, other_fields, _)) = profile_data { + Self::insert_opt_value(&mut result, "agents", agents); + Self::insert_opt_value(&mut result, "categories", categories); + Self::insert_object_entries(&mut result, other_fields.as_ref()); + } + + Value::Object(result) + } + + pub fn import_from_local(state: &AppState) -> Result { + let actual_path = Self::resolve_local_config_path()?; + Self::import_from_path(state, &actual_path) + } + + fn import_from_path( + state: &AppState, + path: &std::path::Path, + ) -> Result { + let obj = Self::read_jsonc_object(path)?; + + let mut settings = Map::new(); + if let Some(agents) = obj.get("agents") { + settings.insert("agents".to_string(), agents.clone()); + } + if let Some(categories) = obj.get("categories") { + settings.insert("categories".to_string(), categories.clone()); + } + settings.insert("useCommonConfig".to_string(), Value::Bool(true)); + + let other = Self::extract_other_fields(&obj); + if !other.is_empty() { + settings.insert("otherFields".to_string(), Value::Object(other)); + } + + let mut global = state.db.get_omo_global_config()?; + Self::merge_global_from_obj(&obj, &mut global); + global.updated_at = chrono::Utc::now().to_rfc3339(); + state.db.save_omo_global_config(&global)?; + + let provider_id = format!("omo-{}", uuid::Uuid::new_v4()); + let name = format!("Imported {}", chrono::Local::now().format("%Y-%m-%d %H:%M")); + let settings_config = + serde_json::to_value(&settings).unwrap_or_else(|_| serde_json::json!({})); + + let provider = crate::provider::Provider { + id: provider_id, + name, + settings_config, + website_url: None, + category: Some("omo".to_string()), + created_at: Some(chrono::Utc::now().timestamp_millis()), + sort_index: None, + notes: None, + meta: None, + icon: None, + icon_color: None, + in_failover_queue: false, + }; + + state.db.save_provider("opencode", &provider)?; + state + .db + .set_omo_provider_current("opencode", &provider.id)?; + Self::write_config_to_file(state)?; + Ok(provider) + } + + pub fn read_local_file() -> Result { + let actual_path = Self::resolve_local_config_path()?; + let metadata = std::fs::metadata(&actual_path).ok(); + let last_modified = metadata + .and_then(|m| m.modified().ok()) + .map(|t| chrono::DateTime::::from(t).to_rfc3339()); + + let obj = Self::read_jsonc_object(&actual_path)?; + + let agents = obj.get("agents").cloned(); + let categories = obj.get("categories").cloned(); + + let other = Self::extract_other_fields(&obj); + let other_fields = if other.is_empty() { + None + } else { + Some(Value::Object(other)) + }; + + let mut global = OmoGlobalConfig::default(); + Self::merge_global_from_obj(&obj, &mut global); + + Ok(OmoLocalFileData { + agents, + categories, + other_fields, + global, + file_path: actual_path.to_string_lossy().to_string(), + last_modified, + }) + } + + fn strip_jsonc_comments(input: &str) -> String { + let mut result = String::with_capacity(input.len()); + let mut chars = input.chars().peekable(); + let mut in_string = false; + let mut escape = false; + + while let Some(&c) = chars.peek() { + if in_string { + result.push(c); + chars.next(); + if escape { + escape = false; + } else if c == '\\' { + escape = true; + } else if c == '"' { + in_string = false; + } + } else if c == '"' { + in_string = true; + result.push(c); + chars.next(); + } else if c == '/' { + chars.next(); + match chars.peek() { + Some('/') => { + chars.next(); + while let Some(&nc) = chars.peek() { + if nc == '\n' { + break; + } + chars.next(); + } + } + Some('*') => { + chars.next(); + while let Some(nc) = chars.next() { + if nc == '*' { + if let Some(&'/') = chars.peek() { + chars.next(); + break; + } + } + } + } + _ => { + result.push('/'); + } + } + } else { + result.push(c); + chars.next(); + } + } + result + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_strip_jsonc_comments() { + let input = r#"{ + // This is a comment + "key": "value", // inline comment + /* multi + line */ + "key2": "val//ue" +}"#; + let result = OmoService::strip_jsonc_comments(input); + let parsed: Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["key"], "value"); + assert_eq!(parsed["key2"], "val//ue"); + } + + #[test] + fn test_merge_config_empty() { + let global = OmoGlobalConfig::default(); + let merged = OmoService::merge_config(&global, None); + assert!(merged.is_object()); + } + + #[test] + fn test_merge_config_with_profile() { + let global = OmoGlobalConfig { + schema_url: Some("https://example.com/schema.json".to_string()), + disabled_agents: vec!["explore".to_string()], + ..Default::default() + }; + let agents = Some(serde_json::json!({ + "Sisyphus": { "model": "claude-opus-4-5" } + })); + let categories = None; + let other_fields = None; + let profile_data = (agents, categories, other_fields, true); + let merged = OmoService::merge_config(&global, Some(&profile_data)); + let obj = merged.as_object().unwrap(); + + assert_eq!(obj["$schema"], "https://example.com/schema.json"); + assert_eq!(obj["disabled_agents"], serde_json::json!(["explore"])); + assert!(obj.contains_key("agents")); + assert_eq!(obj["agents"]["Sisyphus"]["model"], "claude-opus-4-5"); + } + + #[test] + fn test_merge_config_without_common_config() { + let global = OmoGlobalConfig { + schema_url: Some("https://example.com/schema.json".to_string()), + disabled_agents: vec!["explore".to_string()], + ..Default::default() + }; + let agents = Some(serde_json::json!({ + "Sisyphus": { "model": "claude-opus-4-5" } + })); + let categories = None; + let other_fields = None; + let profile_data = (agents, categories, other_fields, false); + let merged = OmoService::merge_config(&global, Some(&profile_data)); + let obj = merged.as_object().unwrap(); + + assert!(!obj.contains_key("$schema")); + assert!(!obj.contains_key("disabled_agents")); + assert!(obj.contains_key("agents")); + } +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index 71f6cbe1b..c5f6abef7 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -164,6 +164,12 @@ impl ProviderService { // OpenCode uses additive mode - always write to live config if matches!(app_type, AppType::OpenCode) { + // OMO providers use exclusive mode and write to dedicated config file. + if provider.category.as_deref() == Some("omo") { + // Do not auto-enable newly added OMO providers. + // Users must explicitly switch/apply an OMO provider to activate it. + return Ok(true); + } write_live_snapshot(&app_type, &provider)?; return Ok(true); } @@ -197,6 +203,15 @@ impl ProviderService { // OpenCode uses additive mode - always update in live config if matches!(app_type, AppType::OpenCode) { + if provider.category.as_deref() == Some("omo") { + let is_omo_current = state + .db + .is_omo_provider_current(app_type.as_str(), &provider.id)?; + if is_omo_current { + crate::services::OmoService::write_config_to_file(state)?; + } + return Ok(true); + } write_live_snapshot(&app_type, &provider)?; return Ok(true); } @@ -242,6 +257,35 @@ impl ProviderService { pub fn delete(state: &AppState, app_type: AppType, id: &str) -> Result<(), AppError> { // OpenCode uses additive mode - no current provider concept if matches!(app_type, AppType::OpenCode) { + let is_omo = state + .db + .get_provider_by_id(id, app_type.as_str())? + .and_then(|p| p.category) + .as_deref() + == Some("omo"); + + if is_omo { + let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?; + let omo_count = state + .db + .get_all_providers(app_type.as_str())? + .values() + .filter(|p| p.category.as_deref() == Some("omo")) + .count(); + + if omo_count <= 1 && was_current { + return Err(AppError::Message( + "无法删除当前启用的最后一个 OMO 配置,请先停用".to_string(), + )); + } + + state.db.delete_provider(app_type.as_str(), id)?; + if was_current { + crate::services::OmoService::delete_config_file()?; + } + return Ok(()); + } + // Remove from database state.db.delete_provider(app_type.as_str(), id)?; // Also remove from live config @@ -267,10 +311,32 @@ impl ProviderService { /// Does NOT delete from database - provider remains in the list. /// This is used when user wants to "remove" a provider from active config /// but keep it available for future use. - pub fn remove_from_live_config(app_type: AppType, id: &str) -> Result<(), AppError> { + pub fn remove_from_live_config( + state: &AppState, + app_type: AppType, + id: &str, + ) -> Result<(), AppError> { match app_type { AppType::OpenCode => { - remove_opencode_provider_from_live(id)?; + let is_omo = state + .db + .get_provider_by_id(id, app_type.as_str())? + .and_then(|p| p.category) + .as_deref() + == Some("omo"); + + if is_omo { + state.db.clear_omo_provider_current(app_type.as_str(), id)?; + let still_has_current = + state.db.get_current_omo_provider("opencode")?.is_some(); + if still_has_current { + crate::services::OmoService::write_config_to_file(state)?; + } else { + crate::services::OmoService::delete_config_file()?; + } + } else { + remove_opencode_provider_from_live(id)?; + } } // Future: add other additive mode apps here _ => { @@ -302,6 +368,11 @@ impl ProviderService { .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; + // OMO providers are switched through their own exclusive path. + if matches!(app_type, AppType::OpenCode) && _provider.category.as_deref() == Some("omo") { + return Self::switch_normal(state, app_type, id, &providers); + } + // Check if proxy takeover mode is active AND proxy server is actually running // Both conditions must be true to use hot-switch mode // Use blocking wait since this is a sync function @@ -373,25 +444,28 @@ impl ProviderService { .get(id) .ok_or_else(|| AppError::Message(format!("供应商 {id} 不存在")))?; + if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo") { + state.db.set_omo_provider_current(app_type.as_str(), id)?; + crate::services::OmoService::write_config_to_file(state)?; + return Ok(()); + } + // Backfill: Backfill current live config to current provider // Use effective current provider (validated existence) to ensure backfill targets valid provider let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?; - if let Some(current_id) = current_id { - if current_id != id { - // OpenCode uses additive mode - all providers coexist in the same file, - // no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini) - if !matches!(app_type, AppType::OpenCode) { - // Only backfill when switching to a different provider - if let Ok(live_config) = read_live_settings(app_type.clone()) { - if let Some(mut current_provider) = providers.get(¤t_id).cloned() { - current_provider.settings_config = live_config; - // Ignore backfill failure, don't affect switch flow - let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); - } + match (current_id, matches!(app_type, AppType::OpenCode)) { + (Some(current_id), false) if current_id != id => { + // Only backfill when switching to a different provider. + if let Ok(live_config) = read_live_settings(app_type.clone()) { + if let Some(mut current_provider) = providers.get(¤t_id).cloned() { + current_provider.settings_config = live_config; + // Ignore backfill failure, don't affect switch flow. + let _ = state.db.save_provider(app_type.as_str(), ¤t_provider); } } } + _ => {} } // OpenCode uses additive mode - skip setting is_current (no such concept) diff --git a/src/App.tsx b/src/App.tsx index de9c6881c..7ecf95d77 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,7 +8,6 @@ import { Plus, Settings, ArrowLeft, - // Bot, // TODO: Agents 功能开发中,暂时不需要 Book, Wrench, RefreshCw, @@ -56,6 +55,7 @@ import { UniversalProviderPanel } from "@/components/universal"; import { McpIcon } from "@/components/BrandIcons"; import { Button } from "@/components/ui/button"; import { SessionManagerPage } from "@/components/sessions/SessionManagerPage"; +import { useDisableCurrentOmo } from "@/lib/query/omo"; type View = | "providers" @@ -68,7 +68,6 @@ type View = | "universal" | "sessions"; -// macOS Overlay mode needs space for traffic light buttons, Windows/Linux use native titlebar const DRAG_BAR_HEIGHT = isWindows() || isLinux() ? 0 : 28; // px const HEADER_HEIGHT = 64; // px const CONTENT_TOP_OFFSET = DRAG_BAR_HEIGHT + HEADER_HEIGHT; @@ -118,7 +117,6 @@ function App() { localStorage.setItem(VIEW_STORAGE_KEY, currentView); }, [currentView]); - // Get settings for visibleApps const { data: settingsData } = useSettingsQuery(); const visibleApps: VisibleApps = settingsData?.visibleApps ?? { claude: true, @@ -127,7 +125,6 @@ function App() { opencode: true, }; - // Get first visible app for fallback const getFirstVisibleApp = (): AppId => { if (visibleApps.claude) return "claude"; if (visibleApps.codex) return "codex"; @@ -136,7 +133,6 @@ function App() { return "claude"; // fallback }; - // If current active app is hidden, switch to first visible app useEffect(() => { if (!visibleApps[activeApp]) { setActiveApp(getFirstVisibleApp()); @@ -145,7 +141,6 @@ function App() { const [editingProvider, setEditingProvider] = useState(null); const [usageProvider, setUsageProvider] = useState(null); - // Confirm action state: 'remove' = remove from live config, 'delete' = delete from database const [confirmAction, setConfirmAction] = useState<{ provider: Provider; action: "remove" | "delete"; @@ -153,7 +148,6 @@ function App() { const [envConflicts, setEnvConflicts] = useState([]); const [showEnvBanner, setShowEnvBanner] = useState(false); - // 使用 Hook 保存最后有效值,用于动画退出期间保持内容显示 const effectiveEditingProvider = useLastValidValue(editingProvider); const effectiveUsageProvider = useLastValidValue(usageProvider); @@ -164,15 +158,12 @@ function App() { const addActionButtonClass = "bg-orange-500 hover:bg-orange-600 dark:bg-orange-500 dark:hover:bg-orange-600 text-white shadow-lg shadow-orange-500/30 dark:shadow-orange-500/40 rounded-full w-8 h-8"; - // 获取代理服务状态 const { isRunning: isProxyRunning, takeoverStatus, status: proxyStatus, } = useProxyStatus(); - // 当前应用的代理是否开启 const isCurrentAppTakeoverActive = takeoverStatus?.[activeApp] || false; - // 当前应用代理实际使用的供应商 ID(从 active_targets 中获取) const activeProviderId = useMemo(() => { const target = proxyStatus?.active_targets?.find( (t) => t.app_type === activeApp, @@ -180,7 +171,6 @@ function App() { return target?.provider_id; }, [proxyStatus?.active_targets, activeApp]); - // 获取供应商列表,当代理服务运行时自动刷新 const { data, isLoading, refetch } = useProvidersQuery(activeApp, { isProxyRunning, }); @@ -188,7 +178,6 @@ function App() { const currentProviderId = data?.currentProviderId ?? ""; const hasSkillsSupport = true; - // 🎯 使用 useProviderActions Hook 统一管理所有 Provider 操作 const { addProvider, updateProvider, @@ -197,7 +186,23 @@ function App() { saveUsageScript, } = useProviderActions(activeApp); - // 监听来自托盘菜单的切换事件 + const disableOmoMutation = useDisableCurrentOmo(); + const handleDisableOmo = () => { + disableOmoMutation.mutate(undefined, { + onSuccess: () => { + toast.success(t("omo.disabled", { defaultValue: "OMO 已停用" })); + }, + onError: (error: Error) => { + toast.error( + t("omo.disableFailed", { + defaultValue: "停用 OMO 失败: {{error}}", + error: extractErrorMessage(error), + }), + ); + }, + }); + }; + useEffect(() => { let unsubscribe: (() => void) | undefined; @@ -221,7 +226,6 @@ function App() { }; }, [activeApp, refetch]); - // 监听统一供应商同步事件,刷新所有应用的供应商列表 useEffect(() => { let unsubscribe: (() => void) | undefined; @@ -229,10 +233,7 @@ function App() { try { const { listen } = await import("@tauri-apps/api/event"); unsubscribe = await listen("universal-provider-synced", async () => { - // 统一供应商同步后刷新所有应用的供应商列表 - // 使用 invalidateQueries 使所有 providers 查询失效 await queryClient.invalidateQueries({ queryKey: ["providers"] }); - // 同时更新托盘菜单 try { await providersApi.updateTrayMenu(); } catch (error) { @@ -253,7 +254,6 @@ function App() { }; }, [queryClient]); - // 应用启动时检测所有应用的环境变量冲突 useEffect(() => { const checkEnvOnStartup = async () => { try { @@ -278,7 +278,6 @@ function App() { checkEnvOnStartup(); }, []); - // 应用启动时检查是否刚完成了配置迁移 useEffect(() => { const checkMigration = async () => { try { @@ -297,7 +296,6 @@ function App() { checkMigration(); }, [t]); - // 应用启动时检查是否刚完成了 Skills 自动导入(统一管理 SSOT) useEffect(() => { const checkSkillsMigration = async () => { try { @@ -326,14 +324,12 @@ function App() { checkSkillsMigration(); }, [t, queryClient]); - // 切换应用时检测当前应用的环境变量冲突 useEffect(() => { const checkEnvOnSwitch = async () => { try { const conflicts = await checkEnvConflicts(activeApp); if (conflicts.length > 0) { - // 合并新检测到的冲突 setEnvConflicts((prev) => { const existingKeys = new Set( prev.map((c) => `${c.varName}:${c.sourcePath}`), @@ -359,7 +355,6 @@ function App() { checkEnvOnSwitch(); }, [activeApp]); - // 全局键盘快捷键 const currentViewRef = useRef(currentView); useEffect(() => { @@ -368,17 +363,14 @@ function App() { useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { - // Cmd/Ctrl + , 打开设置 if (event.key === "," && (event.metaKey || event.ctrlKey)) { event.preventDefault(); setCurrentView("settings"); return; } - // ESC 键返回 if (event.key !== "Escape" || event.defaultPrevented) return; - // 如果有模态框打开(通过 overflow hidden 判断),则不处理全局 ESC,交给模态框处理 if (document.body.style.overflow === "hidden") return; const view = currentViewRef.current; @@ -396,7 +388,6 @@ function App() { }; }, []); - // 打开网站链接 const handleOpenWebsite = async (url: string) => { try { await settingsApi.openExternal(url); @@ -410,22 +401,17 @@ function App() { } }; - // 编辑供应商 const handleEditProvider = async (provider: Provider) => { await updateProvider(provider); setEditingProvider(null); }; - // 确认删除/移除供应商 const handleConfirmAction = async () => { if (!confirmAction) return; const { provider, action } = confirmAction; if (action === "remove") { - // Remove from live config only (for additive mode apps like OpenCode) - // Does NOT delete from database - provider remains in the list await providersApi.removeFromLiveConfig(provider.id, activeApp); - // Invalidate queries to refresh the isInConfig state await queryClient.invalidateQueries({ queryKey: ["opencodeLiveProviderIds"], }); @@ -436,13 +422,11 @@ function App() { { closeButton: true }, ); } else { - // Delete from database await deleteProvider(provider.id); } setConfirmAction(null); }; - // Generate a unique provider key for OpenCode duplication const generateUniqueOpencodeKey = ( originalKey: string, existingKeys: string[], @@ -453,7 +437,6 @@ function App() { return baseKey; } - // If -copy already exists, try -copy-2, -copy-3, ... let counter = 2; while (existingKeys.includes(`${baseKey}-${counter}`)) { counter++; @@ -461,9 +444,7 @@ function App() { return `${baseKey}-${counter}`; }; - // 复制供应商 const handleDuplicateProvider = async (provider: Provider) => { - // 1️⃣ 计算新的 sortIndex:如果原供应商有 sortIndex,则复制它 const newSortIndex = provider.sortIndex !== undefined ? provider.sortIndex + 1 : undefined; @@ -482,7 +463,6 @@ function App() { iconColor: provider.iconColor, }; - // OpenCode: generate unique provider key (used as ID) if (activeApp === "opencode") { const existingKeys = Object.keys(providers); duplicatedProvider.providerKey = generateUniqueOpencodeKey( @@ -491,7 +471,6 @@ function App() { ); } - // 2️⃣ 如果原供应商有 sortIndex,需要将后续所有供应商的 sortIndex +1 if (provider.sortIndex !== undefined) { const updates = Object.values(providers) .filter( @@ -505,7 +484,6 @@ function App() { sortIndex: p.sortIndex! + 1, })); - // 先更新现有供应商的 sortIndex,为新供应商腾出位置 if (updates.length > 0) { try { await providersApi.updateSortOrder(updates, activeApp); @@ -521,11 +499,9 @@ function App() { } } - // 3️⃣ 添加复制的供应商 await addProvider(duplicatedProvider); }; - // 打开提供商终端 const handleOpenTerminal = async (provider: Provider) => { try { await providersApi.openTerminal(provider.id, activeApp); @@ -545,10 +521,8 @@ function App() { } }; - // 导入配置成功后刷新 const handleImportSuccess = async () => { try { - // 导入会影响所有应用的供应商数据:刷新所有 providers 缓存 await queryClient.invalidateQueries({ queryKey: ["providers"], refetchType: "all", @@ -626,7 +600,6 @@ function App() { default: return (
- {/* 独立滚动容器 - 解决 Linux/Ubuntu 下 DndContext 与滚轮事件冲突 */}
{ + setEditingProvider(provider); + }} onDelete={(provider) => setConfirmAction({ provider, action: "delete" }) } @@ -658,6 +633,9 @@ function App() { setConfirmAction({ provider, action: "remove" }) : undefined } + onDisableOmo={ + activeApp === "opencode" ? handleDisableOmo : undefined + } onDuplicate={handleDuplicateProvider} onConfigureUsage={setUsageProvider} onOpenWebsite={handleOpenWebsite} @@ -695,13 +673,11 @@ function App() { className="flex flex-col h-screen overflow-hidden bg-background text-foreground selection:bg-primary/30" style={{ overflowX: "hidden", paddingTop: CONTENT_TOP_OFFSET }} > - {/* 全局拖拽区域(顶部 28px),避免上边框无法拖动 */}
- {/* 环境变量警告横幅 */} {showEnvBanner && envConflicts.length > 0 && ( { - // 删除后重新检测 try { const allConflicts = await checkAllEnvConflicts(); const flatConflicts = Object.values(allConflicts).flat(); @@ -822,7 +797,7 @@ function App() { setSettingsDefaultTab("usage"); setCurrentView("settings"); }} - title={t("settings.usage.title", { + title={t("usage.title", { defaultValue: "使用统计", })} className="hover:bg-black/5 dark:hover:bg-white/5" @@ -970,18 +945,6 @@ function App() { > - {/* TODO: Agents 功能开发中,暂时隐藏入口 */} - {/* {isClaudeApp && ( - - )} */} - {/* 供应商图标 */}
- {/* 健康状态徽章 */} + {isOmo && ( + + OMO + + )} + {isProxyRunning && isInFailoverQueue && health && ( )} - {/* 故障转移优先级徽章 */} {isAutoFailoverEnabled && isInFailoverQueue && failoverPriority && ( @@ -318,10 +303,8 @@ export function ProviderCard({ } as React.CSSProperties } > - {/* 用量信息区域 - hover 时向左移动,为操作按钮腾出空间 */}
- {/* 多套餐时显示套餐数量,单套餐时显示详细信息 */} {hasMultiplePlans ? (
@@ -342,7 +325,6 @@ export function ProviderCard({ inline={true} /> )} - {/* 展开/折叠按钮 - 仅在有多套餐时显示 */} {hasMultiplePlans && (
- {/* 操作按钮区域 - 绝对定位在右侧,hover 时滑入,与用量信息保持间距 */}
onSwitch(provider)} onEdit={() => onEdit(provider)} onDuplicate={() => onDuplicate(provider)} @@ -388,10 +371,10 @@ export function ProviderCard({ ? () => onRemoveFromConfig(provider) : undefined } + onDisableOmo={onDisableOmo} onOpenTerminal={ onOpenTerminal ? () => onOpenTerminal(provider) : undefined } - // 故障转移相关 isAutoFailoverEnabled={isAutoFailoverEnabled} isInFailoverQueue={isInFailoverQueue} onToggleFailover={onToggleFailover} @@ -400,7 +383,6 @@ export function ProviderCard({
- {/* 展开的完整套餐列表 */} {isExpanded && hasMultiplePlans && (
void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; - /** OpenCode: remove from live config (not delete from database) */ onRemoveFromConfig?: (provider: Provider) => void; + onDisableOmo?: () => void; onDuplicate: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void; onOpenWebsite: (url: string) => void; @@ -61,6 +61,7 @@ export function ProviderList({ onEdit, onDelete, onRemoveFromConfig, + onDisableOmo, onDuplicate, onConfigureUsage, onOpenWebsite, @@ -77,14 +78,12 @@ export function ProviderList({ appId, ); - // OpenCode: 查询 live 配置中的供应商 ID 列表,用于判断 isInConfig const { data: opencodeLiveIds } = useQuery({ queryKey: ["opencodeLiveProviderIds"], queryFn: () => providersApi.getOpenCodeLiveProviderIds(), enabled: appId === "opencode", }); - // OpenCode: 判断供应商是否已添加到 opencode.json const isProviderInConfig = useCallback( (providerId: string): boolean => { if (appId !== "opencode") return true; // 非 OpenCode 应用始终返回 true @@ -93,20 +92,18 @@ export function ProviderList({ [appId, opencodeLiveIds], ); - // 流式健康检查 - 功能已隐藏 - // const { checkProvider, isChecking } = useStreamCheck(appId); - - // 故障转移相关 const { data: isAutoFailoverEnabled } = useAutoFailoverEnabled(appId); const { data: failoverQueue } = useFailoverQueue(appId); const addToQueue = useAddToFailoverQueue(); const removeFromQueue = useRemoveFromFailoverQueue(); - // 联动状态:只有当前应用开启代理接管且故障转移开启时才启用故障转移模式 const isFailoverModeActive = isProxyTakeover === true && isAutoFailoverEnabled === true; - // 计算供应商在故障转移队列中的优先级(基于 sortIndex 排序) + const isOpenCode = appId === "opencode"; + const { data: currentOmoId } = useCurrentOmoProviderId(isOpenCode); + const { data: omoProviderCount } = useOmoProviderCount(isOpenCode); + const getFailoverPriority = useCallback( (providerId: string): number | undefined => { if (!isFailoverModeActive || !failoverQueue) return undefined; @@ -118,7 +115,6 @@ export function ProviderList({ [isFailoverModeActive, failoverQueue], ); - // 判断供应商是否在故障转移队列中 const isInFailoverQueue = useCallback( (providerId: string): boolean => { if (!isFailoverModeActive || !failoverQueue) return false; @@ -127,7 +123,6 @@ export function ProviderList({ [isFailoverModeActive, failoverQueue], ); - // 切换供应商的故障转移队列状态 const handleToggleFailover = useCallback( (providerId: string, enabled: boolean) => { if (enabled) { @@ -139,11 +134,6 @@ export function ProviderList({ [appId, addToQueue, removeFromQueue], ); - // handleTest 功能已隐藏 - 供应商请求格式复杂难以统一测试 - // const handleTest = (provider: Provider) => { - // checkProvider(provider.id, provider.name); - // }; - const [searchTerm, setSearchTerm] = useState(""); const [isSearchOpen, setIsSearchOpen] = useState(false); const searchInputRef = useRef(null); @@ -215,36 +205,44 @@ export function ProviderList({ strategy={verticalListSortingStrategy} >
- {filteredProviders.map((provider) => ( - - handleToggleFailover(provider.id, enabled) - } - activeProviderId={activeProviderId} - /> - ))} + {filteredProviders.map((provider) => { + const isOmo = provider.category === "omo"; + const isOmoCurrent = isOmo && provider.id === (currentOmoId || ""); + return ( + + handleToggleFailover(provider.id, enabled) + } + activeProviderId={activeProviderId} + /> + ); + })}
@@ -334,11 +332,13 @@ interface SortableProviderCardProps { isCurrent: boolean; appId: AppId; isInConfig: boolean; + isOmo: boolean; + isLastOmo: boolean; onSwitch: (provider: Provider) => void; onEdit: (provider: Provider) => void; onDelete: (provider: Provider) => void; - /** OpenCode: remove from live config (not delete from database) */ onRemoveFromConfig?: (provider: Provider) => void; + onDisableOmo?: () => void; onDuplicate: (provider: Provider) => void; onConfigureUsage?: (provider: Provider) => void; onOpenWebsite: (url: string) => void; @@ -347,7 +347,6 @@ interface SortableProviderCardProps { isTesting: boolean; isProxyRunning: boolean; isProxyTakeover: boolean; - // 故障转移相关 isAutoFailoverEnabled: boolean; failoverPriority?: number; isInFailoverQueue: boolean; @@ -360,10 +359,13 @@ function SortableProviderCard({ isCurrent, appId, isInConfig, + isOmo, + isLastOmo, onSwitch, onEdit, onDelete, onRemoveFromConfig, + onDisableOmo, onDuplicate, onConfigureUsage, onOpenWebsite, @@ -399,10 +401,13 @@ function SortableProviderCard({ isCurrent={isCurrent} appId={appId} isInConfig={isInConfig} + isOmo={isOmo} + isLastOmo={isLastOmo} onSwitch={onSwitch} onEdit={onEdit} onDelete={onDelete} onRemoveFromConfig={onRemoveFromConfig} + onDisableOmo={onDisableOmo} onDuplicate={onDuplicate} onConfigureUsage={ onConfigureUsage ? (item) => onConfigureUsage(item) : () => undefined @@ -418,7 +423,6 @@ function SortableProviderCard({ listeners, isDragging, }} - // 故障转移相关 isAutoFailoverEnabled={isAutoFailoverEnabled} failoverPriority={failoverPriority} isInFailoverQueue={isInFailoverQueue} diff --git a/src/components/providers/forms/OmoCommonConfigEditor.tsx b/src/components/providers/forms/OmoCommonConfigEditor.tsx new file mode 100644 index 000000000..575d3c54b --- /dev/null +++ b/src/components/providers/forms/OmoCommonConfigEditor.tsx @@ -0,0 +1,161 @@ +import { useTranslation } from "react-i18next"; +import { useEffect, useState } from "react"; +import { FullScreenPanel } from "@/components/common/FullScreenPanel"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import { Save, FolderInput, Loader2 } from "lucide-react"; +import JsonEditor from "@/components/JsonEditor"; +import { + OmoGlobalConfigFields, + type OmoGlobalConfigFieldsRef, +} from "./OmoGlobalConfigFields"; +import type { OmoGlobalConfig } from "@/types/omo"; + +interface OmoCommonConfigEditorProps { + previewValue: string; + useCommonConfig: boolean; + onCommonConfigToggle: (checked: boolean) => void; + isModalOpen: boolean; + onEditClick: () => void; + onModalClose: () => void; + onSave: () => Promise; + isSaving: boolean; + onGlobalConfigStateChange: (config: OmoGlobalConfig) => void; + globalConfigRef: React.RefObject; + fieldsKey: number; +} + +export function OmoCommonConfigEditor({ + previewValue, + useCommonConfig, + onCommonConfigToggle, + isModalOpen, + onEditClick, + onModalClose, + onSave, + isSaving, + onGlobalConfigStateChange, + globalConfigRef, + fieldsKey, +}: OmoCommonConfigEditorProps) { + const { t } = useTranslation(); + const [isDarkMode, setIsDarkMode] = useState(false); + const [isImporting, setIsImporting] = useState(false); + useEffect(() => { + const syncDarkMode = () => + setIsDarkMode(document.documentElement.classList.contains("dark")); + syncDarkMode(); + const observer = new MutationObserver(syncDarkMode); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => observer.disconnect(); + }, []); + const handleImportLocal = async () => { + if (!globalConfigRef.current) return; + setIsImporting(true); + try { + await globalConfigRef.current.importFromLocal(); + } finally { + setIsImporting(false); + } + }; + return ( + <> +
+
+ +
+ +
+
+
+ +
+ {}} + darkMode={isDarkMode} + rows={14} + showValidation={false} + language="json" + /> +
+ + + + + + } + > +
+

+ {t("omo.commonConfigHint", { + defaultValue: + "OMO common config will be merged into all OMO configs that enable it", + })} +

+ } + onStateChange={onGlobalConfigStateChange} + hideSaveButtons + /> +
+
+ + ); +} diff --git a/src/components/providers/forms/OmoFormFields.tsx b/src/components/providers/forms/OmoFormFields.tsx new file mode 100644 index 000000000..2ee356a3c --- /dev/null +++ b/src/components/providers/forms/OmoFormFields.tsx @@ -0,0 +1,1031 @@ +import { useState, useCallback, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Plus, + Trash2, + ChevronDown, + ChevronRight, + Wand2, + Settings, + FolderInput, + Loader2, +} from "lucide-react"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { useReadOmoLocalFile } from "@/lib/query/omo"; +import { + OMO_BUILTIN_AGENTS, + OMO_BUILTIN_CATEGORIES, + type OmoAgentDef, + type OmoCategoryDef, +} from "@/types/omo"; + +const ADVANCED_PLACEHOLDER = `{ + "temperature": 0.5, + "top_p": 0.9, + "budgetTokens": 20000, + "prompt_append": "", + "permission": { "edit": "allow", "bash": "ask" } +}`; + +interface OmoFormFieldsProps { + modelOptions: Array<{ value: string; label: string }>; + modelVariantsMap?: Record; + agents: Record>; + onAgentsChange: (agents: Record>) => void; + categories: Record>; + onCategoriesChange: ( + categories: Record>, + ) => void; + otherFieldsStr: string; + onOtherFieldsStrChange: (value: string) => void; +} + +type CustomModelItem = { key: string; model: string }; +type BuiltinModelDef = Pick< + OmoAgentDef | OmoCategoryDef, + "key" | "display" | "descZh" | "descEn" | "recommended" +>; +type ModelOption = { value: string; label: string }; + +const BUILTIN_AGENT_KEYS = new Set(OMO_BUILTIN_AGENTS.map((a) => a.key)); +const BUILTIN_CATEGORY_KEYS = new Set(OMO_BUILTIN_CATEGORIES.map((c) => c.key)); +const EMPTY_MODEL_VALUE = "__cc_switch_omo_model_empty__"; +const UNAVAILABLE_MODEL_VALUE = "__cc_switch_omo_model_unavailable__"; +const EMPTY_VARIANT_VALUE = "__cc_switch_omo_variant_empty__"; +const UNAVAILABLE_VARIANT_VALUE = "__cc_switch_omo_variant_unavailable__"; + +function getAdvancedStr(config: Record | undefined): string { + if (!config) return ""; + const adv: Record = {}; + for (const [k, v] of Object.entries(config)) { + if (k !== "model" && k !== "variant") adv[k] = v; + } + return Object.keys(adv).length > 0 ? JSON.stringify(adv, null, 2) : ""; +} + +function collectCustomModels( + store: Record>, + builtinKeys: Set, +): CustomModelItem[] { + const customs: CustomModelItem[] = []; + for (const [k, v] of Object.entries(store)) { + if (!builtinKeys.has(k) && typeof v === "object" && v !== null) { + customs.push({ + key: k, + model: ((v as Record).model as string) || "", + }); + } + } + return customs; +} + +function mergeCustomModelsIntoStore( + store: Record>, + builtinKeys: Set, + customs: CustomModelItem[], +): Record> { + const updated = { ...store }; + for (const key of Object.keys(updated)) { + if (!builtinKeys.has(key)) delete updated[key]; + } + for (const custom of customs) { + if (custom.key.trim()) { + updated[custom.key] = { ...updated[custom.key], model: custom.model }; + } + } + return updated; +} + +export function OmoFormFields({ + modelOptions, + modelVariantsMap = {}, + agents, + onAgentsChange, + categories, + onCategoriesChange, + otherFieldsStr, + onOtherFieldsStrChange, +}: OmoFormFieldsProps) { + const { t, i18n } = useTranslation(); + const isZh = i18n.language?.startsWith("zh"); + + const [mainAgentsOpen, setMainAgentsOpen] = useState(true); + const [subAgentsOpen, setSubAgentsOpen] = useState(true); + const [categoriesOpen, setCategoriesOpen] = useState(true); + const [otherFieldsOpen, setOtherFieldsOpen] = useState(false); + + const [expandedAgents, setExpandedAgents] = useState>( + {}, + ); + const [expandedCategories, setExpandedCategories] = useState< + Record + >({}); + const [agentAdvancedDrafts, setAgentAdvancedDrafts] = useState< + Record + >({}); + const [categoryAdvancedDrafts, setCategoryAdvancedDrafts] = useState< + Record + >({}); + + const [customAgents, setCustomAgents] = useState(() => + collectCustomModels(agents, BUILTIN_AGENT_KEYS), + ); + + const [customCategories, setCustomCategories] = useState( + () => collectCustomModels(categories, BUILTIN_CATEGORY_KEYS), + ); + + useEffect(() => { + setCustomAgents(collectCustomModels(agents, BUILTIN_AGENT_KEYS)); + }, [agents]); + + useEffect(() => { + setCustomCategories(collectCustomModels(categories, BUILTIN_CATEGORY_KEYS)); + }, [categories]); + + const syncCustomAgents = useCallback( + (customs: CustomModelItem[]) => { + onAgentsChange( + mergeCustomModelsIntoStore(agents, BUILTIN_AGENT_KEYS, customs), + ); + }, + [agents, onAgentsChange], + ); + + const syncCustomCategories = useCallback( + (customs: CustomModelItem[]) => { + onCategoriesChange( + mergeCustomModelsIntoStore(categories, BUILTIN_CATEGORY_KEYS, customs), + ); + }, + [categories, onCategoriesChange], + ); + + const buildEffectiveModelOptions = useCallback( + (currentModel: string): ModelOption[] => { + if (!currentModel) return modelOptions; + if (modelOptions.some((item) => item.value === currentModel)) { + return modelOptions; + } + return [ + { + value: currentModel, + label: t("omo.currentValueNotEnabled", { + value: currentModel, + defaultValue: "{{value}} (current value, not enabled)", + }), + }, + ...modelOptions, + ]; + }, + [modelOptions, t], + ); + + const resolveRecommendedModel = useCallback( + (recommended?: string): string | undefined => { + if (!recommended || modelOptions.length === 0) return undefined; + + const exact = modelOptions.find((item) => item.value === recommended); + if (exact) return exact.value; + + const bySuffix = modelOptions.find((item) => + item.value.endsWith(`/${recommended}`), + ); + return bySuffix?.value; + }, + [modelOptions], + ); + + const renderModelSelect = ( + currentModel: string, + onChange: (value: string) => void, + placeholder?: string, + ) => { + const options = buildEffectiveModelOptions(currentModel); + return ( + + ); + }; + + const buildEffectiveVariantOptions = useCallback( + (currentModel: string, currentVariant: string): string[] => { + const variantKeys = modelVariantsMap[currentModel] || []; + if (!currentVariant || variantKeys.includes(currentVariant)) { + return variantKeys; + } + return [currentVariant, ...variantKeys]; + }, + [modelVariantsMap], + ); + + const renderVariantSelect = ( + currentModel: string, + currentVariant: string, + onChange: (value: string) => void, + ) => { + const variantOptions = buildEffectiveVariantOptions( + currentModel, + currentVariant, + ); + const hasModel = Boolean(currentModel); + const firstIsUnavailable = + Boolean(currentVariant) && + !(modelVariantsMap[currentModel] || []).includes(currentVariant); + + return ( + + ); + }; + + const handleModelChange = ( + key: string, + model: string, + store: Record>, + setter: (v: Record>) => void, + ) => { + if (model.trim()) { + const nextEntry: Record = { + ...(store[key] || {}), + model, + }; + const currentVariant = + typeof nextEntry.variant === "string" ? nextEntry.variant : ""; + if (currentVariant) { + const validVariants = modelVariantsMap[model] || []; + if (!validVariants.includes(currentVariant)) { + delete nextEntry.variant; + } + } + setter({ ...store, [key]: nextEntry }); + } else { + const existing = store[key]; + if (existing) { + const adv = { ...existing }; + delete adv.model; + delete adv.variant; + if (Object.keys(adv).length > 0) { + setter({ ...store, [key]: adv }); + } else { + const next = { ...store }; + delete next[key]; + setter(next); + } + } + } + }; + + const handleVariantChange = ( + key: string, + variant: string, + store: Record>, + setter: (v: Record>) => void, + ) => { + const existing = store[key]; + if (variant.trim()) { + setter({ ...store, [key]: { ...existing, variant } }); + return; + } + + if (!existing) return; + const nextEntry = { ...existing }; + delete nextEntry.variant; + if (Object.keys(nextEntry).length > 0) { + setter({ ...store, [key]: nextEntry }); + return; + } + + const next = { ...store }; + delete next[key]; + setter(next); + }; + + const handleAdvancedChange = ( + key: string, + rawJson: string, + store: Record>, + setter: (v: Record>) => void, + ): boolean => { + const currentModel = (store[key]?.model as string) || ""; + const currentVariant = (store[key]?.variant as string) || ""; + if (!rawJson.trim()) { + if (currentModel || currentVariant) { + setter({ + ...store, + [key]: { + ...(currentModel ? { model: currentModel } : {}), + ...(currentVariant ? { variant: currentVariant } : {}), + }, + }); + } else { + const next = { ...store }; + delete next[key]; + setter(next); + } + return true; + } + try { + const parsed = JSON.parse(rawJson); + if ( + typeof parsed === "object" && + parsed !== null && + !Array.isArray(parsed) + ) { + const parsedAdvanced = { ...(parsed as Record) }; + delete parsedAdvanced.model; + delete parsedAdvanced.variant; + setter({ + ...store, + [key]: { + ...(currentModel ? { model: currentModel } : {}), + ...(currentVariant ? { variant: currentVariant } : {}), + ...parsedAdvanced, + }, + }); + return true; + } + return false; + } catch { + return false; + } + }; + + type AdvancedScope = "agent" | "category"; + + const setAdvancedDraft = ( + scope: AdvancedScope, + key: string, + value: string, + ) => { + if (scope === "agent") { + setAgentAdvancedDrafts((prev) => ({ ...prev, [key]: value })); + return; + } + setCategoryAdvancedDrafts((prev) => ({ ...prev, [key]: value })); + }; + + const removeAdvancedDraft = (scope: AdvancedScope, key: string) => { + if (scope === "agent") { + setAgentAdvancedDrafts((prev) => { + const copied = { ...prev }; + delete copied[key]; + return copied; + }); + return; + } + setCategoryAdvancedDrafts((prev) => { + const copied = { ...prev }; + delete copied[key]; + return copied; + }); + }; + + const toggleAdvancedEditor = ( + scope: AdvancedScope, + key: string, + advStr: string, + isExpanded: boolean, + ) => { + const willOpen = !isExpanded; + if (scope === "agent") { + setExpandedAgents((prev) => ({ ...prev, [key]: willOpen })); + if (willOpen && agentAdvancedDrafts[key] === undefined) { + setAdvancedDraft(scope, key, advStr); + } + return; + } + setExpandedCategories((prev) => ({ ...prev, [key]: willOpen })); + if (willOpen && categoryAdvancedDrafts[key] === undefined) { + setAdvancedDraft(scope, key, advStr); + } + }; + + const renderAdvancedEditor = ({ + scope, + draftKey, + configKey, + draftValue, + store, + setter, + showHint, + }: { + scope: AdvancedScope; + draftKey: string; + configKey: string; + draftValue: string; + store: Record>; + setter: (value: Record>) => void; + showHint?: boolean; + }) => ( +
+