Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 171 additions & 4 deletions src-tauri/src/app_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::str::FromStr;

use crate::prompt_files::prompt_file_path;
use crate::services::skill::SkillStore;

/// MCP 服务器应用状态(标记应用到哪些客户端)
Expand All @@ -27,6 +28,7 @@ impl McpApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::Hermes => self.hermes,
AppType::OpenClaw => false,
}
}
Expand All @@ -38,6 +40,7 @@ impl McpApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::Hermes => self.hermes = enabled,
AppType::OpenClaw => {}
}
}
Expand All @@ -57,6 +60,9 @@ impl McpApps {
if self.opencode {
apps.push(AppType::OpenCode);
}
if self.hermes {
apps.push(AppType::Hermes);
}
apps
}

Expand All @@ -77,6 +83,8 @@ pub struct SkillApps {
pub gemini: bool,
#[serde(default)]
pub opencode: bool,
#[serde(default)]
pub hermes: bool,
}

impl SkillApps {
Expand All @@ -86,6 +94,7 @@ impl SkillApps {
AppType::Codex => self.codex,
AppType::Gemini => self.gemini,
AppType::OpenCode => self.opencode,
AppType::Hermes => self.hermes,
AppType::OpenClaw => false,
}
}
Expand All @@ -96,12 +105,13 @@ impl SkillApps {
AppType::Codex => self.codex = enabled,
AppType::Gemini => self.gemini = enabled,
AppType::OpenCode => self.opencode = enabled,
AppType::Hermes => self.hermes = enabled,
AppType::OpenClaw => {}
}
}

pub fn is_empty(&self) -> bool {
!self.claude && !self.codex && !self.gemini && !self.opencode
!self.claude && !self.codex && !self.gemini && !self.opencode && !self.hermes
}

pub fn only(app: &AppType) -> Self {
Expand All @@ -125,6 +135,7 @@ impl SkillApps {
self.codex |= other.codex;
self.gemini |= other.gemini;
self.opencode |= other.opencode;
self.hermes |= other.hermes;
}
}

Expand Down Expand Up @@ -223,6 +234,8 @@ pub struct McpRoot {
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub opencode: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub hermes: McpConfig,
#[serde(default, skip_serializing_if = "McpConfig::is_empty")]
pub openclaw: McpConfig,
}

Expand All @@ -236,6 +249,7 @@ impl Default for McpRoot {
codex: McpConfig::default(),
gemini: McpConfig::default(),
opencode: McpConfig::default(),
hermes: McpConfig::default(),
openclaw: McpConfig::default(),
}
}
Expand All @@ -260,6 +274,8 @@ pub struct PromptRoot {
#[serde(default)]
pub opencode: PromptConfig,
#[serde(default)]
pub hermes: PromptConfig,
#[serde(default)]
pub openclaw: PromptConfig,
}

Expand All @@ -275,6 +291,7 @@ pub enum AppType {
Codex,
Gemini,
OpenCode,
Hermes,
OpenClaw,
}

Expand All @@ -285,12 +302,16 @@ impl AppType {
AppType::Codex => "codex",
AppType::Gemini => "gemini",
AppType::OpenCode => "opencode",
AppType::Hermes => "hermes",
AppType::OpenClaw => "openclaw",
}
}

pub fn is_additive_mode(&self) -> bool {
matches!(self, AppType::OpenCode | AppType::OpenClaw)
matches!(
self,
AppType::OpenCode | AppType::Hermes | AppType::OpenClaw
)
}

