feat(omo): add OMO Slim (oh-my-opencode-slim) support

Implement full OMO Slim profile management to align with ai-toolbox:
- Backend: Slim service methods, DAO, Tauri commands, plugin conflict handling
- Frontend: types, API, query hooks, form integration with isSlim parameterization
- Slim variant: 6 agents (no categories), separate config file and plugin name
- Mutual exclusion: standard OMO and Slim cannot coexist as plugins
- i18n: zh/en/ja translations for all Slim agent descriptions
This commit is contained in:
Jason
2026-02-19 20:47:55 +08:00
parent 51476953ae
commit 8e219b5eb1
26 changed files with 1176 additions and 165 deletions
+224 -17
View File
@@ -52,26 +52,48 @@ impl OmoService {
.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",
];
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",
];
const KNOWN_KEYS_SLIM: [&str; 8] = [
"$schema",
"agents",
"sisyphus_agent",
"disabled_agents",
"disabled_mcps",
"disabled_hooks",
"lsp",
"experimental",
];
fn extract_other_fields(obj: &Map<String, Value>) -> Map<String, Value> {
Self::extract_other_fields_with_keys(obj, &Self::KNOWN_KEYS)
}
fn extract_other_fields_slim(obj: &Map<String, Value>) -> Map<String, Value> {
Self::extract_other_fields_with_keys(obj, &Self::KNOWN_KEYS_SLIM)
}
fn extract_other_fields_with_keys(
obj: &Map<String, Value>,
known: &[&str],
) -> Map<String, Value> {
let mut other = Map::new();
for (k, v) in obj {
if !KNOWN_KEYS.contains(&k.as_str()) {
if !known.contains(&k.as_str()) {
other.insert(k.clone(), v.clone());
}
}
@@ -142,6 +164,24 @@ impl OmoService {
}
}
fn config_path_slim() -> PathBuf {
get_opencode_dir().join("oh-my-opencode-slim.jsonc")
}
fn resolve_local_config_path_slim() -> Result<PathBuf, AppError> {
let config_path = Self::config_path_slim();
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)
}
pub fn delete_config_file() -> Result<(), AppError> {
let config_path = Self::config_path();
if config_path.exists() {
@@ -152,6 +192,16 @@ impl OmoService {
Ok(())
}
pub fn delete_config_file_slim() -> Result<(), AppError> {
let config_path = Self::config_path_slim();
if config_path.exists() {
std::fs::remove_file(&config_path).map_err(|e| AppError::io(&config_path, e))?;
log::info!("OMO Slim config file deleted: {config_path:?}");
}
crate::opencode_config::remove_plugin_by_prefix("oh-my-opencode-slim")?;
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")?;
@@ -183,6 +233,36 @@ impl OmoService {
Ok(())
}
pub fn write_config_to_file_slim(state: &AppState) -> Result<(), AppError> {
let global = state.db.get_omo_slim_global_config()?;
let current_omo = state.db.get_current_omo_slim_provider("opencode")?;
let profile_data = current_omo.as_ref().map(|p| {
let agents = p.settings_config.get("agents").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, None, other_fields, use_common_config)
});
let merged = Self::merge_config_slim(&global, profile_data.as_ref());
let config_path = Self::config_path_slim();
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-slim@latest")?;
log::info!("OMO Slim 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);
@@ -219,6 +299,36 @@ impl OmoService {
Value::Object(result)
}
/// Merge config for Slim variant (no categories, no disabled_skills, no background_task, etc.)
fn merge_config_slim(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);
// Slim does NOT support: disabled_skills, background_task, browser_automation_engine, claude_code
Self::insert_opt_value(&mut result, "lsp", &global.lsp);
Self::insert_opt_value(&mut result, "experimental", &global.experimental);
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);
// Slim does NOT have 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)
@@ -277,6 +387,58 @@ impl OmoService {
Ok(provider)
}
pub fn import_from_local_slim(state: &AppState) -> Result<crate::provider::Provider, AppError> {
let actual_path = Self::resolve_local_config_path_slim()?;
let obj = Self::read_jsonc_object(&actual_path)?;
let mut settings = Map::new();
if let Some(agents) = obj.get("agents") {
settings.insert("agents".to_string(), agents.clone());
}
// Slim has no categories
settings.insert("useCommonConfig".to_string(), Value::Bool(true));
let other = Self::extract_other_fields_slim(&obj);
if !other.is_empty() {
settings.insert("otherFields".to_string(), Value::Object(other));
}
let mut global = state.db.get_omo_slim_global_config()?;
Self::merge_global_from_obj(&obj, &mut global);
global.updated_at = chrono::Utc::now().to_rfc3339();
state.db.save_omo_slim_global_config(&global)?;
let provider_id = format!("omo-slim-{}", uuid::Uuid::new_v4());
let name = format!(
"Imported Slim {}",
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-slim".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_slim_provider_current("opencode", &provider.id)?;
Self::write_config_to_file_slim(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();
@@ -293,6 +455,22 @@ impl OmoService {
))
}
pub fn read_local_file_slim() -> Result<OmoLocalFileData, AppError> {
let actual_path = Self::resolve_local_config_path_slim()?;
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)?;
Ok(Self::build_local_file_data_slim_from_obj(
&obj,
actual_path.to_string_lossy().to_string(),
last_modified,
))
}
fn build_local_file_data_from_obj(
obj: &Map<String, Value>,
file_path: String,
@@ -322,6 +500,35 @@ impl OmoService {
}
}
fn build_local_file_data_slim_from_obj(
obj: &Map<String, Value>,
file_path: String,
last_modified: Option<String>,
) -> OmoLocalFileData {
let agents = obj.get("agents").cloned();
// Slim has no categories
let other = Self::extract_other_fields_slim(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);
global.other_fields = other_fields.clone();
OmoLocalFileData {
agents,
categories: None,
other_fields,
global,
file_path,
last_modified,
}
}
fn strip_jsonc_comments(input: &str) -> String {
let mut result = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
+72 -14
View File
@@ -167,8 +167,7 @@ impl ProviderService {
// Additive mode apps (OpenCode, OpenClaw) - always write to live config
if app_type.is_additive_mode() {
// OMO providers use exclusive mode and write to dedicated config file.
if matches!(app_type, AppType::OpenCode)
&& provider.category.as_deref() == Some("omo")
if matches!(app_type, AppType::OpenCode) && 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.
@@ -207,8 +206,7 @@ impl ProviderService {
// Additive mode apps (OpenCode, OpenClaw) - always update in live config
if app_type.is_additive_mode() {
if matches!(app_type, AppType::OpenCode)
&& provider.category.as_deref() == Some("omo")
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo")
{
let is_omo_current = state
.db
@@ -218,6 +216,17 @@ impl ProviderService {
}
return Ok(true);
}
if matches!(app_type, AppType::OpenCode)
&& provider.category.as_deref() == Some("omo-slim")
{
let is_current = state
.db
.is_omo_slim_provider_current(app_type.as_str(), &provider.id)?;
if is_current {
crate::services::OmoService::write_config_to_file_slim(state)?;
}
return Ok(true);
}
write_live_snapshot(&app_type, &provider)?;
return Ok(true);
}
@@ -264,14 +273,12 @@ impl ProviderService {
// Additive mode apps - no current provider concept
if app_type.is_additive_mode() {
if matches!(app_type, AppType::OpenCode) {
let is_omo = state
let provider_category = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category)
.as_deref()
== Some("omo");
.and_then(|p| p.category);
if is_omo {
if provider_category.as_deref() == Some("omo") {
let was_current = state.db.is_omo_provider_current(app_type.as_str(), id)?;
let omo_count = state
.db
@@ -292,6 +299,30 @@ impl ProviderService {
}
return Ok(());
}
if provider_category.as_deref() == Some("omo-slim") {
let was_current = state
.db
.is_omo_slim_provider_current(app_type.as_str(), id)?;
let slim_count = state
.db
.get_all_providers(app_type.as_str())?
.values()
.filter(|p| p.category.as_deref() == Some("omo-slim"))
.count();
if slim_count <= 1 && was_current {
return Err(AppError::Message(
"无法删除当前启用的最后一个 OMO Slim 配置,请先停用".to_string(),
));
}
state.db.delete_provider(app_type.as_str(), id)?;
if was_current {
crate::services::OmoService::delete_config_file_slim()?;
}
return Ok(());
}
}
// Remove from database
state.db.delete_provider(app_type.as_str(), id)?;
@@ -329,14 +360,12 @@ impl ProviderService {
) -> Result<(), AppError> {
match app_type {
AppType::OpenCode => {
let is_omo = state
let provider_category = state
.db
.get_provider_by_id(id, app_type.as_str())?
.and_then(|p| p.category)
.as_deref()
== Some("omo");
.and_then(|p| p.category);
if is_omo {
if provider_category.as_deref() == Some("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();
@@ -345,6 +374,19 @@ impl ProviderService {
} else {
crate::services::OmoService::delete_config_file()?;
}
} else if provider_category.as_deref() == Some("omo-slim") {
state
.db
.clear_omo_slim_provider_current(app_type.as_str(), id)?;
let still_has_current = state
.db
.get_current_omo_slim_provider("opencode")?
.is_some();
if still_has_current {
crate::services::OmoService::write_config_to_file_slim(state)?;
} else {
crate::services::OmoService::delete_config_file_slim()?;
}
} else {
remove_opencode_provider_from_live(id)?;
}
@@ -386,6 +428,13 @@ impl ProviderService {
return Self::switch_normal(state, app_type, id, &providers);
}
// OMO Slim providers are switched through their own exclusive path.
if matches!(app_type, AppType::OpenCode)
&& _provider.category.as_deref() == Some("omo-slim")
{
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
@@ -463,6 +512,15 @@ impl ProviderService {
return Ok(());
}
if matches!(app_type, AppType::OpenCode) && provider.category.as_deref() == Some("omo-slim")
{
state
.db
.set_omo_slim_provider_current(app_type.as_str(), id)?;
crate::services::OmoService::write_config_to_file_slim(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)?;