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
4 changes: 4 additions & 0 deletions app/flowix-desktop/src/app/bootstrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,9 @@ pub fn run() {
}
}
let user_config_arc = user_config.clone();
crate::external_runtime::binary::configure_custom_agent_locations(
&user_config_arc.get_preference().agents,
);

// Agent 可访问目录 store ── 必须在 notebook registry 与 `memo_file_arc`
// 都就绪之后构造 (新 store 会读 notebook registry 播种 + 对账)。
Expand Down Expand Up @@ -453,6 +456,7 @@ pub fn run() {
commands::web::parse_web_page,
// dialog
commands::dialog::select_directory,
commands::dialog::select_agent_runtime_directory,
commands::dialog::select_files,
commands::dialog::save_file_dialog,
commands::dialog::write_export_file,
Expand Down
62 changes: 32 additions & 30 deletions app/flowix-desktop/src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ async fn stop_any_runtime_chat(
pub struct AgentRuntimeAvailability {
available: bool,
reason: Option<String>,
binary_path: Option<String>,
custom_location: bool,
}

#[derive(Clone, Debug, Serialize)]
Expand Down Expand Up @@ -204,57 +206,57 @@ fn executable_available(path: &Path) -> bool {
.unwrap_or(false)
}

fn external_runtime_availability(
agent_type: &str,
display_name: &str,
binary: &Path,
) -> AgentRuntimeAvailability {
let available = executable_available(binary);
let custom_location = crate::external_runtime::binary::custom_location_enabled(agent_type);
let reason = (!available).then(|| {
if custom_location {
format!("Custom {display_name} location is invalid ({})", binary.display())
} else {
format!("{display_name} not found ({})", binary.display())
}
});
AgentRuntimeAvailability {
available,
reason,
binary_path: (!binary.as_os_str().is_empty())
.then(|| binary.to_string_lossy().into_owned()),
custom_location,
}
}

#[tauri::command]
pub fn agent_runtime_status(state: State<'_, AppState>) -> AgentRuntimeStatus {
let ai_config = state.user_config.get_ai_config().model;
let flowix_available = !ai_config.model.trim().is_empty();

let codex_binary = crate::external_runtime::codex::cli::resolve_codex_binary();
let codex_available = executable_available(&codex_binary);

let claude_binary = crate::external_runtime::claude::cli::resolve_claude_binary();
let claude_available = executable_available(&claude_binary);
let gemini_binary = crate::external_runtime::simple_cli::resolve_simple_cli_binary(
crate::external_runtime::simple_cli::SimpleCliKind::Gemini,
);
let gemini_available = executable_available(&gemini_binary);
let hermes_binary = crate::external_runtime::hermes::cli::resolve_hermes_binary();
let hermes_available = executable_available(&hermes_binary);
let openclaw_binary = crate::external_runtime::simple_cli::resolve_simple_cli_binary(
crate::external_runtime::simple_cli::SimpleCliKind::OpenClaw,
);
let openclaw_available = executable_available(&openclaw_binary);

AgentRuntimeStatus {
flowix: AgentRuntimeAvailability {
available: flowix_available,
reason: (!flowix_available).then(|| "Flowix model is not configured".to_string()),
binary_path: None,
custom_location: false,
},
codex: AgentRuntimeAvailability {
available: codex_available,
reason: (!codex_available)
.then(|| format!("Codex CLI not found ({})", codex_binary.display())),
},
claude: AgentRuntimeAvailability {
available: claude_available,
reason: (!claude_available)
.then(|| format!("Claude Code CLI not found ({})", claude_binary.display())),
},
gemini: AgentRuntimeAvailability {
available: gemini_available,
reason: (!gemini_available)
.then(|| format!("Gemini CLI not found ({})", gemini_binary.display())),
},
hermes: AgentRuntimeAvailability {
available: hermes_available,
reason: (!hermes_available)
.then(|| format!("Hermes Agent CLI not found ({})", hermes_binary.display())),
},
openclaw: AgentRuntimeAvailability {
available: openclaw_available,
reason: (!openclaw_available)
.then(|| format!("OpenClaw CLI not found ({})", openclaw_binary.display())),
},
codex: external_runtime_availability("codex", "Codex CLI", &codex_binary),
claude: external_runtime_availability("claude", "Claude Code CLI", &claude_binary),
gemini: external_runtime_availability("gemini", "Gemini CLI", &gemini_binary),
hermes: external_runtime_availability("hermes", "Hermes Agent CLI", &hermes_binary),
openclaw: external_runtime_availability("openclaw", "OpenClaw CLI", &openclaw_binary),
}
}

Expand Down
52 changes: 52 additions & 0 deletions app/flowix-desktop/src/commands/dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,58 @@ pub async fn select_directory(app: tauri::AppHandle) -> Option<String> {
rx.recv().ok().flatten()
}

#[tauri::command]
pub async fn select_agent_runtime_directory(app: tauri::AppHandle) -> Option<String> {
use std::sync::mpsc;
#[cfg(not(target_os = "macos"))]
use tauri_plugin_dialog::DialogExt;
#[cfg(not(target_os = "macos"))]
use tokio::task;

let (tx, rx) = mpsc::channel();

#[cfg(target_os = "macos")]
{
let handle = app.clone();
let state_handle = handle.clone();
handle
.run_on_main_thread(move || {
let result = crate::config::pick_directory_with_bookmark("选择 Agent 所在文件夹")
.map(|(path, bookmark)| {
let state = state_handle.state::<AppState>();
if let Err(e) = state
.security_bookmarks
.record_directory_bookmark(Path::new(&path), bookmark)
{
tracing::warn!(
"[select_agent_runtime_directory] failed to persist bookmark: {e}"
);
}
path
});
tx.send(result).ok();
})
.ok()?;
return rx.recv().ok().flatten();
}

#[cfg(not(target_os = "macos"))]
let handle = app.clone();
#[cfg(not(target_os = "macos"))]
task::spawn_blocking(move || {
let result = handle
.dialog()
.file()
.set_title("Choose Agent folder")
.blocking_pick_folder()
.map(|p| p.to_string());
tx.send(result).ok();
});

#[cfg(not(target_os = "macos"))]
rx.recv().ok().flatten()
}

#[tauri::command]
pub async fn select_files(app: tauri::AppHandle) -> Option<Vec<String>> {
use std::sync::mpsc;
Expand Down
2 changes: 2 additions & 0 deletions app/flowix-desktop/src/commands/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,12 @@ pub fn set_preference(
state: State<AppState>,
app: AppHandle,
) -> Result<(), String> {
let agents = preference.agents.clone();
state
.user_config
.set_preference(preference)
.map(|_| {
crate::external_runtime::binary::configure_custom_agent_locations(&agents);
dispatcher::emit_to(&app, USER_CONFIG_CHANGED_EVENT, "preference");
Ok(())
})
Expand Down
4 changes: 4 additions & 0 deletions app/flowix-desktop/src/config/user.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ pub struct PropertiesConfig {
pub struct AgentsConfig {
#[serde(default)]
pub enabled_by_type: HashMap<String, bool>,
#[serde(default)]
pub custom_location_enabled_by_type: HashMap<String, bool>,
#[serde(default)]
pub custom_locations: HashMap<String, String>,
/// 常用语列表 ── 用户在偏好设置 → 工具 tab 里维护,
/// 在角色选择弹窗作为快捷输入片段注入 composer。
/// 老 preference.json 没有此字段时由 #[serde(default)] 兜底为空数组。
Expand Down
104 changes: 104 additions & 0 deletions app/flowix-desktop/src/external_runtime/binary.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{OnceLock, RwLock};

use crate::config::AgentsConfig;

#[derive(Clone, Default)]
struct CustomAgentLocations {
enabled_by_type: HashMap<String, bool>,
locations: HashMap<String, String>,
}

static CUSTOM_AGENT_LOCATIONS: OnceLock<RwLock<CustomAgentLocations>> = OnceLock::new();

fn store() -> &'static RwLock<CustomAgentLocations> {
CUSTOM_AGENT_LOCATIONS.get_or_init(|| RwLock::new(CustomAgentLocations::default()))
}

pub fn configure_custom_agent_locations(config: &AgentsConfig) {
let next = CustomAgentLocations {
enabled_by_type: config.custom_location_enabled_by_type.clone(),
locations: config.custom_locations.clone(),
};
*store()
.write()
.unwrap_or_else(|poisoned| poisoned.into_inner()) = next;
}

pub fn custom_location_enabled(agent_type: &str) -> bool {
store()
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.enabled_by_type
.get(agent_type)
.copied()
.unwrap_or(false)
}

pub fn custom_agent_binary(agent_type: &str, binary_name: &str) -> Option<PathBuf> {
let config = store()
.read()
.unwrap_or_else(|poisoned| poisoned.into_inner())
.clone();
if !config
.enabled_by_type
.get(agent_type)
.copied()
.unwrap_or(false)
{
return None;
}

let configured = config.locations.get(agent_type)?.trim();
if configured.is_empty() {
return Some(PathBuf::new());
}
Some(resolve_configured_path(Path::new(configured), binary_name))
}

fn resolve_configured_path(configured: &Path, binary_name: &str) -> PathBuf {
if !configured.is_dir() {
return configured.to_path_buf();
}

#[cfg(windows)]
let file_names = [
format!("{binary_name}.exe"),
format!("{binary_name}.cmd"),
format!("{binary_name}.bat"),
binary_name.to_string(),
];
#[cfg(not(windows))]
let file_names = [binary_name.to_string()];

for file_name in &file_names {
let candidate = configured.join(file_name);
if candidate.is_file() {
return candidate;
}
}
configured.join(&file_names[0])
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn configured_file_location_is_returned_verbatim() {
let path = std::env::temp_dir().join("flowix-custom-codex");
assert_eq!(resolve_configured_path(&path, "codex"), path);
}

#[test]
fn configured_directory_resolves_expected_binary_name() {
let dir = tempfile::tempdir().expect("create temp dir");
let resolved = resolve_configured_path(dir.path(), "codex");

#[cfg(windows)]
assert_eq!(resolved, dir.path().join("codex.exe"));
#[cfg(not(windows))]
assert_eq!(resolved, dir.path().join("codex"));
}
}
3 changes: 3 additions & 0 deletions app/flowix-desktop/src/external_runtime/claude/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@ use crate::external_runtime::cli_resolver::{
};

pub(crate) fn resolve_claude_binary() -> PathBuf {
if let Some(path) = crate::external_runtime::binary::custom_agent_binary("claude", "claude") {
return path;
}
resolve_external_cli(&CLAUDE_CLI_SPEC)
}

Expand Down
3 changes: 3 additions & 0 deletions app/flowix-desktop/src/external_runtime/codex/binary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ use std::path::PathBuf;
use crate::external_runtime::cli_resolver::{resolve_external_cli, ExternalCliSpec};

pub(crate) fn resolve_codex_binary() -> PathBuf {
if let Some(path) = crate::external_runtime::binary::custom_agent_binary("codex", "codex") {
return path;
}
resolve_external_cli(&CODEX_CLI_SPEC)
}

Expand Down
3 changes: 3 additions & 0 deletions app/flowix-desktop/src/external_runtime/hermes/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,9 @@ fn normalized_yolo_permission(permission_mode: Option<&str>) -> bool {
}

pub(crate) fn resolve_hermes_binary() -> PathBuf {
if let Some(path) = crate::external_runtime::binary::custom_agent_binary("hermes", "hermes") {
return path;
}
resolve_external_cli(&HERMES_CLI_SPEC)
}

Expand Down
1 change: 1 addition & 0 deletions app/flowix-desktop/src/external_runtime/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
//! 入口模块就两层: `shared` 是真正的 cross-runtime 工具, 其余每个 runtime
//! 都是 `cli + history` (history 只在有磁盘 session 文件的 vendor 里有意义)。

pub mod binary;
pub mod claude;
pub mod cli_resolver;
pub mod codex;
Expand Down
6 changes: 6 additions & 0 deletions app/flowix-desktop/src/external_runtime/simple_cli/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -490,6 +490,12 @@ fn command_args(kind: SimpleCliKind, prompt: &str) -> Vec<String> {
}

pub(crate) fn resolve_simple_cli_binary(kind: SimpleCliKind) -> PathBuf {
if let Some(path) = crate::external_runtime::binary::custom_agent_binary(
kind.key(),
kind.cli_spec().binary_name,
) {
return path;
}
resolve_external_cli(kind.cli_spec())
}

Expand Down
10 changes: 10 additions & 0 deletions app/flowix-web/features/i18n/locales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,11 @@ export const messages = {
"preferences.agents.claude.configure": "配置 settings.json",
"preferences.agents.collapse": "收起配置项",
"preferences.agents.expand": "展开配置项",
"preferences.agents.customLocation.enabled": "使用自定义 Agent 位置",
"preferences.agents.customLocation.choose": "选择文件夹",
"preferences.agents.customLocation.change": "更改文件夹",
"preferences.agents.customLocation.notSelected": "尚未选择 Agent 所在文件夹",
"preferences.agents.customLocation.resolved": "实际可执行文件",
"preferences.quickPhrases.title": "常用语",
"preferences.quickPhrases.subtitle": "在角色选择弹窗中作为快捷输入,提示词 100 字以内",
"preferences.quickPhrases.empty": "还没有常用语,点击下方按钮添加",
Expand Down Expand Up @@ -1156,6 +1161,11 @@ export const messages = {
"preferences.agents.claude.configure": "Edit settings.json",
"preferences.agents.collapse": "Collapse config items",
"preferences.agents.expand": "Expand config items",
"preferences.agents.customLocation.enabled": "Use a custom Agent location",
"preferences.agents.customLocation.choose": "Choose folder",
"preferences.agents.customLocation.change": "Change folder",
"preferences.agents.customLocation.notSelected": "No Agent folder selected",
"preferences.agents.customLocation.resolved": "Resolved executable",
"preferences.quickPhrases.title": "Quick Phrases",
"preferences.quickPhrases.subtitle":
"Insert into the composer from the role popover. Each prompt is limited to 100 characters.",
Expand Down
Loading