From 532b7cedeca79de0989bfde7090abcf3e4b964ad Mon Sep 17 00:00:00 2001 From: ryan <2650306917@qq.com> Date: Tue, 14 Jul 2026 11:57:33 +0800 Subject: [PATCH] agent: support custom executable locations --- app/flowix-desktop/src/app/bootstrap.rs | 4 + app/flowix-desktop/src/commands/agent.rs | 62 ++++++----- app/flowix-desktop/src/commands/dialog.rs | 52 +++++++++ app/flowix-desktop/src/commands/settings.rs | 2 + app/flowix-desktop/src/config/user.rs | 4 + .../src/external_runtime/binary.rs | 104 ++++++++++++++++++ .../src/external_runtime/claude/binary.rs | 3 + .../src/external_runtime/codex/binary.rs | 3 + .../src/external_runtime/hermes/cli.rs | 3 + .../src/external_runtime/mod.rs | 1 + .../src/external_runtime/simple_cli/cli.rs | 6 + app/flowix-web/features/i18n/locales.ts | 10 ++ .../features/preferences/sections/agents.tsx | 88 ++++++++++++++- .../store/user-settings-store.test.ts | 31 +++++- .../preferences/store/user-settings-store.ts | 24 ++++ app/flowix-web/lib/constants.ts | 4 + app/flowix-web/platform/tauri/client.ts | 3 + app/flowix-web/platform/tauri/event-bus.ts | 27 +++-- 18 files changed, 387 insertions(+), 44 deletions(-) create mode 100644 app/flowix-desktop/src/external_runtime/binary.rs diff --git a/app/flowix-desktop/src/app/bootstrap.rs b/app/flowix-desktop/src/app/bootstrap.rs index 27462706..ca913d71 100644 --- a/app/flowix-desktop/src/app/bootstrap.rs +++ b/app/flowix-desktop/src/app/bootstrap.rs @@ -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 播种 + 对账)。 @@ -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, diff --git a/app/flowix-desktop/src/commands/agent.rs b/app/flowix-desktop/src/commands/agent.rs index 34f42224..6d3972db 100644 --- a/app/flowix-desktop/src/commands/agent.rs +++ b/app/flowix-desktop/src/commands/agent.rs @@ -177,6 +177,8 @@ async fn stop_any_runtime_chat( pub struct AgentRuntimeAvailability { available: bool, reason: Option, + binary_path: Option, + custom_location: bool, } #[derive(Clone, Debug, Serialize)] @@ -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), } } diff --git a/app/flowix-desktop/src/commands/dialog.rs b/app/flowix-desktop/src/commands/dialog.rs index 4f119de2..956da5a4 100644 --- a/app/flowix-desktop/src/commands/dialog.rs +++ b/app/flowix-desktop/src/commands/dialog.rs @@ -164,6 +164,58 @@ pub async fn select_directory(app: tauri::AppHandle) -> Option { rx.recv().ok().flatten() } +#[tauri::command] +pub async fn select_agent_runtime_directory(app: tauri::AppHandle) -> Option { + 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::(); + 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> { use std::sync::mpsc; diff --git a/app/flowix-desktop/src/commands/settings.rs b/app/flowix-desktop/src/commands/settings.rs index 22dc40e4..b1ac1563 100644 --- a/app/flowix-desktop/src/commands/settings.rs +++ b/app/flowix-desktop/src/commands/settings.rs @@ -29,10 +29,12 @@ pub fn set_preference( state: State, 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(()) }) diff --git a/app/flowix-desktop/src/config/user.rs b/app/flowix-desktop/src/config/user.rs index 015596e7..82de9d30 100644 --- a/app/flowix-desktop/src/config/user.rs +++ b/app/flowix-desktop/src/config/user.rs @@ -80,6 +80,10 @@ pub struct PropertiesConfig { pub struct AgentsConfig { #[serde(default)] pub enabled_by_type: HashMap, + #[serde(default)] + pub custom_location_enabled_by_type: HashMap, + #[serde(default)] + pub custom_locations: HashMap, /// 常用语列表 ── 用户在偏好设置 → 工具 tab 里维护, /// 在角色选择弹窗作为快捷输入片段注入 composer。 /// 老 preference.json 没有此字段时由 #[serde(default)] 兜底为空数组。 diff --git a/app/flowix-desktop/src/external_runtime/binary.rs b/app/flowix-desktop/src/external_runtime/binary.rs new file mode 100644 index 00000000..3535f262 --- /dev/null +++ b/app/flowix-desktop/src/external_runtime/binary.rs @@ -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, + locations: HashMap, +} + +static CUSTOM_AGENT_LOCATIONS: OnceLock> = OnceLock::new(); + +fn store() -> &'static RwLock { + 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 { + 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")); + } +} diff --git a/app/flowix-desktop/src/external_runtime/claude/binary.rs b/app/flowix-desktop/src/external_runtime/claude/binary.rs index bfa9a629..57def75f 100644 --- a/app/flowix-desktop/src/external_runtime/claude/binary.rs +++ b/app/flowix-desktop/src/external_runtime/claude/binary.rs @@ -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) } diff --git a/app/flowix-desktop/src/external_runtime/codex/binary.rs b/app/flowix-desktop/src/external_runtime/codex/binary.rs index 4a0548b3..b85a906a 100644 --- a/app/flowix-desktop/src/external_runtime/codex/binary.rs +++ b/app/flowix-desktop/src/external_runtime/codex/binary.rs @@ -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) } diff --git a/app/flowix-desktop/src/external_runtime/hermes/cli.rs b/app/flowix-desktop/src/external_runtime/hermes/cli.rs index 4e947a62..f193a375 100644 --- a/app/flowix-desktop/src/external_runtime/hermes/cli.rs +++ b/app/flowix-desktop/src/external_runtime/hermes/cli.rs @@ -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) } diff --git a/app/flowix-desktop/src/external_runtime/mod.rs b/app/flowix-desktop/src/external_runtime/mod.rs index 03df074e..3449adc1 100644 --- a/app/flowix-desktop/src/external_runtime/mod.rs +++ b/app/flowix-desktop/src/external_runtime/mod.rs @@ -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; diff --git a/app/flowix-desktop/src/external_runtime/simple_cli/cli.rs b/app/flowix-desktop/src/external_runtime/simple_cli/cli.rs index 525824d0..e61438fc 100644 --- a/app/flowix-desktop/src/external_runtime/simple_cli/cli.rs +++ b/app/flowix-desktop/src/external_runtime/simple_cli/cli.rs @@ -490,6 +490,12 @@ fn command_args(kind: SimpleCliKind, prompt: &str) -> Vec { } 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()) } diff --git a/app/flowix-web/features/i18n/locales.ts b/app/flowix-web/features/i18n/locales.ts index e70c8e21..2b4f13cf 100644 --- a/app/flowix-web/features/i18n/locales.ts +++ b/app/flowix-web/features/i18n/locales.ts @@ -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": "还没有常用语,点击下方按钮添加", @@ -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.", diff --git a/app/flowix-web/features/preferences/sections/agents.tsx b/app/flowix-web/features/preferences/sections/agents.tsx index ef5cb60a..aa07d96e 100644 --- a/app/flowix-web/features/preferences/sections/agents.tsx +++ b/app/flowix-web/features/preferences/sections/agents.tsx @@ -8,10 +8,12 @@ import { useAgentRuntimeStore } from '@features/agent/store/agent-runtime-store' import { useI18n } from '@features/i18n'; import { SectionHeader } from '@features/preferences/sections/primitives'; import { AgentSection } from '@features/preferences/sections/agent'; -import { agent } from '@platform/tauri/client'; +import { agent, dialogs } from '@platform/tauri/client'; import { Button } from '@shared/ui/button'; import { cn } from '@/lib/utils'; import { toast } from '@/lib/toast'; +import type { AgentTypeKey } from '@/types/agent'; +import { useUserSettingsStore } from '@features/preferences/store/user-settings-store'; type CollapsibleAgentKey = 'flowix' | 'claude' | 'codex'; @@ -21,6 +23,9 @@ export function AgentsSection() { const isChecking = useAgentRuntimeStore((s) => s.isChecking); const refreshIfStale = useAgentRuntimeStore((s) => s.refreshIfStale); const refreshStatus = useAgentRuntimeStore((s) => s.refresh); + const agentsSettings = useUserSettingsStore((s) => s.settings.agents); + const updateSettings = useUserSettingsStore((s) => s.updateSettings); + const flushPending = useUserSettingsStore((s) => s.flushPending); // 单展开态: 任何时刻最多一张 agent 卡片展开, 默认展开 codex。 // 状态在组件生命周期内维持 ── 切走/回来会回到默认; 需要跨会话保留可下沉到 @@ -93,6 +98,83 @@ export function AgentsSection() { } }; + const persistCustomLocation = async ( + typeKey: AgentTypeKey, + enabled: boolean, + location?: string, + ) => { + await updateSettings({ + agents: { + customLocationEnabledByType: { + ...agentsSettings.customLocationEnabledByType, + [typeKey]: enabled, + }, + customLocations: location === undefined + ? agentsSettings.customLocations + : { + ...agentsSettings.customLocations, + [typeKey]: location, + }, + }, + }); + await flushPending(); + await refreshStatus({ force: true, type: typeKey }); + }; + + const chooseCustomLocation = async (typeKey: AgentTypeKey) => { + const location = await dialogs.selectAgentRuntimeDirectory(); + if (!location) return; + await persistCustomLocation(typeKey, true, location); + }; + + const renderCustomLocation = (typeKey: AgentTypeKey) => { + if (typeKey === 'flowix') return null; + const enabled = agentsSettings.customLocationEnabledByType[typeKey] === true; + const configuredPath = agentsSettings.customLocations[typeKey] ?? ''; + const status = statusByType[typeKey]; + return ( +
+
+ + {enabled && ( + + )} +
+ {enabled && ( +
+
+ {configuredPath || t('preferences.agents.customLocation.notSelected')} +
+ {status?.binaryPath && ( +
+ {t('preferences.agents.customLocation.resolved')}: {status.binaryPath} +
+ )} + {status?.reason &&
{status.reason}
} +
+ )} +
+ ); + }; + return (
+ {renderCustomLocation(typeKey)} , ); } @@ -224,10 +307,11 @@ export function AgentsSection() { {t('preferences.agents.codex.configure')} + {renderCustomLocation(typeKey)} , ); } - return null; + return renderCustomLocation(typeKey); }} /> diff --git a/app/flowix-web/features/preferences/store/user-settings-store.test.ts b/app/flowix-web/features/preferences/store/user-settings-store.test.ts index 442482c1..6414af9a 100644 --- a/app/flowix-web/features/preferences/store/user-settings-store.test.ts +++ b/app/flowix-web/features/preferences/store/user-settings-store.test.ts @@ -44,7 +44,12 @@ describe('user-settings-store · agents.quickPhrases sanitize', () => { memoCardVariant: 'detailed', shortcuts: {}, properties: { fields: [] }, - agents: { enabledByType: {}, quickPhrases: [] }, + agents: { + enabledByType: {}, + customLocationEnabledByType: {}, + customLocations: {}, + quickPhrases: [], + }, productUpdates: { enabled: true, lastCheckedAt: 0 }, }, isLoading: false, @@ -183,6 +188,22 @@ describe('user-settings-store · agents.quickPhrases sanitize', () => { expect(kept[0].id).toBe('keep'); }); + it('保存并清理第三方 Agent 自定义位置', async () => { + await useUserSettingsStore.getState().updateSettings({ + agents: { + customLocationEnabledByType: { codex: true }, + customLocations: { codex: ' /opt/custom/bin ' }, + }, + }); + + expect( + useUserSettingsStore.getState().settings.agents.customLocationEnabledByType.codex, + ).toBe(true); + expect(useUserSettingsStore.getState().settings.agents.customLocations.codex).toBe( + '/opt/custom/bin', + ); + }); + it('JSON 序列化往返不丢 quickPhrases ── 与后端 Rust schema 保持一致', async () => { const phrases: QuickPhrase[] = [ { id: 'p1', title: '会议纪要', prompt: '整理这次讨论的要点' }, @@ -206,7 +227,6 @@ describe('user-settings-store · agents.quickPhrases sanitize', () => { ); }); }); - describe('user-settings-store 路 region loadInitial', () => { it('keeps persisted mainland region when loading settings', async () => { mockedPreferences.get.mockResolvedValueOnce({ @@ -235,7 +255,12 @@ describe('user-settings-store 路 region loadInitial', () => { memoCardVariant: 'detailed', shortcuts: {}, properties: { fields: [] }, - agents: { enabledByType: {}, quickPhrases: [] }, + agents: { + enabledByType: {}, + customLocationEnabledByType: {}, + customLocations: {}, + quickPhrases: [], + }, productUpdates: { enabled: true, lastCheckedAt: 0 }, }, isLoading: true, diff --git a/app/flowix-web/features/preferences/store/user-settings-store.ts b/app/flowix-web/features/preferences/store/user-settings-store.ts index 49fba07f..0f22b51f 100644 --- a/app/flowix-web/features/preferences/store/user-settings-store.ts +++ b/app/flowix-web/features/preferences/store/user-settings-store.ts @@ -119,6 +119,14 @@ function mergeSettings(base: UserSettings, updates: UserSettingsUpdate): UserSet ...base.agents.enabledByType, ...(updates.agents?.enabledByType ?? {}), }, + customLocationEnabledByType: { + ...base.agents.customLocationEnabledByType, + ...(updates.agents?.customLocationEnabledByType ?? {}), + }, + customLocations: { + ...base.agents.customLocations, + ...(updates.agents?.customLocations ?? {}), + }, // quickPhrases 整体替换 —— 与 properties.fields 同款, 避免外部传入时 // 仅 patch 部分项导致 sanitize 后顺序错乱。 quickPhrases: updates.agents?.quickPhrases ?? base.agents.quickPhrases, @@ -159,6 +167,14 @@ function sanitizeAgentsConfig(agents: AgentsConfig | undefined): AgentsConfig { ? agents.enabledByType : {}; const rawPhrases = Array.isArray(agents?.quickPhrases) ? agents!.quickPhrases : []; + const customLocationEnabledByType = + agents?.customLocationEnabledByType && typeof agents.customLocationEnabledByType === 'object' + ? agents.customLocationEnabledByType + : {}; + const customLocations = + agents?.customLocations && typeof agents.customLocations === 'object' + ? agents.customLocations + : {}; const seen = new Set(); const quickPhrases: QuickPhrase[] = []; for (const item of rawPhrases) { @@ -175,6 +191,14 @@ function sanitizeAgentsConfig(agents: AgentsConfig | undefined): AgentsConfig { enabledByType: Object.fromEntries( Object.entries(enabledByType).filter(([, value]) => typeof value === 'boolean'), ), + customLocationEnabledByType: Object.fromEntries( + Object.entries(customLocationEnabledByType).filter(([, value]) => typeof value === 'boolean'), + ), + customLocations: Object.fromEntries( + Object.entries(customLocations) + .filter(([, value]) => typeof value === 'string') + .map(([key, value]) => [key, value.trim()]), + ), quickPhrases, }; } diff --git a/app/flowix-web/lib/constants.ts b/app/flowix-web/lib/constants.ts index 0ad3aaf1..8b90db5e 100644 --- a/app/flowix-web/lib/constants.ts +++ b/app/flowix-web/lib/constants.ts @@ -97,6 +97,8 @@ export interface QuickPhrase { export interface AgentsConfig { enabledByType: Partial>; + customLocationEnabledByType: Partial>; + customLocations: Partial>; /** 用户在偏好设置里手工维护的常用语列表;空数组表示未配置。 */ quickPhrases: QuickPhrase[]; } @@ -259,6 +261,8 @@ export const DEFAULT_USER_SETTINGS: UserSettings = { }, agents: { enabledByType: {}, + customLocationEnabledByType: {}, + customLocations: {}, quickPhrases: [], }, productUpdates: { diff --git a/app/flowix-web/platform/tauri/client.ts b/app/flowix-web/platform/tauri/client.ts index 42ac7623..045dce68 100644 --- a/app/flowix-web/platform/tauri/client.ts +++ b/app/flowix-web/platform/tauri/client.ts @@ -352,6 +352,7 @@ export interface SaveFileFilter { export const dialogs = { selectDirectory: () => invoke('select_directory'), + selectAgentRuntimeDirectory: () => invoke('select_agent_runtime_directory'), selectFiles: () => invoke('select_files'), saveFile: (suggestedName?: string, filters?: SaveFileFilter[]) => invoke('save_file_dialog', { @@ -535,6 +536,8 @@ export interface AgentConversationInstance { export interface AgentRuntimeAvailability { available: boolean; reason?: string | null; + binaryPath?: string | null; + customLocation: boolean; } export interface AgentRuntimeStatus { diff --git a/app/flowix-web/platform/tauri/event-bus.ts b/app/flowix-web/platform/tauri/event-bus.ts index 53d97048..90c0cd21 100644 --- a/app/flowix-web/platform/tauri/event-bus.ts +++ b/app/flowix-web/platform/tauri/event-bus.ts @@ -46,6 +46,10 @@ function logHandlerError(event: string, err: unknown): void { console.warn(`[event-bus] handler for "${event}" threw:`, err); } +function hasTauriRuntime(): boolean { + return typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; +} + /** * 订阅后端事件 `event`。 返回 UnlistenFn, 调它从订阅集合里删除 handler; * 若该事件已无 handler, 同时 unlisten 底层 Tauri listener。 @@ -61,7 +65,7 @@ export function subscribe(event: string, handler: (payload: T) => void): Unli set = new Set(); handlers.set(event, set); } - if (!tauriUnlisten) { + if (!tauriUnlisten && hasTauriRuntime()) { // 首次挂: 注册 Tauri listener, payload 转成 unknown 再分发, 让每个 // handler 各自断言。 这里我们无法 await listen (接口要求同步返回 // UnlistenFn), 内部用 .then 接住真实 unlisten。 @@ -78,15 +82,20 @@ export function subscribe(event: string, handler: (payload: T) => void): Unli logHandlerError(event, err); } } - }).then((unlisten) => { - // listen 期间若所有 handler 都被 unsub, 跳过挂载 - if (!handlers.has(event)) { - unlisten(); + }) + .then((unlisten) => { + // listen 期间若所有 handler 都被 unsub, 跳过挂载 + if (!handlers.has(event)) { + unlisten(); + tauriUnlistens.delete(event); + return; + } + tauriUnlistens.set(event, unlisten); + }) + .catch((err) => { tauriUnlistens.delete(event); - return; - } - tauriUnlistens.set(event, unlisten); - }); + console.warn(`[event-bus] failed to listen for "${event}":`, err); + }); } // 用 unknown 中转, 跟 Tauri 内部 typed listen 走的是同一闭包。