pub fn supports_failover(&self) -> bool {
Expand All @@ -303,6 +324,7 @@ impl AppType {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::Hermes,
AppType::OpenClaw,
]
.into_iter()
Expand All @@ -325,14 +347,15 @@ impl FromStr for AppType {
"codex" => Ok(AppType::Codex),
"gemini" => Ok(AppType::Gemini),
"opencode" => Ok(AppType::OpenCode),
"hermes" => Ok(AppType::Hermes),
"openclaw" => Ok(AppType::OpenClaw),
other => Err(AppError::localized(
"unsupported_app",
format!(
"不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, openclaw。"
"不支持的应用标识: '{other}'。可选值: claude, codex, gemini, opencode, hermes, openclaw。"
),
format!(
"Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, openclaw."
"Unsupported app id: '{other}'. Allowed: claude, codex, gemini, opencode, hermes, openclaw."
),
)),
}
Expand All @@ -354,6 +377,9 @@ pub struct CommonConfigSnippets {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opencode: Option<String>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub hermes: Option<String>,

#[serde(default, skip_serializing_if = "Option::is_none")]
pub openclaw: Option<String>,
}
Expand All @@ -366,6 +392,7 @@ impl CommonConfigSnippets {
AppType::Codex => self.codex.as_ref(),
AppType::Gemini => self.gemini.as_ref(),
AppType::OpenCode => self.opencode.as_ref(),
AppType::Hermes => self.hermes.as_ref(),
AppType::OpenClaw => self.openclaw.as_ref(),
}
}
Expand All @@ -377,6 +404,7 @@ impl CommonConfigSnippets {
AppType::Codex => self.codex = snippet,
AppType::Gemini => self.gemini = snippet,
AppType::OpenCode => self.opencode = snippet,
AppType::Hermes => self.hermes = snippet,
AppType::OpenClaw => self.openclaw = snippet,
}
}
Expand Down Expand Up @@ -418,6 +446,7 @@ impl Default for MultiAppConfig {
apps.insert("codex".to_string(), ProviderManager::default());
apps.insert("gemini".to_string(), ProviderManager::default());
apps.insert("opencode".to_string(), ProviderManager::default());
apps.insert("hermes".to_string(), ProviderManager::default());
apps.insert("openclaw".to_string(), ProviderManager::default());

Self {
Expand Down Expand Up @@ -512,6 +541,13 @@ impl MultiAppConfig {
updated = true;
}

if !config.apps.contains_key("hermes") {
config
.apps
.insert("hermes".to_string(), ProviderManager::default());
updated = true;
}

if !config.apps.contains_key("openclaw") {
config
.apps
Expand Down Expand Up @@ -583,6 +619,7 @@ impl MultiAppConfig {
AppType::Codex => &self.mcp.codex,
AppType::Gemini => &self.mcp.gemini,
AppType::OpenCode => &self.mcp.opencode,
AppType::Hermes => &self.mcp.hermes,
AppType::OpenClaw => &self.mcp.openclaw,
}
}
Expand All @@ -594,10 +631,137 @@ impl MultiAppConfig {
AppType::Codex => &mut self.mcp.codex,
AppType::Gemini => &mut self.mcp.gemini,
AppType::OpenCode => &mut self.mcp.opencode,
AppType::Hermes => &mut self.mcp.hermes,
AppType::OpenClaw => &mut self.mcp.openclaw,
}
}

/// 创建默认配置并自动导入已存在的提示词文件
fn default_with_auto_import() -> Result<Self, AppError> {
log::info!("首次启动,创建默认配置并检测提示词文件");

let mut config = Self::default();

// 为每个应用尝试自动导入提示词
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenCode)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::Hermes)?;
Self::auto_import_prompt_if_exists(&mut config, AppType::OpenClaw)?;

Ok(config)
}

/// 已存在配置文件时的 Prompt 自动导入逻辑
///
/// 适用于「老版本已经生成过 config.json,但当时还没有 Prompt 功能」的升级场景。
/// 判定规则:
/// - 仅当所有应用的 prompts 都为空时才尝试导入(避免打扰已经在使用 Prompt 功能的用户)
/// - 每个应用最多导入一次,对应各自的提示词文件(如 CLAUDE.md/AGENTS.md/GEMINI.md)
///
/// 返回值:
/// - Ok(true) 表示至少有一个应用成功导入了提示词
/// - Ok(false) 表示无需导入或未导入任何内容
fn maybe_auto_import_prompts_for_existing_config(&mut self) -> Result<bool, AppError> {
// 如果任一应用已经有提示词配置,说明用户已经在使用 Prompt 功能,避免再次自动导入
if !self.prompts.claude.prompts.is_empty()
|| !self.prompts.codex.prompts.is_empty()
|| !self.prompts.gemini.prompts.is_empty()
|| !self.prompts.opencode.prompts.is_empty()
|| !self.prompts.hermes.prompts.is_empty()
|| !self.prompts.openclaw.prompts.is_empty()
{
return Ok(false);
}

log::info!("检测到已存在配置文件且 Prompt 列表为空,将尝试从现有提示词文件自动导入");

let mut imported = false;
for app in [
AppType::Claude,
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::Hermes,
AppType::OpenClaw,
] {
// 复用已有的单应用导入逻辑
if Self::auto_import_prompt_if_exists(self, app)? {
imported = true;
}
}

Ok(imported)
}

