feat(omo): integrate Oh My OpenCode profile management (#972)

* feat(omo): integrate Oh My OpenCode profile management into Provider system

Adds full-stack OMO support: backend config read/write/import, OMO-specific
provider CRUD with exclusive switching, frontend profile editor with
agent/category/model configuration, global config management, and i18n support.

* feat(omo): add model/variant dropdowns from enabled providers

Replace model text inputs with Select dropdowns sourced from enabled
OpenCode providers, add thinking-level variant selection, and prevent
auto-enabling newly added OMO providers.

* fix(omo): use standard provider action styles for OMO switch button

* fix(omo): replace hardcoded isZh strings with proper i18n t() calls
This commit is contained in:
Dex Miller
2026-02-09 12:18:20 +08:00
committed by GitHub
parent 5502c74a79
commit ed47480fc7
35 changed files with 4304 additions and 737 deletions
+13 -24
View File
@@ -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<ConfigStatus, String> {
Ok(config::get_claude_config_status())
@@ -63,13 +62,11 @@ pub async fn get_config_status(app: String) -> Result<ConfigStatus, String> {
}
}
/// 获取 Claude Code 配置文件路径
#[tauri::command]
pub async fn get_claude_code_config_path() -> Result<String, String> {
Ok(get_claude_settings_path().to_string_lossy().to_string())
}
/// 获取当前生效的配置目录
#[tauri::command]
pub async fn get_config_dir(app: String) -> Result<String, String> {
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<String, String> {
Ok(dir.to_string_lossy().to_string())
}
/// 打开配置文件夹
#[tauri::command]
pub async fn open_config_folder(handle: AppHandle, app: String) -> Result<bool, String> {
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<bool,
Ok(true)
}
/// 弹出系统目录选择器并返回用户选择的路径
#[tauri::command]
pub async fn pick_directory(
app: AppHandle,
@@ -136,14 +131,12 @@ pub async fn pick_directory(
}
}
/// 获取应用配置文件路径
#[tauri::command]
pub async fn get_app_config_path() -> Result<String, String> {
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<bool, String> {
let config_dir = config::get_app_config_dir();
@@ -160,7 +153,6 @@ pub async fn open_app_config_folder(handle: AppHandle) -> Result<bool, String> {
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::<serde_json::Value>(&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::<serde_json::Value>(&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,
+2
View File
@@ -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::*;
+50
View File
@@ -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<OmoLocalFileData, String> {
OmoService::read_local_file().map_err(|e| e.to_string())
}
#[tauri::command]
pub async fn get_current_omo_provider_id(state: State<'_, AppState>) -> Result<String, String> {
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<usize, String> {
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)
}
+6 -47
View File
@@ -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<String, String> {
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<bool, String> {
pub fn remove_provider_from_live_config(
state: tauri::State<'_, AppState>,
app: String,
id: String,
) -> Result<bool, String> {
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<bool, String> {
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<serde_json::Value, String> {
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<String>,
@@ -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<usize, String> {
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<Vec<String>, String> {
crate::opencode_config::get_providers()
+2
View File
@@ -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;
+73
View File
@@ -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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sisyphus_agent: Option<serde_json::Value>,
#[serde(default)]
pub disabled_agents: Vec<String>,
#[serde(default)]
pub disabled_mcps: Vec<String>,
#[serde(default)]
pub disabled_hooks: Vec<String>,
#[serde(default)]
pub disabled_skills: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub lsp: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub experimental: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub background_task: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub browser_automation_engine: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub claude_code: Option<serde_json::Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub other_fields: Option<serde_json::Value>,
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<OmoGlobalConfig, AppError> {
let json_str = self.get_setting("common_config_omo")?;
match json_str {
Some(s) => serde_json::from_str::<OmoGlobalConfig>(&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(())
}
}
+133 -24
View File
@@ -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<String>,
Option<i64>,
Option<usize>,
Option<String>,
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<Option<String>, 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<bool, AppError> {
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<Option<Provider>, AppError> {
let conn = lock_conn!(self.conn);
let row_data: Result<OmoProviderRow, rusqlite::Error> = 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,
}))
}
}
+1
View File
@@ -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;
+2
View File
@@ -52,6 +52,8 @@ pub enum AppError {
},
#[error("数据库错误: {0}")]
Database(String),
#[error("OMO 配置文件不存在")]
OmoConfigNotFound,
#[error("所有供应商已熔断,无可用渠道")]
AllProvidersCircuitOpen,
#[error("未配置供应商")]
+26
View File
@@ -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
+54 -71
View File
@@ -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<Value, AppError> {
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<Value, AppError> {
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<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -110,7 +55,6 @@ pub fn get_providers() -> Result<Map<String, Value>, 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<IndexMap<String, OpenCodeProviderConfig>, AppError> {
let providers = get_providers()?;
let mut result = IndexMap::new();
@@ -155,7 +93,6 @@ pub fn get_typed_providers() -> Result<IndexMap<String, OpenCodeProviderConfig>,
}
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<IndexMap<String, OpenCodeProviderConfig>,
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<Map<String, Value>, AppError> {
let config = read_opencode_config()?;
Ok(config
@@ -183,7 +114,6 @@ pub fn get_mcp_servers() -> Result<Map<String, Value>, 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)
}
+2
View File
@@ -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;
+437
View File
@@ -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<Value>,
pub categories: Option<Value>,
pub other_fields: Option<Value>,
pub global: OmoGlobalConfig,
pub file_path: String,
pub last_modified: Option<String>,
}
type OmoProfileData = (Option<Value>, Option<Value>, Option<Value>, bool);
pub struct OmoService;
impl OmoService {
fn config_path() -> PathBuf {
get_opencode_dir().join("oh-my-opencode.jsonc")
}
fn resolve_local_config_path() -> Result<PathBuf, AppError> {
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<Map<String, Value>, 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<String, Value>) -> Map<String, Value> {
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<String> {
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<String, Value>, 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<String, Value>, key: &str, value: &Option<Value>) {
if let Some(v) = value {
result.insert(key.to_string(), v.clone());
}
}
fn insert_string_array(result: &mut Map<String, Value>, 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<String, Value>, 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<crate::provider::Provider, AppError> {
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<crate::provider::Provider, AppError> {
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<OmoLocalFileData, AppError> {
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::<chrono::Utc>::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"));
}
}
+88 -14
View File
@@ -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(&current_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(), &current_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(&current_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(), &current_provider);
}
}
}
_ => {}
}
// OpenCode uses additive mode - skip setting is_current (no such concept)