/// 检查并自动导入单个应用的提示词文件
///
/// 返回值:
/// - Ok(true) 表示成功导入了非空文件
/// - Ok(false) 表示未导入(文件不存在、内容为空或读取失败)
fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<bool, AppError> {
let file_path = prompt_file_path(&app)?;

// 检查文件是否存在
if !file_path.exists() {
log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
return Ok(false);
}

// 读取文件内容
let content = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(e) => {
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
return Ok(false); // 失败时不中断,继续处理其他应用
}
};

// 检查内容是否为空
if content.trim().is_empty() {
log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
return Ok(false);
}

log::info!("发现提示词文件,自动导入: {file_path:?}");

// 创建提示词对象
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs() as i64;

let id = format!("auto-imported-{timestamp}");
let prompt = crate::prompt::Prompt {
id: id.clone(),
name: format!(
"Auto-imported Prompt {}",
chrono::Local::now().format("%Y-%m-%d %H:%M")
),
content,
description: Some("Automatically imported on first launch".to_string()),
enabled: true, // 自动启用
created_at: Some(timestamp),
updated_at: Some(timestamp),
};

// 插入到对应的应用配置中
let prompts = match app {
AppType::Claude => &mut config.prompts.claude.prompts,
AppType::Codex => &mut config.prompts.codex.prompts,
AppType::Gemini => &mut config.prompts.gemini.prompts,
AppType::OpenCode => &mut config.prompts.opencode.prompts,
AppType::Hermes => &mut config.prompts.hermes.prompts,
AppType::OpenClaw => &mut config.prompts.openclaw.prompts,
};

prompts.insert(id, prompt);

log::info!("自动导入完成: {}", app.as_str());
Ok(true)
}

/// 将 v3.6.x 的分应用 MCP 结构迁移到 v3.7.0 的统一结构
///
/// 迁移策略:
Expand All @@ -623,12 +787,14 @@ impl MultiAppConfig {
AppType::Codex,
AppType::Gemini,
AppType::OpenCode,
AppType::Hermes,
] {
let old_servers = match app {
AppType::Claude => &self.mcp.claude.servers,
AppType::Codex => &self.mcp.codex.servers,
AppType::Gemini => &self.mcp.gemini.servers,
AppType::OpenCode => &self.mcp.opencode.servers,
AppType::Hermes => &self.mcp.hermes.servers,
AppType::OpenClaw => continue,
};

Expand Down Expand Up @@ -733,6 +899,7 @@ impl MultiAppConfig {
self.mcp.codex = McpConfig::default();
self.mcp.gemini = McpConfig::default();
self.mcp.opencode = McpConfig::default();
self.mcp.hermes = McpConfig::default();

Ok(true)
}
Expand Down
6 changes: 5 additions & 1 deletion src-tauri/src/cli/commands/config_common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ fn set(
};

let snippet = match app_type {
AppType::Claude | AppType::Gemini | AppType::OpenCode | AppType::OpenClaw => {
AppType::Claude
| AppType::Gemini
| AppType::OpenCode
| AppType::Hermes
| AppType::OpenClaw => {
let value: serde_json::Value = serde_json::from_str(&raw).map_err(|e| {
AppError::InvalidInput(texts::tui_toast_invalid_json(&e.to_string()))
})?;
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/cli/commands/failover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@ fn takeover_enabled_for(takeovers: &ProxyTakeoverStatus, app_type: &AppType) ->
AppType::Claude => takeovers.claude,
AppType::Codex => takeovers.codex,
AppType::Gemini => takeovers.gemini,
AppType::OpenCode | AppType::OpenClaw => false,
AppType::OpenCode | AppType::Hermes | AppType::OpenClaw => false,
}
}

Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/cli/commands/mcp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ fn import_servers(app_type: AppType) -> Result<(), AppError> {
AppType::Codex => McpService::import_from_codex(&state)?,
AppType::Gemini => McpService::import_from_gemini(&state)?,
AppType::OpenCode => 0,
AppType::Hermes => 0,
AppType::OpenClaw => 0,
};

Expand Down
Loading
Loading