From 0a8db525c6be25454dcb8c2660dbf9ec4563cbab Mon Sep 17 00:00:00 2001 From: bowen628 Date: Fri, 13 Mar 2026 18:27:02 +0800 Subject: [PATCH 1/3] feat(core): expand agent runtime and model configuration flows Align agent execution, remote connect, and model configuration updates across core, desktop, CLI, web UI, and mobile web so the new interaction and configuration paths work consistently end to end. Made-with: Cursor --- src/apps/cli/src/agent/core_adapter.rs | 377 +++-- src/apps/cli/src/agent/mod.rs | 9 +- src/apps/cli/src/config.rs | 16 +- src/apps/cli/src/main.rs | 205 +-- src/apps/cli/src/modes/chat.rs | 141 +- src/apps/cli/src/modes/exec.rs | 55 +- src/apps/cli/src/modes/mod.rs | 1 - src/apps/cli/src/session.rs | 51 +- src/apps/cli/src/ui/chat.rs | 136 +- src/apps/cli/src/ui/markdown.rs | 111 +- src/apps/cli/src/ui/mod.rs | 30 +- src/apps/cli/src/ui/startup.rs | 362 +++-- src/apps/cli/src/ui/string_utils.rs | 16 +- src/apps/cli/src/ui/theme.rs | 17 +- src/apps/cli/src/ui/tool_cards.rs | 171 ++- src/apps/cli/src/ui/widgets.rs | 5 +- src/apps/desktop/src/api/agentic_api.rs | 15 +- src/apps/desktop/src/api/app_state.rs | 20 +- src/apps/desktop/src/api/commands.rs | 79 +- .../desktop/src/api/context_upload_api.rs | 6 +- src/apps/desktop/src/api/dto.rs | 5 +- .../desktop/src/api/image_analysis_api.rs | 1 + src/apps/desktop/src/api/mcp_api.rs | 62 +- src/apps/desktop/src/api/miniapp_api.rs | 44 +- src/apps/desktop/src/api/mod.rs | 4 +- .../desktop/src/api/remote_connect_api.rs | 27 +- src/apps/desktop/src/api/skill_api.rs | 16 +- src/apps/desktop/src/api/snapshot_service.rs | 48 +- src/apps/desktop/src/api/token_usage_api.rs | 29 +- src/apps/desktop/src/api/tool_api.rs | 14 +- src/apps/desktop/src/lib.rs | 12 +- src/apps/desktop/src/main.rs | 5 +- src/apps/relay-server/src/lib.rs | 6 +- src/apps/relay-server/src/main.rs | 3 +- src/apps/relay-server/src/relay/room.rs | 5 +- src/apps/relay-server/src/routes/api.rs | 12 +- src/apps/relay-server/src/routes/websocket.rs | 5 +- src/apps/server/src/main.rs | 9 +- src/apps/server/src/routes/api.rs | 3 +- src/apps/server/src/routes/mod.rs | 3 +- src/apps/server/src/routes/websocket.rs | 18 +- src/crates/api-layer/src/dto.rs | 3 +- src/crates/api-layer/src/lib.rs | 1 - .../custom_subagent_loader.rs | 2 +- .../core/src/agentic/agents/debug_mode.rs | 27 +- .../src/agentic/agents/prompt_builder/mod.rs | 2 +- .../core/src/agentic/agents/registry.rs | 89 +- .../src/agentic/coordination/coordinator.rs | 63 +- .../src/agentic/coordination/scheduler.rs | 40 +- src/crates/core/src/agentic/core/message.rs | 6 +- src/crates/core/src/agentic/core/mod.rs | 6 +- src/crates/core/src/agentic/core/session.rs | 20 +- src/crates/core/src/agentic/events/mod.rs | 8 +- .../src/agentic/execution/execution_engine.rs | 217 ++- src/crates/core/src/agentic/execution/mod.rs | 9 +- .../src/agentic/execution/round_executor.rs | 3 +- .../core/src/agentic/execution/types.rs | 2 +- .../src/agentic/image_analysis/enhancer.rs | 8 +- .../core/src/agentic/image_analysis/mod.rs | 9 +- .../core/src/agentic/persistence/manager.rs | 274 +++- .../core/src/agentic/persistence/mod.rs | 4 +- .../src/agentic/session/history_manager.rs | 73 +- src/crates/core/src/agentic/session/mod.rs | 12 +- .../src/agentic/session/session_manager.rs | 92 +- .../core/src/agentic/tools/image_context.rs | 7 +- .../implementations/ask_user_question_tool.rs | 3 +- .../tools/implementations/bash_tool.rs | 4 +- .../tools/implementations/delete_file_tool.rs | 106 +- .../tools/implementations/file_write_tool.rs | 12 +- .../implementations/get_file_diff_tool.rs | 5 +- .../tools/implementations/glob_tool.rs | 13 +- .../tools/implementations/ide_control_tool.rs | 16 +- .../mermaid_interactive_tool.rs | 168 ++- .../implementations/miniapp_init_tool.rs | 10 +- .../src/agentic/tools/implementations/mod.rs | 4 +- .../tools/implementations/skill_tool.rs | 8 +- .../tools/implementations/skills/registry.rs | 16 +- .../tools/implementations/skills/types.rs | 28 +- .../tool-runtime/src/fs/mod.rs | 2 +- .../tool-runtime/src/util/string.rs | 2 +- .../src/agentic/tools/implementations/util.rs | 5 +- .../tools/implementations/view_image_tool.rs | 3 +- .../core/src/agentic/tools/pipeline/mod.rs | 7 +- .../agentic/tools/pipeline/state_manager.rs | 103 +- .../agentic/tools/pipeline/tool_pipeline.rs | 564 ++++--- .../core/src/agentic/tools/pipeline/types.rs | 9 +- src/crates/core/src/agentic/tools/registry.rs | 7 +- src/crates/core/src/agentic/util/mod.rs | 2 +- .../git-func-agent/ai_service.rs | 117 +- .../git-func-agent/commit_generator.rs | 99 +- .../git-func-agent/context_analyzer.rs | 86 +- .../src/function_agents/git-func-agent/mod.rs | 18 +- .../function_agents/git-func-agent/types.rs | 65 +- .../function_agents/git-func-agent/utils.rs | 71 +- src/crates/core/src/function_agents/mod.rs | 16 +- .../startchat-func-agent/ai_service.rs | 208 +-- .../startchat-func-agent/mod.rs | 22 +- .../startchat-func-agent/types.rs | 78 +- .../work_state_analyzer.rs | 140 +- .../src/stream_handler/mod.rs | 8 +- .../src/stream_handler/responses.rs | 65 +- .../ai/ai_stream_handlers/src/types/gemini.rs | 5 +- .../ai/ai_stream_handlers/src/types/mod.rs | 6 +- .../ai_stream_handlers/src/types/responses.rs | 4 +- .../core/src/infrastructure/ai/client.rs | 271 +++- .../src/infrastructure/ai/client_factory.rs | 146 +- src/crates/core/src/infrastructure/ai/mod.rs | 4 +- .../ai/providers/anthropic/mod.rs | 1 - .../ai/providers/gemini/message_converter.rs | 21 +- .../src/infrastructure/ai/providers/mod.rs | 2 +- .../ai/providers/openai/message_converter.rs | 86 +- .../infrastructure/ai/providers/openai/mod.rs | 1 - .../infrastructure/debug_log/http_server.rs | 116 +- .../core/src/infrastructure/debug_log/mod.rs | 17 +- .../src/infrastructure/debug_log/types.rs | 19 +- .../src/infrastructure/events/event_system.rs | 35 +- .../core/src/infrastructure/events/mod.rs | 10 +- .../infrastructure/filesystem/file_watcher.rs | 3 +- .../core/src/infrastructure/filesystem/mod.rs | 30 +- .../infrastructure/filesystem/path_manager.rs | 89 +- .../src/infrastructure/storage/cleanup.rs | 185 +-- .../core/src/infrastructure/storage/mod.rs | 6 +- .../src/infrastructure/storage/persistence.rs | 105 +- src/crates/core/src/lib.rs | 30 +- src/crates/core/src/miniapp/bridge_builder.rs | 13 +- src/crates/core/src/miniapp/compiler.rs | 10 +- src/crates/core/src/miniapp/exporter.rs | 6 +- src/crates/core/src/miniapp/js_worker.rs | 43 +- src/crates/core/src/miniapp/js_worker_pool.rs | 61 +- src/crates/core/src/miniapp/manager.rs | 54 +- src/crates/core/src/miniapp/mod.rs | 8 +- .../core/src/miniapp/permission_policy.rs | 8 +- src/crates/core/src/miniapp/runtime_detect.rs | 4 +- src/crates/core/src/miniapp/storage.rs | 147 +- .../core/src/service/ai_memory/manager.rs | 22 +- .../core/src/service/ai_rules/service.rs | 20 +- .../core/src/service/bootstrap/bootstrap.rs | 27 +- src/crates/core/src/service/bootstrap/mod.rs | 7 +- src/crates/core/src/service/config/mod.rs | 1 - .../core/src/service/config/providers.rs | 1 + src/crates/core/src/service/config/types.rs | 41 +- src/crates/core/src/service/git/git_utils.rs | 5 +- src/crates/core/src/service/lsp/global.rs | 2 +- src/crates/core/src/service/lsp/manager.rs | 3 - .../core/src/service/mcp/adapter/resource.rs | 10 +- .../core/src/service/mcp/adapter/tool.rs | 13 +- .../core/src/service/mcp/protocol/types.rs | 21 +- src/crates/core/src/service/mod.rs | 4 +- .../remote_connect/bot/command_router.rs | 819 ++++++++--- .../src/service/remote_connect/bot/feishu.rs | 286 +++- .../src/service/remote_connect/bot/mod.rs | 24 +- .../service/remote_connect/bot/telegram.rs | 130 +- .../service/remote_connect/embedded_relay.rs | 4 +- .../core/src/service/remote_connect/mod.rs | 63 +- .../core/src/service/remote_connect/ngrok.rs | 5 +- .../src/service/remote_connect/pairing.rs | 5 +- .../service/remote_connect/qr_generator.rs | 26 +- .../service/remote_connect/relay_client.rs | 12 +- .../service/remote_connect/remote_server.rs | 2 + .../core/src/service/snapshot/manager.rs | 23 +- .../core/src/service/snapshot/service.rs | 6 +- .../src/service/snapshot/snapshot_core.rs | 14 +- .../src/service/snapshot/snapshot_system.rs | 5 +- .../src/service/terminal/src/pty/process.rs | 2 +- .../service/terminal/src/shell/integration.rs | 5 +- .../core/src/service/token_usage/types.rs | 5 +- .../core/src/service/workspace/manager.rs | 39 +- src/crates/core/src/service/workspace/mod.rs | 5 +- .../core/src/service/workspace/service.rs | 252 +++- src/crates/core/src/util/errors.rs | 6 +- src/crates/core/src/util/token_counter.rs | 4 +- src/crates/core/src/util/types/ai.rs | 10 + src/crates/core/src/util/types/config.rs | 11 +- src/crates/core/src/util/types/mod.rs | 8 +- src/crates/transport/src/adapters/cli.rs | 149 +- src/crates/transport/src/adapters/mod.rs | 1 - src/crates/transport/src/adapters/tauri.rs | 593 +++++--- .../transport/src/adapters/websocket.rs | 101 +- src/crates/transport/src/emitter.rs | 7 +- src/crates/transport/src/event_bus.rs | 52 +- src/crates/transport/src/events.rs | 13 +- src/crates/transport/src/lib.rs | 17 +- src/crates/transport/src/traits.rs | 42 +- src/mobile-web/src/App.tsx | 5 +- .../src/components/LanguageToggleButton.tsx | 25 + src/mobile-web/src/i18n/I18nProvider.tsx | 137 ++ src/mobile-web/src/i18n/index.ts | 4 + src/mobile-web/src/i18n/messages.ts | 264 ++++ src/mobile-web/src/i18n/useI18n.ts | 7 + src/mobile-web/src/pages/ChatPage.tsx | 164 ++- src/mobile-web/src/pages/PairingPage.tsx | 55 +- src/mobile-web/src/pages/SessionListPage.tsx | 76 +- src/mobile-web/src/pages/WorkspacePage.tsx | 36 +- .../styles/components/language-toggle.scss | 24 + .../src/styles/components/pairing.scss | 7 + .../src/styles/components/sessions.scss | 1 + .../src/styles/components/workspace.scss | 2 + src/mobile-web/src/styles/index.scss | 1 + .../src/app/components/NavPanel/MainNav.tsx | 2 +- .../sections/sessions/SessionsSection.tsx | 1 - .../RemoteConnectDialog.scss | 2 +- .../RemoteConnectDialog.tsx | 15 +- .../src/app/components/SceneBar/SceneBar.tsx | 2 +- .../components/Modal/Modal.scss | 6 + .../components/Modal/Modal.tsx | 2 +- .../components/Select/Select.tsx | 24 +- .../components/steps/ModelConfigStep.tsx | 188 ++- .../flow_chat/components/ModelSelector.scss | 31 +- .../flow_chat/components/ModelSelector.tsx | 123 +- .../src/flow_chat/hooks/useMessageSender.ts | 10 +- .../flow-chat-manager/MessageModule.ts | 2 + .../flow-chat-manager/PersistenceModule.ts | 2 +- .../flow-chat-manager/SessionModule.ts | 8 +- .../infrastructure/api/service-api/AIApi.ts | 15 + .../api/service-api/AgentAPI.ts | 1 + .../config/components/AIModelConfig.scss | 380 ++++- .../config/components/AIModelConfig.tsx | 1299 ++++++++++++----- .../config/components/DefaultModelConfig.scss | 73 + .../config/components/DefaultModelConfig.tsx | 106 +- .../config/components/ModelSelectionRadio.tsx | 3 +- .../config/services/ConfigManager.ts | 51 +- .../config/services/modelConfigs.ts | 61 +- .../config/services/providerCatalog.ts | 117 ++ src/web-ui/src/locales/en-US/flow-chat.json | 2 + .../src/locales/en-US/settings/ai-model.json | 26 +- src/web-ui/src/locales/zh-CN/flow-chat.json | 2 + .../src/locales/zh-CN/settings/ai-model.json | 26 +- 227 files changed, 9021 insertions(+), 4057 deletions(-) create mode 100644 src/mobile-web/src/components/LanguageToggleButton.tsx create mode 100644 src/mobile-web/src/i18n/I18nProvider.tsx create mode 100644 src/mobile-web/src/i18n/index.ts create mode 100644 src/mobile-web/src/i18n/messages.ts create mode 100644 src/mobile-web/src/i18n/useI18n.ts create mode 100644 src/mobile-web/src/styles/components/language-toggle.scss create mode 100644 src/web-ui/src/infrastructure/config/services/providerCatalog.ts diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index 6951cbc5..2d89592a 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -1,10 +1,10 @@ //! Core Agent adapter -//! +//! //! Adapts bitfun-core's Agentic system to CLI's Agent interface use anyhow::Result; -use std::sync::Arc; use std::path::PathBuf; +use std::sync::Arc; use tokio::sync::mpsc; use super::{Agent, AgentEvent, AgentResponse}; @@ -26,7 +26,7 @@ pub struct CoreAgentAdapter { impl CoreAgentAdapter { pub fn new( - agent_type: String, + agent_type: String, coordinator: Arc, event_queue: Arc, workspace_path: Option, @@ -35,7 +35,7 @@ impl CoreAgentAdapter { "agentic" => "Fang", _ => "AI Assistant", }; - + Self { name: name.to_string(), agent_type: agent_type.clone(), @@ -45,7 +45,7 @@ impl CoreAgentAdapter { session_id: None, } } - + async fn ensure_session(&mut self) -> Result { if let Some(session_id) = &self.session_id { return Ok(session_id.clone()); @@ -56,19 +56,25 @@ impl CoreAgentAdapter { .clone() .or_else(|| std::env::current_dir().ok()) .map(|path| path.to_string_lossy().to_string()); - - let session = self.coordinator.create_session( - format!("CLI Session - {}", chrono::Local::now().format("%Y-%m-%d %H:%M:%S")), - self.agent_type.clone(), - SessionConfig { - workspace_path, - ..Default::default() - }, - ).await?; - + + let session = self + .coordinator + .create_session( + format!( + "CLI Session - {}", + chrono::Local::now().format("%Y-%m-%d %H:%M:%S") + ), + self.agent_type.clone(), + SessionConfig { + workspace_path, + ..Default::default() + }, + ) + .await?; + self.session_id = Some(session.session_id.clone()); tracing::info!("Created session: {}", session.session_id); - + Ok(session.session_id) } } @@ -88,54 +94,59 @@ impl Agent for CoreAgentAdapter { workspace_path: self.workspace_path.clone(), session_id: self.session_id.clone(), }; - + let session_id = self_mut.ensure_session().await?; tracing::info!("Processing message: {}", message); - + let _ = event_tx.send(AgentEvent::Thinking); - - self.coordinator.start_dialog_turn( - session_id.clone(), - message.clone(), - None, - self.agent_type.clone(), - None, - DialogTriggerSource::Cli, - ).await?; - + + self.coordinator + .start_dialog_turn( + session_id.clone(), + message.clone(), + None, + None, + self.agent_type.clone(), + None, + DialogTriggerSource::Cli, + ) + .await?; + let mut accumulated_text = String::new(); - let mut tool_map: std::collections::HashMap = std::collections::HashMap::new(); - + let mut tool_map: std::collections::HashMap = + std::collections::HashMap::new(); + let event_queue = self.event_queue.clone(); let session_id_clone = session_id.clone(); - + loop { let events = event_queue.dequeue_batch(10).await; - + if events.is_empty() { tokio::time::sleep(tokio::time::Duration::from_millis(50)).await; continue; } - + for envelope in events { let event = envelope.event; - + if event.session_id() != Some(&session_id_clone) { continue; } - + tracing::debug!("Received event: {:?}", event); - + match event { CoreEvent::TextChunk { text, .. } => { accumulated_text.push_str(&text); let _ = event_tx.send(AgentEvent::TextChunk(text)); } - - CoreEvent::ToolEvent { tool_event, .. } => { - match tool_event { - ToolEventData::EarlyDetected { tool_id, tool_name } => { - tool_map.insert(tool_id.clone(), ToolCall { + + CoreEvent::ToolEvent { tool_event, .. } => match tool_event { + ToolEventData::EarlyDetected { tool_id, tool_name } => { + tool_map.insert( + tool_id.clone(), + ToolCall { tool_id: Some(tool_id), tool_name: tool_name.clone(), parameters: serde_json::Value::Null, @@ -144,162 +155,212 @@ impl Agent for CoreAgentAdapter { progress: None, progress_message: None, duration_ms: None, - }); - } - - ToolEventData::ParamsPartial { tool_id, tool_name: _, params } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::ParamsPartial; - tool.progress_message = Some(params); - } - } - - ToolEventData::Queued { tool_id, tool_name: _, position } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Queued; - tool.progress_message = Some(format!("Queue position: {}", position)); - } + }, + ); + } + + ToolEventData::ParamsPartial { + tool_id, + tool_name: _, + params, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::ParamsPartial; + tool.progress_message = Some(params); } - - ToolEventData::Waiting { tool_id, tool_name: _, dependencies } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Waiting; - tool.progress_message = Some(format!("Waiting for: {:?}", dependencies)); - } + } + + ToolEventData::Queued { + tool_id, + tool_name: _, + position, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Queued; + tool.progress_message = + Some(format!("Queue position: {}", position)); } - - ToolEventData::Started { tool_id, tool_name, params } => { - tool_map.entry(tool_id.clone()).or_insert_with(|| ToolCall { - tool_id: Some(tool_id.clone()), - tool_name: tool_name.clone(), - parameters: params.clone(), - result: None, - status: ToolCallStatus::Running, - progress: Some(0.0), - progress_message: None, - duration_ms: None, - }); - - let _ = event_tx.send(AgentEvent::ToolCallStart { - tool_name, - parameters: params, - }); + } + + ToolEventData::Waiting { + tool_id, + tool_name: _, + dependencies, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Waiting; + tool.progress_message = + Some(format!("Waiting for: {:?}", dependencies)); } - - ToolEventData::Progress { tool_id, tool_name, message, percentage } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.progress = Some(percentage); - tool.progress_message = Some(message.clone()); - } - - let _ = event_tx.send(AgentEvent::ToolCallProgress { - tool_name, - message, - }); + } + + ToolEventData::Started { + tool_id, + tool_name, + params, + } => { + tool_map.entry(tool_id.clone()).or_insert_with(|| ToolCall { + tool_id: Some(tool_id.clone()), + tool_name: tool_name.clone(), + parameters: params.clone(), + result: None, + status: ToolCallStatus::Running, + progress: Some(0.0), + progress_message: None, + duration_ms: None, + }); + + let _ = event_tx.send(AgentEvent::ToolCallStart { + tool_name, + parameters: params, + }); + } + + ToolEventData::Progress { + tool_id, + tool_name, + message, + percentage, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.progress = Some(percentage); + tool.progress_message = Some(message.clone()); } - - ToolEventData::Streaming { tool_id, tool_name: _, chunks_received } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Streaming; - tool.progress_message = Some(format!("Received {} chunks", chunks_received)); - } + + let _ = + event_tx.send(AgentEvent::ToolCallProgress { tool_name, message }); + } + + ToolEventData::Streaming { + tool_id, + tool_name: _, + chunks_received, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Streaming; + tool.progress_message = + Some(format!("Received {} chunks", chunks_received)); } - - ToolEventData::ConfirmationNeeded { tool_id, tool_name: _, params: _ } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::ConfirmationNeeded; - tool.progress_message = Some("Waiting for user confirmation".to_string()); - } + } + + ToolEventData::ConfirmationNeeded { + tool_id, + tool_name: _, + params: _, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::ConfirmationNeeded; + tool.progress_message = + Some("Waiting for user confirmation".to_string()); } - - ToolEventData::Confirmed { tool_id, tool_name: _ } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Confirmed; - } + } + + ToolEventData::Confirmed { + tool_id, + tool_name: _, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Confirmed; } - - ToolEventData::Rejected { tool_id, tool_name: _ } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Rejected; - tool.result = Some("User rejected execution".to_string()); - } + } + + ToolEventData::Rejected { + tool_id, + tool_name: _, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Rejected; + tool.result = Some("User rejected execution".to_string()); } - - ToolEventData::Completed { tool_id, tool_name, result, duration_ms } => { - let result_str = serde_json::to_string(&result) - .unwrap_or_else(|_| "Success".to_string()); - - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Success; - tool.result = Some(result_str.clone()); - tool.progress = Some(1.0); - tool.duration_ms = Some(duration_ms); - } - - let _ = event_tx.send(AgentEvent::ToolCallComplete { - tool_name, - result: result_str, - success: true, - }); + } + + ToolEventData::Completed { + tool_id, + tool_name, + result, + duration_ms, + } => { + let result_str = serde_json::to_string(&result) + .unwrap_or_else(|_| "Success".to_string()); + + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Success; + tool.result = Some(result_str.clone()); + tool.progress = Some(1.0); + tool.duration_ms = Some(duration_ms); } - - ToolEventData::Failed { tool_id, tool_name, error } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Failed; - tool.result = Some(error.clone()); - } - - let _ = event_tx.send(AgentEvent::ToolCallComplete { - tool_name, - result: error, - success: false, - }); + + let _ = event_tx.send(AgentEvent::ToolCallComplete { + tool_name, + result: result_str, + success: true, + }); + } + + ToolEventData::Failed { + tool_id, + tool_name, + error, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Failed; + tool.result = Some(error.clone()); } - - ToolEventData::Cancelled { tool_id, tool_name: _, reason } => { - if let Some(tool) = tool_map.get_mut(&tool_id) { - tool.status = ToolCallStatus::Cancelled; - tool.result = Some(reason); - } + + let _ = event_tx.send(AgentEvent::ToolCallComplete { + tool_name, + result: error, + success: false, + }); + } + + ToolEventData::Cancelled { + tool_id, + tool_name: _, + reason, + } => { + if let Some(tool) = tool_map.get_mut(&tool_id) { + tool.status = ToolCallStatus::Cancelled; + tool.result = Some(reason); } - - _ => {} } - } - + + _ => {} + }, + CoreEvent::DialogTurnCompleted { .. } => { tracing::info!("Dialog turn completed"); let _ = event_tx.send(AgentEvent::Done); let tool_calls: Vec = tool_map.into_values().collect(); - + return Ok(AgentResponse { tool_calls, success: true, }); } - + CoreEvent::DialogTurnFailed { error, .. } => { tracing::error!("Execution error: {}", error); let _ = event_tx.send(AgentEvent::Error(error.clone())); let tool_calls: Vec = tool_map.into_values().collect(); - + return Ok(AgentResponse { tool_calls, success: false, }); } - + CoreEvent::SystemError { error, .. } => { tracing::error!("System error: {}", error); let _ = event_tx.send(AgentEvent::Error(error.clone())); let tool_calls: Vec = tool_map.into_values().collect(); - + return Ok(AgentResponse { tool_calls, success: false, }); } - + _ => { tracing::debug!("Ignoring event: {:?}", event); } diff --git a/src/apps/cli/src/agent/mod.rs b/src/apps/cli/src/agent/mod.rs index 4d5f0e44..b8b42c06 100644 --- a/src/apps/cli/src/agent/mod.rs +++ b/src/apps/cli/src/agent/mod.rs @@ -1,8 +1,6 @@ /// Agent integration module -/// +/// /// Wraps interaction with bitfun-core's Agent system - - pub mod agentic_system; pub mod core_adapter; @@ -24,10 +22,7 @@ pub enum AgentEvent { parameters: serde_json::Value, }, /// Tool call in progress - ToolCallProgress { - tool_name: String, - message: String, - }, + ToolCallProgress { tool_name: String, message: String }, /// Tool call completed ToolCallComplete { tool_name: String, diff --git a/src/apps/cli/src/config.rs b/src/apps/cli/src/config.rs index 84153b49..7a06d8de 100644 --- a/src/apps/cli/src/config.rs +++ b/src/apps/cli/src/config.rs @@ -1,12 +1,11 @@ /// Configuration management module -/// +/// /// CLI uses core's GlobalConfig system directly (same as tauri version) /// Only CLI-specific configuration is kept here (UI, shortcuts, etc.) - use anyhow::Result; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use std::fs; +use std::path::PathBuf; /// CLI configuration (contains only CLI-specific config) /// AI model configuration uses core's GlobalConfig @@ -107,14 +106,14 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + Ok(config_dir.join("config.toml")) } /// Load configuration pub fn load() -> Result { let config_path = Self::config_path()?; - + if !config_path.exists() { tracing::info!("Config file not found, using defaults"); let config = Self::default(); @@ -131,7 +130,7 @@ impl CliConfig { /// Save configuration pub fn save(&self) -> Result<()> { let config_path = Self::config_path()?; - + if let Some(parent) = config_path.parent() { fs::create_dir_all(parent)?; } @@ -154,7 +153,7 @@ impl CliConfig { .join(".config") .join("bitfun") }; - + fs::create_dir_all(&config_dir)?; Ok(config_dir) } @@ -165,7 +164,4 @@ impl CliConfig { fs::create_dir_all(&sessions_dir)?; Ok(sessions_dir) } - } - - diff --git a/src/apps/cli/src/main.rs b/src/apps/cli/src/main.rs index 9f503564..c19b04e8 100644 --- a/src/apps/cli/src/main.rs +++ b/src/apps/cli/src/main.rs @@ -1,18 +1,17 @@ +mod agent; /// BitFun CLI -/// +/// /// Command-line interface version, supports: /// - Interactive TUI /// - Single command execution /// - Batch task processing - mod config; +mod modes; mod session; mod ui; -mod modes; -mod agent; -use clap::{Parser, Subcommand}; use anyhow::{Context, Result}; +use clap::{Parser, Subcommand}; use config::CliConfig; use modes::chat::ChatMode; @@ -25,7 +24,7 @@ use modes::exec::ExecMode; struct Cli { #[command(subcommand)] command: Option, - + /// Enable verbose logging #[arg(short, long, global = true)] verbose: bool, @@ -38,69 +37,69 @@ enum Commands { /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, - + /// Workspace path #[arg(short, long)] workspace: Option, }, - + /// Execute single command Exec { /// User message message: String, - + /// Agent type #[arg(short, long, default_value = "agentic")] agent: String, - + /// Workspace path #[arg(short, long)] workspace: Option, - + /// Output in JSON format (script-friendly) #[arg(long)] json: bool, - + /// Output git diff patch after execution (for SWE-bench evaluation) /// Without path outputs to terminal, with path saves to file /// Example: --output-patch or --output-patch ./result.patch #[arg(long, num_args = 0..=1, default_missing_value = "-")] output_patch: Option, - + /// Tool execution requires confirmation (default: no confirmation to avoid blocking non-interactive mode) #[arg(long)] confirm: bool, }, - + /// Execute batch tasks Batch { /// Task configuration file path #[arg(short, long)] tasks: String, }, - + /// Session management Sessions { #[command(subcommand)] action: SessionAction, }, - + /// Configuration management Config { #[command(subcommand)] action: ConfigAction, }, - + /// Invoke tool directly Tool { /// Tool name name: String, - + /// Tool parameters (JSON) #[arg(short, long)] params: Option, }, - + /// Health check Health, } @@ -142,30 +141,27 @@ fn resolve_workspace_path(workspace: Option<&str>) -> Option #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); - + let log_level = if cli.verbose { tracing::Level::DEBUG } else { tracing::Level::INFO }; - + let is_tui_mode = matches!(cli.command, None | Some(Commands::Chat { .. })); - + if is_tui_mode { use std::fs::OpenOptions; - - let log_dir = CliConfig::config_dir().ok() + + let log_dir = CliConfig::config_dir() + .ok() .map(|d| d.join("logs")) .unwrap_or_else(|| std::env::temp_dir().join("bitfun-cli")); - + std::fs::create_dir_all(&log_dir).ok(); let log_file = log_dir.join("bitfun-cli.log"); - - if let Ok(file) = OpenOptions::new() - .create(true) - .append(true) - .open(log_file) - { + + if let Ok(file) = OpenOptions::new().create(true).append(true).open(log_file) { tracing_subscriber::fmt() .with_max_level(log_level) .with_writer(move || -> Box { @@ -192,7 +188,7 @@ async fn main() -> Result<()> { .with_target(false) .init(); } - + let config = CliConfig::load().unwrap_or_else(|e| { if !is_tui_mode { eprintln!("Warning: Failed to load config: {}", e); @@ -200,27 +196,27 @@ async fn main() -> Result<()> { } CliConfig::default() }); - + match cli.command { Some(Commands::Chat { agent, workspace }) => { let (workspace, mut startup_terminal) = if workspace.is_none() { use ui::startup::StartupPage; - + let mut terminal = ui::init_terminal()?; let mut startup_page = StartupPage::new(); let selected_workspace = startup_page.run(&mut terminal)?; - + if selected_workspace.is_none() { ui::restore_terminal(terminal)?; println!("Goodbye!"); return Ok(()); } - + (selected_workspace, Some(terminal)) } else { (workspace, None) }; - + if let Some(ref mut term) = startup_terminal { ui::render_loading(term, "Initializing system, please wait...")?; } else { @@ -229,7 +225,7 @@ async fn main() -> Result<()> { let workspace_path = resolve_workspace_path(workspace.as_deref()); tracing::info!("CLI workspace: {:?}", workspace_path); - + bitfun_core::service::config::initialize_global_config() .await .context("Failed to initialize global config service")?; @@ -247,28 +243,31 @@ async fn main() -> Result<()> { }; if let Some(ref svc) = config_service { if let Err(e) = svc.set_config("ai.skip_tool_confirmation", true).await { - tracing::warn!("Failed to temporarily disable tool confirmation, continuing: {}", e); + tracing::warn!( + "Failed to temporarily disable tool confirmation, continuing: {}", + e + ); } } - + use bitfun_core::infrastructure::ai::AIClientFactory; AIClientFactory::initialize_global() .await .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - + let agentic_system = agent::agentic_system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); - + if let Some(ref mut term) = startup_terminal { ui::render_loading(term, "System initialized, starting chat interface...")?; } else { println!("System initialized, starting chat interface...\n"); std::thread::sleep(std::time::Duration::from_millis(500)); } - + let mut chat_mode = ChatMode::new(config, agent, workspace_path, &agentic_system); let chat_result = chat_mode.run(startup_terminal); @@ -280,12 +279,19 @@ async fn main() -> Result<()> { chat_result?; } - - Some(Commands::Exec { message, agent, workspace, json: _, output_patch, confirm }) => { - let workspace_path_resolved = - resolve_workspace_path(workspace.as_deref()).or_else(|| std::env::current_dir().ok()); + + Some(Commands::Exec { + message, + agent, + workspace, + json: _, + output_patch, + confirm, + }) => { + let workspace_path_resolved = resolve_workspace_path(workspace.as_deref()) + .or_else(|| std::env::current_dir().ok()); tracing::info!("CLI workspace: {:?}", workspace_path_resolved); - + bitfun_core::service::config::initialize_global_config() .await .context("Failed to initialize global config service")?; @@ -303,26 +309,29 @@ async fn main() -> Result<()> { }; if let Some(ref svc) = config_service { let desired_skip = !confirm; - if let Err(e) = svc.set_config("ai.skip_tool_confirmation", desired_skip).await { + if let Err(e) = svc + .set_config("ai.skip_tool_confirmation", desired_skip) + .await + { tracing::warn!("Failed to set tool confirmation toggle, continuing: {}", e); } } - + use bitfun_core::infrastructure::ai::AIClientFactory; AIClientFactory::initialize_global() .await .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - + let agentic_system = agent::agentic_system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); - + let mut exec_mode = ExecMode::new( - config, - message, - agent, + config, + message, + agent, &agentic_system, workspace_path_resolved, output_patch, @@ -337,21 +346,21 @@ async fn main() -> Result<()> { run_result?; } - + Some(Commands::Batch { tasks }) => { println!("Executing batch tasks..."); println!("Tasks file: {}", tasks); println!("\nWarning: Batch execution feature coming soon"); } - + Some(Commands::Sessions { action }) => { handle_session_action(action)?; } - + Some(Commands::Config { action }) => { handle_config_action(action, &config)?; } - + Some(Commands::Tool { name, params }) => { println!("Invoking tool: {}", name); if let Some(p) = params { @@ -359,33 +368,33 @@ async fn main() -> Result<()> { } println!("\nWarning: Tool invocation feature coming soon"); } - + Some(Commands::Health) => { println!("BitFun CLI is running normally"); println!("Version: {}", env!("CARGO_PKG_VERSION")); println!("Config directory: {:?}", CliConfig::config_dir()?); } - + None => { - use ui::startup::StartupPage; use modes::chat::ChatExitReason; - + use ui::startup::StartupPage; + loop { let mut terminal = ui::init_terminal()?; let mut startup_page = StartupPage::new(); let workspace = startup_page.run(&mut terminal)?; - + if workspace.is_none() { ui::restore_terminal(terminal)?; println!("Goodbye!"); break; } - + ui::render_loading(&mut terminal, "Initializing system, please wait...")?; let workspace_path = resolve_workspace_path(workspace.as_deref()); tracing::info!("CLI workspace: {:?}", workspace_path); - + bitfun_core::service::config::initialize_global_config() .await .context("Failed to initialize global config service")?; @@ -404,20 +413,23 @@ async fn main() -> Result<()> { if let Some(ref svc) = config_service { let _ = svc.set_config("ai.skip_tool_confirmation", true).await; } - + use bitfun_core::infrastructure::ai::AIClientFactory; AIClientFactory::initialize_global() .await .context("Failed to initialize global AIClientFactory")?; tracing::info!("Global AI client factory initialized"); - + let agentic_system = agent::agentic_system::init_agentic_system() .await .context("Failed to initialize agentic system")?; tracing::info!("Agentic system initialized"); - - ui::render_loading(&mut terminal, "System initialized, starting chat interface...")?; - + + ui::render_loading( + &mut terminal, + "System initialized, starting chat interface...", + )?; + let agent = config.behavior.default_agent.clone(); let mut chat_mode = ChatMode::new(config.clone(), agent, workspace_path, &agentic_system); @@ -429,7 +441,7 @@ async fn main() -> Result<()> { .await; } let exit_reason = exit_reason?; - + match exit_reason { ChatExitReason::Quit => { println!("Goodbye!"); @@ -442,7 +454,7 @@ async fn main() -> Result<()> { } } } - + Ok(()) } @@ -451,18 +463,19 @@ fn handle_session_action(action: SessionAction) -> Result<()> { SessionAction::List => { use session::Session; let sessions = Session::list_all()?; - + if sessions.is_empty() { println!("No history sessions"); return Ok(()); } - + println!("History sessions (total {})\n", sessions.len()); - + for (i, info) in sessions.iter().enumerate() { println!("{}. {} (ID: {})", i + 1, info.title, info.id); - println!(" Agent: {} | Messages: {} | Updated: {}", - info.agent, + println!( + " Agent: {} | Messages: {} | Updated: {}", + info.agent, info.message_count, info.updated_at.format("%Y-%m-%d %H:%M") ); @@ -472,23 +485,28 @@ fn handle_session_action(action: SessionAction) -> Result<()> { println!(); } } - + SessionAction::Show { id } => { use session::Session; - + let session = if id == "last" { - Session::get_last()? - .ok_or_else(|| anyhow::anyhow!("No history sessions"))? + Session::get_last()?.ok_or_else(|| anyhow::anyhow!("No history sessions"))? } else { Session::load(&id)? }; - + println!("Session Details\n"); println!("Title: {}", session.title); println!("ID: {}", session.id); println!("Agent: {}", session.agent); - println!("Created: {}", session.created_at.format("%Y-%m-%d %H:%M:%S")); - println!("Updated: {}", session.updated_at.format("%Y-%m-%d %H:%M:%S")); + println!( + "Created: {}", + session.created_at.format("%Y-%m-%d %H:%M:%S") + ); + println!( + "Updated: {}", + session.updated_at.format("%Y-%m-%d %H:%M:%S") + ); if let Some(ws) = &session.workspace { println!("Workspace: {}", ws); } @@ -498,12 +516,13 @@ fn handle_session_action(action: SessionAction) -> Result<()> { println!(" Tool calls: {}", session.metadata.tool_calls); println!(" Files modified: {}", session.metadata.files_modified); println!(); - + if !session.messages.is_empty() { println!("Recent messages:"); let recent = session.messages.iter().rev().take(3); for msg in recent { - println!(" [{}] {}: {}", + println!( + " [{}] {}: {}", msg.timestamp.format("%H:%M:%S"), msg.role, msg.content.lines().next().unwrap_or("") @@ -511,14 +530,14 @@ fn handle_session_action(action: SessionAction) -> Result<()> { } } } - + SessionAction::Delete { id } => { use session::Session; Session::delete(&id)?; println!("Deleted session: {}", id); } } - + Ok(()) } @@ -541,7 +560,7 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(); println!("Config file: {:?}", CliConfig::config_path()?); } - + ConfigAction::Edit => { let config_path = CliConfig::config_path()?; println!("Config file location: {:?}", config_path); @@ -551,15 +570,13 @@ fn handle_config_action(action: ConfigAction, config: &CliConfig) -> Result<()> println!(" or"); println!(" code {:?}", config_path); } - + ConfigAction::Reset => { let default_config = CliConfig::default(); default_config.save()?; println!("Reset to default configuration"); } } - + Ok(()) } - - diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 8be25ac5..6b974e5b 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -1,7 +1,6 @@ /// Chat mode implementation -/// +/// /// Interactive chat mode with TUI interface - use anyhow::Result; use crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers}; use ratatui::backend::CrosstermBackend; @@ -12,12 +11,12 @@ use std::sync::Arc; use std::time::Duration; use tokio::sync::mpsc; +use crate::agent::{agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent}; use crate::config::CliConfig; use crate::session::Session; use crate::ui::chat::ChatView; use crate::ui::theme::Theme; use crate::ui::{init_terminal, restore_terminal}; -use crate::agent::{Agent, core_adapter::CoreAgentAdapter, agentic_system::AgenticSystem}; use uuid; /// Chat mode exit reason @@ -38,8 +37,8 @@ pub struct ChatMode { impl ChatMode { pub fn new( - config: CliConfig, - agent_name: String, + config: CliConfig, + agent_name: String, workspace_path: Option, agentic_system: &AgenticSystem, ) -> Self { @@ -50,7 +49,7 @@ impl ChatMode { agentic_system.event_queue.clone(), workspace_path.clone(), )) as Arc; - + Self { config, agent_name, @@ -78,7 +77,7 @@ impl ChatMode { .as_ref() .map(|path| path.to_string_lossy().to_string()), ); - + let theme = match self.config.ui.theme.as_str() { "light" => Theme::light(), _ => Theme::dark(), @@ -86,16 +85,18 @@ impl ChatMode { let mut chat_view = ChatView::new(session, theme); let rt_handle = tokio::runtime::Handle::current(); - let (response_tx, mut response_rx) = mpsc::unbounded_channel::(); + let (response_tx, mut response_rx) = + mpsc::unbounded_channel::(); let (stream_tx, mut stream_rx) = mpsc::unbounded_channel::(); - + let mut pending_response: Option>> = None; let mut current_assistant_message_text = String::new(); - let mut current_tool_map: std::collections::HashMap = std::collections::HashMap::new(); + let mut current_tool_map: std::collections::HashMap = + std::collections::HashMap::new(); let mut exit_reason = ChatExitReason::Quit; let mut should_quit = false; - + while !should_quit { terminal.draw(|frame| { chat_view.render(frame); @@ -104,24 +105,27 @@ impl ChatMode { while let Ok(event) = stream_rx.try_recv() { use crate::agent::AgentEvent; use crate::session::{ToolCall, ToolCallStatus}; - + match event { AgentEvent::TextChunk(chunk) => { current_assistant_message_text.push_str(&chunk); chat_view.session.update_last_message_text_flow( current_assistant_message_text.clone(), - true + true, ); } - - AgentEvent::ToolCallStart { tool_name, parameters } => { + + AgentEvent::ToolCallStart { + tool_name, + parameters, + } => { if !current_assistant_message_text.is_empty() { chat_view.session.update_last_message_text_flow( current_assistant_message_text.clone(), - false + false, ); } - + let tool_id = uuid::Uuid::new_v4().to_string(); let tool_call = ToolCall { tool_id: Some(tool_id.clone()), @@ -133,11 +137,11 @@ impl ChatMode { progress_message: None, duration_ms: None, }; - + current_tool_map.insert(tool_id, tool_call.clone()); chat_view.session.add_tool_to_last_message(tool_call); } - + AgentEvent::ToolCallProgress { tool_name, message } => { for (tool_id, tool) in current_tool_map.iter() { if tool.tool_name == tool_name { @@ -149,10 +153,15 @@ impl ChatMode { } } } - - AgentEvent::ToolCallComplete { tool_name, result, success } => { + + AgentEvent::ToolCallComplete { + tool_name, + result, + success, + } => { for (tool_id, tool) in current_tool_map.iter_mut() { - if tool.tool_name == tool_name && tool.status == ToolCallStatus::Running { + if tool.tool_name == tool_name && tool.status == ToolCallStatus::Running + { tool.status = if success { ToolCallStatus::Success } else { @@ -160,7 +169,7 @@ impl ChatMode { }; tool.result = Some(result.clone()); tool.progress = Some(1.0); - + let tid = tool_id.clone(); chat_view.session.update_tool_in_last_message(&tid, |t| { t.status = tool.status.clone(); @@ -171,20 +180,20 @@ impl ChatMode { } } } - + AgentEvent::Done => { if !current_assistant_message_text.is_empty() { chat_view.session.update_last_message_text_flow( current_assistant_message_text.clone(), - false + false, ); } } - + AgentEvent::Error(err) => { chat_view.set_status(Some(format!("Error: {}", err))); } - + _ => {} } } @@ -195,7 +204,7 @@ impl ChatMode { chat_view.set_loading(false); chat_view.set_status(None); } - + if let Some(handle) = &pending_response { if handle.is_finished() { pending_response = None; @@ -208,10 +217,10 @@ impl ChatMode { match event { Event::Key(key) => { if let Some(reason) = self.handle_key_event( - key, - &mut chat_view, - &mut pending_response, - &rt_handle, + key, + &mut chat_view, + &mut pending_response, + &rt_handle, &response_tx, &stream_tx, &mut current_assistant_message_text, @@ -240,8 +249,8 @@ impl ChatMode { } fn handle_key_event( - &self, - key: KeyEvent, + &self, + key: KeyEvent, chat_view: &mut ChatView, pending_response: &mut Option>>, rt_handle: &tokio::runtime::Handle, @@ -253,7 +262,7 @@ impl ChatMode { if key.kind != KeyEventKind::Press && key.kind != KeyEventKind::Repeat { return Ok(None); } - + match (key.code, key.modifiers) { (KeyCode::Char('c'), KeyModifiers::CONTROL) => { tracing::info!("User requested quit"); @@ -277,33 +286,42 @@ impl ChatMode { if let Some(input) = chat_view.send_input() { tracing::info!("User input: {}", input); - + if input.starts_with('/') { self.handle_command(&input, chat_view)?; return Ok(None); } - + chat_view.set_loading(true); chat_view.set_status(Some(format!("{} is thinking...", self.agent_name))); - chat_view.session.add_message("assistant".to_string(), String::new()); - + chat_view + .session + .add_message("assistant".to_string(), String::new()); + current_assistant_message_text.clear(); current_tool_map.clear(); - + let agent = Arc::clone(&self.agent); let input_clone = input.clone(); let resp_tx = response_tx.clone(); let stream_tx_clone = stream_tx.clone(); - + let handle_clone = rt_handle.spawn(async move { - match agent.process_message(input_clone, stream_tx_clone.clone()).await { + match agent + .process_message(input_clone, stream_tx_clone.clone()) + .await + { Ok(response) => { - tracing::info!("Agent response complete: {} tool calls", response.tool_calls.len()); + tracing::info!( + "Agent response complete: {} tool calls", + response.tool_calls.len() + ); let _ = resp_tx.send(response); } Err(e) => { tracing::error!("Agent processing failed: {}", e); - let _ = stream_tx_clone.send(crate::agent::AgentEvent::Error(e.to_string())); + let _ = stream_tx_clone + .send(crate::agent::AgentEvent::Error(e.to_string())); let _ = resp_tx.send(crate::agent::AgentResponse { tool_calls: vec![], success: false, @@ -312,7 +330,7 @@ impl ChatMode { } Ok(()) }); - + *pending_response = Some(handle_clone); } } @@ -423,7 +441,8 @@ impl ChatMode { /agents - List available agents\n\ /switch - Switch agent\n\ /history - Show history\n\ - /export - Export session".to_string(), + /export - Export session" + .to_string(), ); } "/clear" => { @@ -439,7 +458,8 @@ impl ChatMode { • test-writer - Test writing expert\n\ • docs-writer - Documentation expert\n\ • rust-specialist - Rust expert\n\ - • visual-debugger - Visual debugging expert".to_string(), + • visual-debugger - Visual debugging expert" + .to_string(), ); } "/switch" => { @@ -449,34 +469,40 @@ impl ChatMode { format!("Warning: Agent switching feature coming soon\nTip: Use `bitfun chat --agent {}` to start a new session", parts[1]), ); } else { - chat_view.add_message( - "system".to_string(), - "Usage: /switch ".to_string(), - ); + chat_view + .add_message("system".to_string(), "Usage: /switch ".to_string()); } } "/history" => { chat_view.add_message( "system".to_string(), - format!("Current session statistics:\n\ + format!( + "Current session statistics:\n\ • Messages: {}\n\ • Tool calls: {}\n\ • Files modified: {}", - chat_view.session.metadata.message_count, - chat_view.session.metadata.tool_calls, - chat_view.session.metadata.files_modified), + chat_view.session.metadata.message_count, + chat_view.session.metadata.tool_calls, + chat_view.session.metadata.files_modified + ), ); } "/export" => { chat_view.add_message( "system".to_string(), - format!("Session auto-saved to: ~/.config/bitfun/sessions/{}.json", chat_view.session.id), + format!( + "Session auto-saved to: ~/.config/bitfun/sessions/{}.json", + chat_view.session.id + ), ); } _ => { chat_view.add_message( "system".to_string(), - format!("Unknown command: {}\nUse /help to see available commands", parts[0]), + format!( + "Unknown command: {}\nUse /help to see available commands", + parts[0] + ), ); } } @@ -484,4 +510,3 @@ impl ChatMode { Ok(()) } } - diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index 9b8c7963..eca04396 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -1,13 +1,14 @@ +use crate::agent::{ + agentic_system::AgenticSystem, core_adapter::CoreAgentAdapter, Agent, AgentEvent, +}; +use crate::config::CliConfig; /// Exec mode implementation -/// +/// /// Single command execution mode - use anyhow::Result; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::mpsc; -use crate::config::CliConfig; -use crate::agent::{Agent, AgentEvent, core_adapter::CoreAgentAdapter, agentic_system::AgenticSystem}; pub struct ExecMode { #[allow(dead_code)] @@ -21,8 +22,8 @@ pub struct ExecMode { impl ExecMode { pub fn new( - config: CliConfig, - message: String, + config: CliConfig, + message: String, agent_type: String, agentic_system: &AgenticSystem, workspace_path: Option, @@ -35,7 +36,7 @@ impl ExecMode { agentic_system.event_queue.clone(), workspace_path.clone(), )) as Arc; - + Self { config, message, @@ -44,22 +45,22 @@ impl ExecMode { output_patch, } } - + fn get_git_diff(&self) -> Option { let workspace = self.workspace_path.as_ref()?; - + let git_dir = workspace.join(".git"); if !git_dir.exists() { eprintln!("Warning: Workspace is not a git repository, cannot generate patch"); return None; } - + let output = bitfun_core::util::process_manager::create_command("git") .args(["diff", "--no-color"]) .current_dir(workspace) .output() .ok()?; - + if output.status.success() { Some(String::from_utf8_lossy(&output.stdout).to_string()) } else { @@ -69,7 +70,11 @@ impl ExecMode { } pub async fn run(&mut self) -> Result<()> { - tracing::info!("Executing command, Agent: {}, Message: {}", self.agent.name(), self.message); + tracing::info!( + "Executing command, Agent: {}, Message: {}", + self.agent.name(), + self.message + ); println!("Executing: {}", self.message); println!(); @@ -90,13 +95,23 @@ impl ExecMode { use std::io::Write; std::io::stdout().flush().ok(); } - AgentEvent::ToolCallStart { tool_name, parameters: _ } => { + AgentEvent::ToolCallStart { + tool_name, + parameters: _, + } => { println!("\nTool call: {}", tool_name); } - AgentEvent::ToolCallProgress { tool_name: _, message } => { + AgentEvent::ToolCallProgress { + tool_name: _, + message, + } => { println!(" In progress: {}", message); } - AgentEvent::ToolCallComplete { tool_name, result, success } => { + AgentEvent::ToolCallComplete { + tool_name, + result, + success, + } => { if success { println!(" [+] {}: {}", tool_name, result); } else { @@ -115,13 +130,16 @@ impl ExecMode { } let result = handle.await; - + match result { Ok(Ok(response)) => { if response.success { println!("Execution complete"); if !response.tool_calls.is_empty() { - println!("\nTool call statistics: {} tools invoked", response.tool_calls.len()); + println!( + "\nTool call statistics: {} tools invoked", + response.tool_calls.len() + ); } } else { println!("Execution failed"); @@ -136,7 +154,7 @@ impl ExecMode { return Err(e.into()); } } - + if let Some(ref output_target) = self.output_patch { println!("\n--- Generating Patch ---"); if let Some(patch) = self.get_git_diff() { @@ -168,4 +186,3 @@ impl ExecMode { Ok(()) } } - diff --git a/src/apps/cli/src/modes/mod.rs b/src/apps/cli/src/modes/mod.rs index cfdcaa47..2ce28d65 100644 --- a/src/apps/cli/src/modes/mod.rs +++ b/src/apps/cli/src/modes/mod.rs @@ -1,4 +1,3 @@ /// Different interaction modes - pub mod chat; pub mod exec; diff --git a/src/apps/cli/src/session.rs b/src/apps/cli/src/session.rs index 27fde505..eb170fc4 100644 --- a/src/apps/cli/src/session.rs +++ b/src/apps/cli/src/session.rs @@ -1,12 +1,11 @@ /// Session management module -/// +/// /// Responsible for creating, saving, loading and managing chat sessions - use anyhow::Result; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -use std::path::PathBuf; use std::fs; -use chrono::{DateTime, Utc}; +use std::path::PathBuf; use crate::config::CliConfig; @@ -137,7 +136,7 @@ impl Session { pub fn new(agent: String, workspace: Option) -> Self { let id = uuid::Uuid::new_v4().to_string(); let now = Utc::now(); - + Self { id: id.clone(), title: format!("Session {}", now.format("%m-%d %H:%M")), @@ -159,7 +158,7 @@ impl Session { timestamp: Utc::now(), flow_items: Vec::new(), }; - + self.messages.push(message); self.metadata.message_count = self.messages.len(); self.updated_at = Utc::now(); @@ -169,7 +168,11 @@ impl Session { pub fn update_last_message_text_flow(&mut self, content: String, is_streaming: bool) { if let Some(last_message) = self.messages.last_mut() { if let Some(last_item) = last_message.flow_items.last_mut() { - if let FlowItem::Text { content: ref mut c, is_streaming: ref mut s } = last_item { + if let FlowItem::Text { + content: ref mut c, + is_streaming: ref mut s, + } = last_item + { *c = content.clone(); *s = is_streaming; } else { @@ -188,7 +191,7 @@ impl Session { self.updated_at = Utc::now(); } } - + /// Add tool call to the last message pub fn add_tool_to_last_message(&mut self, tool_call: ToolCall) { if let Some(last_message) = self.messages.last_mut() { @@ -197,9 +200,13 @@ impl Session { self.updated_at = Utc::now(); } } - + /// Update tool call status in the last message - pub fn update_tool_in_last_message(&mut self, tool_id: &str, update_fn: impl FnOnce(&mut ToolCall)) { + pub fn update_tool_in_last_message( + &mut self, + tool_id: &str, + update_fn: impl FnOnce(&mut ToolCall), + ) { if let Some(last_message) = self.messages.last_mut() { for item in last_message.flow_items.iter_mut() { if let FlowItem::Tool { tool_call } = item { @@ -227,31 +234,35 @@ impl Session { pub fn load(id: &str) -> Result { let sessions_dir = CliConfig::sessions_dir()?; let session_file = sessions_dir.join(format!("{}.json", id)); - + if !session_file.exists() { anyhow::bail!("Session not found: {}", id); } let content = fs::read_to_string(&session_file)?; let session: Self = serde_json::from_str(&content)?; - tracing::info!("Loaded session: {} ({} messages)", session.title, session.messages.len()); + tracing::info!( + "Loaded session: {} ({} messages)", + session.title, + session.messages.len() + ); Ok(session) } /// List all sessions pub fn list_all() -> Result> { let sessions_dir = CliConfig::sessions_dir()?; - + if !sessions_dir.exists() { return Ok(Vec::new()); } let mut sessions = Vec::new(); - + for entry in fs::read_dir(sessions_dir)? { let entry = entry?; let path = entry.path(); - + if path.extension().and_then(|s| s.to_str()) != Some("json") { continue; } @@ -271,7 +282,7 @@ impl Session { fn load_info(path: &PathBuf) -> Result { let content = fs::read_to_string(path)?; let session: Self = serde_json::from_str(&content)?; - + Ok(SessionInfo { id: session.id, title: session.title, @@ -287,19 +298,19 @@ impl Session { pub fn delete(id: &str) -> Result<()> { let sessions_dir = CliConfig::sessions_dir()?; let session_file = sessions_dir.join(format!("{}.json", id)); - + if session_file.exists() { fs::remove_file(session_file)?; tracing::info!("Deleted session: {}", id); } - + Ok(()) } /// Get most recent session pub fn get_last() -> Result> { let sessions = Self::list_all()?; - + if let Some(info) = sessions.first() { Ok(Some(Self::load(&info.id)?)) } else { @@ -319,5 +330,3 @@ pub struct SessionInfo { pub message_count: usize, pub workspace: Option, } - - diff --git a/src/apps/cli/src/ui/chat.rs b/src/apps/cli/src/ui/chat.rs index cf050f0d..fab52e24 100644 --- a/src/apps/cli/src/ui/chat.rs +++ b/src/apps/cli/src/ui/chat.rs @@ -1,5 +1,4 @@ /// Chat mode TUI interface - use ratatui::{ layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Modifier, Style}, @@ -10,10 +9,10 @@ use ratatui::{ use std::collections::VecDeque; use unicode_width::UnicodeWidthStr; -use super::theme::{Theme, StyleKind}; -use super::widgets::{Spinner, HelpText}; use super::markdown::MarkdownRenderer; -use crate::session::{Message, Session, FlowItem}; +use super::theme::{StyleKind, Theme}; +use super::widgets::{HelpText, Spinner}; +use crate::session::{FlowItem, Message, Session}; /// Chat interface state pub struct ChatView { @@ -77,11 +76,11 @@ impl ChatView { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // header - Constraint::Min(10), // messages area - Constraint::Length(1), // status bar - Constraint::Length(3), // input area - Constraint::Length(1), // shortcuts hint + Constraint::Length(3), // header + Constraint::Min(10), // messages area + Constraint::Length(1), // status bar + Constraint::Length(3), // input area + Constraint::Length(1), // shortcuts hint ]) .split(size); @@ -97,8 +96,11 @@ impl ChatView { fn render_header(&self, frame: &mut Frame, area: Rect) { let title = format!(" BitFun CLI v{} ", env!("CARGO_PKG_VERSION")); let agent_info = format!(" Agent: {} ", self.session.agent); - - let workspace = self.session.workspace.as_ref() + + let workspace = self + .session + .workspace + .as_ref() .map(|w| format!("Workspace: {}", w)) .unwrap_or_else(|| "No workspace".to_string()); @@ -112,15 +114,13 @@ impl ChatView { .fg(ratatui::style::Color::Rgb(147, 51, 234)) .add_modifier(Modifier::BOLD); - let text = vec![ - Line::from(vec![ - Span::styled(&title, title_style), - Span::raw(" "), - Span::styled(&agent_info, self.theme.style(StyleKind::Primary)), - Span::raw(" "), - Span::styled(&workspace, self.theme.style(StyleKind::Muted)), - ]), - ]; + let text = vec![Line::from(vec![ + Span::styled(&title, title_style), + Span::raw(" "), + Span::styled(&agent_info, self.theme.style(StyleKind::Primary)), + Span::raw(" "), + Span::styled(&workspace, self.theme.style(StyleKind::Muted)), + ])]; let paragraph = Paragraph::new(text) .block(header) @@ -135,7 +135,7 @@ impl ChatView { } else { " Conversation ".to_string() }; - + let block = Block::default() .borders(Borders::ALL) .border_style(self.theme.style(StyleKind::Border)) @@ -169,7 +169,9 @@ impl ChatView { frame.render_widget(paragraph, inner); } else { - let messages: Vec = self.session.messages + let messages: Vec = self + .session + .messages .iter() .flat_map(|msg| self.render_message(msg)) .collect(); @@ -177,28 +179,28 @@ impl ChatView { if !messages.is_empty() { let total_lines = messages.len(); let visible_lines = inner.height as usize; - + if self.browse_mode { let view_position = if self.scroll_offset >= total_lines { 0 } else { total_lines.saturating_sub(self.scroll_offset + visible_lines) }; - + *self.list_state.offset_mut() = view_position; - + let selected_index = view_position + visible_lines / 2; - self.list_state.select(Some(selected_index.min(total_lines.saturating_sub(1)))); - + self.list_state + .select(Some(selected_index.min(total_lines.saturating_sub(1)))); } else if self.auto_scroll { let bottom_offset = total_lines.saturating_sub(visible_lines); *self.list_state.offset_mut() = bottom_offset; - + let last_index = total_lines.saturating_sub(1); self.list_state.select(Some(last_index)); self.scroll_offset = 0; } - + if self.browse_mode { let progress_pct = if self.scroll_offset == 0 { 100 @@ -207,7 +209,7 @@ impl ChatView { } else { ((total_lines - self.scroll_offset) * 100 / total_lines).min(100) }; - + let scroll_indicator = format!("{}%", progress_pct); let indicator_area = Rect { x: inner.x + inner.width.saturating_sub(12), @@ -215,7 +217,7 @@ impl ChatView { width: 10, height: 1, }; - + let indicator_widget = Paragraph::new(scroll_indicator) .style(self.theme.style(StyleKind::Info)) .alignment(Alignment::Right); @@ -223,8 +225,7 @@ impl ChatView { } } - let list = List::new(messages) - .highlight_style(Style::default()); + let list = List::new(messages).highlight_style(Style::default()); frame.render_stateful_widget(list, inner, &mut self.list_state); } @@ -233,14 +234,14 @@ impl ChatView { self.spinner.tick(); let loading_text = format!("{} Thinking...", self.spinner.current()); let loading_span = Span::styled(loading_text, self.theme.style(StyleKind::Primary)); - + let loading_area = Rect { x: inner.x + 2, y: inner.y + inner.height.saturating_sub(1), width: inner.width.saturating_sub(4), height: 1, }; - + let paragraph = Paragraph::new(loading_span); frame.render_widget(paragraph, loading_area); } @@ -262,7 +263,7 @@ impl ChatView { }; let time = message.timestamp.format("%H:%M:%S"); - + items.push(ListItem::new(Line::from(vec![Span::raw("")]))); items.push(ListItem::new(Line::from(vec![ @@ -274,11 +275,17 @@ impl ChatView { if !message.flow_items.is_empty() { for flow_item in &message.flow_items { match flow_item { - FlowItem::Text { content, is_streaming } => { - if message.role == "assistant" && MarkdownRenderer::has_markdown_syntax(content) { + FlowItem::Text { + content, + is_streaming, + } => { + if message.role == "assistant" + && MarkdownRenderer::has_markdown_syntax(content) + { let available_width = 80; - let markdown_lines = self.markdown_renderer.render(content, available_width); - + let markdown_lines = + self.markdown_renderer.render(content, available_width); + for md_line in markdown_lines { let mut spans = vec![Span::raw(" ")]; spans.extend(md_line.spans); @@ -293,7 +300,7 @@ impl ChatView { ]))); } } - + if *is_streaming { items.push(ListItem::new(Line::from(vec![ Span::raw(" "), @@ -301,19 +308,24 @@ impl ChatView { ]))); } } - + FlowItem::Tool { tool_call } => { items.push(ListItem::new(Line::from(""))); - let tool_items = crate::ui::tool_cards::render_tool_card(tool_call, &self.theme); + let tool_items = + crate::ui::tool_cards::render_tool_card(tool_call, &self.theme); items.extend(tool_items); } } } } else { - if message.role == "assistant" && MarkdownRenderer::has_markdown_syntax(&message.content) { + if message.role == "assistant" + && MarkdownRenderer::has_markdown_syntax(&message.content) + { let available_width = 80; - let markdown_lines = self.markdown_renderer.render(&message.content, available_width); - + let markdown_lines = self + .markdown_renderer + .render(&message.content, available_width); + for md_line in markdown_lines { let mut spans = vec![Span::raw(" ")]; spans.extend(md_line.spans); @@ -332,7 +344,7 @@ impl ChatView { items } - + /// Render status bar fn render_status_bar(&self, frame: &mut Frame, area: Rect) { let status_text = if let Some(status) = &self.status { @@ -365,11 +377,7 @@ impl ChatView { Span::raw(&self.input) }; - let paragraph = Paragraph::new(Line::from(vec![ - Span::raw("> "), - input_text, - ])) - .block(block); + let paragraph = Paragraph::new(Line::from(vec![Span::raw("> "), input_text])).block(block); frame.render_widget(paragraph, area); @@ -378,7 +386,7 @@ impl ChatView { // Calculate display width to cursor position let byte_pos = self.char_pos_to_byte_pos(self.cursor); let display_width = self.input[..byte_pos].width() as u16; - + frame.set_cursor_position(( area.x + 3 + display_width, // "> " + display width area.y + 1, @@ -410,8 +418,7 @@ impl ChatView { style: self.theme.style(StyleKind::Muted), }; - let paragraph = Paragraph::new(help.render()) - .alignment(Alignment::Center); + let paragraph = Paragraph::new(help.render()).alignment(Alignment::Center); frame.render_widget(paragraph, area); } @@ -430,7 +437,7 @@ impl ChatView { } let input = self.input.clone(); - + // Add to history self.input_history.push_front(input.clone()); if self.input_history.len() > 50 { @@ -452,7 +459,7 @@ impl ChatView { if c.is_control() || c == '\u{0}' { return; } - + let byte_pos = self.char_pos_to_byte_pos(self.cursor); self.input.insert(byte_pos, c); self.cursor += 1; @@ -552,11 +559,13 @@ impl ChatView { pub fn scroll_up(&mut self, lines: usize) { if self.browse_mode { - let total_lines: usize = self.session.messages + let total_lines: usize = self + .session + .messages .iter() .flat_map(|msg| self.render_message(msg)) .count(); - + self.scroll_offset = (self.scroll_offset + lines).min(total_lines.saturating_sub(1)); } else { self.browse_mode = true; @@ -568,7 +577,7 @@ impl ChatView { pub fn scroll_down(&mut self, lines: usize) { if self.scroll_offset > 0 { self.scroll_offset = self.scroll_offset.saturating_sub(lines); - + if self.scroll_offset == 0 && self.browse_mode { self.browse_mode = false; self.auto_scroll = true; @@ -577,11 +586,13 @@ impl ChatView { } pub fn scroll_to_top(&mut self) { - let total_lines: usize = self.session.messages + let total_lines: usize = self + .session + .messages .iter() .flat_map(|msg| self.render_message(msg)) .count(); - + self.browse_mode = true; self.auto_scroll = false; self.scroll_offset = total_lines.saturating_sub(1); @@ -593,4 +604,3 @@ impl ChatView { self.scroll_offset = 0; } } - diff --git a/src/apps/cli/src/ui/markdown.rs b/src/apps/cli/src/ui/markdown.rs index b46791f8..7dd99fb8 100644 --- a/src/apps/cli/src/ui/markdown.rs +++ b/src/apps/cli/src/ui/markdown.rs @@ -1,12 +1,11 @@ /// Markdown rendering utilities - -use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd, HeadingLevel}; +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use ratatui::{ style::{Modifier, Style}, text::{Line, Span}, }; -use super::theme::{Theme, StyleKind}; +use super::theme::{StyleKind, Theme}; /// Markdown renderer pub struct MarkdownRenderer { @@ -18,20 +17,20 @@ impl MarkdownRenderer { pub fn new(theme: Theme) -> Self { Self { theme } } - + pub fn render(&self, markdown: &str, _width: usize) -> Vec> { let mut lines = Vec::new(); let mut current_line_spans: Vec> = Vec::new(); - + // Style stack let mut style_stack: Vec = Vec::new(); let mut list_level: usize = 0; let mut in_code_block = false; let mut code_block_lang = String::new(); - + let options = Options::all(); let parser = Parser::new_ext(markdown, options); - + for event in parser { match event { Event::Start(tag) => { @@ -42,7 +41,7 @@ impl MarkdownRenderer { lines.push(Line::from(std::mem::take(&mut current_line_spans))); } lines.push(Line::from("")); - + // Heading prefix let prefix = match level { HeadingLevel::H1 => "# ", @@ -54,9 +53,11 @@ impl MarkdownRenderer { }; current_line_spans.push(Span::styled( prefix.to_string(), - self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD) + self.theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), )); - + style_stack.push(StyleModifier::Heading); } Tag::Paragraph => { @@ -65,7 +66,7 @@ impl MarkdownRenderer { Tag::BlockQuote(_) => { current_line_spans.push(Span::styled( "│ ".to_string(), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), )); style_stack.push(StyleModifier::Quote); } @@ -79,12 +80,12 @@ impl MarkdownRenderer { lines.push(Line::from(std::mem::take(&mut current_line_spans))); } lines.push(Line::from("")); - + // Add language identifier if !code_block_lang.is_empty() { current_line_spans.push(Span::styled( format!("```{}", code_block_lang), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), )); lines.push(Line::from(std::mem::take(&mut current_line_spans))); } @@ -104,7 +105,7 @@ impl MarkdownRenderer { current_line_spans.push(Span::raw(indent)); current_line_spans.push(Span::styled( "• ".to_string(), - self.theme.style(StyleKind::Primary) + self.theme.style(StyleKind::Primary), )); } Tag::Strong => { @@ -119,13 +120,13 @@ impl MarkdownRenderer { Tag::Image { .. } => { current_line_spans.push(Span::styled( "[Image]".to_string(), - self.theme.style(StyleKind::Info) + self.theme.style(StyleKind::Info), )); } _ => {} } } - + Event::End(tag_end) => { match tag_end { TagEnd::Heading(_) => { @@ -157,7 +158,7 @@ impl MarkdownRenderer { if !code_block_lang.is_empty() { lines.push(Line::from(Span::styled( "```".to_string(), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), ))); code_block_lang.clear(); } @@ -194,17 +195,14 @@ impl MarkdownRenderer { _ => {} } } - + Event::Text(text) => { let style = self.compute_style(&style_stack, in_code_block); - + if in_code_block { // Code block: process each line separately for line in text.lines() { - current_line_spans.push(Span::styled( - format!(" {}", line), - style - )); + current_line_spans.push(Span::styled(format!(" {}", line), style)); lines.push(Line::from(std::mem::take(&mut current_line_spans))); } } else { @@ -212,76 +210,83 @@ impl MarkdownRenderer { current_line_spans.push(Span::styled(text.to_string(), style)); } } - + Event::Code(code) => { // Inline code current_line_spans.push(Span::styled( format!("`{}`", code), - self.theme.style(StyleKind::Success) + self.theme.style(StyleKind::Success), )); } - + Event::SoftBreak | Event::HardBreak => { if !in_code_block && !current_line_spans.is_empty() { lines.push(Line::from(std::mem::take(&mut current_line_spans))); } } - + Event::Rule => { lines.push(Line::from(Span::styled( "─".repeat(60), - self.theme.style(StyleKind::Muted) + self.theme.style(StyleKind::Muted), ))); } - + _ => {} } } - + // Process remaining spans if !current_line_spans.is_empty() { lines.push(Line::from(current_line_spans)); } - + // Remove trailing empty lines - while lines.last().map_or(false, |line| line.spans.is_empty() || - (line.spans.len() == 1 && line.spans[0].content.is_empty())) { + while lines.last().map_or(false, |line| { + line.spans.is_empty() || (line.spans.len() == 1 && line.spans[0].content.is_empty()) + }) { lines.pop(); } - + lines } - + fn compute_style(&self, stack: &[StyleModifier], in_code_block: bool) -> Style { let mut style = Style::default(); - + if in_code_block { return self.theme.style(StyleKind::Success); } - + for modifier in stack { style = match modifier { StyleModifier::Bold => style.add_modifier(Modifier::BOLD), StyleModifier::Italic => style.add_modifier(Modifier::ITALIC), - StyleModifier::Heading => self.theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD), + StyleModifier::Heading => self + .theme + .style(StyleKind::Primary) + .add_modifier(Modifier::BOLD), StyleModifier::Quote => style.fg(self.theme.muted), - StyleModifier::Link => self.theme.style(StyleKind::Info).add_modifier(Modifier::UNDERLINED), + StyleModifier::Link => self + .theme + .style(StyleKind::Info) + .add_modifier(Modifier::UNDERLINED), }; } - + style } - + pub fn has_markdown_syntax(text: &str) -> bool { - text.contains("**") || - text.contains("__") || - text.contains("*") || - text.contains("_") || - text.contains("`") || - text.contains("#") || - text.contains("[") || - text.contains(">") || - text.contains("```") + text.contains("**") + || text.contains("__") + || text.contains("*") + || text.contains("_") + || text.contains("`") + || text.contains("#") + || text.contains("[") + || text.contains(">") + || text.contains("```") } } @@ -298,7 +303,7 @@ enum StyleModifier { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_has_markdown_syntax() { assert!(MarkdownRenderer::has_markdown_syntax("**bold**")); @@ -307,7 +312,7 @@ mod tests { assert!(MarkdownRenderer::has_markdown_syntax("# Title")); assert!(!MarkdownRenderer::has_markdown_syntax("plain text")); } - + #[test] fn test_render_simple() { let theme = Theme::default(); @@ -315,7 +320,7 @@ mod tests { let lines = renderer.render("**bold** text", 80); assert!(!lines.is_empty()); } - + #[test] fn test_render_code_block() { let theme = Theme::default(); diff --git a/src/apps/cli/src/ui/mod.rs b/src/apps/cli/src/ui/mod.rs index 57f7559a..90e1b56e 100644 --- a/src/apps/cli/src/ui/mod.rs +++ b/src/apps/cli/src/ui/mod.rs @@ -1,14 +1,13 @@ /// TUI interface module -/// +/// /// Build terminal user interface using ratatui - pub mod chat; -pub mod theme; -pub mod widgets; +pub mod markdown; pub mod startup; -pub mod tool_cards; pub mod string_utils; -pub mod markdown; +pub mod theme; +pub mod tool_cards; +pub mod widgets; use anyhow::Result; use crossterm::{ @@ -44,7 +43,10 @@ pub fn restore_terminal(mut terminal: Terminal>) -> } /// Render a loading/status message on the terminal (stays in alternate screen) -pub fn render_loading(terminal: &mut Terminal>, message: &str) -> Result<()> { +pub fn render_loading( + terminal: &mut Terminal>, + message: &str, +) -> Result<()> { let msg = message.to_string(); terminal.draw(|frame| { let area = frame.area(); @@ -57,14 +59,12 @@ pub fn render_loading(terminal: &mut Terminal>, mes ]) .split(area); - let text = vec![ - Line::from(Span::styled( - msg, - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - )), - ]; + let text = vec![Line::from(Span::styled( + msg, + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ))]; let paragraph = Paragraph::new(text).alignment(Alignment::Center); frame.render_widget(paragraph, chunks[1]); diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 6f015670..d1cea763 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1,5 +1,4 @@ /// Startup page module - use anyhow::Result; use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind}; use ratatui::{ @@ -138,10 +137,10 @@ struct SessionItem { impl StartupPage { pub fn new() -> Self { let config = CliConfig::load().unwrap_or_default(); - + let mut list_state = ListState::default(); list_state.select(Some(0)); - + let menu_items = vec![ MenuItem { name: "New Session".to_string(), @@ -169,7 +168,7 @@ impl StartupPage { action: MenuAction::Exit, }, ]; - + Self { menu_items, selected: 0, @@ -181,11 +180,11 @@ impl StartupPage { pub fn run(&mut self, terminal: &mut Terminal) -> Result> { terminal.clear()?; - + loop { terminal.draw(|f| self.render(f))?; - // Check if finished + // Check if finished if let PageState::Finished(result) = &self.page_state { return match result { StartupResult::NewSession(ws) => Ok(Some(ws.clone())), @@ -205,8 +204,8 @@ impl StartupPage { if event::poll(Duration::from_millis(100))? { match event::read()? { Event::Key(key) => { - self.handle_key(key)?; - } + self.handle_key(key)?; + } Event::Resize(_, _) => { terminal.clear()?; } @@ -235,8 +234,8 @@ impl StartupPage { .direction(Direction::Vertical) .constraints([ Constraint::Length(12), // Logo area - Constraint::Min(10), // Menu area - Constraint::Length(3), // Hints area + Constraint::Min(10), // Menu area + Constraint::Length(3), // Hints area ]) .split(area); @@ -251,7 +250,7 @@ impl StartupPage { .map(|(i, item)| { let is_selected = i == self.selected; let icon = if is_selected { "▶" } else { " " }; - + let style = if is_selected { Style::default() .fg(Color::Cyan) @@ -277,14 +276,13 @@ impl StartupPage { }) .collect(); - let list = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title(" BitFun CLI - Main Menu ") - .title_alignment(Alignment::Center) - .border_style(Style::default().fg(Color::Cyan)), - ); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .title(" BitFun CLI - Main Menu ") + .title_alignment(Alignment::Center) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_stateful_widget(list, chunks[1], &mut self.list_state); @@ -309,7 +307,7 @@ impl StartupPage { let use_fancy_logo = area.width >= 80; let mut lines = vec![]; lines.push(Line::from("")); - + if use_fancy_logo { let logo = vec![ " ██████╗ ██╗████████╗███████╗██╗ ██╗███╗ ██╗", @@ -371,7 +369,7 @@ impl StartupPage { .fg(Color::Gray) .add_modifier(Modifier::ITALIC), ))); - + let version = format!("v{}", env!("CARGO_PKG_VERSION")); lines.push(Line::from(Span::styled( version, @@ -382,20 +380,29 @@ impl StartupPage { frame.render_widget(paragraph, area); } - fn render_workspace_select(&mut self, frame: &mut Frame, area: Rect, page: &WorkspaceSelectPage) { + fn render_workspace_select( + &mut self, + frame: &mut Frame, + area: Rect, + page: &WorkspaceSelectPage, + ) { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title - Constraint::Length(3), // Input box - Constraint::Min(5), // Help - Constraint::Length(5), // Hints + Constraint::Length(3), // Title + Constraint::Length(3), // Input box + Constraint::Min(5), // Help + Constraint::Length(5), // Hints ]) .split(area); // Title let title = Paragraph::new("Enter workspace path") - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); @@ -406,27 +413,35 @@ impl StartupPage { } else { &page.custom_input }; - + let input_style = if page.custom_input.is_empty() { Style::default().fg(Color::DarkGray) } else { - Style::default().fg(Color::Yellow).add_modifier(Modifier::UNDERLINED) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::UNDERLINED) }; - - let input = Paragraph::new(input_display) - .style(input_style) - .block(Block::default().borders(Borders::ALL).title(" Workspace Path ").border_style(Style::default().fg(Color::Yellow))); + + let input = Paragraph::new(input_display).style(input_style).block( + Block::default() + .borders(Borders::ALL) + .title(" Workspace Path ") + .border_style(Style::default().fg(Color::Yellow)), + ); frame.render_widget(input, chunks[1]); // Help let help_lines = vec![ Line::from(""), - Line::from(vec![ - Span::styled("Tips:", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)), - ]), - Line::from(vec![ - Span::raw(" • You can enter relative or absolute path"), - ]), + Line::from(vec![Span::styled( + "Tips:", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + )]), + Line::from(vec![Span::raw( + " • You can enter relative or absolute path", + )]), Line::from(vec![ Span::raw(" • Use "), Span::styled(".", Style::default().fg(Color::Green)), @@ -442,9 +457,9 @@ impl StartupPage { Span::styled("~", Style::default().fg(Color::Green)), Span::raw(" for home directory (e.g.: ~/projects)"), ]), - Line::from(vec![ - Span::raw(" • Leave empty and press Enter for current directory"), - ]), + Line::from(vec![Span::raw( + " • Leave empty and press Enter for current directory", + )]), ]; let help = Paragraph::new(help_lines) .style(Style::default().fg(Color::Gray)) @@ -461,11 +476,12 @@ impl StartupPage { Span::styled(" Backspace ", Style::default().fg(Color::Yellow)), Span::raw("Delete"), ]), - Line::from(vec![ - Span::styled(" Type characters... ", Style::default().fg(Color::DarkGray)), - ]), + Line::from(vec![Span::styled( + " Type characters... ", + Style::default().fg(Color::DarkGray), + )]), ]; - + let paragraph = Paragraph::new(hints_text) .alignment(Alignment::Center) .style(Style::default().fg(Color::Gray)); @@ -477,15 +493,19 @@ impl StartupPage { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Settings list - Constraint::Length(5), // Hints + Constraint::Length(3), // Title + Constraint::Min(10), // Settings list + Constraint::Length(5), // Hints ]) .split(area); // Title let title = Paragraph::new("Settings") - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); @@ -498,17 +518,21 @@ impl StartupPage { .map(|(i, setting)| { let is_selected = i == page.selected; let is_editing = page.editing == Some(i); - + let icon = if is_selected { "▶" } else { " " }; - + let style = if is_selected { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; let value_style = if is_editing { - Style::default().fg(Color::Yellow).add_modifier(Modifier::UNDERLINED) + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::UNDERLINED) } else if setting.editable { Style::default().fg(Color::Green) } else { @@ -543,8 +567,11 @@ impl StartupPage { let mut list_state = ListState::default(); list_state.select(Some(page.selected)); - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_stateful_widget(list, chunks[1], &mut list_state); @@ -557,9 +584,10 @@ impl StartupPage { Span::styled(" Esc ", Style::default().fg(Color::Red)), Span::raw("Cancel"), ]), - Line::from(vec![ - Span::styled(" Enter new value... ", Style::default().fg(Color::Yellow)), - ]), + Line::from(vec![Span::styled( + " Enter new value... ", + Style::default().fg(Color::Yellow), + )]), ] } else { vec![ @@ -571,9 +599,10 @@ impl StartupPage { Span::styled(" Esc ", Style::default().fg(Color::Red)), Span::raw("Back"), ]), - Line::from(vec![ - Span::styled(" Changes will be auto-saved to config file ", Style::default().fg(Color::DarkGray)), - ]), + Line::from(vec![Span::styled( + " Changes will be auto-saved to config file ", + Style::default().fg(Color::DarkGray), + )]), ] }; @@ -588,16 +617,20 @@ impl StartupPage { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Session list - Constraint::Length(3), // Hints + Constraint::Length(3), // Title + Constraint::Min(10), // Session list + Constraint::Length(3), // Hints ]) .split(area); // Title let title_text = format!("History Sessions (total {})", page.sessions.len()); let title = Paragraph::new(title_text) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); @@ -608,7 +641,9 @@ impl StartupPage { Line::from(""), Line::from(Span::styled( "No history sessions yet", - Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), )), Line::from(""), Line::from(Span::styled( @@ -618,7 +653,11 @@ impl StartupPage { ]; let paragraph = Paragraph::new(empty_text) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_widget(paragraph, chunks[1]); } else { // Session list @@ -629,9 +668,11 @@ impl StartupPage { .map(|(i, session)| { let is_selected = i == page.selected; let icon = if is_selected { "▶" } else { " " }; - + let style = if is_selected { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -664,8 +705,11 @@ impl StartupPage { let mut list_state = ListState::default(); list_state.select(Some(page.selected)); - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_stateful_widget(list, chunks[1], &mut list_state); } @@ -691,16 +735,20 @@ impl StartupPage { let chunks = Layout::default() .direction(Direction::Vertical) .constraints([ - Constraint::Length(3), // Title - Constraint::Min(10), // Model list - Constraint::Length(5), // Hints + Constraint::Length(3), // Title + Constraint::Min(10), // Model list + Constraint::Length(5), // Hints ]) .split(area); // Title let title_text = format!("AI Model Configuration (total {})", page.models.len()); let title = Paragraph::new(title_text) - .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)) + .style( + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ) .alignment(Alignment::Center) .block(Block::default().borders(Borders::ALL)); frame.render_widget(title, chunks[0]); @@ -711,7 +759,9 @@ impl StartupPage { Line::from(""), Line::from(Span::styled( "No models configured yet", - Style::default().fg(Color::Gray).add_modifier(Modifier::ITALIC), + Style::default() + .fg(Color::Gray) + .add_modifier(Modifier::ITALIC), )), Line::from(""), Line::from(Span::styled( @@ -721,7 +771,11 @@ impl StartupPage { ]; let paragraph = Paragraph::new(empty_text) .alignment(Alignment::Center) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_widget(paragraph, chunks[1]); } else { // Model list @@ -732,9 +786,11 @@ impl StartupPage { .map(|(i, model)| { let is_selected = i == page.selected; let icon = if is_selected { "▶" } else { " " }; - + let style = if is_selected { - Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD) + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD) } else { Style::default().fg(Color::White) }; @@ -752,7 +808,14 @@ impl StartupPage { Line::from(vec![ Span::styled(icon, Style::default().fg(Color::Green)), Span::raw(" "), - Span::styled(status_icon, Style::default().fg(if model.is_default { Color::Yellow } else { Color::Green })), + Span::styled( + status_icon, + Style::default().fg(if model.is_default { + Color::Yellow + } else { + Color::Green + }), + ), Span::raw(" "), Span::styled(&model.name, style), ]), @@ -774,8 +837,11 @@ impl StartupPage { let mut list_state = ListState::default(); list_state.select(Some(page.selected)); - let list = List::new(items) - .block(Block::default().borders(Borders::ALL).border_style(Style::default().fg(Color::Cyan))); + let list = List::new(items).block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Cyan)), + ); frame.render_stateful_widget(list, chunks[1], &mut list_state); } @@ -816,15 +882,21 @@ impl StartupPage { return Ok(()); } - let page_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); - + let page_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); + let result = match page_state { PageState::MainMenu => { self.page_state = PageState::MainMenu; self.handle_main_menu_key(key) } PageState::WorkspaceSelect(mut page) => { - let old_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); + let old_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); let result = self.handle_workspace_key(key, &mut page); if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { if !matches!(old_state, PageState::Finished(_)) { @@ -836,7 +908,10 @@ impl StartupPage { result } PageState::Settings(mut page) => { - let old_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); + let old_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); let result = self.handle_settings_key(key, &mut page); if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { if !matches!(old_state, PageState::Finished(_)) { @@ -848,7 +923,10 @@ impl StartupPage { result } PageState::AIModels(mut page) => { - let old_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); + let old_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); let result = self.handle_ai_models_key(key, &mut page); if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { if !matches!(old_state, PageState::Finished(_)) { @@ -860,7 +938,10 @@ impl StartupPage { result } PageState::History(mut page) => { - let old_state = std::mem::replace(&mut self.page_state, PageState::Finished(StartupResult::Exit)); + let old_state = std::mem::replace( + &mut self.page_state, + PageState::Finished(StartupResult::Exit), + ); let result = self.handle_history_key(key, &mut page); if matches!(self.page_state, PageState::Finished(StartupResult::Exit)) { if !matches!(old_state, PageState::Finished(_)) { @@ -876,7 +957,7 @@ impl StartupPage { Ok(()) } }; - + result } @@ -907,7 +988,8 @@ impl StartupPage { MenuAction::ContinueLastSession => { // Load last session if let Ok(Some(session)) = Session::get_last() { - self.page_state = PageState::Finished(StartupResult::ContinueSession(session.id)); + self.page_state = + PageState::Finished(StartupResult::ContinueSession(session.id)); } else { // No history session, enter new session self.page_state = PageState::WorkspaceSelect(WorkspaceSelectPage { @@ -947,7 +1029,11 @@ impl StartupPage { Ok(()) } - fn handle_workspace_key(&mut self, key: KeyEvent, page: &mut WorkspaceSelectPage) -> Result<()> { + fn handle_workspace_key( + &mut self, + key: KeyEvent, + page: &mut WorkspaceSelectPage, + ) -> Result<()> { match key.code { KeyCode::Enter => { // If input is empty, use current directory @@ -1000,18 +1086,21 @@ impl StartupPage { } Ok(()) } - + fn expand_path(&self, path: &str) -> String { let path = path.trim(); - + // Handle paths starting with ~ if path.starts_with('~') { if let Some(home) = dirs::home_dir() { let rest = &path[1..]; - return home.join(rest.trim_start_matches('/')).to_string_lossy().to_string(); + return home + .join(rest.trim_start_matches('/')) + .to_string_lossy() + .to_string(); } } - + // Handle relative and absolute paths if let Ok(absolute) = std::fs::canonicalize(path) { absolute.to_string_lossy().to_string() @@ -1058,11 +1147,12 @@ impl StartupPage { KeyCode::Enter => { if page.settings[page.selected].key == "ai_models" { let models = Self::load_ai_models_sync(); - let default_model_id = models.iter() + let default_model_id = models + .iter() .find(|m| m.is_default) .map(|m| m.id.clone()) .unwrap_or_default(); - + self.page_state = PageState::AIModels(AIModelsPage { models, selected: 0, @@ -1130,20 +1220,27 @@ impl StartupPage { let selected_model_id = page.models[page.selected].id.clone(); let result = tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(async { - use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::config::types::GlobalConfig; - + use bitfun_core::service::config::GlobalConfigManager; + match GlobalConfigManager::get_service().await { Ok(config_service) => { - let mut global_config = config_service.get_config::(None).await?; - global_config.ai.default_models.primary = Some(selected_model_id.clone()); - config_service.set_config("ai.default_models.primary", &global_config.ai.default_models.primary).await + let mut global_config = + config_service.get_config::(None).await?; + global_config.ai.default_models.primary = + Some(selected_model_id.clone()); + config_service + .set_config( + "ai.default_models.primary", + &global_config.ai.default_models.primary, + ) + .await } Err(e) => Err(e), } }) }); - + if result.is_ok() { page.models = Self::load_ai_models_sync(); page.default_model_id = selected_model_id; @@ -1172,7 +1269,8 @@ impl StartupPage { key: "ai_models".to_string(), name: "AI Model Configuration".to_string(), value: "Manage AI models".to_string(), - description: "View and manage all AI model configurations (press Enter to enter)".to_string(), + description: "View and manage all AI model configurations (press Enter to enter)" + .to_string(), editable: false, // Not directly editable, enters sub-page }, SettingItem { @@ -1207,41 +1305,41 @@ impl StartupPage { } async fn load_ai_models() -> Vec { - use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::config::types::GlobalConfig; - + use bitfun_core::service::config::GlobalConfigManager; + match GlobalConfigManager::get_service().await { - Ok(config_service) => { - match config_service.get_config::(None).await { - Ok(global_config) => { - let default_model_id = global_config.ai.default_models.primary - .unwrap_or_default(); - - global_config.ai.models - .iter() - .map(|m| AIModelItem { - id: m.id.clone(), - name: m.name.clone(), - provider: m.provider.clone(), - model_name: m.model_name.clone(), - enabled: m.enabled, - is_default: m.id == default_model_id, - }) - .collect() - } - Err(e) => { - tracing::warn!("Failed to get GlobalConfig: {}", e); - vec![] - } + Ok(config_service) => match config_service.get_config::(None).await { + Ok(global_config) => { + let default_model_id = + global_config.ai.default_models.primary.unwrap_or_default(); + + global_config + .ai + .models + .iter() + .map(|m| AIModelItem { + id: m.id.clone(), + name: m.name.clone(), + provider: m.provider.clone(), + model_name: m.model_name.clone(), + enabled: m.enabled, + is_default: m.id == default_model_id, + }) + .collect() } - } + Err(e) => { + tracing::warn!("Failed to get GlobalConfig: {}", e); + vec![] + } + }, Err(e) => { tracing::warn!("Failed to get config service: {}", e); vec![] } } } - + fn load_ai_models_sync() -> Vec { tokio::task::block_in_place(|| { tokio::runtime::Handle::current().block_on(Self::load_ai_models()) @@ -1280,7 +1378,7 @@ impl StartupPage { "ai_models" => {} _ => {} } - + self.config.save()?; Ok(()) } diff --git a/src/apps/cli/src/ui/string_utils.rs b/src/apps/cli/src/ui/string_utils.rs index 47635076..25553732 100644 --- a/src/apps/cli/src/ui/string_utils.rs +++ b/src/apps/cli/src/ui/string_utils.rs @@ -3,32 +3,32 @@ /// Safely truncate string to specified byte length pub fn truncate_str(s: &str, max_bytes: usize) -> String { let first_line = s.lines().next().unwrap_or(""); - + if first_line.len() <= max_bytes { return first_line.to_string(); } - + let mut boundary = max_bytes; while boundary > 0 && !first_line.is_char_boundary(boundary) { boundary -= 1; } - + if boundary == 0 { return String::new(); } - + format!("{}...", &first_line[..boundary]) } /// Prettify tool result display pub fn prettify_result(s: &str) -> String { let first_line = s.lines().next().unwrap_or(""); - - let looks_like_debug = first_line.contains("Some(") + + let looks_like_debug = first_line.contains("Some(") || first_line.contains(": None") || (first_line.matches('{').count() > 2) || first_line.contains("_tokens:"); - + if looks_like_debug { if s.contains("Success") || s.contains("Ok") { return "✓ Execution successful".to_string(); @@ -36,6 +36,6 @@ pub fn prettify_result(s: &str) -> String { return "Done".to_string(); } } - + truncate_str(s, 80) } diff --git a/src/apps/cli/src/ui/theme.rs b/src/apps/cli/src/ui/theme.rs index b204daed..688acd34 100644 --- a/src/apps/cli/src/ui/theme.rs +++ b/src/apps/cli/src/ui/theme.rs @@ -1,5 +1,4 @@ /// Theme and style definitions - use ratatui::style::{Color, Modifier, Style}; #[derive(Debug, Clone)] @@ -17,14 +16,14 @@ pub struct Theme { impl Theme { pub fn dark() -> Self { Self { - primary: Color::Rgb(59, 130, 246), // blue - success: Color::Rgb(34, 197, 94), // green - warning: Color::Rgb(251, 191, 36), // yellow - error: Color::Rgb(239, 68, 68), // red - info: Color::Rgb(147, 197, 253), // light blue - muted: Color::Rgb(156, 163, 175), // gray - background: Color::Rgb(17, 24, 39), // dark gray background - border: Color::Rgb(55, 65, 81), // border gray + primary: Color::Rgb(59, 130, 246), // blue + success: Color::Rgb(34, 197, 94), // green + warning: Color::Rgb(251, 191, 36), // yellow + error: Color::Rgb(239, 68, 68), // red + info: Color::Rgb(147, 197, 253), // light blue + muted: Color::Rgb(156, 163, 175), // gray + background: Color::Rgb(17, 24, 39), // dark gray background + border: Color::Rgb(55, 65, 81), // border gray } } diff --git a/src/apps/cli/src/ui/tool_cards.rs b/src/apps/cli/src/ui/tool_cards.rs index bfeb9a0d..239f831b 100644 --- a/src/apps/cli/src/ui/tool_cards.rs +++ b/src/apps/cli/src/ui/tool_cards.rs @@ -1,31 +1,29 @@ /// Tool card rendering - use ratatui::{ text::{Line, Span}, widgets::ListItem, }; +use super::string_utils::{prettify_result, truncate_str}; +use super::theme::{StyleKind, Theme}; use crate::session::ToolCall; -use super::theme::{Theme, StyleKind}; -use super::string_utils::{truncate_str, prettify_result}; -pub fn render_tool_card<'a>( - tool_call: &'a ToolCall, - theme: &Theme, -) -> Vec> { +pub fn render_tool_card<'a>(tool_call: &'a ToolCall, theme: &Theme) -> Vec> { let mut items = Vec::new(); - + // Choose specialized renderer based on tool type match tool_call.tool_name.as_str() { "read_file" | "read_file_tool" => render_read_file_card(&mut items, tool_call, theme), - "write_file" | "write_file_tool" | "search_replace" => render_write_file_card(&mut items, tool_call, theme), + "write_file" | "write_file_tool" | "search_replace" => { + render_write_file_card(&mut items, tool_call, theme) + } "bash_tool" | "run_terminal_cmd" => render_bash_tool_card(&mut items, tool_call, theme), "codebase_search" => render_codebase_search_card(&mut items, tool_call, theme), "grep" => render_grep_card(&mut items, tool_call, theme), "list_dir" | "ls" => render_list_dir_card(&mut items, tool_call, theme), _ => render_default_tool_card(&mut items, tool_call, theme), } - + items } @@ -35,21 +33,25 @@ fn render_read_file_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - + // Get file path - let file_path = tool_call.parameters.get("file_path") + let file_path = tool_call + .parameters + .get("file_path") .or_else(|| tool_call.parameters.get("target_file")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + // Status icon let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running | ToolCallStatus::Streaming => ("*", theme.style(StyleKind::Primary)), + ToolCallStatus::Running | ToolCallStatus::Streaming => { + ("*", theme.style(StyleKind::Primary)) + } ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + // Top border items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), @@ -58,17 +60,17 @@ fn render_read_file_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + // File path items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(file_path, theme.style(StyleKind::Primary)), ]))); - + // Result (if available) if let Some(result) = &tool_call.result { let summary = truncate_str(result, 80); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Muted)), @@ -87,19 +89,21 @@ fn render_write_file_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - - let file_path = tool_call.parameters.get("file_path") + + let file_path = tool_call + .parameters + .get("file_path") .or_else(|| tool_call.parameters.get("target_file")) .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + let (status_icon, status_style) = match &tool_call.status { ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[Edit] "), @@ -107,12 +111,12 @@ fn render_write_file_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(file_path, theme.style(StyleKind::Primary)), ]))); - + if let Some(result) = &tool_call.result { items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), @@ -132,18 +136,22 @@ fn render_bash_tool_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - - let command = tool_call.parameters.get("command") + + let command = tool_call + .parameters + .get("command") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running | ToolCallStatus::Streaming => ("*", theme.style(StyleKind::Primary)), + ToolCallStatus::Running | ToolCallStatus::Streaming => { + ("*", theme.style(StyleKind::Primary)) + } ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[Bash] "), @@ -151,15 +159,15 @@ fn render_bash_tool_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + // Command (limited length) let cmd_display = truncate_str(command, 60); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(cmd_display, theme.style(StyleKind::Info)), ]))); - + // Output summary if let Some(result) = &tool_call.result { let lines: Vec<&str> = result.lines().collect(); @@ -168,9 +176,9 @@ fn render_bash_tool_card<'a>( } else { result.clone() }; - + let summary_short = truncate_str(&summary, 80); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary_short, theme.style(StyleKind::Muted)), @@ -189,18 +197,20 @@ fn render_codebase_search_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - - let query = tool_call.parameters.get("query") + + let query = tool_call + .parameters + .get("query") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + let (status_icon, status_style) = match &tool_call.status { ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[Search] "), @@ -208,12 +218,12 @@ fn render_codebase_search_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(query, theme.style(StyleKind::Primary)), ]))); - + if let Some(result) = &tool_call.result { // Try to parse result count let summary = if result.contains("chunk") { @@ -221,7 +231,7 @@ fn render_codebase_search_card<'a>( } else { "Search complete" }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Success)), @@ -234,24 +244,22 @@ fn render_codebase_search_card<'a>( } } -fn render_grep_card<'a>( - items: &mut Vec>, - tool_call: &'a ToolCall, - theme: &Theme, -) { +fn render_grep_card<'a>(items: &mut Vec>, tool_call: &'a ToolCall, theme: &Theme) { use crate::session::ToolCallStatus; - - let pattern = tool_call.parameters.get("pattern") + + let pattern = tool_call + .parameters + .get("pattern") .and_then(|v| v.as_str()) .unwrap_or("unknown"); - + let (status_icon, status_style) = match &tool_call.status { ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[Grep] "), @@ -259,16 +267,16 @@ fn render_grep_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(pattern, theme.style(StyleKind::Primary)), ]))); - + if let Some(result) = &tool_call.result { let lines_count = result.lines().count(); let summary = format!("Found {} matches", lines_count); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Success)), @@ -281,25 +289,23 @@ fn render_grep_card<'a>( } } -fn render_list_dir_card<'a>( - items: &mut Vec>, - tool_call: &'a ToolCall, - theme: &Theme, -) { +fn render_list_dir_card<'a>(items: &mut Vec>, tool_call: &'a ToolCall, theme: &Theme) { use crate::session::ToolCallStatus; - - let path = tool_call.parameters.get("target_directory") + + let path = tool_call + .parameters + .get("target_directory") .or_else(|| tool_call.parameters.get("path")) .and_then(|v| v.as_str()) .unwrap_or("."); - + let (status_icon, status_style) = match &tool_call.status { ToolCallStatus::Running => ("*", theme.style(StyleKind::Primary)), ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw("[List] "), @@ -307,16 +313,16 @@ fn render_list_dir_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" │ "), Span::styled(path, theme.style(StyleKind::Primary)), ]))); - + if let Some(result) = &tool_call.result { let items_count = result.lines().count(); let summary = format!("{} items", items_count); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Success)), @@ -335,18 +341,20 @@ fn render_default_tool_card<'a>( theme: &Theme, ) { use crate::session::ToolCallStatus; - + let (icon, _color) = crate::ui::theme::tool_icon(&tool_call.tool_name); - + let (status_icon, status_style) = match &tool_call.status { - ToolCallStatus::Running | ToolCallStatus::Streaming => ("*", theme.style(StyleKind::Primary)), + ToolCallStatus::Running | ToolCallStatus::Streaming => { + ("*", theme.style(StyleKind::Primary)) + } ToolCallStatus::Success => ("+", theme.style(StyleKind::Success)), ToolCallStatus::Failed => ("x", theme.style(StyleKind::Error)), ToolCallStatus::Queued => ("||", theme.style(StyleKind::Muted)), ToolCallStatus::Waiting => ("...", theme.style(StyleKind::Warning)), _ => ("-", theme.style(StyleKind::Muted)), }; - + items.push(ListItem::new(Line::from(vec![ Span::raw(" ┌─ "), Span::raw(icon), @@ -355,7 +363,7 @@ fn render_default_tool_card<'a>( Span::raw(" "), Span::styled(status_icon, status_style), ]))); - + // Show parameter summary (only key fields) let param_summary = extract_key_params(&tool_call.parameters); if !param_summary.is_empty() { @@ -364,7 +372,7 @@ fn render_default_tool_card<'a>( Span::styled(param_summary, theme.style(StyleKind::Info)), ]))); } - + // Progress info if let Some(progress_msg) = &tool_call.progress_message { items.push(ListItem::new(Line::from(vec![ @@ -372,11 +380,11 @@ fn render_default_tool_card<'a>( Span::styled(progress_msg, theme.style(StyleKind::Muted)), ]))); } - + // Result if let Some(result) = &tool_call.result { let summary = prettify_result(result); - + items.push(ListItem::new(Line::from(vec![ Span::raw(" └─ "), Span::styled(summary, theme.style(StyleKind::Muted)), @@ -391,8 +399,16 @@ fn render_default_tool_card<'a>( fn extract_key_params(params: &serde_json::Value) -> String { if let Some(obj) = params.as_object() { - let priority_keys = ["path", "file_path", "target_file", "query", "pattern", "command", "message"]; - + let priority_keys = [ + "path", + "file_path", + "target_file", + "query", + "pattern", + "command", + "message", + ]; + for key in &priority_keys { if let Some(value) = obj.get(*key) { if let Some(s) = value.as_str() { @@ -400,7 +416,7 @@ fn extract_key_params(params: &serde_json::Value) -> String { } } } - + for (_key, value) in obj.iter() { if let Some(s) = value.as_str() { if s.len() < 100 { @@ -409,7 +425,6 @@ fn extract_key_params(params: &serde_json::Value) -> String { } } } - + String::new() } - diff --git a/src/apps/cli/src/ui/widgets.rs b/src/apps/cli/src/ui/widgets.rs index 748c033a..cb3a4f7d 100644 --- a/src/apps/cli/src/ui/widgets.rs +++ b/src/apps/cli/src/ui/widgets.rs @@ -1,5 +1,4 @@ /// Custom TUI widgets - use ratatui::{ style::Style, text::{Line, Span}, @@ -33,7 +32,7 @@ pub struct HelpText { impl HelpText { pub fn render(&self) -> Line<'_> { let mut spans = Vec::new(); - + for (i, (key, desc)) in self.shortcuts.iter().enumerate() { if i > 0 { spans.push(Span::raw(" ")); @@ -41,7 +40,7 @@ impl HelpText { spans.push(Span::styled(format!("[{}]", key), self.style)); spans.push(Span::raw(desc)); } - + Line::from(spans) } } diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index f37d31e7..0bd6f839 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -7,10 +7,12 @@ use std::sync::Arc; use tauri::{AppHandle, State}; use crate::api::app_state::AppState; -use bitfun_core::agentic::tools::image_context::get_image_context; -use bitfun_core::agentic::coordination::{ConversationCoordinator, DialogScheduler, DialogTriggerSource}; +use bitfun_core::agentic::coordination::{ + ConversationCoordinator, DialogScheduler, DialogTriggerSource, +}; use bitfun_core::agentic::core::*; use bitfun_core::agentic::image_analysis::ImageContextData; +use bitfun_core::agentic::tools::image_context::get_image_context; #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] @@ -48,6 +50,7 @@ pub struct CreateSessionResponse { pub struct StartDialogTurnRequest { pub session_id: String, pub user_input: String, + pub original_user_input: Option, pub agent_type: String, pub workspace_path: Option, pub turn_id: Option, @@ -204,6 +207,7 @@ pub async fn start_dialog_turn( let StartDialogTurnRequest { session_id, user_input, + original_user_input, agent_type, workspace_path, turn_id, @@ -220,6 +224,7 @@ pub async fn start_dialog_turn( .start_dialog_turn_with_image_contexts( session_id, user_input, + original_user_input, resolved_image_contexts, turn_id, agent_type, @@ -233,6 +238,7 @@ pub async fn start_dialog_turn( .submit( session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -288,7 +294,10 @@ fn resolve_missing_image_payloads( image.mime_type = stored.mime_type.clone(); } - let mut metadata = image.metadata.take().unwrap_or_else(|| serde_json::json!({})); + let mut metadata = image + .metadata + .take() + .unwrap_or_else(|| serde_json::json!({})); if !metadata.is_object() { metadata = serde_json::json!({ "raw_metadata": metadata }); } diff --git a/src/apps/desktop/src/api/app_state.rs b/src/apps/desktop/src/api/app_state.rs index 489f90e4..548e90cd 100644 --- a/src/apps/desktop/src/api/app_state.rs +++ b/src/apps/desktop/src/api/app_state.rs @@ -47,7 +47,9 @@ pub struct AppState { } impl AppState { - pub async fn new_async(token_usage_service: Arc) -> BitFunResult { + pub async fn new_async( + token_usage_service: Arc, + ) -> BitFunResult { let start_time = std::time::Instant::now(); let config_service = config::get_global_config_service().await.map_err(|e| { @@ -66,8 +68,9 @@ impl AppState { }; let workspace_service = Arc::new(workspace::WorkspaceService::new().await?); - let workspace_identity_watch_service = - Arc::new(workspace::WorkspaceIdentityWatchService::new(workspace_service.clone())); + let workspace_identity_watch_service = Arc::new( + workspace::WorkspaceIdentityWatchService::new(workspace_service.clone()), + ); workspace::set_global_workspace_service(workspace_service.clone()); let filesystem_service = Arc::new(filesystem::FileSystemServiceFactory::create_default()); @@ -119,11 +122,12 @@ impl AppState { .map(|workspace| workspace.root_path); if let Some(workspace_path) = initial_workspace_path.clone() { - if let Err(e) = bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( - workspace_path.clone(), - None, - ) - .await + if let Err(e) = + bitfun_core::service::snapshot::initialize_snapshot_manager_for_workspace( + workspace_path.clone(), + None, + ) + .await { log::warn!( "Failed to restore snapshot system on startup: path={}, error={}", diff --git a/src/apps/desktop/src/api/commands.rs b/src/apps/desktop/src/api/commands.rs index e5f45338..30b9bffa 100644 --- a/src/apps/desktop/src/api/commands.rs +++ b/src/apps/desktop/src/api/commands.rs @@ -2,8 +2,10 @@ use crate::api::app_state::AppState; use crate::api::dto::WorkspaceInfoDto; -use bitfun_core::service::workspace::{ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions}; use bitfun_core::infrastructure::{file_watcher, FileOperationOptions, SearchMatchType}; +use bitfun_core::service::workspace::{ + ScanOptions, WorkspaceInfo, WorkspaceKind, WorkspaceOpenOptions, +}; use log::{debug, error, info, warn}; use serde::Deserialize; use std::path::Path; @@ -52,6 +54,11 @@ pub struct TestAIConfigConnectionRequest { pub config: bitfun_core::service::config::types::AIModelConfig, } +#[derive(Debug, Deserialize)] +pub struct ListAIModelsByConfigRequest { + pub config: bitfun_core::service::config::types::AIModelConfig, +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct FixMermaidCodeRequest { @@ -427,6 +434,26 @@ pub async fn test_ai_config_connection( } } +#[tauri::command] +pub async fn list_ai_models_by_config( + request: ListAIModelsByConfigRequest, +) -> Result, String> { + let config_name = request.config.name.clone(); + let ai_config = request + .config + .try_into() + .map_err(|e| format!("Failed to convert configuration: {}", e))?; + let ai_client = bitfun_core::infrastructure::ai::client::AIClient::new(ai_config); + + ai_client.list_models().await.map_err(|e| { + error!( + "Failed to list models for config: name={}, error={}", + config_name, e + ); + format!("Failed to list models: {}", e) + }) +} + #[tauri::command] pub async fn fix_mermaid_code( state: State<'_, AppState>, @@ -599,7 +626,10 @@ pub async fn open_workspace( .sync_watched_workspaces() .await { - warn!("Failed to sync workspace identity watchers after open: {}", e); + warn!( + "Failed to sync workspace identity watchers after open: {}", + e + ); } info!( @@ -621,7 +651,11 @@ pub async fn create_assistant_workspace( state: State<'_, AppState>, _request: CreateAssistantWorkspaceRequest, ) -> Result { - match state.workspace_service.create_assistant_workspace(None).await { + match state + .workspace_service + .create_assistant_workspace(None) + .await + { Ok(workspace_info) => { if let Err(e) = state .workspace_identity_watch_service @@ -667,9 +701,10 @@ pub async fn delete_assistant_workspace( )); } - let assistant_id = workspace_info.assistant_id.clone().ok_or_else(|| { - "Default assistant workspace cannot be deleted".to_string() - })?; + let assistant_id = workspace_info + .assistant_id + .clone() + .ok_or_else(|| "Default assistant workspace cannot be deleted".to_string())?; if !state .workspace_service @@ -738,19 +773,29 @@ pub async fn delete_assistant_workspace( } async fn clear_directory_contents(directory: &Path) -> Result<(), String> { - tokio::fs::create_dir_all(directory) - .await - .map_err(|e| format!("Failed to create workspace directory '{}': {}", directory.display(), e))?; + tokio::fs::create_dir_all(directory).await.map_err(|e| { + format!( + "Failed to create workspace directory '{}': {}", + directory.display(), + e + ) + })?; - let mut entries = tokio::fs::read_dir(directory) - .await - .map_err(|e| format!("Failed to read workspace directory '{}': {}", directory.display(), e))?; + let mut entries = tokio::fs::read_dir(directory).await.map_err(|e| { + format!( + "Failed to read workspace directory '{}': {}", + directory.display(), + e + ) + })?; - while let Some(entry) = entries - .next_entry() - .await - .map_err(|e| format!("Failed to iterate workspace directory '{}': {}", directory.display(), e))? - { + while let Some(entry) = entries.next_entry().await.map_err(|e| { + format!( + "Failed to iterate workspace directory '{}': {}", + directory.display(), + e + ) + })? { let entry_path = entry.path(); let file_type = entry.file_type().await.map_err(|e| { format!( diff --git a/src/apps/desktop/src/api/context_upload_api.rs b/src/apps/desktop/src/api/context_upload_api.rs index 9be133a1..e556269b 100644 --- a/src/apps/desktop/src/api/context_upload_api.rs +++ b/src/apps/desktop/src/api/context_upload_api.rs @@ -1,10 +1,8 @@ //! Temporary Image Storage API use bitfun_core::agentic::tools::image_context::{ - create_image_context_provider as create_core_image_context_provider, - store_image_contexts, - GlobalImageContextProvider, - ImageContextData as CoreImageContextData, + create_image_context_provider as create_core_image_context_provider, store_image_contexts, + GlobalImageContextProvider, ImageContextData as CoreImageContextData, }; use serde::{Deserialize, Serialize}; diff --git a/src/apps/desktop/src/api/dto.rs b/src/apps/desktop/src/api/dto.rs index 1aeca6d6..e7f7cb35 100644 --- a/src/apps/desktop/src/api/dto.rs +++ b/src/apps/desktop/src/api/dto.rs @@ -77,7 +77,10 @@ impl WorkspaceInfoDto { .statistics .as_ref() .map(ProjectStatisticsDto::from_workspace_statistics), - identity: info.identity.as_ref().map(WorkspaceIdentityDto::from_workspace_identity), + identity: info + .identity + .as_ref() + .map(WorkspaceIdentityDto::from_workspace_identity), } } } diff --git a/src/apps/desktop/src/api/image_analysis_api.rs b/src/apps/desktop/src/api/image_analysis_api.rs index 5f18d9ec..14662811 100644 --- a/src/apps/desktop/src/api/image_analysis_api.rs +++ b/src/apps/desktop/src/api/image_analysis_api.rs @@ -93,6 +93,7 @@ pub async fn send_enhanced_message( .submit( request.session_id.clone(), enhanced_message, + Some(request.original_message.clone()), Some(request.dialog_turn_id.clone()), request.agent_type.clone(), None, diff --git a/src/apps/desktop/src/api/mcp_api.rs b/src/apps/desktop/src/api/mcp_api.rs index 41647df0..f4728e2f 100644 --- a/src/apps/desktop/src/api/mcp_api.rs +++ b/src/apps/desktop/src/api/mcp_api.rs @@ -331,14 +331,17 @@ pub async fn fetch_mcp_app_resource( state: State<'_, AppState>, request: FetchMCPAppResourceRequest, ) -> Result { - let mcp_service = state.mcp_service.as_ref() + let mcp_service = state + .mcp_service + .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; if !request.resource_uri.starts_with("ui://") { return Err("Resource URI must use ui:// scheme".to_string()); } - let connection = mcp_service.server_manager() + let connection = mcp_service + .server_manager() .get_connection(&request.server_id) .await .ok_or_else(|| format!("MCP server not connected: {}", request.server_id))?; @@ -353,7 +356,8 @@ pub async fn fetch_mcp_app_resource( .into_iter() .map(|c| { // Extract CSP and permissions from _meta.ui (MCP Apps spec path) - let (csp, permissions) = c.meta + let (csp, permissions) = c + .meta .as_ref() .and_then(|meta| meta.ui.as_ref()) .map(|ui| { @@ -363,12 +367,15 @@ pub async fn fetch_mcp_app_resource( frame_domains: core_csp.frame_domains.clone(), base_uri_domains: core_csp.base_uri_domains.clone(), }); - let permissions = ui.permissions.as_ref().map(|core_perm| McpUiResourcePermissions { - camera: core_perm.camera.clone(), - microphone: core_perm.microphone.clone(), - geolocation: core_perm.geolocation.clone(), - clipboard_write: core_perm.clipboard_write.clone(), - }); + let permissions = + ui.permissions + .as_ref() + .map(|core_perm| McpUiResourcePermissions { + camera: core_perm.camera.clone(), + microphone: core_perm.microphone.clone(), + geolocation: core_perm.geolocation.clone(), + clipboard_write: core_perm.clipboard_write.clone(), + }); (csp, permissions) }) .unwrap_or((None, None)); @@ -410,29 +417,50 @@ pub async fn send_mcp_app_message( state: State<'_, AppState>, request: SendMCPAppMessageRequest, ) -> Result { - let mcp_service = state.mcp_service.as_ref() + let mcp_service = state + .mcp_service + .as_ref() .ok_or_else(|| "MCP service not initialized".to_string())?; - let connection = mcp_service.server_manager() + let connection = mcp_service + .server_manager() .get_connection(&request.server_id) .await .ok_or_else(|| format!("MCP server not connected: {}", request.server_id))?; let msg = &request.message; - let method = msg.get("method").and_then(|m| m.as_str()).ok_or_else(|| "Missing method".to_string())?; + let method = msg + .get("method") + .and_then(|m| m.as_str()) + .ok_or_else(|| "Missing method".to_string())?; let id = msg.get("id").cloned(); - let params = msg.get("params").cloned().unwrap_or(serde_json::Value::Null); + let params = msg + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); let result_value: serde_json::Value = match method { "tools/call" => { - let name = params.get("name").and_then(|n| n.as_str()).ok_or_else(|| "tools/call: missing name".to_string())?; + let name = params + .get("name") + .and_then(|n| n.as_str()) + .ok_or_else(|| "tools/call: missing name".to_string())?; let arguments = params.get("arguments").cloned(); - let result = connection.call_tool(name, arguments).await.map_err(|e| e.to_string())?; + let result = connection + .call_tool(name, arguments) + .await + .map_err(|e| e.to_string())?; serde_json::to_value(result).map_err(|e| e.to_string())? } "resources/read" => { - let uri = params.get("uri").and_then(|u| u.as_str()).ok_or_else(|| "resources/read: missing uri".to_string())?; - let result = connection.read_resource(uri).await.map_err(|e| e.to_string())?; + let uri = params + .get("uri") + .and_then(|u| u.as_str()) + .ok_or_else(|| "resources/read: missing uri".to_string())?; + let result = connection + .read_resource(uri) + .await + .map_err(|e| e.to_string())?; serde_json::to_value(result).map_err(|e| e.to_string())? } "ping" => { diff --git a/src/apps/desktop/src/api/miniapp_api.rs b/src/apps/desktop/src/api/miniapp_api.rs index d78aa0fa..16295741 100644 --- a/src/apps/desktop/src/api/miniapp_api.rs +++ b/src/apps/desktop/src/api/miniapp_api.rs @@ -1,11 +1,11 @@ //! MiniApp API — Tauri commands for MiniApp CRUD, JS Worker, and dialog. use crate::api::app_state::AppState; +use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; use bitfun_core::miniapp::{ - MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppSource, - InstallResult as CoreInstallResult, + InstallResult as CoreInstallResult, MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, + MiniAppSource, }; -use bitfun_core::infrastructure::events::{emit_global_event, BackendEvent}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::path::PathBuf; @@ -481,18 +481,21 @@ pub async fn miniapp_worker_call( .resolve_policy_for_app(&request.app_id, &app.permissions, workspace_root.as_deref()) .await; let policy_json = serde_json::to_string(&policy).map_err(|e| e.to_string())?; - let worker_revision = state.miniapp_manager.build_worker_revision(&app, &policy_json); + let worker_revision = state + .miniapp_manager + .build_worker_revision(&app, &policy_json); let should_emit_restart = !was_running || deps_installed || app.runtime.worker_restart_required; - let result = pool.call( - &request.app_id, - &worker_revision, - &policy_json, - app.permissions.node.as_ref(), - &request.method, - request.params, - ) - .await - .map_err(|e| e.to_string())?; + let result = pool + .call( + &request.app_id, + &worker_revision, + &policy_json, + app.permissions.node.as_ref(), + &request.method, + request.params, + ) + .await + .map_err(|e| e.to_string())?; if should_emit_restart { let app = state .miniapp_manager @@ -501,7 +504,14 @@ pub async fn miniapp_worker_call( .map_err(|e| e.to_string())?; emit_miniapp_event( "miniapp-worker-restarted", - miniapp_payload(&app, if deps_installed { "deps-installed" } else { "runtime-restart" }), + miniapp_payload( + &app, + if deps_installed { + "deps-installed" + } else { + "runtime-restart" + }, + ), ) .await; } @@ -522,7 +532,9 @@ pub async fn miniapp_worker_stop(state: State<'_, AppState>, app_id: String) -> } #[tauri::command] -pub async fn miniapp_worker_list_running(state: State<'_, AppState>) -> Result, String> { +pub async fn miniapp_worker_list_running( + state: State<'_, AppState>, +) -> Result, String> { let Some(ref pool) = state.js_worker_pool else { return Ok(vec![]); }; diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 2932251c..f099ca31 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -17,8 +17,10 @@ pub mod image_analysis_api; pub mod lsp_api; pub mod lsp_workspace_api; pub mod mcp_api; +pub mod miniapp_api; pub mod project_context_api; pub mod prompt_template_api; +pub mod remote_connect_api; pub mod runtime_api; pub mod session_api; pub mod skill_api; @@ -30,7 +32,5 @@ pub mod system_api; pub mod terminal_api; pub mod token_usage_api; pub mod tool_api; -pub mod remote_connect_api; -pub mod miniapp_api; pub use app_state::{AppState, AppStatistics, HealthStatus}; diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 94ca6847..995edc3c 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -1,8 +1,9 @@ //! Tauri commands for Remote Connect. use bitfun_core::service::remote_connect::{ - bot::{self, BotConfig}, lan, ConnectionMethod, ConnectionResult, PairingState, - RemoteConnectConfig, RemoteConnectService, + bot::{self, BotConfig}, + lan, ConnectionMethod, ConnectionResult, PairingState, RemoteConnectConfig, + RemoteConnectService, }; use regex::Regex; use serde::{Deserialize, Serialize}; @@ -76,7 +77,9 @@ async fn restore_saved_bots() { let holder = get_service_holder(); let guard = holder.read().await; - let Some(service) = guard.as_ref() else { return }; + let Some(service) = guard.as_ref() else { + return; + }; for conn in &data.connections { if !conn.chat_state.paired { @@ -268,10 +271,9 @@ fn detect_default_gateway_ip() -> Option { return None; } let stdout = String::from_utf8_lossy(&output.stdout); - let re = Regex::new( - r"(?m)^\s*0\.0\.0\.0\s+0\.0\.0\.0\s+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)\s+", - ) - .ok()?; + let re = + Regex::new(r"(?m)^\s*0\.0\.0\.0\s+0\.0\.0\.0\s+([0-9]+\.[0-9]+\.[0-9]+\.[0-9]+)\s+") + .ok()?; return re .captures(&stdout) .and_then(|c| c.get(1).map(|m| m.as_str().to_string())); @@ -467,8 +469,7 @@ pub async fn remote_connect_configure_custom_server(url: String) -> Result<(), S if guard.is_none() { let mut config = RemoteConnectConfig::default(); config.custom_server_url = Some(url); - let service = - RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; + let service = RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; *guard = Some(service); } Ok(()) @@ -483,9 +484,7 @@ pub struct ConfigureBotRequest { } #[tauri::command] -pub async fn remote_connect_configure_bot( - request: ConfigureBotRequest, -) -> Result<(), String> { +pub async fn remote_connect_configure_bot(request: ConfigureBotRequest) -> Result<(), String> { let holder = get_service_holder(); let mut guard = holder.write().await; @@ -507,8 +506,7 @@ pub async fn remote_connect_configure_bot( BotConfig::Feishu { .. } => config.bot_feishu = Some(bot_config), BotConfig::Telegram { .. } => config.bot_telegram = Some(bot_config), } - let service = - RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; + let service = RemoteConnectService::new(config).map_err(|e| format!("init: {e}"))?; *guard = Some(service); } else if let Some(service) = guard.as_mut() { service.update_bot_config(bot_config); @@ -516,4 +514,3 @@ pub async fn remote_connect_configure_bot( Ok(()) } - diff --git a/src/apps/desktop/src/api/skill_api.rs b/src/apps/desktop/src/api/skill_api.rs index 34e76b65..d2712e30 100644 --- a/src/apps/desktop/src/api/skill_api.rs +++ b/src/apps/desktop/src/api/skill_api.rs @@ -118,7 +118,9 @@ pub async fn get_skill_configs( let workspace_root = workspace_root_from_input(workspace_path.as_deref()); if force_refresh.unwrap_or(false) { - registry.refresh_for_workspace(workspace_root.as_deref()).await; + registry + .refresh_for_workspace(workspace_root.as_deref()) + .await; } let all_skills = registry @@ -152,7 +154,9 @@ pub async fn set_skill_enabled( ) .map_err(|e| format!("Failed to save skill config: {}", e))?; - registry.refresh_for_workspace(workspace_root.as_deref()).await; + registry + .refresh_for_workspace(workspace_root.as_deref()) + .await; Ok(format!( "Skill '{}' configuration saved successfully", @@ -329,7 +333,9 @@ pub async fn delete_skill( } } - registry.refresh_for_workspace(workspace_root.as_deref()).await; + registry + .refresh_for_workspace(workspace_root.as_deref()) + .await; info!( "Skill deleted: name={}, path={}", @@ -454,7 +460,9 @@ pub async fn download_skill_market( )); } - registry.refresh_for_workspace(workspace_path.as_deref()).await; + registry + .refresh_for_workspace(workspace_path.as_deref()) + .await; let mut installed_skills: Vec = registry .get_all_skills_for_workspace(workspace_path.as_deref()) .await diff --git a/src/apps/desktop/src/api/snapshot_service.rs b/src/apps/desktop/src/api/snapshot_service.rs index 1e6b5e97..eb1700f2 100644 --- a/src/apps/desktop/src/api/snapshot_service.rs +++ b/src/apps/desktop/src/api/snapshot_service.rs @@ -215,7 +215,9 @@ fn resolve_workspace_dir(workspace_path: &str) -> Result { Ok(workspace_dir) } -async fn ensure_snapshot_manager_ready(workspace_path: &str) -> Result, String> { +async fn ensure_snapshot_manager_ready( + workspace_path: &str, +) -> Result, String> { let workspace_dir = resolve_workspace_dir(workspace_path)?; if let Some(manager) = get_snapshot_manager_for_workspace(&workspace_dir) { @@ -360,33 +362,24 @@ pub async fn rollback_to_turn( use bitfun_core::agentic::persistence::PersistenceManager; match try_get_path_manager_arc() { - Ok(path_manager) => { - match PersistenceManager::new(path_manager) { - Ok(persistence_manager) => { - match persistence_manager - .delete_turns_from( - &workspace_path, - &request.session_id, - request.turn_index, - ) - .await - { - Ok(count) => { - deleted_turns_count = count; - } - Err(e) => { - warn!("Failed to delete conversation turns: session_id={}, turn_index={}, error={}", request.session_id, request.turn_index, e); - } + Ok(path_manager) => match PersistenceManager::new(path_manager) { + Ok(persistence_manager) => { + match persistence_manager + .delete_turns_from(&workspace_path, &request.session_id, request.turn_index) + .await + { + Ok(count) => { + deleted_turns_count = count; + } + Err(e) => { + warn!("Failed to delete conversation turns: session_id={}, turn_index={}, error={}", request.session_id, request.turn_index, e); } - } - Err(e) => { - warn!( - "Failed to create PersistenceManager: error={}", - e - ); } } - } + Err(e) => { + warn!("Failed to create PersistenceManager: error={}", e); + } + }, Err(e) => { warn!("Failed to create PathManager: error={}", e); } @@ -540,7 +533,10 @@ pub async fn get_session_turns( } } Err(e) => { - warn!("Failed to create PersistenceManager: error={}, falling back to snapshot", e); + warn!( + "Failed to create PersistenceManager: error={}, falling back to snapshot", + e + ); } } } diff --git a/src/apps/desktop/src/api/token_usage_api.rs b/src/apps/desktop/src/api/token_usage_api.rs index 4ac13e00..57f757c0 100644 --- a/src/apps/desktop/src/api/token_usage_api.rs +++ b/src/apps/desktop/src/api/token_usage_api.rs @@ -93,22 +93,18 @@ pub async fn get_model_token_stats( debug!("Getting token stats for model: {}", request.model_id); match request.time_range { - Some(time_range) => { - state - .token_usage_service - .get_model_stats_filtered(&request.model_id, time_range, request.include_subagent) - .await - .map_err(|e| { - error!("Failed to get filtered model stats: {}", e); - format!("Failed to get filtered model stats: {}", e) - }) - } - None => { - Ok(state - .token_usage_service - .get_model_stats(&request.model_id) - .await) - } + Some(time_range) => state + .token_usage_service + .get_model_stats_filtered(&request.model_id, time_range, request.include_subagent) + .await + .map_err(|e| { + error!("Failed to get filtered model stats: {}", e); + format!("Failed to get filtered model stats: {}", e) + }), + None => Ok(state + .token_usage_service + .get_model_stats(&request.model_id) + .await), } } @@ -197,4 +193,3 @@ pub async fn clear_all_token_stats(state: State<'_, AppState>) -> Result<(), Str format!("Failed to clear all stats: {}", e) }) } - diff --git a/src/apps/desktop/src/api/tool_api.rs b/src/apps/desktop/src/api/tool_api.rs index 92f5ad15..d1928b46 100644 --- a/src/apps/desktop/src/api/tool_api.rs +++ b/src/apps/desktop/src/api/tool_api.rs @@ -8,9 +8,9 @@ use std::sync::Arc; use crate::api::context_upload_api::create_image_context_provider; use bitfun_core::agentic::{ - WorkspaceBinding, tools::framework::ToolUseContext, tools::{get_all_tools, get_readonly_tools}, + WorkspaceBinding, }; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -117,12 +117,8 @@ fn is_relative_path(value: Option<&serde_json::Value>) -> bool { fn tool_requires_workspace_path(tool_name: &str, input: &serde_json::Value) -> bool { match tool_name { "Bash" => true, - "Glob" | "Grep" => { - input.get("path").is_none() || is_relative_path(input.get("path")) - } - "Read" | "Write" | "Edit" | "GetFileDiff" => { - is_relative_path(input.get("file_path")) - } + "Glob" | "Grep" => input.get("path").is_none() || is_relative_path(input.get("path")), + "Read" | "Write" | "Edit" | "GetFileDiff" => is_relative_path(input.get("file_path")), _ => false, } } @@ -132,7 +128,9 @@ fn ensure_workspace_requirement( input: &serde_json::Value, workspace_path: Option<&str>, ) -> Result<(), String> { - if tool_requires_workspace_path(tool_name, input) && !has_explicit_workspace_path(workspace_path) { + if tool_requires_workspace_path(tool_name, input) + && !has_explicit_workspace_path(workspace_path) + { return Err(format!( "workspacePath is required to execute tool '{}' with workspace-relative input", tool_name diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 54708948..2c947139 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -26,7 +26,6 @@ use api::ai_rules_api::*; use api::clipboard_file_api::*; use api::commands::*; use api::config_api::*; -use api::session_api::*; use api::diff_api::*; use api::git_agent_api::*; use api::git_api::*; @@ -35,6 +34,7 @@ use api::lsp_api::*; use api::lsp_workspace_api::*; use api::mcp_api::*; use api::runtime_api::*; +use api::session_api::*; use api::skill_api::*; use api::snapshot_service::*; use api::startchat_agent_api::*; @@ -323,6 +323,7 @@ pub async fn run() { get_statistics, test_ai_connection, test_ai_config_connection, + list_ai_models_by_config, initialize_ai, set_agent_model, get_agent_models, @@ -713,10 +714,10 @@ async fn init_agentic_system() -> anyhow::Result<( .map_err(|e| anyhow::anyhow!("Failed to initialize token usage service: {}", e))?, ); let token_usage_subscriber = Arc::new( - bitfun_core::service::token_usage::TokenUsageSubscriber::new(token_usage_service.clone()) + bitfun_core::service::token_usage::TokenUsageSubscriber::new(token_usage_service.clone()), ); event_router.subscribe_internal("token_usage".to_string(), token_usage_subscriber); - + log::info!("Token usage service initialized and subscriber registered"); // Create the DialogScheduler and wire up the outcome notification channel @@ -836,7 +837,10 @@ fn init_services(app_handle: tauri::AppHandle, default_log_level: log::LevelFilt .set_event_emitter(emitter.clone()) .await { - log::error!("Failed to initialize workspace identity watch service: {}", e); + log::error!( + "Failed to initialize workspace identity watch service: {}", + e + ); } if let Err(e) = service::lsp::initialize_global_lsp_manager().await { diff --git a/src/apps/desktop/src/main.rs b/src/apps/desktop/src/main.rs index eee4fb53..e910e3ee 100644 --- a/src/apps/desktop/src/main.rs +++ b/src/apps/desktop/src/main.rs @@ -1,5 +1,8 @@ // Hide console window in Windows release builds -#![cfg_attr(all(not(debug_assertions), target_os = "windows"), windows_subsystem = "windows")] +#![cfg_attr( + all(not(debug_assertions), target_os = "windows"), + windows_subsystem = "windows" +)] #[tokio::main(flavor = "multi_thread", worker_threads = 4)] async fn main() { diff --git a/src/apps/relay-server/src/lib.rs b/src/apps/relay-server/src/lib.rs index 3be16d62..b8e472fa 100644 --- a/src/apps/relay-server/src/lib.rs +++ b/src/apps/relay-server/src/lib.rs @@ -12,7 +12,7 @@ pub mod relay; pub mod routes; -pub use relay::room::{RoomManager, ResponsePayload}; +pub use relay::room::{ResponsePayload, RoomManager}; pub use routes::api::AppState; use axum::extract::DefaultBodyLimit; @@ -94,9 +94,7 @@ impl WebAssetStore for MemoryAssetStore { fn get_file(&self, room_id: &str, path: &str) -> Option> { let manifest = self.room_manifests.get(room_id)?; - let hash = manifest - .get(path) - .or_else(|| manifest.get("index.html"))?; + let hash = manifest.get(path).or_else(|| manifest.get("index.html"))?; let content = self.content_store.get(hash)?; Some(content.value().as_ref().clone()) } diff --git a/src/apps/relay-server/src/main.rs b/src/apps/relay-server/src/main.rs index 29d3a9db..1616650b 100644 --- a/src/apps/relay-server/src/main.rs +++ b/src/apps/relay-server/src/main.rs @@ -42,8 +42,7 @@ async fn main() -> anyhow::Result<()> { if let Some(static_dir) = &cfg.static_dir { info!("Serving static files from: {static_dir}"); app = app.fallback_service( - tower_http::services::ServeDir::new(static_dir) - .append_index_html_on_directories(true), + tower_http::services::ServeDir::new(static_dir).append_index_html_on_directories(true), ); } diff --git a/src/apps/relay-server/src/relay/room.rs b/src/apps/relay-server/src/relay/room.rs index 85a263ce..592b3299 100644 --- a/src/apps/relay-server/src/relay/room.rs +++ b/src/apps/relay-server/src/relay/room.rs @@ -156,10 +156,7 @@ impl RoomManager { .and_then(|r| r.desktop.as_ref().map(|d| d.public_key.clone())) } - pub fn register_pending( - &self, - correlation_id: String, - ) -> oneshot::Receiver { + pub fn register_pending(&self, correlation_id: String) -> oneshot::Receiver { let (tx, rx) = oneshot::channel(); self.pending_requests.insert(correlation_id, tx); rx diff --git a/src/apps/relay-server/src/routes/api.rs b/src/apps/relay-server/src/routes/api.rs index b046c95b..f365295a 100644 --- a/src/apps/relay-server/src/routes/api.rs +++ b/src/apps/relay-server/src/routes/api.rs @@ -210,7 +210,9 @@ pub async fn upload_web( if rel_path.contains("..") { continue; } - let decoded = B64.decode(b64_content).map_err(|_| StatusCode::BAD_REQUEST)?; + let decoded = B64 + .decode(b64_content) + .map_err(|_| StatusCode::BAD_REQUEST)?; let hash = hex_sha256(&decoded); if !state.asset_store.has_content(&hash) { @@ -327,7 +329,9 @@ pub async fn upload_web_files( if rel_path.contains("..") { continue; } - let decoded = B64.decode(&entry.content).map_err(|_| StatusCode::BAD_REQUEST)?; + let decoded = B64 + .decode(&entry.content) + .map_err(|_| StatusCode::BAD_REQUEST)?; let actual_hash = hex_sha256(&decoded); if actual_hash != entry.hash { tracing::warn!( @@ -352,7 +356,9 @@ pub async fn upload_web_files( } tracing::info!("Room {room_id}: upload-web-files stored {stored} new files"); - Ok(Json(serde_json::json!({ "status": "ok", "files_stored": stored }))) + Ok(Json( + serde_json::json!({ "status": "ok", "files_stored": stored }), + )) } /// `GET /r/{*rest}` — serve per-room mobile-web static files. diff --git a/src/apps/relay-server/src/routes/websocket.rs b/src/apps/relay-server/src/routes/websocket.rs index 13cf88e9..3e456121 100644 --- a/src/apps/relay-server/src/routes/websocket.rs +++ b/src/apps/relay-server/src/routes/websocket.rs @@ -66,10 +66,7 @@ pub enum OutboundProtocol { }, } -pub async fn websocket_handler( - ws: WebSocketUpgrade, - State(state): State, -) -> Response { +pub async fn websocket_handler(ws: WebSocketUpgrade, State(state): State) -> Response { ws.max_message_size(64 * 1024 * 1024) .max_frame_size(64 * 1024 * 1024) .max_write_buffer_size(64 * 1024 * 1024) diff --git a/src/apps/server/src/main.rs b/src/apps/server/src/main.rs index fc22d0ef..6e76cde2 100644 --- a/src/apps/server/src/main.rs +++ b/src/apps/server/src/main.rs @@ -1,19 +1,14 @@ +use anyhow::Result; /// BitFun Server /// /// Web server with support for: /// - RESTful API /// - WebSocket real-time communication /// - Static file serving (frontend) - -use axum::{ - routing::get, - Router, - Json, -}; +use axum::{routing::get, Json, Router}; use serde::Serialize; use std::net::SocketAddr; use tower_http::cors::CorsLayer; -use anyhow::Result; mod routes; diff --git a/src/apps/server/src/routes/api.rs b/src/apps/server/src/routes/api.rs index fd6a50fc..5e94b12f 100644 --- a/src/apps/server/src/routes/api.rs +++ b/src/apps/server/src/routes/api.rs @@ -1,8 +1,7 @@ /// HTTP API routes /// /// Provides RESTful API endpoints - -use axum::{Json, extract::State}; +use axum::{extract::State, Json}; use serde::Serialize; use crate::AppState; diff --git a/src/apps/server/src/routes/mod.rs b/src/apps/server/src/routes/mod.rs index 52b1ce29..0f3f3704 100644 --- a/src/apps/server/src/routes/mod.rs +++ b/src/apps/server/src/routes/mod.rs @@ -1,6 +1,5 @@ +pub mod api; /// Routes module /// /// Contains all HTTP and WebSocket routes - pub mod websocket; -pub mod api; diff --git a/src/apps/server/src/routes/websocket.rs b/src/apps/server/src/routes/websocket.rs index 543833de..a1bb1b5a 100644 --- a/src/apps/server/src/routes/websocket.rs +++ b/src/apps/server/src/routes/websocket.rs @@ -1,9 +1,9 @@ +use anyhow::Result; /// WebSocket handler /// /// Implements real-time bidirectional communication with frontend: /// - Command request/response (JSON RPC format) /// - Event push (streaming output, tool calls, etc.) - use axum::{ extract::{ ws::{Message, WebSocket, WebSocketUpgrade}, @@ -13,7 +13,6 @@ use axum::{ }; use futures_util::{SinkExt, StreamExt}; use serde::{Deserialize, Serialize}; -use anyhow::Result; use crate::AppState; @@ -54,10 +53,7 @@ pub struct ErrorInfo { } /// WebSocket connection handler -pub async fn websocket_handler( - ws: WebSocketUpgrade, - State(state): State, -) -> Response { +pub async fn websocket_handler(ws: WebSocketUpgrade, State(state): State) -> Response { tracing::info!("New WebSocket connection"); ws.on_upgrade(|socket| handle_socket(socket, state)) } @@ -165,12 +161,10 @@ async fn handle_command( _state: &AppState, ) -> Result { match method { - "ping" => { - Ok(serde_json::json!({ - "pong": true, - "timestamp": chrono::Utc::now().timestamp(), - })) - } + "ping" => Ok(serde_json::json!({ + "pong": true, + "timestamp": chrono::Utc::now().timestamp(), + })), _ => { tracing::warn!("Unknown command: {}", method); Err(anyhow::anyhow!("Unknown command: {}", method)) diff --git a/src/crates/api-layer/src/dto.rs b/src/crates/api-layer/src/dto.rs index 0ab65a55..80ad3589 100644 --- a/src/crates/api-layer/src/dto.rs +++ b/src/crates/api-layer/src/dto.rs @@ -1,7 +1,6 @@ /// Data Transfer Objects (DTO) - Platform-agnostic request and response types /// /// These types are used by all platforms (CLI, Tauri, Server) - use serde::{Deserialize, Serialize}; /// Execute agent task request @@ -27,7 +26,7 @@ pub struct ExecuteAgentResponse { /// Image data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageData { - pub data: String, // Base64 + pub data: String, // Base64 pub mime_type: String, } diff --git a/src/crates/api-layer/src/lib.rs b/src/crates/api-layer/src/lib.rs index c22940c3..1507afac 100644 --- a/src/crates/api-layer/src/lib.rs +++ b/src/crates/api-layer/src/lib.rs @@ -4,7 +4,6 @@ /// - CLI (apps/cli) /// - Tauri Desktop (apps/desktop) /// - Web Server (apps/server) - pub mod dto; pub mod handlers; diff --git a/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs b/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs index d3b44abd..0b2929df 100644 --- a/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs +++ b/src/crates/core/src/agentic/agents/custom_subagents/custom_subagent_loader.rs @@ -1,6 +1,6 @@ -use log::{error}; use crate::agentic::agents::Agent; use crate::infrastructure::get_path_manager_arc; +use log::error; use std::collections::HashMap; use std::path::{Path, PathBuf}; diff --git a/src/crates/core/src/agentic/agents/debug_mode.rs b/src/crates/core/src/agentic/agents/debug_mode.rs index 65970bee..7b1bac8c 100644 --- a/src/crates/core/src/agentic/agents/debug_mode.rs +++ b/src/crates/core/src/agentic/agents/debug_mode.rs @@ -1,13 +1,13 @@ //! Debug Mode - Evidence-driven debugging mode -use log::debug; use super::prompt_builder::PromptBuilder; use super::Agent; -use async_trait::async_trait; use crate::service::config::global::GlobalConfigManager; use crate::service::config::types::{DebugModeConfig, LanguageDebugTemplate}; use crate::service::lsp::project_detector::{ProjectDetector, ProjectInfo}; use crate::util::errors::BitFunResult; +use async_trait::async_trait; +use log::debug; use std::path::Path; pub struct DebugMode; @@ -70,7 +70,7 @@ impl DebugMode { .get("javascript") .map(|t| t.enabled && !t.instrumentation_template.trim().is_empty()) .unwrap_or(false); - + if use_custom { if let Some(template) = config.language_templates.get("javascript") { output.push_str(&Self::render_template(template, config)); @@ -84,9 +84,9 @@ impl DebugMode { let matched_user_templates: Vec<_> = user_other_templates .iter() .filter(|(lang, _)| { - detected_languages.iter().any(|detected| { - detected.to_lowercase() == lang.to_lowercase() - }) + detected_languages + .iter() + .any(|detected| detected.to_lowercase() == lang.to_lowercase()) }) .collect(); @@ -109,7 +109,7 @@ impl DebugMode { output } - + fn render_builtin_js_template(config: &DebugModeConfig) -> String { let mut section = "## JavaScript / TypeScript Instrumentation\n\n".to_string(); section.push_str("```javascript\n"); @@ -175,11 +175,7 @@ impl DebugMode { } /// Builds session-level configuration with dynamic values like server endpoint and log path. - fn build_session_level_rule( - &self, - config: &DebugModeConfig, - workspace_path: &str, - ) -> String { + fn build_session_level_rule(&self, config: &DebugModeConfig, workspace_path: &str) -> String { let log_path = if config.log_path.starts_with('/') || config.log_path.starts_with('.') { config.log_path.clone() } else { @@ -290,12 +286,11 @@ impl Agent for DebugMode { debug!( "Debug mode project detection: languages={:?}, types={:?}", - project_info.languages, - project_info.project_types + project_info.languages, project_info.project_types ); - let system_prompt_template = - get_embedded_prompt("debug_mode").unwrap_or("Debug mode prompt not found in embedded files"); + let system_prompt_template = get_embedded_prompt("debug_mode") + .unwrap_or("Debug mode prompt not found in embedded files"); let language_templates = Self::build_language_templates_prompt(&debug_config, &project_info.languages); diff --git a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs index 504c3421..b4c83123 100644 --- a/src/crates/core/src/agentic/agents/prompt_builder/mod.rs +++ b/src/crates/core/src/agentic/agents/prompt_builder/mod.rs @@ -1,3 +1,3 @@ mod prompt_builder; -pub use prompt_builder::PromptBuilder; \ No newline at end of file +pub use prompt_builder::PromptBuilder; diff --git a/src/crates/core/src/agentic/agents/registry.rs b/src/crates/core/src/agentic/agents/registry.rs index 0659bc73..3775b102 100644 --- a/src/crates/core/src/agentic/agents/registry.rs +++ b/src/crates/core/src/agentic/agents/registry.rs @@ -108,6 +108,13 @@ impl AgentInfo { } } +fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str { + match agent_type { + "agentic" | "Cowork" | "Plan" | "debug" | "Claw" => "auto", + _ => "primary", + } +} + async fn get_mode_configs() -> HashMap { if let Ok(config_service) = GlobalConfigManager::get_service().await { config_service @@ -194,7 +201,11 @@ impl AgentRegistry { } } - fn find_agent_entry(&self, agent_type: &str, workspace_root: Option<&Path>) -> Option { + fn find_agent_entry( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Option { if let Some(entry) = self.read_agents().get(agent_type).cloned() { return Some(entry); } @@ -297,7 +308,11 @@ impl AgentRegistry { } /// Get a agent by ID (searches all categories including hidden) - pub fn get_agent(&self, agent_type: &str, workspace_root: Option<&Path>) -> Option> { + pub fn get_agent( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Option> { self.find_agent_entry(agent_type, workspace_root) .map(|entry| entry.agent) } @@ -340,7 +355,11 @@ impl AgentRegistry { /// get agent tools from config /// if not set, return default tools /// tool configuration synchronization is implemented through tool_config_sync, here only read configuration - pub async fn get_agent_tools(&self, agent_type: &str, workspace_root: Option<&Path>) -> Vec { + pub async fn get_agent_tools( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Vec { let entry = self.find_agent_entry(agent_type, workspace_root); let Some(entry) = entry else { return Vec::new(); @@ -418,7 +437,8 @@ impl AgentRegistry { /// - custom subagent: read enabled and model configuration from custom_config cache pub async fn get_subagents_info(&self, workspace_root: Option<&Path>) -> Vec { if let Some(workspace_root) = workspace_root { - let is_project_cache_loaded = self.read_project_subagents().contains_key(workspace_root); + let is_project_cache_loaded = + self.read_project_subagents().contains_key(workspace_root); if !is_project_cache_loaded { self.load_custom_subagents(workspace_root).await; } @@ -447,7 +467,11 @@ impl AgentRegistry { drop(map); if let Some(workspace_root) = workspace_root { if let Some(project_entries) = self.read_project_subagents().get(workspace_root) { - result.extend(project_entries.values().map(|entry| AgentInfo::from_agent_entry(entry))); + result.extend( + project_entries + .values() + .map(|entry| AgentInfo::from_agent_entry(entry)), + ); } } result @@ -695,7 +719,10 @@ impl AgentRegistry { } } - Err(BitFunError::agent(format!("Subagent not found: {}", agent_id))) + Err(BitFunError::agent(format!( + "Subagent not found: {}", + agent_id + ))) } /// get model ID used by agent from agent_models[agent_type] in configuration @@ -718,20 +745,20 @@ impl AgentRegistry { // check if it is a custom subagent, if so, read from cache if let Some(entry) = self.find_agent_entry(agent_type, workspace_root) { if let Some(config) = entry.custom_config { - let model = config.model; - if !model.is_empty() { + let model = config.model; + if !model.is_empty() { + debug!( + "[AgentRegistry] Custom subagent '{}' using model from cache: {}", + agent_type, model + ); + return Ok(model); + } + // empty model, use default value debug!( - "[AgentRegistry] Custom subagent '{}' using model from cache: {}", - agent_type, model + "[AgentRegistry] Custom subagent '{}' using default model: primary", + agent_type ); - return Ok(model); - } - // empty model, use default value - debug!( - "[AgentRegistry] Custom subagent '{}' using default model: primary", - agent_type - ); - return Ok("primary".to_string()); + return Ok("primary".to_string()); } } @@ -753,12 +780,12 @@ impl AgentRegistry { ) }; - // use default primary model + let default_model_id = default_model_id_for_builtin_agent(agent_type); warn!( - "[AgentRegistry] Agent '{}' has no model configured, using default primary model", - agent_type + "[AgentRegistry] Agent '{}' has no model configured, using default model '{}'", + agent_type, default_model_id ); - Ok("primary".to_string()) + Ok(default_model_id.to_string()) } /// Get the default agent type @@ -779,3 +806,21 @@ pub fn get_agent_registry() -> Arc { }) .clone() } + +#[cfg(test)] +mod tests { + use super::default_model_id_for_builtin_agent; + + #[test] + fn top_level_modes_default_to_auto() { + for agent_type in ["agentic", "Cowork", "Plan", "debug", "Claw"] { + assert_eq!(default_model_id_for_builtin_agent(agent_type), "auto"); + } + } + + #[test] + fn non_mode_agents_default_to_primary() { + assert_eq!(default_model_id_for_builtin_agent("Explore"), "primary"); + assert_eq!(default_model_id_for_builtin_agent("CodeReview"), "primary"); + } +} diff --git a/src/crates/core/src/agentic/coordination/coordinator.rs b/src/crates/core/src/agentic/coordination/coordinator.rs index 2c854799..7b5e83e8 100644 --- a/src/crates/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/core/src/agentic/coordination/coordinator.rs @@ -120,7 +120,9 @@ impl ConversationCoordinator { || workspace_service .get_workspace_by_path(&workspace_path_buf) .await - .map(|workspace| workspace.workspace_kind == crate::service::workspace::WorkspaceKind::Assistant) + .map(|workspace| { + workspace.workspace_kind == crate::service::workspace::WorkspaceKind::Assistant + }) .unwrap_or(false) { if normalized_agent_type != "Claw" { @@ -167,7 +169,9 @@ impl ConversationCoordinator { config: SessionConfig, ) -> BitFunResult { let workspace_path = config.workspace_path.clone().ok_or_else(|| { - BitFunError::Validation("workspace_path is required when creating a session".to_string()) + BitFunError::Validation( + "workspace_path is required when creating a session".to_string(), + ) })?; self.create_session_with_workspace_and_creator( None, @@ -189,7 +193,9 @@ impl ConversationCoordinator { config: SessionConfig, ) -> BitFunResult { let workspace_path = config.workspace_path.clone().ok_or_else(|| { - BitFunError::Validation("workspace_path is required when creating a session".to_string()) + BitFunError::Validation( + "workspace_path is required when creating a session".to_string(), + ) })?; self.create_session_with_workspace_and_creator( session_id, @@ -263,11 +269,7 @@ impl ConversationCoordinator { Ok(session) } - async fn sync_session_metadata_to_workspace( - &self, - session: &Session, - workspace_path: String, - ) { + async fn sync_session_metadata_to_workspace(&self, session: &Session, workspace_path: String) { use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::PathManager; use crate::service::session::{SessionMetadata, SessionStatus}; @@ -461,7 +463,9 @@ impl ConversationCoordinator { ) -> BitFunResult { let agent_registry = get_agent_registry(); if let Some(workspace) = workspace { - agent_registry.load_custom_subagents(workspace.root_path()).await; + agent_registry + .load_custom_subagents(workspace.root_path()) + .await; } let current_agent = agent_registry .get_agent(agent_type, workspace.map(|binding| binding.root_path())) @@ -491,6 +495,7 @@ impl ConversationCoordinator { &self, session_id: String, user_input: String, + original_user_input: Option, turn_id: Option, agent_type: String, workspace_path: Option, @@ -499,6 +504,7 @@ impl ConversationCoordinator { self.start_dialog_turn_internal( session_id, user_input, + original_user_input, None, turn_id, agent_type, @@ -512,6 +518,7 @@ impl ConversationCoordinator { &self, session_id: String, user_input: String, + original_user_input: Option, image_contexts: Vec, turn_id: Option, agent_type: String, @@ -521,6 +528,7 @@ impl ConversationCoordinator { self.start_dialog_turn_internal( session_id, user_input, + original_user_input, Some(image_contexts), turn_id, agent_type, @@ -663,6 +671,7 @@ impl ConversationCoordinator { &self, session_id: String, user_input: String, + original_user_input: Option, image_contexts: Option>, turn_id: Option, agent_type: String, @@ -852,7 +861,7 @@ impl ConversationCoordinator { } } - let original_user_input = user_input.clone(); + let original_user_input = original_user_input.unwrap_or_else(|| user_input.clone()); // Build image metadata for workspace turn persistence (before image_contexts is consumed) // Also stores original_text so the UI can display the user's actual input @@ -906,7 +915,11 @@ impl ConversationCoordinator { .await?; let wrapped_user_input = self - .wrap_user_input(&effective_agent_type, user_input, session_workspace.as_ref()) + .wrap_user_input( + &effective_agent_type, + user_input, + session_workspace.as_ref(), + ) .await?; // Start new dialog turn (sets state to Processing internally) @@ -957,6 +970,10 @@ impl ConversationCoordinator { "enable_tools".to_string(), session.config.enable_tools.to_string(), ); + context_vars.insert( + "original_user_input".to_string(), + original_user_input.clone(), + ); // Pass model_id for token usage tracking if let Some(model_id) = &session.config.model_id { @@ -1271,8 +1288,14 @@ impl ConversationCoordinator { } /// Delete session - pub async fn delete_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { - self.session_manager.delete_session(workspace_path, session_id).await?; + pub async fn delete_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { + self.session_manager + .delete_session(workspace_path, session_id) + .await?; self.emit_event(AgenticEvent::SessionDeleted { session_id: session_id.to_string(), }) @@ -1281,8 +1304,14 @@ impl ConversationCoordinator { } /// Restore session - pub async fn restore_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { - self.session_manager.restore_session(workspace_path, session_id).await + pub async fn restore_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { + self.session_manager + .restore_session(workspace_path, session_id) + .await } /// List all sessions @@ -1500,7 +1529,9 @@ impl ConversationCoordinator { ); } - Ok(SubagentResult { text: response_text }) + Ok(SubagentResult { + text: response_text, + }) } /// Clean up subagent session resources diff --git a/src/crates/core/src/agentic/coordination/scheduler.rs b/src/crates/core/src/agentic/coordination/scheduler.rs index b520647a..25d034de 100644 --- a/src/crates/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/core/src/agentic/coordination/scheduler.rs @@ -29,6 +29,7 @@ const DEBOUNCE_DELAY: Duration = Duration::from_secs(1); #[derive(Debug)] pub struct QueuedTurn { pub user_input: String, + pub original_user_input: Option, pub turn_id: Option, pub agent_type: String, pub workspace_path: Option, @@ -98,6 +99,7 @@ impl DialogScheduler { &self, session_id: String, user_input: String, + original_user_input: Option, turn_id: Option, agent_type: String, workspace_path: Option, @@ -114,6 +116,7 @@ impl DialogScheduler { .start_dialog_turn( session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -128,6 +131,7 @@ impl DialogScheduler { .start_dialog_turn( session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -149,6 +153,7 @@ impl DialogScheduler { self.enqueue( &session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -161,6 +166,7 @@ impl DialogScheduler { .start_dialog_turn( session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -175,6 +181,7 @@ impl DialogScheduler { self.enqueue( &session_id, user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -196,6 +203,7 @@ impl DialogScheduler { &self, session_id: &str, user_input: String, + original_user_input: Option, turn_id: Option, agent_type: String, workspace_path: Option, @@ -219,6 +227,7 @@ impl DialogScheduler { .or_default() .push_back(QueuedTurn { user_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -285,13 +294,20 @@ impl DialogScheduler { session_id_clone ); - let (merged_input, turn_id, agent_type, workspace_path, trigger_source) = - merge_messages(messages); + let ( + merged_input, + original_user_input, + turn_id, + agent_type, + workspace_path, + trigger_source, + ) = merge_messages(messages); if let Err(e) = coordinator .start_dialog_turn( session_id_clone.clone(), merged_input, + original_user_input, turn_id, agent_type, workspace_path, @@ -360,11 +376,19 @@ impl DialogScheduler { /// ``` fn merge_messages( messages: Vec, -) -> (String, Option, String, Option, DialogTriggerSource) { +) -> ( + String, + Option, + Option, + String, + Option, + DialogTriggerSource, +) { if messages.len() == 1 { let m = messages.into_iter().next().unwrap(); return ( m.user_input, + m.original_user_input, m.turn_id, m.agent_type, m.workspace_path, @@ -381,6 +405,7 @@ fn merge_messages( .last() .map(|m| m.trigger_source) .unwrap_or(DialogTriggerSource::DesktopUi); + let original_user_input = messages.last().and_then(|m| m.original_user_input.clone()); let entries: Vec = messages .iter() @@ -393,7 +418,14 @@ fn merge_messages( entries.join("\n\n") ); - (merged, None, agent_type, workspace_path, trigger_source) + ( + merged, + original_user_input, + None, + agent_type, + workspace_path, + trigger_source, + ) } // ── Global instance ────────────────────────────────────────────────────────── diff --git a/src/crates/core/src/agentic/core/message.rs b/src/crates/core/src/agentic/core/message.rs index bca0b912..8f27f9c7 100644 --- a/src/crates/core/src/agentic/core/message.rs +++ b/src/crates/core/src/agentic/core/message.rs @@ -110,11 +110,7 @@ impl From for AIMessage { .filter(|s| !s.is_empty()) .map(str::to_string) .or_else(|| { - image - .image_path - .as_ref() - .filter(|s| !s.is_empty()) - .cloned() + image.image_path.as_ref().filter(|s| !s.is_empty()).cloned() }) .unwrap_or_else(|| image.id.clone()); diff --git a/src/crates/core/src/agentic/core/mod.rs b/src/crates/core/src/agentic/core/mod.rs index 1541ad90..90524d06 100644 --- a/src/crates/core/src/agentic/core/mod.rs +++ b/src/crates/core/src/agentic/core/mod.rs @@ -4,14 +4,14 @@ pub mod dialog_turn; pub mod message; +pub mod messages_helper; pub mod model_round; pub mod session; pub mod state; -pub mod messages_helper; pub use dialog_turn::{DialogTurn, DialogTurnState, TurnStats}; pub use message::{Message, MessageContent, MessageRole, ToolCall, ToolResult}; -pub use model_round::ModelRound; -pub use session::{Session, SessionConfig, SessionSummary, CompressionState}; pub use messages_helper::MessageHelper; +pub use model_round::ModelRound; +pub use session::{CompressionState, Session, SessionConfig, SessionSummary}; pub use state::{ProcessingPhase, SessionState, ToolExecutionState}; diff --git a/src/crates/core/src/agentic/core/session.rs b/src/crates/core/src/agentic/core/session.rs index e0183010..22a0a045 100644 --- a/src/crates/core/src/agentic/core/session.rs +++ b/src/crates/core/src/agentic/core/session.rs @@ -11,11 +11,20 @@ pub struct Session { pub session_id: String, pub session_name: String, pub agent_type: String, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "created_by", alias = "createdBy")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "created_by", + alias = "createdBy" + )] pub created_by: Option, /// Associated resources - #[serde(skip_serializing_if = "Option::is_none", alias = "sandbox_session_id", alias = "sandboxSessionId")] + #[serde( + skip_serializing_if = "Option::is_none", + alias = "sandbox_session_id", + alias = "sandboxSessionId" + )] pub snapshot_session_id: Option, /// Dialog turn ID list @@ -146,7 +155,12 @@ pub struct SessionSummary { pub session_id: String, pub session_name: String, pub agent_type: String, - #[serde(default, skip_serializing_if = "Option::is_none", alias = "created_by", alias = "createdBy")] + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "created_by", + alias = "createdBy" + )] pub created_by: Option, pub turn_count: usize, pub created_at: SystemTime, diff --git a/src/crates/core/src/agentic/events/mod.rs b/src/crates/core/src/agentic/events/mod.rs index b55b6439..fdbde912 100644 --- a/src/crates/core/src/agentic/events/mod.rs +++ b/src/crates/core/src/agentic/events/mod.rs @@ -1,13 +1,11 @@ //! Event Layer -//! +//! //! Provides event queue, routing and management functionality -pub mod types; pub mod queue; pub mod router; +pub mod types; -pub use types::*; pub use queue::*; pub use router::*; - - +pub use types::*; diff --git a/src/crates/core/src/agentic/execution/execution_engine.rs b/src/crates/core/src/agentic/execution/execution_engine.rs index 38ace630..3e280365 100644 --- a/src/crates/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/core/src/agentic/execution/execution_engine.rs @@ -131,6 +131,32 @@ impl ExecutionEngine { Self::is_recoverable_historical_image_error(err) } + fn resolve_configured_model_id( + ai_config: &crate::service::config::types::AIConfig, + model_id: &str, + ) -> String { + ai_config + .resolve_model_selection(model_id) + .unwrap_or_else(|| model_id.to_string()) + } + + fn resolve_locked_auto_model_id( + ai_config: &crate::service::config::types::AIConfig, + model_id: Option<&String>, + ) -> Option { + let model_id = model_id?; + let trimmed = model_id.trim(); + if trimmed.is_empty() || trimmed == "auto" || trimmed == "default" { + return None; + } + + ai_config.resolve_model_selection(trimmed) + } + + fn should_use_fast_auto_model(turn_index: usize, original_user_input: &str) -> bool { + turn_index == 0 && original_user_input.chars().count() <= 10 + } + async fn build_ai_messages_for_send( messages: &[Message], provider: &str, @@ -382,10 +408,18 @@ impl ExecutionEngine { // 1. Get current agent let agent_registry = get_agent_registry(); if let Some(workspace) = context.workspace.as_ref() { - agent_registry.load_custom_subagents(workspace.root_path()).await; + agent_registry + .load_custom_subagents(workspace.root_path()) + .await; } let current_agent = agent_registry - .get_agent(&agent_type, context.workspace.as_ref().map(|workspace| workspace.root_path())) + .get_agent( + &agent_type, + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), + ) .ok_or_else(|| BitFunError::NotFound(format!("Agent not found: {}", agent_type)))?; info!( "Current Agent: {} ({})", @@ -393,18 +427,94 @@ impl ExecutionEngine { current_agent.id() ); + let session = self + .session_manager + .get_session(&context.session_id) + .ok_or_else(|| { + BitFunError::Session(format!("Session not found: {}", context.session_id)) + })?; + // 2. Get AI client // Get model ID from AgentRegistry - let model_id = agent_registry + let configured_model_id = agent_registry .get_model_id_for_agent( &agent_type, - context.workspace.as_ref().map(|workspace| workspace.root_path()), + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), ) .await .map_err(|e| BitFunError::AIClient(format!("Failed to get model ID: {}", e)))?; + let model_id = if configured_model_id == "auto" { + let config_service = get_global_config_service().await.map_err(|e| { + BitFunError::AIClient(format!( + "Failed to get config service for auto model resolution: {}", + e + )) + })?; + let ai_config: crate::service::config::types::AIConfig = config_service + .get_config(Some("ai")) + .await + .unwrap_or_default(); + + let locked_model_id = + Self::resolve_locked_auto_model_id(&ai_config, session.config.model_id.as_ref()); + let raw_locked_model_id = session.config.model_id.clone(); + + if let Some(locked_model_id) = locked_model_id { + locked_model_id + } else { + if let Some(raw_locked_model_id) = raw_locked_model_id.as_ref() { + let trimmed = raw_locked_model_id.trim(); + if !trimmed.is_empty() && trimmed != "auto" && trimmed != "default" { + warn!( + "Ignoring invalid locked auto model for session: session_id={}, model_id={}", + context.session_id, trimmed + ); + } + } + + let original_user_input = context + .context + .get("original_user_input") + .cloned() + .unwrap_or_default(); + let use_fast_model = + Self::should_use_fast_auto_model(context.turn_index, &original_user_input); + let fallback_model = if use_fast_model { "fast" } else { "primary" }; + let resolved_model_id = ai_config.resolve_model_selection(fallback_model); + + if let Some(resolved_model_id) = resolved_model_id { + self.session_manager + .update_session_model_id(&context.session_id, &resolved_model_id) + .await?; + + info!( + "Auto model resolved: session_id={}, turn_index={}, user_input_chars={}, strategy={}, resolved_model_id={}", + context.session_id, + context.turn_index, + original_user_input.chars().count(), + fallback_model, + resolved_model_id + ); + + resolved_model_id + } else { + warn!( + "Auto model strategy unresolved, keeping symbolic selector: session_id={}, strategy={}", + context.session_id, fallback_model + ); + fallback_model.to_string() + } + } + } else { + configured_model_id.clone() + }; info!( - "Agent using model: agent={}, model_id={}", + "Agent using model: agent={}, configured_model_id={}, resolved_model_id={}", current_agent.name(), + configured_model_id, model_id ); @@ -482,7 +592,10 @@ impl ExecutionEngine { let allowed_tools = agent_registry .get_agent_tools( &agent_type, - context.workspace.as_ref().map(|workspace| workspace.root_path()), + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), ) .await; let enable_tools = context @@ -502,13 +615,6 @@ impl ExecutionEngine { (vec![], None) }; - // Get session configuration - let session = self - .session_manager - .get_session(&context.session_id) - .ok_or_else(|| { - BitFunError::Session(format!("Session not found: {}", context.session_id)) - })?; let enable_context_compression = session.config.enable_context_compression; let compression_threshold = session.config.compression_threshold; // Detect whether the primary model supports multimodal image inputs. @@ -521,20 +627,7 @@ impl ExecutionEngine { let ai_config: crate::service::config::types::AIConfig = service.get_config(Some("ai")).await.unwrap_or_default(); - let resolved_id = match model_id.as_str() { - "primary" => ai_config - .default_models - .primary - .clone() - .unwrap_or_else(|| model_id.clone()), - "fast" => ai_config - .default_models - .fast - .clone() - .or_else(|| ai_config.default_models.primary.clone()) - .unwrap_or_else(|| model_id.clone()), - _ => model_id.clone(), - }; + let resolved_id = Self::resolve_configured_model_id(&ai_config, &model_id); let model_cfg = ai_config .models @@ -702,7 +795,10 @@ impl ExecutionEngine { let ai_messages = Self::build_ai_messages_for_send( &messages, &ai_client.config.format, - context.workspace.as_ref().map(|workspace| workspace.root_path()), + context + .workspace + .as_ref() + .map(|workspace| workspace.root_path()), &context.dialog_turn_id, ) .await?; @@ -995,3 +1091,68 @@ impl ExecutionEngine { let _ = self.event_queue.enqueue(event, Some(priority)).await; } } + +#[cfg(test)] +mod tests { + use super::ExecutionEngine; + use crate::service::config::types::AIConfig; + use crate::service::config::types::AIModelConfig; + + fn build_model(id: &str, name: &str, model_name: &str) -> AIModelConfig { + AIModelConfig { + id: id.to_string(), + name: name.to_string(), + model_name: model_name.to_string(), + provider: "anthropic".to_string(), + enabled: true, + ..Default::default() + } + } + + #[test] + fn auto_model_uses_fast_for_short_first_message() { + assert!(ExecutionEngine::should_use_fast_auto_model(0, "你好")); + assert!(ExecutionEngine::should_use_fast_auto_model(0, "1234567890")); + } + + #[test] + fn auto_model_uses_primary_for_long_first_message() { + assert!(!ExecutionEngine::should_use_fast_auto_model( + 0, + "12345678901" + )); + } + + #[test] + fn auto_model_uses_primary_after_first_turn() { + assert!(!ExecutionEngine::should_use_fast_auto_model(1, "短消息")); + } + + #[test] + fn resolve_configured_fast_model_falls_back_to_primary_when_fast_is_stale() { + let mut ai_config = AIConfig::default(); + ai_config.models = vec![build_model("model-primary", "Primary", "claude-sonnet-4.5")]; + ai_config.default_models.primary = Some("model-primary".to_string()); + ai_config.default_models.fast = Some("deleted-fast-model".to_string()); + + assert_eq!( + ExecutionEngine::resolve_configured_model_id(&ai_config, "fast"), + "model-primary" + ); + } + + #[test] + fn invalid_locked_auto_model_is_ignored() { + let mut ai_config = AIConfig::default(); + ai_config.models = vec![build_model("model-primary", "Primary", "claude-sonnet-4.5")]; + ai_config.default_models.primary = Some("model-primary".to_string()); + + assert_eq!( + ExecutionEngine::resolve_locked_auto_model_id( + &ai_config, + Some(&"deleted-fast-model".to_string()) + ), + None + ); + } +} diff --git a/src/crates/core/src/agentic/execution/mod.rs b/src/crates/core/src/agentic/execution/mod.rs index 0f8a664d..af22b10f 100644 --- a/src/crates/core/src/agentic/execution/mod.rs +++ b/src/crates/core/src/agentic/execution/mod.rs @@ -1,14 +1,13 @@ //! Execution Engine Layer -//! +//! //! Responsible for AI interaction and model round control -pub mod types; -pub mod stream_processor; -pub mod round_executor; pub mod execution_engine; +pub mod round_executor; +pub mod stream_processor; +pub mod types; pub use execution_engine::*; pub use round_executor::*; pub use stream_processor::*; pub use types::{ExecutionContext, ExecutionResult, FinishReason, RoundContext, RoundResult}; - diff --git a/src/crates/core/src/agentic/execution/round_executor.rs b/src/crates/core/src/agentic/execution/round_executor.rs index 357f069e..0d70a807 100644 --- a/src/crates/core/src/agentic/execution/round_executor.rs +++ b/src/crates/core/src/agentic/execution/round_executor.rs @@ -362,7 +362,8 @@ impl RoundExecutor { (None, None, false) // Default: no timeout, requires confirmation }; - let skip_from_context = context.context_vars + let skip_from_context = context + .context_vars .get("skip_tool_confirmation") .map(|v| v == "true") .unwrap_or(false); diff --git a/src/crates/core/src/agentic/execution/types.rs b/src/crates/core/src/agentic/execution/types.rs index 80cef1e5..0ca43b04 100644 --- a/src/crates/core/src/agentic/execution/types.rs +++ b/src/crates/core/src/agentic/execution/types.rs @@ -2,8 +2,8 @@ use crate::agentic::core::Message; use crate::agentic::tools::pipeline::SubagentParentInfo; -use serde_json::Value; use crate::agentic::WorkspaceBinding; +use serde_json::Value; use std::collections::HashMap; use tokio_util::sync::CancellationToken; diff --git a/src/crates/core/src/agentic/image_analysis/enhancer.rs b/src/crates/core/src/agentic/image_analysis/enhancer.rs index 93b38876..3d24e799 100644 --- a/src/crates/core/src/agentic/image_analysis/enhancer.rs +++ b/src/crates/core/src/agentic/image_analysis/enhancer.rs @@ -23,12 +23,16 @@ impl MessageEnhancer { if !image_analyses.is_empty() { enhanced.push_str("User uploaded "); enhanced.push_str(&image_analyses.len().to_string()); - enhanced.push_str(" image(s). AI's understanding of the image content is as follows:\n\n"); + enhanced + .push_str(" image(s). AI's understanding of the image content is as follows:\n\n"); for (idx, analysis) in image_analyses.iter().enumerate() { enhanced.push_str(&format!("[Image {}]\n", idx + 1)); enhanced.push_str(&format!("• Summary: {}\n", analysis.summary)); - enhanced.push_str(&format!("• Detailed description: {}\n", analysis.detailed_description)); + enhanced.push_str(&format!( + "• Detailed description: {}\n", + analysis.detailed_description + )); if !analysis.detected_elements.is_empty() { enhanced.push_str("• Key elements: "); diff --git a/src/crates/core/src/agentic/image_analysis/mod.rs b/src/crates/core/src/agentic/image_analysis/mod.rs index 0778eb2a..aef9a8e4 100644 --- a/src/crates/core/src/agentic/image_analysis/mod.rs +++ b/src/crates/core/src/agentic/image_analysis/mod.rs @@ -9,11 +9,10 @@ pub mod types; pub use enhancer::MessageEnhancer; pub use image_processing::{ - build_multimodal_message, decode_data_url, detect_mime_type_from_bytes, load_image_from_path, - optimize_image_for_provider, optimize_image_with_size_limit, - process_image_contexts_for_provider, resolve_image_path, - resolve_vision_model_from_ai_config, resolve_vision_model_from_global_config, - build_multimodal_message_with_images, ProcessedImage, + build_multimodal_message, build_multimodal_message_with_images, decode_data_url, + detect_mime_type_from_bytes, load_image_from_path, optimize_image_for_provider, + optimize_image_with_size_limit, process_image_contexts_for_provider, resolve_image_path, + resolve_vision_model_from_ai_config, resolve_vision_model_from_global_config, ProcessedImage, }; pub use processor::ImageAnalyzer; pub use types::*; diff --git a/src/crates/core/src/agentic/persistence/manager.rs b/src/crates/core/src/agentic/persistence/manager.rs index 8d9fc6d5..537b0f30 100644 --- a/src/crates/core/src/agentic/persistence/manager.rs +++ b/src/crates/core/src/agentic/persistence/manager.rs @@ -87,11 +87,13 @@ impl PersistenceManager { } fn metadata_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id).join("metadata.json") + self.session_dir(workspace_path, session_id) + .join("metadata.json") } fn state_path(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id).join("state.json") + self.session_dir(workspace_path, session_id) + .join("state.json") } fn turns_dir(&self, workspace_path: &Path, session_id: &str) -> PathBuf { @@ -99,7 +101,8 @@ impl PersistenceManager { } fn snapshots_dir(&self, workspace_path: &Path, session_id: &str) -> PathBuf { - self.session_dir(workspace_path, session_id).join("snapshots") + self.session_dir(workspace_path, session_id) + .join("snapshots") } fn turn_path(&self, workspace_path: &Path, session_id: &str, turn_index: usize) -> PathBuf { @@ -123,13 +126,20 @@ impl PersistenceManager { async fn ensure_project_sessions_dir(&self, workspace_path: &Path) -> BitFunResult { let dir = self.project_sessions_dir(workspace_path); - fs::create_dir_all(&dir) - .await - .map_err(|e| BitFunError::io(format!("Failed to create project sessions directory: {}", e)))?; + fs::create_dir_all(&dir).await.map_err(|e| { + BitFunError::io(format!( + "Failed to create project sessions directory: {}", + e + )) + })?; Ok(dir) } - async fn ensure_session_dir(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { + async fn ensure_session_dir( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { let dir = self.session_dir(workspace_path, session_id); fs::create_dir_all(&dir) .await @@ -137,7 +147,11 @@ impl PersistenceManager { Ok(dir) } - async fn ensure_turns_dir(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { + async fn ensure_turns_dir( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { let dir = self.turns_dir(workspace_path, session_id); fs::create_dir_all(&dir) .await @@ -157,14 +171,21 @@ impl PersistenceManager { Ok(dir) } - async fn read_json_optional(&self, path: &Path) -> BitFunResult> { + async fn read_json_optional( + &self, + path: &Path, + ) -> BitFunResult> { if !path.exists() { return Ok(None); } - let content = fs::read_to_string(path) - .await - .map_err(|e| BitFunError::io(format!("Failed to read JSON file {}: {}", path.display(), e)))?; + let content = fs::read_to_string(path).await.map_err(|e| { + BitFunError::io(format!( + "Failed to read JSON file {}: {}", + path.display(), + e + )) + })?; let value = serde_json::from_str::(&content).map_err(|e| { BitFunError::Deserialization(format!( @@ -179,7 +200,10 @@ impl PersistenceManager { async fn write_json_atomic(&self, path: &Path, value: &T) -> BitFunResult<()> { let parent = path.parent().ok_or_else(|| { - BitFunError::io(format!("Target path has no parent directory: {}", path.display())) + BitFunError::io(format!( + "Target path has no parent directory: {}", + path.display() + )) })?; fs::create_dir_all(parent) @@ -197,7 +221,10 @@ impl PersistenceManager { for attempt in 0..=JSON_WRITE_MAX_RETRIES { let tmp_path = Self::build_temp_json_path(path, attempt)?; if let Err(e) = fs::write(&tmp_path, &json_bytes).await { - return Err(BitFunError::io(format!("Failed to write temp JSON file: {}", e))); + return Err(BitFunError::io(format!( + "Failed to write temp JSON file: {}", + e + ))); } match Self::replace_file_from_temp(path, &tmp_path).await { @@ -228,12 +255,19 @@ impl PersistenceManager { path.display() ); fs::write(path, &json_bytes).await.map_err(|e| { - BitFunError::io(format!("Failed fallback JSON overwrite {}: {}", path.display(), e)) + BitFunError::io(format!( + "Failed fallback JSON overwrite {}: {}", + path.display(), + e + )) })?; return Ok(()); } - return Err(BitFunError::io(format!("Failed to replace JSON file: {}", error))); + return Err(BitFunError::io(format!( + "Failed to replace JSON file: {}", + error + ))); } Err(BitFunError::io(format!( @@ -253,7 +287,10 @@ impl PersistenceManager { fn build_temp_json_path(path: &Path, attempt: usize) -> BitFunResult { let parent = path.parent().ok_or_else(|| { - BitFunError::io(format!("Target path has no parent directory: {}", path.display())) + BitFunError::io(format!( + "Target path has no parent directory: {}", + path.display() + )) })?; let file_name = path @@ -438,11 +475,9 @@ impl PersistenceManager { .await .map_err(|e| BitFunError::io(format!("Failed to read sessions root: {}", e)))?; - while let Some(entry) = entries - .next_entry() - .await - .map_err(|e| BitFunError::io(format!("Failed to read session directory entry: {}", e)))? - { + while let Some(entry) = entries.next_entry().await.map_err(|e| { + BitFunError::io(format!("Failed to read session directory entry: {}", e)) + })? { let file_type = entry .file_type() .await @@ -452,7 +487,10 @@ impl PersistenceManager { } let session_id = entry.file_name().to_string_lossy().to_string(); - match self.load_session_metadata(workspace_path, &session_id).await { + match self + .load_session_metadata(workspace_path, &session_id) + .await + { Ok(Some(metadata)) => metadata_list.push(metadata), Ok(None) => {} Err(e) => { @@ -502,30 +540,47 @@ impl PersistenceManager { index.sessions.push(metadata.clone()); } - index.sessions.sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at)); + index + .sessions + .sort_by(|a, b| b.last_active_at.cmp(&a.last_active_at)); index.updated_at = Self::system_time_to_unix_ms(SystemTime::now()); index.schema_version = SESSION_SCHEMA_VERSION; self.write_json_atomic(&index_path, &index).await } - async fn remove_index_entry(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { + async fn remove_index_entry( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { let index_path = self.index_path(workspace_path); - let Some(mut index) = self.read_json_optional::(&index_path).await? else { + let Some(mut index) = self + .read_json_optional::(&index_path) + .await? + else { return Ok(()); }; - index.sessions.retain(|value| value.session_id != session_id); + index + .sessions + .retain(|value| value.session_id != session_id); index.updated_at = Self::system_time_to_unix_ms(SystemTime::now()); self.write_json_atomic(&index_path, &index).await } - pub async fn list_session_metadata(&self, workspace_path: &Path) -> BitFunResult> { + pub async fn list_session_metadata( + &self, + workspace_path: &Path, + ) -> BitFunResult> { if !workspace_path.exists() { return Ok(Vec::new()); } let index_path = self.index_path(workspace_path); - if let Some(index) = self.read_json_optional::(&index_path).await? { + if let Some(index) = self + .read_json_optional::(&index_path) + .await? + { return Ok(index.sessions); } @@ -537,15 +592,19 @@ impl PersistenceManager { workspace_path: &Path, metadata: &SessionMetadata, ) -> BitFunResult<()> { - self.ensure_session_dir(workspace_path, &metadata.session_id).await?; + self.ensure_session_dir(workspace_path, &metadata.session_id) + .await?; let file = StoredSessionMetadataFile { schema_version: SESSION_SCHEMA_VERSION, metadata: metadata.clone(), }; - self.write_json_atomic(&self.metadata_path(workspace_path, &metadata.session_id), &file) - .await?; + self.write_json_atomic( + &self.metadata_path(workspace_path, &metadata.session_id), + &file, + ) + .await?; self.upsert_index_entry(workspace_path, metadata).await } @@ -566,8 +625,10 @@ impl PersistenceManager { workspace_path: &Path, session_id: &str, ) -> BitFunResult> { - self.read_json_optional::(&self.state_path(workspace_path, session_id)) - .await + self.read_json_optional::( + &self.state_path(workspace_path, session_id), + ) + .await } async fn save_stored_session_state( @@ -589,7 +650,8 @@ impl PersistenceManager { turn_index: usize, messages: &[Message], ) -> BitFunResult<()> { - self.ensure_snapshots_dir(workspace_path, session_id).await?; + self.ensure_snapshots_dir(workspace_path, session_id) + .await?; let snapshot = StoredTurnContextSnapshotFile { schema_version: SESSION_SCHEMA_VERSION, @@ -717,13 +779,16 @@ impl PersistenceManager { if !workspace_path.exists() { return Ok(()); } - self.ensure_session_dir(workspace_path, &session.session_id).await?; + self.ensure_session_dir(workspace_path, &session.session_id) + .await?; let existing_metadata = self .load_session_metadata(workspace_path, &session.session_id) .await?; - let metadata = self.build_session_metadata(workspace_path, session, existing_metadata.as_ref()); - self.save_session_metadata(workspace_path, &metadata).await?; + let metadata = + self.build_session_metadata(workspace_path, session, existing_metadata.as_ref()); + self.save_session_metadata(workspace_path, &metadata) + .await?; let state = StoredSessionStateFile { schema_version: SESSION_SCHEMA_VERSION, @@ -737,11 +802,17 @@ impl PersistenceManager { } /// Load session - pub async fn load_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { + pub async fn load_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { let metadata = self .load_session_metadata(workspace_path, session_id) .await? - .ok_or_else(|| BitFunError::NotFound(format!("Session metadata not found: {}", session_id)))?; + .ok_or_else(|| { + BitFunError::NotFound(format!("Session metadata not found: {}", session_id)) + })?; let stored_state = self .load_stored_session_state(workspace_path, session_id) .await?; @@ -814,12 +885,16 @@ impl PersistenceManager { } /// Delete session - pub async fn delete_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { + pub async fn delete_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { let dir = self.session_dir(workspace_path, session_id); if dir.exists() { - fs::remove_dir_all(&dir) - .await - .map_err(|e| BitFunError::io(format!("Failed to delete session directory: {}", e)))?; + fs::remove_dir_all(&dir).await.map_err(|e| { + BitFunError::io(format!("Failed to delete session directory: {}", e)) + })?; } self.remove_index_entry(workspace_path, session_id).await?; @@ -869,7 +944,8 @@ impl PersistenceManager { workspace_path: &Path, turn: &DialogTurnData, ) -> BitFunResult<()> { - self.ensure_turns_dir(workspace_path, &turn.session_id).await?; + self.ensure_turns_dir(workspace_path, &turn.session_id) + .await?; let file = StoredDialogTurnFile { schema_version: SESSION_SCHEMA_VERSION, @@ -893,13 +969,15 @@ impl PersistenceManager { ) }); - let turns = self.load_session_turns(workspace_path, &turn.session_id).await?; + let turns = self + .load_session_turns(workspace_path, &turn.session_id) + .await?; metadata.turn_count = turns.len(); metadata.message_count = turns.iter().map(Self::estimate_turn_message_count).sum(); metadata.tool_call_count = turns.iter().map(DialogTurnData::count_tool_calls).sum(); - metadata.last_active_at = turn.end_time.unwrap_or_else(|| { - Self::system_time_to_unix_ms(SystemTime::now()) - }); + metadata.last_active_at = turn + .end_time + .unwrap_or_else(|| Self::system_time_to_unix_ms(SystemTime::now())); metadata.workspace_path = Some(workspace_path.to_string_lossy().to_string()); self.save_session_metadata(workspace_path, &metadata).await } @@ -960,7 +1038,10 @@ impl PersistenceManager { let mut turns = Vec::with_capacity(indexed_paths.len()); for (_, path) in indexed_paths { - if let Some(file) = self.read_json_optional::(&path).await? { + if let Some(file) = self + .read_json_optional::(&path) + .await? + { turns.push(file.turn); } } @@ -988,7 +1069,10 @@ impl PersistenceManager { let turns = self.load_session_turns(workspace_path, session_id).await?; let mut deleted = 0usize; - for turn in turns.into_iter().filter(|value| value.turn_index > turn_index) { + for turn in turns + .into_iter() + .filter(|value| value.turn_index > turn_index) + { let path = self.turn_path(workspace_path, session_id, turn.turn_index); if path.exists() { fs::remove_file(&path) @@ -998,13 +1082,23 @@ impl PersistenceManager { } } - if let Some(mut metadata) = self.load_session_metadata(workspace_path, session_id).await? { + if let Some(mut metadata) = self + .load_session_metadata(workspace_path, session_id) + .await? + { let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; metadata.turn_count = remaining_turns.len(); - metadata.message_count = remaining_turns.iter().map(Self::estimate_turn_message_count).sum(); - metadata.tool_call_count = remaining_turns.iter().map(DialogTurnData::count_tool_calls).sum(); + metadata.message_count = remaining_turns + .iter() + .map(Self::estimate_turn_message_count) + .sum(); + metadata.tool_call_count = remaining_turns + .iter() + .map(DialogTurnData::count_tool_calls) + .sum(); metadata.last_active_at = Self::system_time_to_unix_ms(SystemTime::now()); - self.save_session_metadata(workspace_path, &metadata).await?; + self.save_session_metadata(workspace_path, &metadata) + .await?; } Ok(deleted) @@ -1019,7 +1113,10 @@ impl PersistenceManager { let turns = self.load_session_turns(workspace_path, session_id).await?; let mut deleted = 0usize; - for turn in turns.into_iter().filter(|value| value.turn_index >= turn_index) { + for turn in turns + .into_iter() + .filter(|value| value.turn_index >= turn_index) + { let path = self.turn_path(workspace_path, session_id, turn.turn_index); if path.exists() { fs::remove_file(&path) @@ -1029,22 +1126,36 @@ impl PersistenceManager { } } - if let Some(mut metadata) = self.load_session_metadata(workspace_path, session_id).await? { + if let Some(mut metadata) = self + .load_session_metadata(workspace_path, session_id) + .await? + { let remaining_turns = self.load_session_turns(workspace_path, session_id).await?; metadata.turn_count = remaining_turns.len(); - metadata.message_count = remaining_turns.iter().map(Self::estimate_turn_message_count).sum(); - metadata.tool_call_count = remaining_turns.iter().map(DialogTurnData::count_tool_calls).sum(); + metadata.message_count = remaining_turns + .iter() + .map(Self::estimate_turn_message_count) + .sum(); + metadata.tool_call_count = remaining_turns + .iter() + .map(DialogTurnData::count_tool_calls) + .sum(); metadata.last_active_at = Self::system_time_to_unix_ms(SystemTime::now()); - self.save_session_metadata(workspace_path, &metadata).await?; + self.save_session_metadata(workspace_path, &metadata) + .await?; } Ok(deleted) } pub async fn touch_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { - if let Some(mut metadata) = self.load_session_metadata(workspace_path, session_id).await? { + if let Some(mut metadata) = self + .load_session_metadata(workspace_path, session_id) + .await? + { metadata.touch(); - self.save_session_metadata(workspace_path, &metadata).await?; + self.save_session_metadata(workspace_path, &metadata) + .await?; } Ok(()) } @@ -1061,9 +1172,9 @@ impl PersistenceManager { async fn ensure_legacy_session_dir(&self, session_id: &str) -> BitFunResult { let dir = self.legacy_session_dir(session_id); - fs::create_dir_all(&dir) - .await - .map_err(|e| BitFunError::io(format!("Failed to create legacy session directory: {}", e)))?; + fs::create_dir_all(&dir).await.map_err(|e| { + BitFunError::io(format!("Failed to create legacy session directory: {}", e)) + })?; Ok(dir) } @@ -1073,8 +1184,9 @@ impl PersistenceManager { let messages_path = dir.join("messages.jsonl"); let sanitized_message = Self::sanitize_message_for_persistence(message); - let json = serde_json::to_string(&sanitized_message) - .map_err(|e| BitFunError::serialization(format!("Failed to serialize message: {}", e)))?; + let json = serde_json::to_string(&sanitized_message).map_err(|e| { + BitFunError::serialization(format!("Failed to serialize message: {}", e)) + })?; let mut file = fs::OpenOptions::new() .create(true) @@ -1162,7 +1274,9 @@ impl PersistenceManager { .append(true) .open(&compressed_path) .await - .map_err(|e| BitFunError::io(format!("Failed to open compressed message file: {}", e)))?; + .map_err(|e| { + BitFunError::io(format!("Failed to open compressed message file: {}", e)) + })?; file.write_all(json.as_bytes()) .await @@ -1188,7 +1302,9 @@ impl PersistenceManager { .truncate(true) .open(&compressed_path) .await - .map_err(|e| BitFunError::io(format!("Failed to open compressed message file: {}", e)))?; + .map_err(|e| { + BitFunError::io(format!("Failed to open compressed message file: {}", e)) + })?; let sanitized_messages = Self::sanitize_messages_for_persistence(messages); for message in &sanitized_messages { @@ -1196,9 +1312,9 @@ impl PersistenceManager { BitFunError::serialization(format!("Failed to serialize compressed message: {}", e)) })?; - file.write_all(json.as_bytes()) - .await - .map_err(|e| BitFunError::io(format!("Failed to write compressed message: {}", e)))?; + file.write_all(json.as_bytes()).await.map_err(|e| { + BitFunError::io(format!("Failed to write compressed message: {}", e)) + })?; file.write_all(b"\n") .await .map_err(|e| BitFunError::io(format!("Failed to write newline: {}", e)))?; @@ -1224,19 +1340,17 @@ impl PersistenceManager { return Ok(None); } - let file = fs::File::open(&compressed_path) - .await - .map_err(|e| BitFunError::io(format!("Failed to open compressed message file: {}", e)))?; + let file = fs::File::open(&compressed_path).await.map_err(|e| { + BitFunError::io(format!("Failed to open compressed message file: {}", e)) + })?; let reader = BufReader::new(file); let mut lines = reader.lines(); let mut messages = Vec::new(); - while let Some(line) = lines - .next_line() - .await - .map_err(|e| BitFunError::io(format!("Failed to read compressed message line: {}", e)))? - { + while let Some(line) = lines.next_line().await.map_err(|e| { + BitFunError::io(format!("Failed to read compressed message line: {}", e)) + })? { if line.trim().is_empty() { continue; } diff --git a/src/crates/core/src/agentic/persistence/mod.rs b/src/crates/core/src/agentic/persistence/mod.rs index b6ac4cec..e60f7a01 100644 --- a/src/crates/core/src/agentic/persistence/mod.rs +++ b/src/crates/core/src/agentic/persistence/mod.rs @@ -1,9 +1,7 @@ //! Persistence layer -//! +//! //! Responsible for persistent storage and loading of data pub mod manager; pub use manager::PersistenceManager; - - diff --git a/src/crates/core/src/agentic/session/history_manager.rs b/src/crates/core/src/agentic/session/history_manager.rs index 702ce7b4..f83ac119 100644 --- a/src/crates/core/src/agentic/session/history_manager.rs +++ b/src/crates/core/src/agentic/session/history_manager.rs @@ -1,12 +1,12 @@ //! Message History Manager -//! +//! //! Manages session message history, supports memory caching and persistence -use log::debug; use crate::agentic::core::Message; use crate::agentic::persistence::PersistenceManager; use crate::util::errors::BitFunResult; use dashmap::DashMap; +use log::debug; use std::sync::Arc; /// Message history configuration @@ -27,10 +27,10 @@ impl Default for HistoryConfig { pub struct MessageHistoryManager { /// Message history in memory (by session ID) histories: Arc>>, - + /// Persistence manager persistence: Arc, - + /// Configuration config: HistoryConfig, } @@ -43,14 +43,14 @@ impl MessageHistoryManager { config, } } - + /// Create session history pub async fn create_session(&self, session_id: &str) -> BitFunResult<()> { self.histories.insert(session_id.to_string(), vec![]); debug!("Created session history: session_id={}", session_id); Ok(()) } - + /// Add message pub async fn add_message(&self, session_id: &str, message: Message) -> BitFunResult<()> { // 1. Add to memory @@ -58,33 +58,37 @@ impl MessageHistoryManager { messages.push(message.clone()); } else { // Session doesn't exist, create and add - self.histories.insert(session_id.to_string(), vec![message.clone()]); + self.histories + .insert(session_id.to_string(), vec![message.clone()]); } - + // 2. Persist if self.config.enable_persistence { - self.persistence.append_message(session_id, &message).await?; + self.persistence + .append_message(session_id, &message) + .await?; } - + Ok(()) } - + /// Get message history pub async fn get_messages(&self, session_id: &str) -> BitFunResult> { // First try to get from memory if let Some(messages) = self.histories.get(session_id) { return Ok(messages.clone()); } - + // Load from persistence if self.config.enable_persistence { let messages = self.persistence.load_messages(session_id).await?; - + // Cache to memory if !messages.is_empty() { - self.histories.insert(session_id.to_string(), messages.clone()); + self.histories + .insert(session_id.to_string(), messages.clone()); } - + Ok(messages) } else { Ok(vec![]) @@ -99,7 +103,7 @@ impl MessageHistoryManager { before_message_id: Option<&str>, ) -> BitFunResult<(Vec, bool)> { let messages = self.get_messages(session_id).await?; - + if messages.is_empty() { return Ok((vec![], false)); } @@ -116,24 +120,29 @@ impl MessageHistoryManager { let start_idx = end_idx.saturating_sub(limit); let has_more = start_idx > 0; - + Ok((messages[start_idx..end_idx].to_vec(), has_more)) } - + /// Get recent N messages - pub async fn get_recent_messages(&self, session_id: &str, count: usize) -> BitFunResult> { + pub async fn get_recent_messages( + &self, + session_id: &str, + count: usize, + ) -> BitFunResult> { let messages = self.get_messages(session_id).await?; let start = messages.len().saturating_sub(count); Ok(messages[start..].to_vec()) } - + /// Get message count pub async fn count_messages(&self, session_id: &str) -> usize { if let Some(messages) = self.histories.get(session_id) { messages.len() } else if self.config.enable_persistence { // Load from persistence - self.persistence.load_messages(session_id) + self.persistence + .load_messages(session_id) .await .map(|msgs| msgs.len()) .unwrap_or(0) @@ -141,43 +150,45 @@ impl MessageHistoryManager { 0 } } - + /// Clear message history pub async fn clear_messages(&self, session_id: &str) -> BitFunResult<()> { // Clear memory if let Some(mut messages) = self.histories.get_mut(session_id) { messages.clear(); } - + // Clear persistence if self.config.enable_persistence { self.persistence.clear_messages(session_id).await?; } - + debug!("Cleared session message history: session_id={}", session_id); Ok(()) } - + /// Delete session pub async fn delete_session(&self, session_id: &str) -> BitFunResult<()> { // Remove from memory self.histories.remove(session_id); - + // Delete from persistence if self.config.enable_persistence { self.persistence.delete_messages(session_id).await?; } - + debug!("Deleted session history: session_id={}", session_id); Ok(()) } - + /// Restore session (load from persistence) - pub async fn restore_session(&self, session_id: &str, messages: Vec) -> BitFunResult<()> { + pub async fn restore_session( + &self, + session_id: &str, + messages: Vec, + ) -> BitFunResult<()> { self.histories.insert(session_id.to_string(), messages); debug!("Restored session history: session_id={}", session_id); Ok(()) } } - - diff --git a/src/crates/core/src/agentic/session/mod.rs b/src/crates/core/src/agentic/session/mod.rs index 71c19c9d..baac1fed 100644 --- a/src/crates/core/src/agentic/session/mod.rs +++ b/src/crates/core/src/agentic/session/mod.rs @@ -1,13 +1,11 @@ //! Session Management Layer -//! +//! //! Provides session lifecycle management, message history, and context management -pub mod session_manager; -pub mod history_manager; pub mod compression_manager; +pub mod history_manager; +pub mod session_manager; -pub use session_manager::*; -pub use history_manager::*; pub use compression_manager::*; - - +pub use history_manager::*; +pub use session_manager::*; diff --git a/src/crates/core/src/agentic/session/session_manager.rs b/src/crates/core/src/agentic/session/session_manager.rs index 8fa24fe9..6e50b47b 100644 --- a/src/crates/core/src/agentic/session/session_manager.rs +++ b/src/crates/core/src/agentic/session/session_manager.rs @@ -3,8 +3,8 @@ //! Responsible for session CRUD, lifecycle management, and resource association use crate::agentic::core::{ - CompressionState, DialogTurn, Message, ProcessingPhase, Session, - SessionConfig, SessionState, SessionSummary, TurnStats, + CompressionState, DialogTurn, Message, ProcessingPhase, Session, SessionConfig, SessionState, + SessionSummary, TurnStats, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::persistence::PersistenceManager; @@ -131,7 +131,8 @@ impl SessionManager { .join("\n\n"); if !assistant_text.trim().is_empty() { - messages.push(Message::assistant(assistant_text).with_turn_id(turn.turn_id.clone())); + messages + .push(Message::assistant(assistant_text).with_turn_id(turn.turn_id.clone())); } } @@ -325,9 +326,10 @@ impl SessionManager { } if self.config.enable_persistence { - if let (Some(workspace_path), Some(session)) = - (self.session_workspace_path(session_id), self.sessions.get(session_id)) - { + if let (Some(workspace_path), Some(session)) = ( + self.session_workspace_path(session_id), + self.sessions.get(session_id), + ) { self.persistence_manager .save_session(&workspace_path, &session) .await?; @@ -342,6 +344,42 @@ impl SessionManager { Ok(()) } + /// Update session model id (in-memory + persistence) + pub async fn update_session_model_id( + &self, + session_id: &str, + model_id: &str, + ) -> BitFunResult<()> { + if let Some(mut session) = self.sessions.get_mut(session_id) { + session.config.model_id = Some(model_id.to_string()); + session.updated_at = SystemTime::now(); + session.last_activity_at = SystemTime::now(); + } else { + return Err(BitFunError::NotFound(format!( + "Session not found: {}", + session_id + ))); + } + + if self.config.enable_persistence { + if let (Some(workspace_path), Some(session)) = ( + self.session_workspace_path(session_id), + self.sessions.get(session_id), + ) { + self.persistence_manager + .save_session(&workspace_path, &session) + .await?; + } + } + + debug!( + "Session model id updated: session_id={}, model_id={}", + session_id, model_id + ); + + Ok(()) + } + /// Update session activity time pub fn touch_session(&self, session_id: &str) { if let Some(mut session) = self.sessions.get_mut(session_id) { @@ -350,7 +388,11 @@ impl SessionManager { } /// Delete session (cascade delete all resources) - pub async fn delete_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult<()> { + pub async fn delete_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult<()> { // 1. Clean up snapshot system resources (including physical snapshot files) if let Ok(snapshot_manager) = ensure_snapshot_manager_for_workspace(workspace_path) { let snapshot_service = snapshot_manager.get_snapshot_service(); @@ -400,7 +442,11 @@ impl SessionManager { } /// Restore session (from persistent storage) - pub async fn restore_session(&self, workspace_path: &Path, session_id: &str) -> BitFunResult { + pub async fn restore_session( + &self, + workspace_path: &Path, + session_id: &str, + ) -> BitFunResult { // Check if session is already in memory let session_already_in_memory = self.sessions.contains_key(session_id); @@ -432,9 +478,10 @@ impl SessionManager { latest_turn_index = Some(turn_index); msgs } - None => self - .rebuild_messages_from_turns(workspace_path, session_id) - .await?, + None => { + self.rebuild_messages_from_turns(workspace_path, session_id) + .await? + } }; if messages.is_empty() { @@ -602,12 +649,13 @@ impl SessionManager { let session = self .get_session(session_id) .ok_or_else(|| BitFunError::NotFound(format!("Session not found: {}", session_id)))?; - let workspace_path = Self::session_workspace_from_config(&session.config).ok_or_else(|| { - BitFunError::Validation(format!( - "Session workspace_path is missing: {}", - session_id - )) - })?; + let workspace_path = + Self::session_workspace_from_config(&session.config).ok_or_else(|| { + BitFunError::Validation(format!( + "Session workspace_path is missing: {}", + session_id + )) + })?; let turn_index = session.dialog_turn_ids.len(); // Pass frontend's turnId @@ -706,10 +754,12 @@ impl SessionManager { .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; - let has_assistant_text = turn - .model_rounds - .iter() - .any(|round| round.text_items.iter().any(|item| !item.content.trim().is_empty())); + let has_assistant_text = turn.model_rounds.iter().any(|round| { + round + .text_items + .iter() + .any(|item| !item.content.trim().is_empty()) + }); if !has_assistant_text && !final_response.trim().is_empty() { let round_index = turn.model_rounds.len(); turn.model_rounds.push(ModelRoundData { diff --git a/src/crates/core/src/agentic/tools/image_context.rs b/src/crates/core/src/agentic/tools/image_context.rs index fedebf1e..f870c87d 100644 --- a/src/crates/core/src/agentic/tools/image_context.rs +++ b/src/crates/core/src/agentic/tools/image_context.rs @@ -32,7 +32,7 @@ const DEFAULT_IMAGE_MAX_AGE_SECS: u64 = 300; pub trait ImageContextProvider: Send + Sync + std::fmt::Debug { /// Get image context data by image_id fn get_image(&self, image_id: &str) -> Option; - + /// Optional: delete image context (clean up after use) fn remove_image(&self, image_id: &str) { // Default implementation: do nothing @@ -57,7 +57,9 @@ pub fn store_image_contexts(images: Vec) { } pub fn get_image_context(image_id: &str) -> Option { - IMAGE_STORAGE.get(image_id).map(|entry| entry.value().0.clone()) + IMAGE_STORAGE + .get(image_id) + .map(|entry| entry.value().0.clone()) } pub fn remove_image_context(image_id: &str) { @@ -120,4 +122,3 @@ fn current_unix_timestamp() -> u64 { .unwrap_or_default() .as_secs() } - diff --git a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs index 861e7e4f..84b2c410 100644 --- a/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ask_user_question_tool.rs @@ -105,7 +105,8 @@ impl AskUserQuestionTool { fn format_result_for_assistant(questions: &[Question], answers: &Value) -> String { // Try flat structure first (frontend sends {"0": "...", "1": [...]}), // then fall back to nested {"answers": {...}} for backward compatibility - let answers_obj = answers.as_object() + let answers_obj = answers + .as_object() .or_else(|| answers.get("answers").and_then(|v| v.as_object())); if let Some(answers_map) = answers_obj { diff --git a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs index e7aa0edc..882da94d 100644 --- a/src/crates/core/src/agentic/tools/implementations/bash_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/bash_tool.rs @@ -405,7 +405,9 @@ Usage notes: let binding = terminal_api.session_manager().binding(); let workspace_path = context .workspace_root() - .ok_or_else(|| BitFunError::tool("workspace_path is required for Bash tool".to_string()))? + .ok_or_else(|| { + BitFunError::tool("workspace_path is required for Bash tool".to_string()) + })? .to_string_lossy() .to_string(); diff --git a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs index e0df0761..55201e7f 100644 --- a/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/delete_file_tool.rs @@ -1,13 +1,15 @@ -use log::debug; +use crate::agentic::tools::framework::{ + Tool, ToolRenderOptions, ToolResult, ToolUseContext, ValidationResult, +}; +use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; +use log::debug; use serde_json::{json, Value}; use std::path::Path; use tokio::fs; -use crate::agentic::tools::framework::{Tool, ToolUseContext, ToolResult, ValidationResult, ToolRenderOptions}; -use crate::util::errors::{BitFunError, BitFunResult}; /// File deletion tool - provides safe file/directory deletion functionality -/// +/// /// This tool automatically integrates with the snapshot system, all deletion operations are recorded and support rollback pub struct DeleteFileTool; @@ -22,7 +24,7 @@ impl Tool for DeleteFileTool { fn name(&self) -> &str { "Delete" } - + async fn description(&self) -> BitFunResult { Ok(r#"Deletes a file or directory from the filesystem. This operation is tracked by the snapshot system and can be rolled back if needed. @@ -73,7 +75,7 @@ Important notes: - All deletions can be rolled back through the snapshot interface - The tool will fail gracefully if permissions are insufficient"#.to_string()) } - + fn input_schema(&self) -> Value { json!({ "type": "object", @@ -90,20 +92,24 @@ Important notes: "required": ["path"] }) } - + fn is_readonly(&self) -> bool { false } - + fn is_concurrency_safe(&self, _input: Option<&Value>) -> bool { false } - + fn needs_permissions(&self, _input: Option<&Value>) -> bool { true } - - async fn validate_input(&self, input: &Value, _context: Option<&ToolUseContext>) -> ValidationResult { + + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { // Validate path parameter let path_str = match input.get("path").and_then(|v| v.as_str()) { Some(p) => p, @@ -116,7 +122,7 @@ Important notes: }; } }; - + if path_str.is_empty() { return ValidationResult { result: false, @@ -125,9 +131,9 @@ Important notes: meta: None, }; } - + let path = Path::new(path_str); - + // Validate if path is absolute if !path.is_absolute() { return ValidationResult { @@ -137,7 +143,7 @@ Important notes: meta: None, }; } - + // Validate if path exists if !path.exists() { return ValidationResult { @@ -147,19 +153,20 @@ Important notes: meta: None, }; } - + // If directory, check if recursive deletion is needed if path.is_dir() { - let recursive = input.get("recursive") + let recursive = input + .get("recursive") .and_then(|v| v.as_bool()) .unwrap_or(false); - + // Check if directory is empty let is_empty = match fs::read_dir(path).await { Ok(mut entries) => entries.next_entry().await.ok().flatten().is_none(), Err(_) => false, }; - + if !is_empty && !recursive { return ValidationResult { result: false, @@ -173,7 +180,7 @@ Important notes: }; } } - + ValidationResult { result: true, message: None, @@ -181,13 +188,14 @@ Important notes: meta: None, } } - + fn render_tool_use_message(&self, input: &Value, _options: &ToolRenderOptions) -> String { if let Some(path) = input.get("path").and_then(|v| v.as_str()) { - let recursive = input.get("recursive") + let recursive = input + .get("recursive") .and_then(|v| v.as_bool()) .unwrap_or(false); - + if recursive { format!("Deleting directory and contents: {}", path) } else { @@ -197,49 +205,63 @@ Important notes: "Deleting file or directory".to_string() } } - + fn render_result_for_assistant(&self, output: &Value) -> String { if let Some(path) = output.get("path").and_then(|v| v.as_str()) { - let is_directory = output.get("is_directory") + let is_directory = output + .get("is_directory") .and_then(|v| v.as_bool()) .unwrap_or(false); - + let type_name = if is_directory { "directory" } else { "file" }; - + format!("Successfully deleted {} at: {}", type_name, path) } else { "Deletion completed".to_string() } } - - async fn call_impl(&self, input: &Value, _context: &ToolUseContext) -> BitFunResult> { - let path_str = input.get("path") + + async fn call_impl( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let path_str = input + .get("path") .and_then(|v| v.as_str()) .ok_or_else(|| BitFunError::tool("path is required".to_string()))?; - - let recursive = input.get("recursive") + + let recursive = input + .get("recursive") .and_then(|v| v.as_bool()) .unwrap_or(false); - + let path = Path::new(path_str); let is_directory = path.is_dir(); - - debug!("DeleteFile tool deleting {}: {}", if is_directory { "directory" } else { "file" }, path_str); - + + debug!( + "DeleteFile tool deleting {}: {}", + if is_directory { "directory" } else { "file" }, + path_str + ); + // Execute deletion operation if is_directory { if recursive { - fs::remove_dir_all(path).await + fs::remove_dir_all(path) + .await .map_err(|e| BitFunError::tool(format!("Failed to delete directory: {}", e)))?; } else { - fs::remove_dir(path).await + fs::remove_dir(path) + .await .map_err(|e| BitFunError::tool(format!("Failed to delete directory: {}", e)))?; } } else { - fs::remove_file(path).await + fs::remove_file(path) + .await .map_err(|e| BitFunError::tool(format!("Failed to delete file: {}", e)))?; } - + // Build result let result_data = json!({ "success": true, @@ -247,9 +269,9 @@ Important notes: "is_directory": is_directory, "recursive": recursive }); - + let result_text = self.render_result_for_assistant(&result_data); - + Ok(vec![ToolResult::Result { data: result_data, result_for_assistant: Some(result_text), diff --git a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs index 776d15ba..b2251700 100644 --- a/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/file_write_tool.rs @@ -69,10 +69,7 @@ Usage: input: &Value, context: Option<&ToolUseContext>, ) -> ValidationResult { - let file_path = match input - .get("file_path") - .and_then(|v| v.as_str()) - { + let file_path = match input.get("file_path").and_then(|v| v.as_str()) { Some(path) if !path.is_empty() => path, _ => { return ValidationResult { @@ -93,10 +90,9 @@ Usage: }; } - if let Err(err) = resolve_path_with_workspace( - file_path, - context.and_then(|ctx| ctx.workspace_root()), - ) { + if let Err(err) = + resolve_path_with_workspace(file_path, context.and_then(|ctx| ctx.workspace_root())) + { return ValidationResult { result: false, message: Some(err.to_string()), diff --git a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs index ffd8f171..0887f0c7 100644 --- a/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/get_file_diff_tool.rs @@ -418,7 +418,10 @@ Usage: // Priority 1: Try baseline diff let path = Path::new(&resolved_path); - if let Some(result) = self.try_baseline_diff(&path, context.workspace_root()).await { + if let Some(result) = self + .try_baseline_diff(&path, context.workspace_root()) + .await + { match result { Ok(data) => { debug!("GetFileDiff tool using baseline diff"); diff --git a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs index 1578fc70..a494af0b 100644 --- a/src/crates/core/src/agentic/tools/implementations/glob_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/glob_tool.rs @@ -202,14 +202,11 @@ impl Tool for GlobTool { })?; workspace_root.join(user_path) } - None => context - .workspace_root() - .map(PathBuf::from) - .ok_or_else(|| { - BitFunError::tool( - "workspace_path is required when Glob path is omitted".to_string(), - ) - })?, + None => context.workspace_root().map(PathBuf::from).ok_or_else(|| { + BitFunError::tool( + "workspace_path is required when Glob path is omitted".to_string(), + ) + })?, }; let limit = input diff --git a/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs b/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs index 6fd779b6..3e9b1da9 100644 --- a/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/ide_control_tool.rs @@ -58,11 +58,17 @@ impl IdeControlTool { /// Validate if panel type is valid fn is_valid_panel_type(&self, panel_type: &str) -> bool { - matches!(panel_type, - "git-settings" | "git-diff" | - "config-center" | "planner" | - "files" | "code-editor" | "markdown-editor" | - "ai-session" | "mermaid-editor" + matches!( + panel_type, + "git-settings" + | "git-diff" + | "config-center" + | "planner" + | "files" + | "code-editor" + | "markdown-editor" + | "ai-session" + | "mermaid-editor" ) } diff --git a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs index 2b3c511b..f6b76a49 100644 --- a/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/mermaid_interactive_tool.rs @@ -1,14 +1,14 @@ //! Mermaid interactive diagram tool -//! +//! //! Allows Agent to generate Mermaid diagrams with interactive features, supports node click navigation and highlight states -use log::debug; -use crate::agentic::tools::framework::{Tool, ToolUseContext, ToolResult, ValidationResult}; +use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext, ValidationResult}; +use crate::infrastructure::events::event_system::{get_global_event_system, BackendEvent}; use crate::util::errors::BitFunResult; -use crate::infrastructure::events::event_system::{BackendEvent, get_global_event_system}; -use serde_json::{json, Value}; use async_trait::async_trait; use chrono::Utc; +use log::debug; +use serde_json::{json, Value}; /// Mermaid interactive diagram tool pub struct MermaidInteractiveTool; @@ -21,21 +21,34 @@ impl MermaidInteractiveTool { /// Validate if Mermaid code is valid, returns validation result and error message fn validate_mermaid_code(&self, code: &str) -> (bool, Option) { let trimmed = code.trim(); - + // Check if empty if trimmed.is_empty() { return (false, Some("Mermaid code cannot be empty".to_string())); } - + // Check if starts with valid diagram type let valid_starters = vec![ - "graph ", "flowchart ", "sequenceDiagram", "classDiagram", - "stateDiagram", "erDiagram", "gantt", "pie", "journey", - "timeline", "mindmap", "gitgraph", "C4Context", "C4Container" + "graph ", + "flowchart ", + "sequenceDiagram", + "classDiagram", + "stateDiagram", + "erDiagram", + "gantt", + "pie", + "journey", + "timeline", + "mindmap", + "gitgraph", + "C4Context", + "C4Container", ]; - - let starts_with_valid = valid_starters.iter().any(|starter| trimmed.starts_with(starter)); - + + let starts_with_valid = valid_starters + .iter() + .any(|starter| trimmed.starts_with(starter)); + if !starts_with_valid { return (false, Some(format!( "Mermaid code must start with a valid diagram type. Supported diagram types: graph, flowchart, sequenceDiagram, classDiagram, stateDiagram, erDiagram, gantt, pie, journey, timeline, mindmap, etc.\nCurrent code start: {}", @@ -46,45 +59,50 @@ impl MermaidInteractiveTool { } ))); } - + // Check basic syntax structure let lines: Vec<&str> = trimmed.lines().collect(); if lines.len() < 2 { return (false, Some("Mermaid code needs at least 2 lines (diagram type declaration and at least one node/relationship)".to_string())); } - + // Check if graph/flowchart has node definitions if trimmed.starts_with("graph ") || trimmed.starts_with("flowchart ") { // Check if there are arrows or node definitions - let has_arrow = trimmed.contains("-->") || trimmed.contains("---") || trimmed.contains("==>"); + let has_arrow = + trimmed.contains("-->") || trimmed.contains("---") || trimmed.contains("==>"); let has_node = trimmed.contains('[') || trimmed.contains('(') || trimmed.contains('{'); - + if !has_arrow && !has_node { return (false, Some("Flowchart (graph/flowchart) must contain node definitions and connections. Example: A[Node] --> B[Node]".to_string())); } } - + // Check if sequenceDiagram has participants if trimmed.starts_with("sequenceDiagram") { - if !trimmed.contains("participant") && !trimmed.contains("->>") && !trimmed.contains("-->>") { + if !trimmed.contains("participant") + && !trimmed.contains("->>") + && !trimmed.contains("-->>") + { return (false, Some("Sequence diagram (sequenceDiagram) must contain participant definitions and interaction arrows. Example: participant A\nA->>B: Message".to_string())); } } - + // Check if classDiagram has class definitions if trimmed.starts_with("classDiagram") { - if !trimmed.contains("class ") && !trimmed.contains("<|--") && !trimmed.contains("..>") { + if !trimmed.contains("class ") && !trimmed.contains("<|--") && !trimmed.contains("..>") + { return (false, Some("Class diagram (classDiagram) must contain class definitions and relationships. Example: class A\nclass B\nA <|-- B".to_string())); } } - + // Check if stateDiagram has state definitions if trimmed.starts_with("stateDiagram") { if !trimmed.contains("state ") && !trimmed.contains("[*]") && !trimmed.contains("-->") { return (false, Some("State diagram (stateDiagram) must contain state definitions and transitions. Example: state A\n[*] --> A".to_string())); } } - + // Check for unclosed brackets let open_brackets = trimmed.matches('[').count(); let close_brackets = trimmed.matches(']').count(); @@ -94,7 +112,7 @@ impl MermaidInteractiveTool { open_brackets, close_brackets ))); } - + let open_parens = trimmed.matches('(').count(); let close_parens = trimmed.matches(')').count(); if open_parens != close_parens { @@ -103,7 +121,7 @@ impl MermaidInteractiveTool { open_parens, close_parens ))); } - + let open_braces = trimmed.matches('{').count(); let close_braces = trimmed.matches('}').count(); if open_braces != close_braces { @@ -112,16 +130,19 @@ impl MermaidInteractiveTool { open_braces, close_braces ))); } - + // Check for obvious syntax errors (like isolated arrows) - let lines_with_arrows: Vec<&str> = lines.iter() + let lines_with_arrows: Vec<&str> = lines + .iter() .filter(|line| { let trimmed_line = line.trim(); - trimmed_line.contains("-->") || trimmed_line.contains("---") || trimmed_line.contains("==>") + trimmed_line.contains("-->") + || trimmed_line.contains("---") + || trimmed_line.contains("==>") }) .copied() .collect(); - + for line in &lines_with_arrows { let trimmed_line = line.trim(); // Check if there are node identifiers before and after arrows @@ -139,7 +160,7 @@ impl MermaidInteractiveTool { } } } - + (true, None) } @@ -161,26 +182,29 @@ impl MermaidInteractiveTool { } // Check required field: file_path is required - let has_file_path = node_data.get("file_path") + let has_file_path = node_data + .get("file_path") .and_then(|v| v.as_str()) .map(|s| !s.is_empty()) .unwrap_or(false); - + if !has_file_path { return false; } // Get node type (defaults to file) - let node_type = node_data.get("node_type") + let node_type = node_data + .get("node_type") .and_then(|v| v.as_str()) .unwrap_or("file"); // For file type, line_number is required if node_type == "file" { - let has_line_number = node_data.get("line_number") + let has_line_number = node_data + .get("line_number") .and_then(|v| v.as_u64()) .is_some(); - + if !has_line_number { return false; } @@ -397,7 +421,11 @@ Mermaid Syntax: false } - async fn validate_input(&self, input: &Value, _context: Option<&ToolUseContext>) -> ValidationResult { + async fn validate_input( + &self, + input: &Value, + _context: Option<&ToolUseContext>, + ) -> ValidationResult { // Validate mermaid_code let mermaid_code = match input.get("mermaid_code").and_then(|v| v.as_str()) { Some(code) if !code.trim().is_empty() => code, @@ -418,7 +446,8 @@ Mermaid Syntax: // Validate Mermaid code format (returns detailed error message) let (is_valid, error_msg) = self.validate_mermaid_code(mermaid_code); if !is_valid { - let error_message = error_msg.unwrap_or_else(|| "Invalid Mermaid diagram syntax".to_string()); + let error_message = + error_msg.unwrap_or_else(|| "Invalid Mermaid diagram syntax".to_string()); return ValidationResult { result: false, message: Some(format!( @@ -458,16 +487,19 @@ Mermaid Syntax: fn render_result_for_assistant(&self, output: &Value) -> String { if let Some(success) = output.get("success").and_then(|v| v.as_bool()) { if success { - let title = output.get("title") + let title = output + .get("title") .and_then(|v| v.as_str()) .unwrap_or("Mermaid diagram"); - - let node_count = output.get("metadata") + + let node_count = output + .get("metadata") .and_then(|m| m.get("node_count")) .and_then(|v| v.as_u64()) .unwrap_or(0); - let interactive_nodes = output.get("metadata") + let interactive_nodes = output + .get("metadata") .and_then(|m| m.get("interactive_nodes")) .and_then(|v| v.as_u64()) .unwrap_or(0); @@ -485,7 +517,7 @@ Mermaid Syntax: } } } - + if let Some(error) = output.get("error").and_then(|v| v.as_str()) { return format!("Failed to create Mermaid diagram: {}", error); } @@ -493,15 +525,22 @@ Mermaid Syntax: "Mermaid diagram creation result unknown".to_string() } - fn render_tool_use_message(&self, input: &Value, _options: &crate::agentic::tools::framework::ToolRenderOptions) -> String { - let title = input.get("title") + fn render_tool_use_message( + &self, + input: &Value, + _options: &crate::agentic::tools::framework::ToolRenderOptions, + ) -> String { + let title = input + .get("title") .and_then(|v| v.as_str()) .unwrap_or("Interactive Mermaid Diagram"); - let has_metadata = input.get("node_metadata") + let has_metadata = input + .get("node_metadata") .and_then(|v| v.as_object()) .map(|obj| obj.len()) - .unwrap_or(0) > 0; + .unwrap_or(0) + > 0; if has_metadata { format!("Creating interactive diagram: {}", title) @@ -510,11 +549,16 @@ Mermaid Syntax: } } - async fn call_impl(&self, input: &Value, context: &ToolUseContext) -> BitFunResult> { - let mermaid_code = input.get("mermaid_code") + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + let mermaid_code = input + .get("mermaid_code") .and_then(|v| v.as_str()) .ok_or_else(|| anyhow::anyhow!("Missing mermaid_code field"))?; - + // Validate Mermaid code let (is_valid, error_msg) = self.validate_mermaid_code(mermaid_code); if !is_valid { @@ -538,15 +582,19 @@ Mermaid Syntax: }]); } - let title = input.get("title") + let title = input + .get("title") .and_then(|v| v.as_str()) .unwrap_or("Interactive Mermaid Diagram"); - let mode = input.get("mode") + let mode = input + .get("mode") .and_then(|v| v.as_str()) .unwrap_or("interactive"); - let session_id = context.session_id.clone() + let session_id = context + .session_id + .clone() .unwrap_or_else(|| format!("mermaid-{}", Utc::now().timestamp_millis())); // Build interactive configuration @@ -566,17 +614,19 @@ Mermaid Syntax: } // Calculate statistics - let node_count = mermaid_code.lines() + let node_count = mermaid_code + .lines() .filter(|line| { let trimmed = line.trim(); - !trimmed.is_empty() && - !trimmed.starts_with("%%") && - !trimmed.starts_with("style") && - !trimmed.starts_with("classDef") + !trimmed.is_empty() + && !trimmed.starts_with("%%") + && !trimmed.starts_with("style") + && !trimmed.starts_with("classDef") }) .count(); - let interactive_nodes = input.get("node_metadata") + let interactive_nodes = input + .get("node_metadata") .and_then(|v| v.as_object()) .map(|obj| obj.len()) .unwrap_or(0); @@ -614,7 +664,7 @@ Mermaid Syntax: "timestamp": Utc::now().timestamp_millis(), "session_id": session_id.clone() } - }) + }), }; debug!("MermaidInteractive tool creating diagram, mode: {}, title: {}, node_count: {}, interactive_nodes: {}", diff --git a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs index 9ec535ed..d0c942ef 100644 --- a/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/miniapp_init_tool.rs @@ -2,10 +2,10 @@ use crate::agentic::tools::framework::{Tool, ToolResult, ToolUseContext}; use crate::infrastructure::events::{emit_global_event, BackendEvent}; +use crate::miniapp::try_get_global_miniapp_manager; use crate::miniapp::types::{ FsPermissions, MiniAppPermissions, MiniAppSource, NetPermissions, ShellPermissions, }; -use crate::miniapp::try_get_global_miniapp_manager; use crate::util::errors::{BitFunError, BitFunResult}; use async_trait::async_trait; use serde_json::{json, Value}; @@ -146,8 +146,12 @@ Returns app_id and the app root directory. Use the root directory and file names read: Some(vec!["{appdata}".to_string(), "{workspace}".to_string()]), write: Some(vec!["{appdata}".to_string()]), }), - shell: Some(ShellPermissions { allow: Some(Vec::new()) }), - net: Some(NetPermissions { allow: Some(vec!["*".to_string()]) }), + shell: Some(ShellPermissions { + allow: Some(Vec::new()), + }), + net: Some(NetPermissions { + allow: Some(vec!["*".to_string()]), + }), node: None, }; diff --git a/src/crates/core/src/agentic/tools/implementations/mod.rs b/src/crates/core/src/agentic/tools/implementations/mod.rs index a398cb73..348dad69 100644 --- a/src/crates/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/mod.rs @@ -17,10 +17,10 @@ pub mod linter_tool; pub mod log_tool; pub mod ls_tool; pub mod mermaid_interactive_tool; +pub mod miniapp_init_tool; pub mod session_control_tool; pub mod skill_tool; pub mod skills; -pub mod miniapp_init_tool; pub mod task_tool; pub mod terminal_control_tool; pub mod todo_write_tool; @@ -45,11 +45,11 @@ pub use linter_tool::ReadLintsTool; pub use log_tool::LogTool; pub use ls_tool::LSTool; pub use mermaid_interactive_tool::MermaidInteractiveTool; +pub use miniapp_init_tool::InitMiniAppTool; pub use session_control_tool::SessionControlTool; pub use skill_tool::SkillTool; pub use task_tool::TaskTool; pub use terminal_control_tool::TerminalControlTool; pub use todo_write_tool::TodoWriteTool; -pub use miniapp_init_tool::InitMiniAppTool; pub use view_image_tool::ViewImageTool; pub use web_tools::{WebFetchTool, WebSearchTool}; diff --git a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs index 0d0bacca..82f96a28 100644 --- a/src/crates/core/src/agentic/tools/implementations/skill_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/skill_tool.rs @@ -59,9 +59,11 @@ Important: async fn build_description(&self, workspace_root: Option<&Path>) -> String { let registry = get_skill_registry(); let available_skills = match workspace_root { - Some(workspace_root) => registry - .get_enabled_skills_xml_for_workspace(Some(workspace_root)) - .await, + Some(workspace_root) => { + registry + .get_enabled_skills_xml_for_workspace(Some(workspace_root)) + .await + } None => registry.get_enabled_skills_xml().await, }; diff --git a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs index f8b80b0a..b6671357 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/registry.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/registry.rs @@ -218,7 +218,10 @@ impl SkillRegistry { let by_name = self.scan_skill_map_for_workspace(workspace_root).await; let mut cache = self.cache.write().await; *cache = by_name; - debug!("SkillRegistry refreshed for workspace, {} skills loaded", cache.len()); + debug!( + "SkillRegistry refreshed for workspace, {} skills loaded", + cache.len() + ); } /// Ensure cache is initialized @@ -239,7 +242,10 @@ impl SkillRegistry { cache.values().cloned().collect() } - pub async fn get_all_skills_for_workspace(&self, workspace_root: Option<&Path>) -> Vec { + pub async fn get_all_skills_for_workspace( + &self, + workspace_root: Option<&Path>, + ) -> Vec { self.scan_skill_map_for_workspace(workspace_root) .await .into_values() @@ -375,9 +381,9 @@ impl SkillRegistry { workspace_root: Option<&Path>, ) -> BitFunResult { let skill_map = self.scan_skill_map_for_workspace(workspace_root).await; - let info = skill_map.get(skill_name).ok_or_else(|| { - BitFunError::tool(format!("Skill '{}' not found", skill_name)) - })?; + let info = skill_map + .get(skill_name) + .ok_or_else(|| BitFunError::tool(format!("Skill '{}' not found", skill_name)))?; if !info.enabled { return Err(BitFunError::tool(format!( diff --git a/src/crates/core/src/agentic/tools/implementations/skills/types.rs b/src/crates/core/src/agentic/tools/implementations/skills/types.rs index 88794252..3d839744 100644 --- a/src/crates/core/src/agentic/tools/implementations/skills/types.rs +++ b/src/crates/core/src/agentic/tools/implementations/skills/types.rs @@ -88,13 +88,17 @@ impl SkillData { .get("name") .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .ok_or_else(|| BitFunError::tool("Missing required field 'name' in SKILL.md".to_string()))?; + .ok_or_else(|| { + BitFunError::tool("Missing required field 'name' in SKILL.md".to_string()) + })?; let description = metadata .get("description") .and_then(|v| v.as_str()) .map(|s| s.to_string()) - .ok_or_else(|| BitFunError::tool("Missing required field 'description' in SKILL.md".to_string()))?; + .ok_or_else(|| { + BitFunError::tool("Missing required field 'description' in SKILL.md".to_string()) + })?; // enabled field defaults to true if not present let enabled = metadata @@ -102,11 +106,7 @@ impl SkillData { .and_then(|v| v.as_bool()) .unwrap_or(true); - let skill_content = if with_content { - body - } else { - String::new() - }; + let skill_content = if with_content { body } else { String::new() }; Ok(SkillData { name, @@ -119,7 +119,7 @@ impl SkillData { } /// Set enabled status and save to SKILL.md file - /// + /// /// If enabled is true, remove enabled field (use default value) /// If enabled is false, write enabled: false pub fn set_enabled_and_save(skill_md_path: &str, enabled: bool) -> BitFunResult<()> { @@ -127,19 +127,16 @@ impl SkillData { .map_err(|e| BitFunError::tool(format!("Failed to load SKILL.md: {}", e)))?; // Get mutable mapping of metadata - let map = metadata - .as_mapping_mut() - .ok_or_else(|| BitFunError::tool("Invalid SKILL.md: metadata is not a mapping".to_string()))?; + let map = metadata.as_mapping_mut().ok_or_else(|| { + BitFunError::tool("Invalid SKILL.md: metadata is not a mapping".to_string()) + })?; if enabled { // When enabling, remove enabled field (use default value) map.remove(&Value::String("enabled".to_string())); } else { // When disabling, write enabled: false - map.insert( - Value::String("enabled".to_string()), - Value::Bool(false), - ); + map.insert(Value::String("enabled".to_string()), Value::Bool(false)); } FrontMatterMarkdown::save(skill_md_path, &metadata, &body) @@ -167,4 +164,3 @@ impl SkillData { ) } } - diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs index 726fcd7e..a8c766c5 100644 --- a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs +++ b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/fs/mod.rs @@ -1,2 +1,2 @@ +pub mod edit_file; pub mod read_file; -pub mod edit_file; \ No newline at end of file diff --git a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs index a00b0436..4d3a2f8d 100644 --- a/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs +++ b/src/crates/core/src/agentic/tools/implementations/tool-runtime/src/util/string.rs @@ -9,4 +9,4 @@ pub fn normalize_string(s: &str) -> String { pub fn truncate_string_by_chars(s: &str, kept_chars: usize) -> String { let chars: Vec = s.chars().collect(); chars[..kept_chars].into_iter().collect() -} \ No newline at end of file +} diff --git a/src/crates/core/src/agentic/tools/implementations/util.rs b/src/crates/core/src/agentic/tools/implementations/util.rs index 3735b58e..1b9dcccb 100644 --- a/src/crates/core/src/agentic/tools/implementations/util.rs +++ b/src/crates/core/src/agentic/tools/implementations/util.rs @@ -24,7 +24,10 @@ pub fn normalize_path(path: &str) -> String { .to_string() } -pub fn resolve_path_with_workspace(path: &str, workspace_root: Option<&Path>) -> BitFunResult { +pub fn resolve_path_with_workspace( + path: &str, + workspace_root: Option<&Path>, +) -> BitFunResult { if Path::new(path).is_absolute() { Ok(normalize_path(path)) } else { diff --git a/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs b/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs index b4b338dd..57ef2d18 100644 --- a/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs +++ b/src/crates/core/src/agentic/tools/implementations/view_image_tool.rs @@ -170,8 +170,7 @@ impl ViewImageTool { let fallback_mime = data_url_mime .as_deref() .or_else(|| Some(ctx_mime_type.as_str())); - let processed = - optimize_image_for_provider(data, primary_provider, fallback_mime)?; + let processed = optimize_image_for_provider(data, primary_provider, fallback_mime)?; let optimized_data_url = format!( "data:{};base64,{}", processed.mime_type, diff --git a/src/crates/core/src/agentic/tools/pipeline/mod.rs b/src/crates/core/src/agentic/tools/pipeline/mod.rs index c295ddef..92e36c1e 100644 --- a/src/crates/core/src/agentic/tools/pipeline/mod.rs +++ b/src/crates/core/src/agentic/tools/pipeline/mod.rs @@ -1,12 +1,11 @@ //! Tool pipeline module -//! +//! //! Provides complete lifecycle management for tool execution -pub mod types; pub mod state_manager; pub mod tool_pipeline; +pub mod types; -pub use types::*; pub use state_manager::*; pub use tool_pipeline::*; - +pub use types::*; diff --git a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs index c5d52854..dfc61445 100644 --- a/src/crates/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/core/src/agentic/tools/pipeline/state_manager.rs @@ -1,19 +1,19 @@ //! Tool state manager -//! +//! //! Manages the status and lifecycle of tool execution tasks -use log::debug; use super::types::ToolTask; use crate::agentic::core::ToolExecutionState; -use crate::agentic::events::{EventQueue, AgenticEvent, ToolEventData, EventPriority}; +use crate::agentic::events::{AgenticEvent, EventPriority, EventQueue, ToolEventData}; use dashmap::DashMap; +use log::debug; use std::sync::Arc; /// Tool state manager pub struct ToolStateManager { /// Tool task status (by tool ID) tasks: Arc>, - + /// Event queue event_queue: Arc, } @@ -51,52 +51,50 @@ impl ToolStateManager { event_queue, } } - + /// Create task pub async fn create_task(&self, task: ToolTask) -> String { let tool_id = task.tool_call.tool_id.clone(); self.tasks.insert(tool_id.clone(), task); tool_id } - + /// Update task state - pub async fn update_state( - &self, - tool_id: &str, - new_state: ToolExecutionState, - ) { + pub async fn update_state(&self, tool_id: &str, new_state: ToolExecutionState) { if let Some(mut task) = self.tasks.get_mut(tool_id) { let old_state = task.state.clone(); task.state = new_state.clone(); - + // Update timestamp match &new_state { ToolExecutionState::Running { .. } | ToolExecutionState::Streaming { .. } => { task.started_at = Some(std::time::SystemTime::now()); } - ToolExecutionState::Completed { .. } | ToolExecutionState::Failed { .. } | ToolExecutionState::Cancelled { .. } => { + ToolExecutionState::Completed { .. } + | ToolExecutionState::Failed { .. } + | ToolExecutionState::Cancelled { .. } => { task.completed_at = Some(std::time::SystemTime::now()); } _ => {} } - + debug!( "Tool state changed: tool_id={}, old_state={:?}, new_state={:?}", tool_id, format!("{:?}", old_state).split('{').next().unwrap_or(""), format!("{:?}", new_state).split('{').next().unwrap_or("") ); - + // Send state change event self.emit_state_change_event(task.clone()).await; } } - + /// Get task pub fn get_task(&self, tool_id: &str) -> Option { self.tasks.get(tool_id).map(|t| t.clone()) } - + /// Update task arguments pub fn update_task_arguments(&self, tool_id: &str, new_arguments: serde_json::Value) { if let Some(mut task) = self.tasks.get_mut(tool_id) { @@ -107,7 +105,7 @@ impl ToolStateManager { task.tool_call.arguments = new_arguments; } } - + /// Get all tasks of a session pub fn get_session_tasks(&self, session_id: &str) -> Vec { self.tasks @@ -116,7 +114,7 @@ impl ToolStateManager { .map(|entry| entry.value().clone()) .collect() } - + /// Get all tasks of a dialog turn pub fn get_dialog_turn_tasks(&self, dialog_turn_id: &str) -> Vec { self.tasks @@ -125,27 +123,28 @@ impl ToolStateManager { .map(|entry| entry.value().clone()) .collect() } - + /// Delete task pub fn remove_task(&self, tool_id: &str) { self.tasks.remove(tool_id); } - + /// Clear all tasks of a session pub fn clear_session(&self, session_id: &str) { - let to_remove: Vec<_> = self.tasks + let to_remove: Vec<_> = self + .tasks .iter() .filter(|entry| entry.value().context.session_id == session_id) .map(|entry| entry.key().clone()) .collect(); - + for tool_id in to_remove { self.tasks.remove(&tool_id); } - + debug!("Cleared session tool tasks: session_id={}", session_id); } - + /// Send state change event (full version) async fn emit_state_change_event(&self, task: ToolTask) { let tool_event = match &task.state { @@ -154,51 +153,61 @@ impl ToolStateManager { tool_name: task.tool_call.tool_name.clone(), position: *position, }, - + ToolExecutionState::Waiting { dependencies } => ToolEventData::Waiting { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), dependencies: dependencies.clone(), }, - + ToolExecutionState::Running { .. } => ToolEventData::Started { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), params: task.tool_call.arguments.clone(), }, - - ToolExecutionState::Streaming { chunks_received, .. } => ToolEventData::Streaming { + + ToolExecutionState::Streaming { + chunks_received, .. + } => ToolEventData::Streaming { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), chunks_received: *chunks_received, }, - - ToolExecutionState::AwaitingConfirmation { params, .. } => ToolEventData::ConfirmationNeeded { - tool_id: task.tool_call.tool_id.clone(), - tool_name: task.tool_call.tool_name.clone(), - params: params.clone(), - }, - - ToolExecutionState::Completed { result, duration_ms } => ToolEventData::Completed { + + ToolExecutionState::AwaitingConfirmation { params, .. } => { + ToolEventData::ConfirmationNeeded { + tool_id: task.tool_call.tool_id.clone(), + tool_name: task.tool_call.tool_name.clone(), + params: params.clone(), + } + } + + ToolExecutionState::Completed { + result, + duration_ms, + } => ToolEventData::Completed { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), result: Self::sanitize_tool_result_for_event(&result.content()), duration_ms: *duration_ms, }, - - ToolExecutionState::Failed { error, is_retryable: _ } => ToolEventData::Failed { + + ToolExecutionState::Failed { + error, + is_retryable: _, + } => ToolEventData::Failed { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), error: error.clone(), }, - + ToolExecutionState::Cancelled { reason } => ToolEventData::Cancelled { tool_id: task.tool_call.tool_id.clone(), tool_name: task.tool_call.tool_name.clone(), reason: reason.clone(), }, }; - + // Determine priority based on tool event type let priority = match &task.state { // Critical state change: High priority (user needs to see immediately) @@ -217,7 +226,7 @@ impl ToolStateManager { | ToolExecutionState::Streaming { .. } => EventPriority::Normal, }; - + let event_subagent_parent_info = task.context.subagent_parent_info.map(|info| info.into()); let event = AgenticEvent::ToolEvent { session_id: task.context.session_id, @@ -225,17 +234,17 @@ impl ToolStateManager { tool_event, subagent_parent_info: event_subagent_parent_info, }; - + let _ = self.event_queue.enqueue(event, Some(priority)).await; } - + /// Get statistics pub fn get_stats(&self) -> ToolStats { let tasks: Vec<_> = self.tasks.iter().map(|e| e.value().clone()).collect(); - + let mut stats = ToolStats::default(); stats.total = tasks.len(); - + for task in tasks { match task.state { ToolExecutionState::Queued { .. } => stats.queued += 1, @@ -248,7 +257,7 @@ impl ToolStateManager { ToolExecutionState::Cancelled { .. } => stats.cancelled += 1, } } - + stats } } diff --git a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs index 430b0b66..0b430793 100644 --- a/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -1,28 +1,30 @@ //! Tool pipeline -//! -//! Manages the complete lifecycle of tools: +//! +//! Manages the complete lifecycle of tools: //! confirmation, execution, caching, retries, etc. -use log::{debug, info, warn, error}; use super::state_manager::ToolStateManager; use super::types::*; -use crate::agentic::core::{ToolCall, ToolResult as ModelToolResult, ToolExecutionState}; +use crate::agentic::core::{ToolCall, ToolExecutionState, ToolResult as ModelToolResult}; use crate::agentic::events::types::ToolEventData; -use crate::agentic::tools::registry::ToolRegistry; -use crate::agentic::tools::framework::{ToolUseContext, ToolOptions, ToolResult as FrameworkToolResult}; +use crate::agentic::tools::framework::{ + ToolOptions, ToolResult as FrameworkToolResult, ToolUseContext, +}; use crate::agentic::tools::image_context::ImageContextProviderRef; +use crate::agentic::tools::registry::ToolRegistry; use crate::util::errors::{BitFunError, BitFunResult}; +use dashmap::DashMap; use futures::future::join_all; +use log::{debug, error, info, warn}; use std::collections::HashMap; use std::sync::Arc; use std::time::Instant; -use tokio::time::{timeout, Duration}; use tokio::sync::{oneshot, RwLock as TokioRwLock}; -use dashmap::DashMap; +use tokio::time::{timeout, Duration}; use tokio_util::sync::CancellationToken; /// Convert framework::ToolResult to core::ToolResult -/// +/// /// Ensure always has result_for_assistant, avoid tool message content being empty fn convert_tool_result( framework_result: FrameworkToolResult, @@ -30,13 +32,16 @@ fn convert_tool_result( tool_name: &str, ) -> ModelToolResult { match framework_result { - FrameworkToolResult::Result { data, result_for_assistant } => { + FrameworkToolResult::Result { + data, + result_for_assistant, + } => { // If the tool does not provide result_for_assistant, generate default friendly description let assistant_text = result_for_assistant.or_else(|| { // Generate natural language description based on data generate_default_assistant_text(tool_name, &data) }); - + ModelToolResult { tool_id: tool_id.to_string(), tool_name: tool_name.to_string(), @@ -45,11 +50,11 @@ fn convert_tool_result( is_error: false, duration_ms: None, } - }, + } FrameworkToolResult::Progress { content, .. } => { // Progress message also generates friendly text let assistant_text = generate_default_assistant_text(tool_name, &content); - + ModelToolResult { tool_id: tool_id.to_string(), tool_name: tool_name.to_string(), @@ -58,11 +63,11 @@ fn convert_tool_result( is_error: false, duration_ms: None, } - }, + } FrameworkToolResult::StreamChunk { data, .. } => { // Streaming data block also generates friendly text let assistant_text = generate_default_assistant_text(tool_name, &data); - + ModelToolResult { tool_id: tool_id.to_string(), tool_name: tool_name.to_string(), @@ -71,7 +76,7 @@ fn convert_tool_result( is_error: false, duration_ms: None, } - }, + } } } @@ -79,32 +84,45 @@ fn convert_tool_result( fn generate_default_assistant_text(tool_name: &str, data: &serde_json::Value) -> Option { // Check if data is null or empty if data.is_null() { - return Some(format!("Tool {} completed, but no result returned.", tool_name)); + return Some(format!( + "Tool {} completed, but no result returned.", + tool_name + )); } - + // If it is an empty object or empty array - if (data.is_object() && data.as_object().map_or(false, |o| o.is_empty())) || - (data.is_array() && data.as_array().map_or(false, |a| a.is_empty())) { - return Some(format!("Tool {} completed, returned empty result.", tool_name)); + if (data.is_object() && data.as_object().map_or(false, |o| o.is_empty())) + || (data.is_array() && data.as_array().map_or(false, |a| a.is_empty())) + { + return Some(format!( + "Tool {} completed, returned empty result.", + tool_name + )); } - + // Try to extract common fields to generate description if let Some(obj) = data.as_object() { // Check if there is a success field if let Some(success) = obj.get("success").and_then(|v| v.as_bool()) { if success { if let Some(message) = obj.get("message").and_then(|v| v.as_str()) { - return Some(format!("Tool {} completed successfully: {}", tool_name, message)); + return Some(format!( + "Tool {} completed successfully: {}", + tool_name, message + )); } return Some(format!("Tool {} completed successfully.", tool_name)); } else { if let Some(error) = obj.get("error").and_then(|v| v.as_str()) { - return Some(format!("Tool {} completed with error: {}", tool_name, error)); + return Some(format!( + "Tool {} completed with error: {}", + tool_name, error + )); } return Some(format!("Tool {} completed with error.", tool_name)); } } - + // Check if there is a result/data/content field for key in &["result", "data", "content", "output"] { if let Some(value) = obj.get(*key) { @@ -115,7 +133,7 @@ fn generate_default_assistant_text(tool_name: &str, data: &serde_json::Value) -> } } } - + // If there are multiple fields, provide field list let field_names: Vec<&str> = obj.keys().take(5).map(|s| s.as_str()).collect(); if !field_names.is_empty() { @@ -126,30 +144,38 @@ fn generate_default_assistant_text(tool_name: &str, data: &serde_json::Value) -> )); } } - + // If it is a string, return directly (but limit length) if let Some(text) = data.as_str() { if !text.is_empty() { if text.len() <= 500 { return Some(format!("Tool {} completed: {}", tool_name, text)); } else { - return Some(format!("Tool {} completed, returned {} characters of text result.", tool_name, text.len())); + return Some(format!( + "Tool {} completed, returned {} characters of text result.", + tool_name, + text.len() + )); } } } - + // If it is a number or boolean if data.is_number() || data.is_boolean() { return Some(format!("Tool {} completed, returned: {}", tool_name, data)); } - + // Default: simply describe data type Some(format!( "Tool {} completed, returned {} type of result.", tool_name, - if data.is_object() { "object" } - else if data.is_array() { "array" } - else { "data" } + if data.is_object() { + "object" + } else if data.is_array() { + "array" + } else { + "data" + } )) } @@ -194,7 +220,7 @@ impl ToolPipeline { image_context_provider, } } - + /// Execute multiple tool calls pub async fn execute_tools( &self, @@ -212,7 +238,8 @@ impl ToolPipeline { let all_concurrency_safe = { let registry = self.tool_registry.read().await; tool_calls.iter().all(|tc| { - registry.get_tool(&tc.tool_name) + registry + .get_tool(&tc.tool_name) .map(|tool| tool.is_concurrency_safe(Some(&tc.arguments))) .unwrap_or(false) // If the tool does not exist, it is considered unsafe }) @@ -246,16 +273,19 @@ impl ToolPipeline { } } } - + /// Execute tools in parallel - async fn execute_parallel(&self, task_ids: Vec) -> BitFunResult> { + async fn execute_parallel( + &self, + task_ids: Vec, + ) -> BitFunResult> { let futures: Vec<_> = task_ids .iter() .map(|id| self.execute_single_tool(id.clone())) .collect(); - + let results = join_all(futures).await; - + // Collect results, including failed results let mut all_results = Vec::new(); for (idx, result) in results.into_iter().enumerate() { @@ -263,7 +293,7 @@ impl ToolPipeline { Ok(r) => all_results.push(r), Err(e) => { error!("Tool execution failed: error={}", e); - + // Get task information from state manager if let Some(task) = self.state_manager.get_task(&task_ids[idx]) { // Create error result to return to model @@ -288,20 +318,23 @@ impl ToolPipeline { } } } - + Ok(all_results) } - + /// Execute tools sequentially - async fn execute_sequential(&self, task_ids: Vec) -> BitFunResult> { + async fn execute_sequential( + &self, + task_ids: Vec, + ) -> BitFunResult> { let mut results = Vec::new(); - + for task_id in task_ids { match self.execute_single_tool(task_id.clone()).await { Ok(result) => results.push(result), Err(e) => { error!("Tool execution failed: error={}", e); - + // Get task information from state manager if let Some(task) = self.state_manager.get_task(&task_id) { // Create error result to return to model @@ -326,26 +359,30 @@ impl ToolPipeline { } } } - + Ok(results) } - + /// Execute single tool async fn execute_single_tool(&self, tool_id: String) -> BitFunResult { let start_time = Instant::now(); - + debug!("Starting tool execution: tool_id={}", tool_id); - + // Get task - let task = self.state_manager + let task = self + .state_manager .get_task(&tool_id) .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; - + let tool_name = task.tool_call.tool_name.clone(); let tool_args = task.tool_call.arguments.clone(); let tool_is_error = task.tool_call.is_error; - - debug!("Tool task details: tool_name={}, tool_id={}", tool_name, tool_id); + + debug!( + "Tool task details: tool_name={}, tool_id={}", + tool_name, tool_id + ); if tool_name.is_empty() || tool_is_error { let error_msg = format!( @@ -354,60 +391,68 @@ impl ToolPipeline { Please regenerate the tool call with valid tool name and arguments." ); self.state_manager - .update_state(&tool_id, ToolExecutionState::Failed { - error: error_msg.clone(), - is_retryable: false, - }) + .update_state( + &tool_id, + ToolExecutionState::Failed { + error: error_msg.clone(), + is_retryable: false, + }, + ) .await; return Err(BitFunError::Validation(error_msg)); } - + // Security check: check if the tool is in the allowed list // If allowed_tools is not empty, only allow execution of tools in the whitelist - if !task.context.allowed_tools.is_empty() - && !task.context.allowed_tools.contains(&tool_name) + if !task.context.allowed_tools.is_empty() + && !task.context.allowed_tools.contains(&tool_name) { let error_msg = format!( "Tool '{}' is not in the allowed list: {:?}", - tool_name, - task.context.allowed_tools + tool_name, task.context.allowed_tools ); warn!("Tool not allowed: {}", error_msg); - + // Update state to failed self.state_manager - .update_state(&tool_id, ToolExecutionState::Failed { - error: error_msg.clone(), - is_retryable: false, - }) + .update_state( + &tool_id, + ToolExecutionState::Failed { + error: error_msg.clone(), + is_retryable: false, + }, + ) .await; - + return Err(BitFunError::Validation(error_msg)); } - + // Create cancellation token let cancellation_token = CancellationToken::new(); - self.cancellation_tokens.insert(tool_id.clone(), cancellation_token.clone()); - + self.cancellation_tokens + .insert(tool_id.clone(), cancellation_token.clone()); + debug!("Executing tool: tool_name={}", tool_name); - + let tool = { let registry = self.tool_registry.read().await; - registry.get_tool(&task.tool_call.tool_name).ok_or_else(|| { - let error_msg = format!( - "Tool '{}' is not registered or enabled.", - task.tool_call.tool_name, - ); - error!("{}", error_msg); - BitFunError::tool(error_msg) - })? + registry + .get_tool(&task.tool_call.tool_name) + .ok_or_else(|| { + let error_msg = format!( + "Tool '{}' is not registered or enabled.", + task.tool_call.tool_name, + ); + error!("{}", error_msg); + BitFunError::tool(error_msg) + })? }; let is_streaming = tool.supports_streaming(); - let needs_confirmation = task.options.confirm_before_run - && tool.needs_permissions(Some(&tool_args)); + let needs_confirmation = + task.options.confirm_before_run && tool.needs_permissions(Some(&tool_args)); if needs_confirmation { info!("Tool requires confirmation: tool_name={}", tool_name); @@ -424,17 +469,23 @@ impl ToolPipeline { self.confirmation_channels.insert(tool_id.clone(), tx); self.state_manager - .update_state(&tool_id, ToolExecutionState::AwaitingConfirmation { - params: tool_args.clone(), - timeout_at, - }) + .update_state( + &tool_id, + ToolExecutionState::AwaitingConfirmation { + params: tool_args.clone(), + timeout_at, + }, + ) .await; debug!("Waiting for confirmation: tool_name={}", tool_name); let confirmation_result = match task.options.confirmation_timeout_secs { Some(timeout_secs) => { - debug!("Waiting for user confirmation with timeout: timeout_secs={}, tool_name={}", timeout_secs, tool_name); + debug!( + "Waiting for user confirmation with timeout: timeout_secs={}, tool_name={}", + timeout_secs, tool_name + ); // There is a timeout limit match timeout(Duration::from_secs(timeout_secs), rx).await { Ok(result) => Some(result), @@ -442,7 +493,10 @@ impl ToolPipeline { } } None => { - debug!("Waiting for user confirmation without timeout: tool_name={}", tool_name); + debug!( + "Waiting for user confirmation without timeout: tool_name={}", + tool_name + ); Some(rx.await) } }; @@ -453,82 +507,116 @@ impl ToolPipeline { } Some(Ok(ConfirmationResponse::Rejected(reason))) => { self.state_manager - .update_state(&tool_id, ToolExecutionState::Cancelled { - reason: format!("User rejected: {}", reason), - }) + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: format!("User rejected: {}", reason), + }, + ) .await; - return Err(BitFunError::Validation(format!("Tool was rejected by user: {}", reason))); + return Err(BitFunError::Validation(format!( + "Tool was rejected by user: {}", + reason + ))); } Some(Err(_)) => { // Channel closed self.state_manager - .update_state(&tool_id, ToolExecutionState::Cancelled { - reason: "Confirmation channel closed".to_string(), - }) + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: "Confirmation channel closed".to_string(), + }, + ) .await; return Err(BitFunError::service("Confirmation channel closed")); } None => { self.state_manager - .update_state(&tool_id, ToolExecutionState::Cancelled { - reason: "Confirmation timeout".to_string(), - }) + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: "Confirmation timeout".to_string(), + }, + ) .await; warn!("Confirmation timeout: {}", tool_name); - return Err(BitFunError::Timeout(format!("Confirmation timeout: {}", tool_name))); + return Err(BitFunError::Timeout(format!( + "Confirmation timeout: {}", + tool_name + ))); } } self.confirmation_channels.remove(&tool_id); } - + if cancellation_token.is_cancelled() { self.state_manager - .update_state(&tool_id, ToolExecutionState::Cancelled { - reason: "Tool was cancelled before execution".to_string(), - }) + .update_state( + &tool_id, + ToolExecutionState::Cancelled { + reason: "Tool was cancelled before execution".to_string(), + }, + ) .await; self.cancellation_tokens.remove(&tool_id); - return Err(BitFunError::Cancelled("Tool was cancelled before execution".to_string())); + return Err(BitFunError::Cancelled( + "Tool was cancelled before execution".to_string(), + )); } - + // Set initial state if is_streaming { self.state_manager - .update_state(&tool_id, ToolExecutionState::Streaming { - started_at: std::time::SystemTime::now(), - chunks_received: 0, - }) + .update_state( + &tool_id, + ToolExecutionState::Streaming { + started_at: std::time::SystemTime::now(), + chunks_received: 0, + }, + ) .await; } else { self.state_manager - .update_state(&tool_id, ToolExecutionState::Running { - started_at: std::time::SystemTime::now(), - progress: None, - }) + .update_state( + &tool_id, + ToolExecutionState::Running { + started_at: std::time::SystemTime::now(), + progress: None, + }, + ) .await; } - - let result = self.execute_with_retry(&task, cancellation_token.clone(), tool).await; - + + let result = self + .execute_with_retry(&task, cancellation_token.clone(), tool) + .await; + self.cancellation_tokens.remove(&tool_id); - + match result { Ok(tool_result) => { let duration_ms = start_time.elapsed().as_millis() as u64; - + self.state_manager - .update_state(&tool_id, ToolExecutionState::Completed { - result: convert_to_framework_result(&tool_result), - duration_ms, - }) + .update_state( + &tool_id, + ToolExecutionState::Completed { + result: convert_to_framework_result(&tool_result), + duration_ms, + }, + ) .await; - - info!("Tool completed: tool_name={}, duration_ms={}", tool_name, duration_ms); - + + info!( + "Tool completed: tool_name={}, duration_ms={}", + tool_name, duration_ms + ); + Ok(ToolExecutionResult { tool_id, tool_name, @@ -539,21 +627,24 @@ impl ToolPipeline { Err(e) => { let error_msg = e.to_string(); let is_retryable = task.options.max_retries > 0; - + self.state_manager - .update_state(&tool_id, ToolExecutionState::Failed { - error: error_msg.clone(), - is_retryable, - }) + .update_state( + &tool_id, + ToolExecutionState::Failed { + error: error_msg.clone(), + is_retryable, + }, + ) .await; - + error!("Tool failed: tool_name={}, error={}", tool_name, error_msg); - + Err(e) } } } - + /// Execute with retry async fn execute_with_retry( &self, @@ -567,29 +658,36 @@ impl ToolPipeline { loop { // Check cancellation token if cancellation_token.is_cancelled() { - return Err(BitFunError::Cancelled("Tool execution was cancelled".to_string())); + return Err(BitFunError::Cancelled( + "Tool execution was cancelled".to_string(), + )); } attempts += 1; - let result = self.execute_tool_impl(task, cancellation_token.clone(), tool.clone()).await; - + let result = self + .execute_tool_impl(task, cancellation_token.clone(), tool.clone()) + .await; + match result { Ok(r) => return Ok(r), Err(e) => { if attempts >= max_attempts { return Err(e); } - - debug!("Retrying tool execution: attempt={}/{}, error={}", attempts, max_attempts, e); - + + debug!( + "Retrying tool execution: attempt={}/{}, error={}", + attempts, max_attempts, e + ); + // Wait for a period of time and retry tokio::time::sleep(Duration::from_millis(100 * attempts as u64)).await; } } } } - + /// Actual execution of tool async fn execute_tool_impl( &self, @@ -599,9 +697,11 @@ impl ToolPipeline { ) -> BitFunResult { // Check cancellation token if cancellation_token.is_cancelled() { - return Err(BitFunError::Cancelled("Tool execution was cancelled".to_string())); + return Err(BitFunError::Cancelled( + "Tool execution was cancelled".to_string(), + )); } - + // Build tool context (pass all resource IDs) let tool_context = ToolUseContext { tool_call_id: Some(task.tool_call.tool_id.clone()), @@ -627,7 +727,7 @@ impl ToolPipeline { is_custom_command: None, custom_data: Some({ let mut map = HashMap::new(); - + if let Some(snapshot_id) = task .context .context_vars @@ -645,7 +745,8 @@ impl ToolPipeline { } } - if let Some(provider) = task.context.context_vars.get("primary_model_provider") { + if let Some(provider) = task.context.context_vars.get("primary_model_provider") + { if !provider.is_empty() { map.insert( "primary_model_provider".to_string(), @@ -678,7 +779,7 @@ impl ToolPipeline { ); } } - + map }), }), @@ -687,31 +788,41 @@ impl ToolPipeline { subagent_parent_info: task.context.subagent_parent_info.clone(), cancellation_token: Some(cancellation_token), }; - + let execution_future = tool.call(&task.tool_call.arguments, &tool_context); - + let tool_results = match task.options.timeout_secs { Some(timeout_secs) => { let timeout_duration = Duration::from_secs(timeout_secs); let result = timeout(timeout_duration, execution_future) .await - .map_err(|_| BitFunError::Timeout(format!("Tool execution timeout: {}", task.tool_call.tool_name)))?; + .map_err(|_| { + BitFunError::Timeout(format!( + "Tool execution timeout: {}", + task.tool_call.tool_name + )) + })?; result? } - None => { - execution_future.await? - } + None => execution_future.await?, }; - + if tool.supports_streaming() && tool_results.len() > 1 { self.handle_streaming_results(task, &tool_results).await?; } - - tool_results.into_iter().last() + + tool_results + .into_iter() + .last() .map(|r| convert_tool_result(r, &task.tool_call.tool_id, &task.tool_call.tool_name)) - .ok_or_else(|| BitFunError::Tool(format!("Tool did not return result: {}", task.tool_call.tool_name))) + .ok_or_else(|| { + BitFunError::Tool(format!( + "Tool did not return result: {}", + task.tool_call.tool_name + )) + }) } - + /// Handle streaming results async fn handle_streaming_results( &self, @@ -719,19 +830,27 @@ impl ToolPipeline { results: &[FrameworkToolResult], ) -> BitFunResult<()> { let mut chunks_received = 0; - + for result in results { - if let FrameworkToolResult::StreamChunk { data, chunk_index: _, is_final: _ } = result { + if let FrameworkToolResult::StreamChunk { + data, + chunk_index: _, + is_final: _, + } = result + { chunks_received += 1; - + // Update state self.state_manager - .update_state(&task.tool_call.tool_id, ToolExecutionState::Streaming { - started_at: std::time::SystemTime::now(), - chunks_received, - }) + .update_state( + &task.tool_call.tool_id, + ToolExecutionState::Streaming { + started_at: std::time::SystemTime::now(), + chunks_received, + }, + ) .await; - + // Send StreamChunk event let _event_data = ToolEventData::StreamChunk { tool_id: task.tool_call.tool_id.clone(), @@ -740,10 +859,10 @@ impl ToolPipeline { }; } } - + Ok(()) } - + /// Cancel tool execution pub async fn cancel_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> { // 1. Trigger cancellation token @@ -751,66 +870,93 @@ impl ToolPipeline { token.cancel(); debug!("Cancellation token triggered: tool_id={}", tool_id); } else { - debug!("Cancellation token not found (tool may have completed): tool_id={}", tool_id); + debug!( + "Cancellation token not found (tool may have completed): tool_id={}", + tool_id + ); } - + // 2. Clean up confirmation channel (if waiting for confirmation) if let Some((_, _tx)) = self.confirmation_channels.remove(tool_id) { // Channel will be automatically closed, causing await rx to return Err debug!("Cleared confirmation channel: tool_id={}", tool_id); } - + // 3. Update state to cancelled self.state_manager - .update_state(tool_id, ToolExecutionState::Cancelled { - reason: reason.clone(), - }) + .update_state( + tool_id, + ToolExecutionState::Cancelled { + reason: reason.clone(), + }, + ) .await; - - info!("Tool execution cancelled: tool_id={}, reason={}", tool_id, reason); + + info!( + "Tool execution cancelled: tool_id={}, reason={}", + tool_id, reason + ); Ok(()) } - + /// Cancel all tools for a dialog turn pub async fn cancel_dialog_turn_tools(&self, dialog_turn_id: &str) -> BitFunResult<()> { - info!("Cancelling all tools for dialog turn: dialog_turn_id={}", dialog_turn_id); - + info!( + "Cancelling all tools for dialog turn: dialog_turn_id={}", + dialog_turn_id + ); + let tasks = self.state_manager.get_dialog_turn_tasks(dialog_turn_id); debug!("Found {} tool tasks for dialog turn", tasks.len()); - + let mut cancelled_count = 0; let mut skipped_count = 0; - + for task in tasks { // Only cancel tasks in cancellable states let can_cancel = matches!( task.state, ToolExecutionState::Queued { .. } - | ToolExecutionState::Waiting { .. } - | ToolExecutionState::Running { .. } - | ToolExecutionState::AwaitingConfirmation { .. } + | ToolExecutionState::Waiting { .. } + | ToolExecutionState::Running { .. } + | ToolExecutionState::AwaitingConfirmation { .. } ); - + if can_cancel { - debug!("Cancelling tool: tool_id={}, state={:?}", task.tool_call.tool_id, task.state); - self.cancel_tool(&task.tool_call.tool_id, "Dialog turn cancelled".to_string()).await?; + debug!( + "Cancelling tool: tool_id={}, state={:?}", + task.tool_call.tool_id, task.state + ); + self.cancel_tool(&task.tool_call.tool_id, "Dialog turn cancelled".to_string()) + .await?; cancelled_count += 1; } else { - debug!("Skipping tool (state not cancellable): tool_id={}, state={:?}", task.tool_call.tool_id, task.state); + debug!( + "Skipping tool (state not cancellable): tool_id={}, state={:?}", + task.tool_call.tool_id, task.state + ); skipped_count += 1; } } - - info!("Tool cancellation completed: cancelled={}, skipped={}", cancelled_count, skipped_count); + + info!( + "Tool cancellation completed: cancelled={}, skipped={}", + cancelled_count, skipped_count + ); Ok(()) } - + /// Confirm tool execution - pub async fn confirm_tool(&self, tool_id: &str, updated_input: Option) -> BitFunResult<()> { - let task = self.state_manager + pub async fn confirm_tool( + &self, + tool_id: &str, + updated_input: Option, + ) -> BitFunResult<()> { + let task = self + .state_manager .get_task(tool_id) .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; - + // Check if the state is waiting for confirmation if !matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) { return Err(BitFunError::Validation(format!( @@ -818,29 +964,33 @@ impl ToolPipeline { task.state ))); } - + // If the user modified the parameters, update the task parameters first if let Some(new_args) = updated_input { debug!("User updated tool arguments: tool_id={}", tool_id); self.state_manager.update_task_arguments(tool_id, new_args); } - + // Get sender from map and send confirmation response if let Some((_, tx)) = self.confirmation_channels.remove(tool_id) { let _ = tx.send(ConfirmationResponse::Confirmed); info!("User confirmed tool execution: tool_id={}", tool_id); Ok(()) } else { - Err(BitFunError::NotFound(format!("Confirmation channel not found: {}", tool_id))) + Err(BitFunError::NotFound(format!( + "Confirmation channel not found: {}", + tool_id + ))) } } - + /// Reject tool execution pub async fn reject_tool(&self, tool_id: &str, reason: String) -> BitFunResult<()> { - let task = self.state_manager + let task = self + .state_manager .get_task(tool_id) .ok_or_else(|| BitFunError::NotFound(format!("Tool task not found: {}", tool_id)))?; - + // Check if the state is waiting for confirmation if !matches!(task.state, ToolExecutionState::AwaitingConfirmation { .. }) { return Err(BitFunError::Validation(format!( @@ -848,20 +998,26 @@ impl ToolPipeline { task.state ))); } - + // Get sender from map and send rejection response if let Some((_, tx)) = self.confirmation_channels.remove(tool_id) { let _ = tx.send(ConfirmationResponse::Rejected(reason.clone())); - info!("User rejected tool execution: tool_id={}, reason={}", tool_id, reason); + info!( + "User rejected tool execution: tool_id={}, reason={}", + tool_id, reason + ); Ok(()) } else { // If the channel does not exist, mark it as cancelled directly self.state_manager - .update_state(tool_id, ToolExecutionState::Cancelled { - reason: format!("User rejected: {}", reason), - }) + .update_state( + tool_id, + ToolExecutionState::Cancelled { + reason: format!("User rejected: {}", reason), + }, + ) .await; - + Ok(()) } } diff --git a/src/crates/core/src/agentic/tools/pipeline/types.rs b/src/crates/core/src/agentic/tools/pipeline/types.rs index 224411b1..9d499336 100644 --- a/src/crates/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/core/src/agentic/tools/pipeline/types.rs @@ -1,8 +1,8 @@ //! Tool pipeline type definitions -use crate::agentic::WorkspaceBinding; use crate::agentic::core::{ToolCall, ToolExecutionState}; use crate::agentic::events::SubagentParentInfo as EventSubagentParentInfo; +use crate::agentic::WorkspaceBinding; use std::collections::HashMap; use std::time::SystemTime; @@ -75,7 +75,11 @@ pub struct ToolTask { } impl ToolTask { - pub fn new(tool_call: ToolCall, context: ToolExecutionContext, options: ToolExecutionOptions) -> Self { + pub fn new( + tool_call: ToolCall, + context: ToolExecutionContext, + options: ToolExecutionOptions, + ) -> Self { Self { tool_call, context, @@ -96,4 +100,3 @@ pub struct ToolExecutionResult { pub result: crate::agentic::core::ToolResult, pub execution_time_ms: u64, } - diff --git a/src/crates/core/src/agentic/tools/registry.rs b/src/crates/core/src/agentic/tools/registry.rs index 96e260d0..1c24ecc4 100644 --- a/src/crates/core/src/agentic/tools/registry.rs +++ b/src/crates/core/src/agentic/tools/registry.rs @@ -183,8 +183,10 @@ pub async fn get_all_tools() -> Vec> { let registry_lock = registry.read().await; let all_tools = registry_lock.get_all_tools(); let wrapped_tools = crate::service::snapshot::get_snapshot_wrapped_tools(); - let file_tool_names: std::collections::HashSet = - wrapped_tools.iter().map(|tool| tool.name().to_string()).collect(); + let file_tool_names: std::collections::HashSet = wrapped_tools + .iter() + .map(|tool| tool.name().to_string()) + .collect(); let mut result = wrapped_tools; for tool in all_tools { @@ -256,4 +258,3 @@ pub async fn get_all_registered_tool_names() -> Vec { .map(|tool| tool.name().to_string()) .collect() } - diff --git a/src/crates/core/src/agentic/util/mod.rs b/src/crates/core/src/agentic/util/mod.rs index 21877382..ccd0765a 100644 --- a/src/crates/core/src/agentic/util/mod.rs +++ b/src/crates/core/src/agentic/util/mod.rs @@ -1,3 +1,3 @@ pub mod list_files; -pub use list_files::get_formatted_files_list; \ No newline at end of file +pub use list_files::get_formatted_files_list; diff --git a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs index 005f2154..f2d18259 100644 --- a/src/crates/core/src/function_agents/git-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/git-func-agent/ai_service.rs @@ -1,18 +1,17 @@ +use super::types::{ + AICommitAnalysis, AgentError, AgentResult, CommitFormat, CommitMessageOptions, CommitType, + Language, ProjectContext, +}; +use crate::infrastructure::ai::AIClient; +use crate::util::types::Message; /** * AI service layer * * Handles AI client interaction and provides intelligent analysis for commit message generation */ - use log::{debug, error, warn}; -use super::types::{ - AgentError, AgentResult, AICommitAnalysis, CommitFormat, - CommitMessageOptions, CommitType, Language, ProjectContext, -}; -use crate::infrastructure::ai::AIClient; -use crate::util::types::Message; -use std::sync::Arc; use serde_json::Value; +use std::sync::Arc; /// Prompt template constants (embedded at compile time) const COMMIT_MESSAGE_PROMPT: &str = include_str!("prompts/commit_message.md"); @@ -24,21 +23,22 @@ pub struct AIAnalysisService { impl AIAnalysisService { pub async fn new_with_agent_config( factory: std::sync::Arc, - agent_name: &str + agent_name: &str, ) -> AgentResult { let ai_client = match factory.get_client_by_func_agent(agent_name).await { Ok(client) => client, Err(e) => { error!("Failed to get AI client: {}", e); - return Err(AgentError::internal_error(format!("Failed to get AI client: {}", e))); + return Err(AgentError::internal_error(format!( + "Failed to get AI client: {}", + e + ))); } }; - - Ok(Self { - ai_client, - }) + + Ok(Self { ai_client }) } - + pub async fn generate_commit_message_ai( &self, diff_content: &str, @@ -48,42 +48,44 @@ impl AIAnalysisService { if diff_content.is_empty() { return Err(AgentError::invalid_input("Code changes are empty")); } - + let processed_diff = self.truncate_diff_if_needed(diff_content, 50000); - - let prompt = self.build_commit_prompt( - &processed_diff, - project_context, - options, - ); - + + let prompt = self.build_commit_prompt(&processed_diff, project_context, options); + let ai_response = self.call_ai(&prompt).await?; - + self.parse_commit_response(&ai_response) } - + async fn call_ai(&self, prompt: &str) -> AgentResult { debug!("Sending request to AI: prompt_length={}", prompt.len()); - + let messages = vec![Message::user(prompt.to_string())]; - let response = self.ai_client + let response = self + .ai_client .send_message(messages, None) .await .map_err(|e| { error!("AI call failed: {}", e); AgentError::internal_error(format!("AI call failed: {}", e)) })?; - - debug!("AI response received: response_length={}", response.text.len()); - + + debug!( + "AI response received: response_length={}", + response.text.len() + ); + if response.text.is_empty() { error!("AI response is empty"); - Err(AgentError::internal_error("AI response is empty".to_string())) + Err(AgentError::internal_error( + "AI response is empty".to_string(), + )) } else { Ok(response.text) } } - + fn build_commit_prompt( &self, diff_content: &str, @@ -94,14 +96,14 @@ impl AIAnalysisService { Language::Chinese => "Chinese", Language::English => "English", }; - + let format_desc = match options.format { CommitFormat::Conventional => "Conventional Commits", CommitFormat::Angular => "Angular Style", CommitFormat::Simple => "Simple Format", CommitFormat::Custom => "Custom Format", }; - + COMMIT_MESSAGE_PROMPT .replace("{project_type}", &project_context.project_type) .replace("{tech_stack}", &project_context.tech_stack.join(", ")) @@ -110,13 +112,14 @@ impl AIAnalysisService { .replace("{diff_content}", diff_content) .replace("{max_title_length}", &options.max_title_length.to_string()) } - + fn parse_commit_response(&self, response: &str) -> AgentResult { let json_str = self.extract_json_from_response(response)?; - - let value: Value = serde_json::from_str(&json_str) - .map_err(|e| AgentError::analysis_error(format!("Failed to parse AI response: {}", e)))?; - + + let value: Value = serde_json::from_str(&json_str).map_err(|e| { + AgentError::analysis_error(format!("Failed to parse AI response: {}", e)) + })?; + Ok(AICommitAnalysis { commit_type: self.parse_commit_type(value["type"].as_str().unwrap_or("chore"))?, scope: value["scope"].as_str().map(|s| s.to_string()), @@ -130,51 +133,55 @@ impl AIAnalysisService { .as_str() .unwrap_or("AI analysis") .to_string(), - confidence: value["confidence"] - .as_f64() - .unwrap_or(0.8) as f32, + confidence: value["confidence"].as_f64().unwrap_or(0.8) as f32, }) } - + fn extract_json_from_response(&self, response: &str) -> AgentResult { let trimmed = response.trim(); - + if trimmed.starts_with('{') { return Ok(trimmed.to_string()); } - + if let Some(start) = trimmed.find("```json") { - let json_start = start + 7; + let json_start = start + 7; if let Some(end_offset) = trimmed[json_start..].find("```") { let json_end = json_start + end_offset; let json_str = trimmed[json_start..json_end].trim(); return Ok(json_str.to_string()); } } - + if let Some(start) = trimmed.find('{') { if let Some(end) = trimmed.rfind('}') { let json_str = &trimmed[start..=end]; return Ok(json_str.to_string()); } } - - Err(AgentError::analysis_error("Cannot extract JSON from response")) + + Err(AgentError::analysis_error( + "Cannot extract JSON from response", + )) } - + fn truncate_diff_if_needed(&self, diff: &str, max_chars: usize) -> String { if diff.len() <= max_chars { return diff.to_string(); } - - warn!("Diff too large ({} chars), truncating to {} chars", diff.len(), max_chars); - + + warn!( + "Diff too large ({} chars), truncating to {} chars", + diff.len(), + max_chars + ); + let mut truncated = diff.chars().take(max_chars - 100).collect::(); truncated.push_str("\n\n... [content truncated] ..."); - + truncated } - + fn parse_commit_type(&self, s: &str) -> AgentResult { match s.to_lowercase().as_str() { "feat" | "feature" => Ok(CommitType::Feat), diff --git a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs index a06e63d1..0123b178 100644 --- a/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs +++ b/src/crates/core/src/function_agents/git-func-agent/commit_generator.rs @@ -1,15 +1,14 @@ +use super::ai_service::AIAnalysisService; +use super::context_analyzer::ContextAnalyzer; +use super::types::*; +use crate::infrastructure::ai::AIClientFactory; +use crate::service::git::{GitDiffParams, GitService}; /** * Git Function Agent - commit message generator * * Uses AI to deeply analyze code changes and generate compliant commit messages */ - use log::{debug, info}; -use super::types::*; -use super::ai_service::AIAnalysisService; -use super::context_analyzer::ContextAnalyzer; -use crate::service::git::{GitService, GitDiffParams}; -use crate::infrastructure::ai::AIClientFactory; use std::path::Path; use std::sync::Arc; @@ -21,48 +20,64 @@ impl CommitGenerator { options: CommitMessageOptions, factory: Arc, ) -> AgentResult { - info!("Generating commit message (AI-driven): repo_path={:?}", repo_path); - + info!( + "Generating commit message (AI-driven): repo_path={:?}", + repo_path + ); + let status = GitService::get_status(repo_path) .await .map_err(|e| AgentError::git_error(format!("Failed to get Git status: {}", e)))?; - + let changed_files: Vec = status.staged.iter().map(|f| f.path.clone()).collect(); - + if changed_files.is_empty() { - return Err(AgentError::invalid_input("Staging area is empty, please stage files first")); + return Err(AgentError::invalid_input( + "Staging area is empty, please stage files first", + )); } - - debug!("Staged files: count={}, files={:?}", changed_files.len(), changed_files); - + + debug!( + "Staged files: count={}, files={:?}", + changed_files.len(), + changed_files + ); + let diff_content = Self::get_full_diff(repo_path).await?; - + if diff_content.trim().is_empty() { return Err(AgentError::invalid_input("Diff content is empty")); } - + let project_context = ContextAnalyzer::analyze_project_context(repo_path) .await .unwrap_or_default(); // Fallback to default on failure - - debug!("Project context: type={}, tech_stack={:?}", project_context.project_type, project_context.tech_stack); - - let ai_service = AIAnalysisService::new_with_agent_config(factory, "git-func-agent").await?; - + + debug!( + "Project context: type={}, tech_stack={:?}", + project_context.project_type, project_context.tech_stack + ); + + let ai_service = + AIAnalysisService::new_with_agent_config(factory, "git-func-agent").await?; + let ai_analysis = ai_service .generate_commit_message_ai(&diff_content, &project_context, &options) .await?; - - debug!("AI analysis completed: commit_type={:?}, confidence={}", ai_analysis.commit_type, ai_analysis.confidence); - + + debug!( + "AI analysis completed: commit_type={:?}, confidence={}", + ai_analysis.commit_type, ai_analysis.confidence + ); + let changes_summary = Self::build_changes_summary(&status, &changed_files); - + let full_message = Self::assemble_full_message( &ai_analysis.title, &ai_analysis.body, &ai_analysis.breaking_changes, ); - + Ok(CommitMessage { title: ai_analysis.title, body: ai_analysis.body, @@ -74,7 +89,7 @@ impl CommitGenerator { changes_summary, }) } - + async fn get_full_diff(repo_path: &Path) -> AgentResult { let diff_params = GitDiffParams { staged: Some(true), @@ -82,24 +97,24 @@ impl CommitGenerator { files: None, ..Default::default() }; - + let diff = GitService::get_diff(repo_path, &diff_params) .await .map_err(|e| AgentError::git_error(format!("Failed to get diff: {}", e)))?; - + debug!("Got staged diff: length={} chars", diff.len()); Ok(diff) } - + fn build_changes_summary( status: &crate::service::git::GitStatus, changed_files: &[String], ) -> ChangesSummary { - let total_additions = status.staged.iter().map(|_| 10u32).sum::() + - status.unstaged.iter().map(|_| 10u32).sum::(); - let total_deletions = status.staged.iter().map(|_| 5u32).sum::() + - status.unstaged.iter().map(|_| 5u32).sum::(); - + let total_additions = status.staged.iter().map(|_| 10u32).sum::() + + status.unstaged.iter().map(|_| 10u32).sum::(); + let total_deletions = status.staged.iter().map(|_| 5u32).sum::() + + status.unstaged.iter().map(|_| 5u32).sum::(); + let file_changes: Vec = changed_files .iter() .map(|path| { @@ -113,7 +128,7 @@ impl CommitGenerator { } }) .collect(); - + let affected_modules: Vec = changed_files .iter() .filter_map(|path| super::utils::extract_module_name(path)) @@ -121,9 +136,9 @@ impl CommitGenerator { .into_iter() .take(3) .collect(); - + let change_patterns = super::utils::detect_change_patterns(&file_changes); - + ChangesSummary { total_additions, total_deletions, @@ -133,28 +148,28 @@ impl CommitGenerator { change_patterns, } } - + fn assemble_full_message( title: &str, body: &Option, footer: &Option, ) -> String { let mut parts = vec![title.to_string()]; - + if let Some(body_text) = body { if !body_text.is_empty() { parts.push(String::new()); parts.push(body_text.clone()); } } - + if let Some(footer_text) = footer { if !footer_text.is_empty() { parts.push(String::new()); parts.push(footer_text.clone()); } } - + parts.join("\n") } } diff --git a/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs b/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs index dc4f46a8..d30de87d 100644 --- a/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs +++ b/src/crates/core/src/function_agents/git-func-agent/context_analyzer.rs @@ -1,28 +1,27 @@ +use super::types::*; /** * Context analyzer * * Provides project context for AI to better understand code changes */ - use log::debug; -use super::types::*; -use std::path::Path; use std::fs; +use std::path::Path; pub struct ContextAnalyzer; impl ContextAnalyzer { pub async fn analyze_project_context(repo_path: &Path) -> AgentResult { debug!("Analyzing project context: repo_path={:?}", repo_path); - + let project_type = Self::detect_project_type(repo_path)?; - + let tech_stack = Self::detect_tech_stack(repo_path)?; - + let project_docs = Self::read_project_docs(repo_path); - + let code_standards = Self::detect_code_standards(repo_path); - + Ok(ProjectContext { project_type, tech_stack, @@ -30,22 +29,22 @@ impl ContextAnalyzer { code_standards, }) } - + fn detect_project_type(repo_path: &Path) -> AgentResult { if repo_path.join("Cargo.toml").exists() { if repo_path.join("src-tauri").exists() { return Ok("tauri-app".to_string()); } - + if let Ok(content) = fs::read_to_string(repo_path.join("Cargo.toml")) { if content.contains("[lib]") { return Ok("rust-library".to_string()); } } - + return Ok("rust-application".to_string()); } - + if repo_path.join("package.json").exists() { if let Ok(content) = fs::read_to_string(repo_path.join("package.json")) { if content.contains("\"react\"") { @@ -60,32 +59,33 @@ impl ContextAnalyzer { } return Ok("nodejs-app".to_string()); } - + if repo_path.join("go.mod").exists() { return Ok("go-application".to_string()); } - - if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() { + + if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() + { return Ok("python-application".to_string()); } - + if repo_path.join("pom.xml").exists() { return Ok("java-maven-app".to_string()); } - + if repo_path.join("build.gradle").exists() { return Ok("java-gradle-app".to_string()); } - + Ok("unknown".to_string()) } - + fn detect_tech_stack(repo_path: &Path) -> AgentResult> { let mut stack = Vec::new(); - + if repo_path.join("Cargo.toml").exists() { stack.push("Rust".to_string()); - + if let Ok(content) = fs::read_to_string(repo_path.join("Cargo.toml")) { if content.contains("tokio") { stack.push("Tokio".to_string()); @@ -101,7 +101,7 @@ impl ContextAnalyzer { } } } - + if repo_path.join("package.json").exists() { if let Ok(content) = fs::read_to_string(repo_path.join("package.json")) { if content.contains("\"typescript\"") { @@ -109,7 +109,7 @@ impl ContextAnalyzer { } else { stack.push("JavaScript".to_string()); } - + if content.contains("\"react\"") { stack.push("React".to_string()); } @@ -124,19 +124,20 @@ impl ContextAnalyzer { } } } - + if repo_path.join("go.mod").exists() { stack.push("Go".to_string()); } - - if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() { + + if repo_path.join("requirements.txt").exists() || repo_path.join("pyproject.toml").exists() + { stack.push("Python".to_string()); } - + if repo_path.join("pom.xml").exists() || repo_path.join("build.gradle").exists() { stack.push("Java".to_string()); } - + if let Ok(entries) = fs::read_dir(repo_path) { for entry in entries.flatten() { let path = entry.path(); @@ -156,17 +157,17 @@ impl ContextAnalyzer { } } } - + if stack.is_empty() { stack.push("Unknown".to_string()); } - + Ok(stack) } - + fn read_project_docs(repo_path: &Path) -> Option { let readme_paths = ["README.md", "README", "README.txt", "readme.md"]; - + for readme_name in &readme_paths { let readme_path = repo_path.join(readme_name); if readme_path.exists() { @@ -176,40 +177,41 @@ impl ContextAnalyzer { } } } - + None } - + fn detect_code_standards(repo_path: &Path) -> Option { let mut standards = Vec::new(); - + if repo_path.join("rustfmt.toml").exists() || repo_path.join(".rustfmt.toml").exists() { standards.push("rustfmt"); } if repo_path.join("clippy.toml").exists() { standards.push("clippy"); } - - if repo_path.join(".eslintrc.js").exists() || - repo_path.join(".eslintrc.json").exists() || - repo_path.join("eslint.config.js").exists() { + + if repo_path.join(".eslintrc.js").exists() + || repo_path.join(".eslintrc.json").exists() + || repo_path.join("eslint.config.js").exists() + { standards.push("ESLint"); } if repo_path.join(".prettierrc").exists() || repo_path.join("prettier.config.js").exists() { standards.push("Prettier"); } - + if repo_path.join(".flake8").exists() { standards.push("flake8"); } if repo_path.join(".pylintrc").exists() { standards.push("pylint"); } - + if repo_path.join(".editorconfig").exists() { standards.push("EditorConfig"); } - + if standards.is_empty() { None } else { diff --git a/src/crates/core/src/function_agents/git-func-agent/mod.rs b/src/crates/core/src/function_agents/git-func-agent/mod.rs index d7e74eb1..cf39872e 100644 --- a/src/crates/core/src/function_agents/git-func-agent/mod.rs +++ b/src/crates/core/src/function_agents/git-func-agent/mod.rs @@ -1,20 +1,19 @@ +pub mod ai_service; +pub mod commit_generator; +pub mod context_analyzer; /** * Git Function Agent - module entry * * Provides Git-related intelligent functions: * - Automatic commit message generation */ - pub mod types; pub mod utils; -pub mod ai_service; -pub mod context_analyzer; -pub mod commit_generator; -pub use types::*; pub use ai_service::AIAnalysisService; -pub use context_analyzer::ContextAnalyzer; pub use commit_generator::CommitGenerator; +pub use context_analyzer::ContextAnalyzer; +pub use types::*; use crate::infrastructure::ai::AIClientFactory; use std::path::Path; @@ -29,7 +28,7 @@ impl GitFunctionAgent { pub fn new(factory: Arc) -> Self { Self { factory } } - + pub async fn generate_commit_message( &self, repo_path: &Path, @@ -37,9 +36,10 @@ impl GitFunctionAgent { ) -> AgentResult { CommitGenerator::generate_commit_message(repo_path, options, self.factory.clone()).await } - + /// Quickly generate commit message (use default options) pub async fn quick_commit_message(&self, repo_path: &Path) -> AgentResult { - self.generate_commit_message(repo_path, CommitMessageOptions::default()).await + self.generate_commit_message(repo_path, CommitMessageOptions::default()) + .await } } diff --git a/src/crates/core/src/function_agents/git-func-agent/types.rs b/src/crates/core/src/function_agents/git-func-agent/types.rs index ec0a937c..70c0b03f 100644 --- a/src/crates/core/src/function_agents/git-func-agent/types.rs +++ b/src/crates/core/src/function_agents/git-func-agent/types.rs @@ -3,7 +3,6 @@ * * Defines data structures for commit message generation */ - use serde::{Deserialize, Serialize}; use std::fmt; @@ -12,16 +11,16 @@ use std::fmt; pub struct CommitMessageOptions { #[serde(default = "default_commit_format")] pub format: CommitFormat, - + #[serde(default = "default_true")] pub include_files: bool, - + #[serde(default = "default_max_length")] pub max_title_length: usize, - + #[serde(default = "default_true")] pub include_body: bool, - + #[serde(default = "default_language")] pub language: Language, } @@ -77,21 +76,21 @@ pub enum Language { pub struct CommitMessage { /// Title (50-72 chars) pub title: String, - + pub body: Option, - + /// Footer info (breaking changes, etc.) pub footer: Option, - + pub full_message: String, - + pub commit_type: CommitType, - + pub scope: Option, - + /// Confidence (0.0-1.0) pub confidence: f32, - + pub changes_summary: ChangesSummary, } @@ -141,15 +140,15 @@ impl fmt::Display for CommitType { #[serde(rename_all = "camelCase")] pub struct ChangesSummary { pub total_additions: u32, - + pub total_deletions: u32, - + pub files_changed: u32, - + pub file_changes: Vec, - + pub affected_modules: Vec, - + pub change_patterns: Vec, } @@ -157,13 +156,13 @@ pub struct ChangesSummary { #[serde(rename_all = "camelCase")] pub struct FileChange { pub path: String, - + pub change_type: FileChangeType, - + pub additions: u32, - + pub deletions: u32, - + pub file_type: String, } @@ -216,21 +215,21 @@ impl AgentError { error_type: AgentErrorType::GitError, } } - + pub fn analysis_error(msg: impl Into) -> Self { Self { message: msg.into(), error_type: AgentErrorType::AnalysisError, } } - + pub fn invalid_input(msg: impl Into) -> Self { Self { message: msg.into(), error_type: AgentErrorType::InvalidInput, } } - + pub fn internal_error(msg: impl Into) -> Self { Self { message: msg.into(), @@ -245,11 +244,11 @@ pub type AgentResult = Result; pub struct ProjectContext { /// Project type (e.g., web-app, library, cli-tool, etc.) pub project_type: String, - + pub tech_stack: Vec, - + pub project_docs: Option, - + pub code_standards: Option, } @@ -267,16 +266,16 @@ impl Default for ProjectContext { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AICommitAnalysis { pub commit_type: CommitType, - + pub scope: Option, - + pub title: String, - + pub body: Option, - + pub breaking_changes: Option, - + pub reasoning: String, - + pub confidence: f32, } diff --git a/src/crates/core/src/function_agents/git-func-agent/utils.rs b/src/crates/core/src/function_agents/git-func-agent/utils.rs index d04d805f..dd191e42 100644 --- a/src/crates/core/src/function_agents/git-func-agent/utils.rs +++ b/src/crates/core/src/function_agents/git-func-agent/utils.rs @@ -3,7 +3,6 @@ * * Provides various helper utilities */ - use super::types::*; use std::path::Path; @@ -17,56 +16,71 @@ pub fn infer_file_type(path: &str) -> String { pub fn extract_module_name(path: &str) -> Option { let path = Path::new(path); - + if let Some(parent) = path.parent() { if let Some(dir_name) = parent.file_name() { return Some(dir_name.to_string_lossy().to_string()); } } - + path.file_stem() .map(|name| name.to_string_lossy().to_string()) } pub fn is_config_file(path: &str) -> bool { let config_patterns = [ - ".json", ".yaml", ".yml", ".toml", ".xml", ".ini", ".conf", - "config", "package.json", "cargo.toml", "tsconfig", + ".json", + ".yaml", + ".yml", + ".toml", + ".xml", + ".ini", + ".conf", + "config", + "package.json", + "cargo.toml", + "tsconfig", ]; - + let path_lower = path.to_lowercase(); - config_patterns.iter().any(|pattern| path_lower.contains(pattern)) + config_patterns + .iter() + .any(|pattern| path_lower.contains(pattern)) } pub fn is_doc_file(path: &str) -> bool { let doc_patterns = [".md", ".txt", ".rst", "readme", "changelog", "license"]; - + let path_lower = path.to_lowercase(); - doc_patterns.iter().any(|pattern| path_lower.contains(pattern)) + doc_patterns + .iter() + .any(|pattern| path_lower.contains(pattern)) } pub fn is_test_file(path: &str) -> bool { let test_patterns = ["test", "spec", "__tests__", ".test.", ".spec."]; - + let path_lower = path.to_lowercase(); - test_patterns.iter().any(|pattern| path_lower.contains(pattern)) + test_patterns + .iter() + .any(|pattern| path_lower.contains(pattern)) } pub fn detect_change_patterns(file_changes: &[FileChange]) -> Vec { let mut patterns = Vec::new(); - + let mut has_code_changes = false; let mut has_test_changes = false; let mut has_doc_changes = false; let mut has_config_changes = false; let mut has_new_files = false; - + for change in file_changes { match change.change_type { FileChangeType::Added => has_new_files = true, _ => {} } - + if is_test_file(&change.path) { has_test_changes = true; } else if is_doc_file(&change.path) { @@ -77,43 +91,44 @@ pub fn detect_change_patterns(file_changes: &[FileChange]) -> Vec has_code_changes = true; } } - + if has_new_files && has_code_changes { patterns.push(ChangePattern::FeatureAddition); } - + if has_code_changes && !has_new_files { patterns.push(ChangePattern::BugFix); } - + if has_test_changes { patterns.push(ChangePattern::TestUpdate); } - + if has_doc_changes { patterns.push(ChangePattern::DocumentationUpdate); } - + if has_config_changes { - if file_changes.iter().any(|f| - f.path.contains("package.json") || - f.path.contains("cargo.toml") || - f.path.contains("requirements.txt") - ) { + if file_changes.iter().any(|f| { + f.path.contains("package.json") + || f.path.contains("cargo.toml") + || f.path.contains("requirements.txt") + }) { patterns.push(ChangePattern::DependencyUpdate); } else { patterns.push(ChangePattern::ConfigChange); } } - + // Large code changes with few files may indicate refactoring - let total_lines = file_changes.iter() + let total_lines = file_changes + .iter() .map(|f| f.additions + f.deletions) .sum::(); - + if has_code_changes && total_lines > 200 && file_changes.len() < 5 { patterns.push(ChangePattern::Refactoring); } - + patterns } diff --git a/src/crates/core/src/function_agents/mod.rs b/src/crates/core/src/function_agents/mod.rs index dce5cd56..3e90239c 100644 --- a/src/crates/core/src/function_agents/mod.rs +++ b/src/crates/core/src/function_agents/mod.rs @@ -13,19 +13,9 @@ pub mod startchat_func_agent; pub use git_func_agent::GitFunctionAgent; pub use startchat_func_agent::StartchatFunctionAgent; -pub use git_func_agent::{ - CommitMessage, - CommitMessageOptions, - CommitFormat, - CommitType, -}; +pub use git_func_agent::{CommitFormat, CommitMessage, CommitMessageOptions, CommitType}; pub use startchat_func_agent::{ - WorkStateAnalysis, - WorkStateOptions, - GreetingMessage, - CurrentWorkState, - GitWorkState, - PredictedAction, - QuickAction, + CurrentWorkState, GitWorkState, GreetingMessage, PredictedAction, QuickAction, + WorkStateAnalysis, WorkStateOptions, }; diff --git a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs index f5c73fa0..57fca80a 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/ai_service.rs @@ -1,13 +1,12 @@ +use super::types::*; +use crate::infrastructure::ai::AIClient; +use crate::util::types::Message; /** * AI analysis service * * Provides AI-driven work state analysis for the Startchat function agent */ - -use log::{debug, warn, error}; -use super::types::*; -use crate::infrastructure::ai::AIClient; -use crate::util::types::Message; +use log::{debug, error, warn}; use std::sync::Arc; /// Prompt template constants (embedded at compile time) @@ -20,21 +19,22 @@ pub struct AIWorkStateService { impl AIWorkStateService { pub async fn new_with_agent_config( factory: Arc, - agent_name: &str + agent_name: &str, ) -> AgentResult { let ai_client = match factory.get_client_by_func_agent(agent_name).await { Ok(client) => client, Err(e) => { error!("Failed to get AI client: {}", e); - return Err(AgentError::internal_error(format!("Failed to get AI client: {}", e))); + return Err(AgentError::internal_error(format!( + "Failed to get AI client: {}", + e + ))); } }; - - Ok(Self { - ai_client, - }) + + Ok(Self { ai_client }) } - + pub async fn generate_complete_analysis( &self, git_state: &Option, @@ -42,36 +42,45 @@ impl AIWorkStateService { language: &Language, ) -> AgentResult { let prompt = self.build_complete_analysis_prompt(git_state, git_diff, language); - - debug!("Calling AI to generate complete analysis: prompt_length={}", prompt.len()); - + + debug!( + "Calling AI to generate complete analysis: prompt_length={}", + prompt.len() + ); + let response = self.call_ai(&prompt).await?; - + self.parse_complete_analysis(&response) } - + async fn call_ai(&self, prompt: &str) -> AgentResult { debug!("Sending request to AI: prompt_length={}", prompt.len()); - + let messages = vec![Message::user(prompt.to_string())]; - let response = self.ai_client + let response = self + .ai_client .send_message(messages, None) .await .map_err(|e| { error!("AI call failed: {}", e); AgentError::internal_error(format!("AI call failed: {}", e)) })?; - - debug!("AI response received: response_length={}", response.text.len()); - + + debug!( + "AI response received: response_length={}", + response.text.len() + ); + if response.text.is_empty() { error!("AI response is empty"); - Err(AgentError::internal_error("AI response is empty".to_string())) + Err(AgentError::internal_error( + "AI response is empty".to_string(), + )) } else { Ok(response.text) } } - + fn build_complete_analysis_prompt( &self, git_state: &Option, @@ -83,14 +92,14 @@ impl AIWorkStateService { Language::Chinese => "Please respond in Chinese.", Language::English => "Please respond in English.", }; - + // Build Git state section let git_state_section = if let Some(git) = git_state { let mut section = format!( "## Git Status\n\n- Current branch: {}\n- Unstaged files: {}\n- Staged files: {}\n- Unpushed commits: {}\n", git.current_branch, git.unstaged_files, git.staged_files, git.unpushed_commits ); - + if !git.modified_files.is_empty() { section.push_str("\nModified files:\n"); for file in git.modified_files.iter().take(10) { @@ -101,12 +110,13 @@ impl AIWorkStateService { } else { String::new() }; - + // Build Git diff section let git_diff_section = if !git_diff.is_empty() { let max_diff_length = 8000; if git_diff.len() > max_diff_length { - let truncated_diff = git_diff.char_indices() + let truncated_diff = git_diff + .char_indices() .take_while(|(idx, _)| *idx < max_diff_length) .map(|(_, c)| c) .collect::(); @@ -120,14 +130,14 @@ impl AIWorkStateService { } else { String::new() }; - + // Use template replacement WORK_STATE_ANALYSIS_PROMPT .replace("{lang_instruction}", lang_instruction) .replace("{git_state_section}", &git_state_section) .replace("{git_diff_section}", &git_diff_section) } - + fn parse_complete_analysis(&self, response: &str) -> AgentResult { let json_str = if let Some(start) = response.find('{') { if let Some(end) = response.rfind('}') { @@ -138,30 +148,36 @@ impl AIWorkStateService { } else { response }; - + debug!("Parsing JSON response: length={}", json_str.len()); - - let parsed: serde_json::Value = serde_json::from_str(json_str) - .map_err(|e| { - error!("Failed to parse complete analysis response: {}, response: {}", e, response); - AgentError::internal_error(format!("Failed to parse complete analysis response: {}", e)) - })?; - + + let parsed: serde_json::Value = serde_json::from_str(json_str).map_err(|e| { + error!( + "Failed to parse complete analysis response: {}, response: {}", + e, response + ); + AgentError::internal_error(format!("Failed to parse complete analysis response: {}", e)) + })?; + let summary = parsed["summary"] .as_str() .unwrap_or("You were working on development, with multiple files modified.") .to_string(); - + let ongoing_work = Vec::new(); - - let mut predicted_actions = if let Some(actions_array) = parsed["predicted_actions"].as_array() { - self.parse_predicted_actions_from_value(actions_array)? - } else { - Vec::new() - }; - + + let mut predicted_actions = + if let Some(actions_array) = parsed["predicted_actions"].as_array() { + self.parse_predicted_actions_from_value(actions_array)? + } else { + Vec::new() + }; + if predicted_actions.len() < 3 { - warn!("AI generated insufficient predicted actions ({}), adding defaults", predicted_actions.len()); + warn!( + "AI generated insufficient predicted actions ({}), adding defaults", + predicted_actions.len() + ); while predicted_actions.len() < 3 { predicted_actions.push(PredictedAction { description: "Continue current development".to_string(), @@ -171,26 +187,39 @@ impl AIWorkStateService { }); } } else if predicted_actions.len() > 3 { - warn!("AI generated too many predicted actions ({}), truncating to 3", predicted_actions.len()); + warn!( + "AI generated too many predicted actions ({}), truncating to 3", + predicted_actions.len() + ); predicted_actions.truncate(3); } - + let mut quick_actions = if let Some(actions_array) = parsed["quick_actions"].as_array() { self.parse_quick_actions_from_value(actions_array)? } else { Vec::new() }; - + if quick_actions.len() < 6 { // Don't fill defaults here, frontend has its own defaultActions with i18n support - warn!("AI generated insufficient quick actions ({}), frontend will use defaults", quick_actions.len()); + warn!( + "AI generated insufficient quick actions ({}), frontend will use defaults", + quick_actions.len() + ); } else if quick_actions.len() > 6 { - warn!("AI generated too many quick actions ({}), truncating to 6", quick_actions.len()); + warn!( + "AI generated too many quick actions ({}), truncating to 6", + quick_actions.len() + ); quick_actions.truncate(6); } - - debug!("Parsing completed: predicted_actions={}, quick_actions={}", predicted_actions.len(), quick_actions.len()); - + + debug!( + "Parsing completed: predicted_actions={}, quick_actions={}", + predicted_actions.len(), + quick_actions.len() + ); + Ok(AIGeneratedAnalysis { summary, ongoing_work, @@ -198,35 +227,31 @@ impl AIWorkStateService { quick_actions, }) } - - fn parse_predicted_actions_from_value(&self, actions_array: &[serde_json::Value]) -> AgentResult> { + + fn parse_predicted_actions_from_value( + &self, + actions_array: &[serde_json::Value], + ) -> AgentResult> { let mut actions = Vec::new(); - + for action_value in actions_array { let description = action_value["description"] .as_str() .unwrap_or("Continue current work") .to_string(); - - let priority_str = action_value["priority"] - .as_str() - .unwrap_or("Medium"); - + + let priority_str = action_value["priority"].as_str().unwrap_or("Medium"); + let priority = match priority_str { "High" => ActionPriority::High, "Low" => ActionPriority::Low, _ => ActionPriority::Medium, }; - - let icon = action_value["icon"] - .as_str() - .unwrap_or("") - .to_string(); - - let is_reminder = action_value["is_reminder"] - .as_bool() - .unwrap_or(false); - + + let icon = action_value["icon"].as_str().unwrap_or("").to_string(); + + let is_reminder = action_value["is_reminder"].as_bool().unwrap_or(false); + actions.push(PredictedAction { description, priority, @@ -234,33 +259,28 @@ impl AIWorkStateService { is_reminder, }); } - + Ok(actions) } - - fn parse_quick_actions_from_value(&self, actions_array: &[serde_json::Value]) -> AgentResult> { + + fn parse_quick_actions_from_value( + &self, + actions_array: &[serde_json::Value], + ) -> AgentResult> { let mut quick_actions = Vec::new(); - + for action_value in actions_array { let title = action_value["title"] .as_str() .unwrap_or("Quick Action") .to_string(); - - let command = action_value["command"] - .as_str() - .unwrap_or("") - .to_string(); - - let icon = action_value["icon"] - .as_str() - .unwrap_or("") - .to_string(); - - let action_type_str = action_value["action_type"] - .as_str() - .unwrap_or("Custom"); - + + let command = action_value["command"].as_str().unwrap_or("").to_string(); + + let icon = action_value["icon"].as_str().unwrap_or("").to_string(); + + let action_type_str = action_value["action_type"].as_str().unwrap_or("Custom"); + let action_type = match action_type_str { "Continue" => QuickActionType::Continue, "ViewStatus" => QuickActionType::ViewStatus, @@ -268,7 +288,7 @@ impl AIWorkStateService { "Visualize" => QuickActionType::Visualize, _ => QuickActionType::Custom, }; - + quick_actions.push(QuickAction { title, command, @@ -276,7 +296,7 @@ impl AIWorkStateService { action_type, }); } - + Ok(quick_actions) } } diff --git a/src/crates/core/src/function_agents/startchat-func-agent/mod.rs b/src/crates/core/src/function_agents/startchat-func-agent/mod.rs index 904ae3ea..9c1dec30 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/mod.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/mod.rs @@ -1,20 +1,19 @@ +pub mod ai_service; /** * Startchat Function Agent - module entry * * Provides work state analysis and greeting generation on session start */ - pub mod types; pub mod work_state_analyzer; -pub mod ai_service; +pub use ai_service::AIWorkStateService; pub use types::*; pub use work_state_analyzer::WorkStateAnalyzer; -pub use ai_service::AIWorkStateService; +use crate::infrastructure::ai::AIClientFactory; use std::path::Path; use std::sync::Arc; -use crate::infrastructure::ai::AIClientFactory; /// Combines work state analysis and greeting generation pub struct StartchatFunctionAgent { @@ -25,7 +24,7 @@ impl StartchatFunctionAgent { pub fn new(factory: Arc) -> Self { Self { factory } } - + /// Analyze work state and generate greeting pub async fn analyze_work_state( &self, @@ -34,16 +33,20 @@ impl StartchatFunctionAgent { ) -> AgentResult { WorkStateAnalyzer::analyze_work_state(self.factory.clone(), repo_path, options).await } - + /// Quickly analyze work state (use default options with specified language) - pub async fn quick_analyze(&self, repo_path: &Path, language: Language) -> AgentResult { + pub async fn quick_analyze( + &self, + repo_path: &Path, + language: Language, + ) -> AgentResult { let options = WorkStateOptions { language, ..WorkStateOptions::default() }; self.analyze_work_state(repo_path, options).await } - + /// Generate greeting only (do not analyze Git status) pub async fn generate_greeting_only(&self, repo_path: &Path) -> AgentResult { let options = WorkStateOptions { @@ -52,8 +55,7 @@ impl StartchatFunctionAgent { include_quick_actions: false, language: Language::Chinese, }; - + self.analyze_work_state(repo_path, options).await } } - diff --git a/src/crates/core/src/function_agents/startchat-func-agent/types.rs b/src/crates/core/src/function_agents/startchat-func-agent/types.rs index 8babe95f..a18aa96e 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/types.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/types.rs @@ -3,7 +3,6 @@ * * Defines data structures for work state analysis and greeting info at session start */ - use serde::{Deserialize, Serialize}; use std::fmt; @@ -12,13 +11,13 @@ use std::fmt; pub struct WorkStateOptions { #[serde(default = "default_true")] pub analyze_git: bool, - + #[serde(default = "default_true")] pub predict_next_actions: bool, - + #[serde(default = "default_true")] pub include_quick_actions: bool, - + #[serde(default = "default_language")] pub language: Language, } @@ -52,13 +51,13 @@ pub enum Language { #[serde(rename_all = "camelCase")] pub struct WorkStateAnalysis { pub greeting: GreetingMessage, - + pub current_state: CurrentWorkState, - + pub predicted_actions: Vec, - + pub quick_actions: Vec, - + pub analyzed_at: String, } @@ -66,9 +65,9 @@ pub struct WorkStateAnalysis { #[serde(rename_all = "camelCase")] pub struct GreetingMessage { pub title: String, - + pub subtitle: String, - + pub tagline: Option, } @@ -76,11 +75,11 @@ pub struct GreetingMessage { #[serde(rename_all = "camelCase")] pub struct CurrentWorkState { pub summary: String, - + pub git_state: Option, - + pub ongoing_work: Vec, - + pub time_info: TimeInfo, } @@ -88,15 +87,15 @@ pub struct CurrentWorkState { #[serde(rename_all = "camelCase")] pub struct GitWorkState { pub current_branch: String, - + pub unstaged_files: u32, - + pub staged_files: u32, - + pub unpushed_commits: u32, - + pub ahead_behind: Option, - + /// List of modified files (show at most the first few) pub modified_files: Vec, } @@ -105,7 +104,7 @@ pub struct GitWorkState { #[serde(rename_all = "camelCase")] pub struct AheadBehind { pub ahead: u32, - + pub behind: u32, } @@ -113,9 +112,9 @@ pub struct AheadBehind { #[serde(rename_all = "camelCase")] pub struct FileModification { pub path: String, - + pub change_type: FileChangeType, - + pub module: Option, } @@ -145,13 +144,13 @@ impl fmt::Display for FileChangeType { #[serde(rename_all = "camelCase")] pub struct WorkItem { pub title: String, - + pub description: String, - + pub related_files: Vec, - + pub category: WorkCategory, - + pub icon: String, } @@ -188,10 +187,10 @@ impl fmt::Display for WorkCategory { pub struct TimeInfo { /// Minutes since last commit pub minutes_since_last_commit: Option, - + /// Last commit time description (e.g., "2 hours ago") pub last_commit_time_desc: Option, - + /// Current time of day (morning/afternoon/evening) pub time_of_day: TimeOfDay, } @@ -220,11 +219,11 @@ impl fmt::Display for TimeOfDay { #[serde(rename_all = "camelCase")] pub struct PredictedAction { pub description: String, - + pub priority: ActionPriority, - + pub icon: String, - + pub is_reminder: bool, } @@ -250,12 +249,12 @@ impl fmt::Display for ActionPriority { #[serde(rename_all = "camelCase")] pub struct QuickAction { pub title: String, - + /// Action command (natural language) pub command: String, - + pub icon: String, - + pub action_type: QuickActionType, } @@ -272,11 +271,11 @@ pub enum QuickActionType { #[serde(rename_all = "camelCase")] pub struct AIGeneratedAnalysis { pub summary: String, - + pub ongoing_work: Vec, - + pub predicted_actions: Vec, - + pub quick_actions: Vec, } @@ -309,21 +308,21 @@ impl AgentError { error_type: AgentErrorType::GitError, } } - + pub fn analysis_error(msg: impl Into) -> Self { Self { message: msg.into(), error_type: AgentErrorType::AnalysisError, } } - + pub fn invalid_input(msg: impl Into) -> Self { Self { message: msg.into(), error_type: AgentErrorType::InvalidInput, } } - + pub fn internal_error(msg: impl Into) -> Self { Self { message: msg.into(), @@ -333,4 +332,3 @@ impl AgentError { } pub type AgentResult = Result; - diff --git a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs index ca536c60..67f37155 100644 --- a/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs +++ b/src/crates/core/src/function_agents/startchat-func-agent/work_state_analyzer.rs @@ -1,15 +1,14 @@ +use super::types::*; +use crate::infrastructure::ai::AIClientFactory; +use chrono::{Local, Timelike}; /** * Work state analyzer * * Analyzes the user's current work state, including Git status and file changes */ - use log::{debug, info}; -use super::types::*; use std::path::Path; use std::sync::Arc; -use chrono::{Local, Timelike}; -use crate::infrastructure::ai::AIClientFactory; pub struct WorkStateAnalyzer; @@ -20,46 +19,51 @@ impl WorkStateAnalyzer { options: WorkStateOptions, ) -> AgentResult { info!("Analyzing work state: repo_path={:?}", repo_path); - + let greeting = Self::generate_greeting(&options); - + let git_state = if options.analyze_git { Self::analyze_git_state(repo_path).await.ok() } else { None }; - - let git_diff = if git_state.as_ref().map_or(false, |g| g.unstaged_files > 0 || g.staged_files > 0) { + + let git_diff = if git_state + .as_ref() + .map_or(false, |g| g.unstaged_files > 0 || g.staged_files > 0) + { Self::get_git_diff(repo_path).await.unwrap_or_default() } else { String::new() }; - + let time_info = Self::get_time_info(repo_path).await; - - let ai_analysis = Self::generate_complete_analysis_with_ai(factory, &git_state, &git_diff, &options).await?; - + + let ai_analysis = + Self::generate_complete_analysis_with_ai(factory, &git_state, &git_diff, &options) + .await?; + debug!("AI complete analysis generation succeeded"); let summary = ai_analysis.summary; let ongoing_work = ai_analysis.ongoing_work; - let predicted_actions = if options.predict_next_actions { - ai_analysis.predicted_actions - } else { - Vec::new() + let predicted_actions = if options.predict_next_actions { + ai_analysis.predicted_actions + } else { + Vec::new() }; - let quick_actions = if options.include_quick_actions { - ai_analysis.quick_actions - } else { - Vec::new() + let quick_actions = if options.include_quick_actions { + ai_analysis.quick_actions + } else { + Vec::new() }; - + let current_state = CurrentWorkState { summary, git_state, ongoing_work, time_info, }; - + Ok(WorkStateAnalysis { greeting, current_state, @@ -68,7 +72,7 @@ impl WorkStateAnalyzer { analyzed_at: Local::now().to_rfc3339(), }) } - + fn generate_greeting(_options: &WorkStateOptions) -> GreetingMessage { // Frontend uses its own static greeting from i18n. GreetingMessage { @@ -77,38 +81,38 @@ impl WorkStateAnalyzer { tagline: None, } } - + async fn get_git_diff(repo_path: &Path) -> AgentResult { debug!("Getting Git diff"); - + let unstaged_output = crate::util::process_manager::create_command("git") .arg("diff") .arg("HEAD") .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get git diff: {}", e)))?; - + let mut diff = String::from_utf8_lossy(&unstaged_output.stdout).to_string(); - + let staged_output = crate::util::process_manager::create_command("git") .arg("diff") .arg("--cached") .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get staged diff: {}", e)))?; - + let staged_diff = String::from_utf8_lossy(&staged_output.stdout); - + if !staged_diff.is_empty() { diff.push_str("\n\n=== Staged Changes ===\n\n"); diff.push_str(&staged_diff); } - + debug!("Git diff retrieved: length={} chars", diff.len()); - + Ok(diff) } - + async fn generate_complete_analysis_with_ai( factory: Arc, git_state: &Option, @@ -116,41 +120,44 @@ impl WorkStateAnalyzer { options: &WorkStateOptions, ) -> AgentResult { use super::ai_service::AIWorkStateService; - + debug!("Starting AI complete analysis generation"); - - let ai_service = AIWorkStateService::new_with_agent_config(factory, "startchat-func-agent").await?; - ai_service.generate_complete_analysis(git_state, git_diff, &options.language).await + + let ai_service = + AIWorkStateService::new_with_agent_config(factory, "startchat-func-agent").await?; + ai_service + .generate_complete_analysis(git_state, git_diff, &options.language) + .await } - + async fn analyze_git_state(repo_path: &Path) -> AgentResult { let current_branch = Self::get_current_branch(repo_path)?; - + let status_output = crate::util::process_manager::create_command("git") .arg("status") .arg("--porcelain") .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get git status: {}", e)))?; - + let status_str = String::from_utf8_lossy(&status_output.stdout); - + let mut unstaged_files = 0; let mut staged_files = 0; let mut modified_files = Vec::new(); - + for line in status_str.lines() { if line.is_empty() { continue; } - + let status_code = &line[0..2]; let file_path = if line.len() > 3 { line[3..].trim().to_string() } else { continue; }; - + let (change_type, is_staged) = match status_code { "A " => (FileChangeType::Added, true), " M" => (FileChangeType::Modified, false), @@ -162,13 +169,13 @@ impl WorkStateAnalyzer { "R " => (FileChangeType::Renamed, true), _ => (FileChangeType::Modified, false), }; - + if is_staged { staged_files += 1; } else { unstaged_files += 1; } - + if modified_files.len() < 10 { modified_files.push(FileModification { path: file_path.clone(), @@ -177,10 +184,10 @@ impl WorkStateAnalyzer { }); } } - + let unpushed_commits = Self::get_unpushed_commits(repo_path)?; let ahead_behind = Self::get_ahead_behind(repo_path).ok(); - + Ok(GitWorkState { current_branch, unstaged_files, @@ -190,7 +197,7 @@ impl WorkStateAnalyzer { modified_files, }) } - + fn get_current_branch(repo_path: &Path) -> AgentResult { let output = crate::util::process_manager::create_command("git") .arg("branch") @@ -198,10 +205,10 @@ impl WorkStateAnalyzer { .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get current branch: {}", e)))?; - + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) } - + fn get_unpushed_commits(repo_path: &Path) -> AgentResult { let output = crate::util::process_manager::create_command("git") .arg("log") @@ -209,19 +216,17 @@ impl WorkStateAnalyzer { .arg("--oneline") .current_dir(repo_path) .output(); - + if let Ok(output) = output { if output.status.success() { - let count = String::from_utf8_lossy(&output.stdout) - .lines() - .count() as u32; + let count = String::from_utf8_lossy(&output.stdout).lines().count() as u32; return Ok(count); } } - + Ok(0) } - + fn get_ahead_behind(repo_path: &Path) -> AgentResult { let output = crate::util::process_manager::create_command("git") .arg("rev-list") @@ -231,14 +236,14 @@ impl WorkStateAnalyzer { .current_dir(repo_path) .output() .map_err(|e| AgentError::git_error(format!("Failed to get ahead/behind: {}", e)))?; - + if !output.status.success() { return Err(AgentError::git_error("No upstream branch configured")); } - + let result = String::from_utf8_lossy(&output.stdout); let parts: Vec<&str> = result.trim().split_whitespace().collect(); - + if parts.len() >= 2 { let ahead = parts[0].parse().unwrap_or(0); let behind = parts[1].parse().unwrap_or(0); @@ -247,17 +252,17 @@ impl WorkStateAnalyzer { Err(AgentError::git_error("Failed to parse ahead/behind info")) } } - + fn extract_module(file_path: &str) -> Option { let path = Path::new(file_path); - + if let Some(component) = path.components().next() { return Some(component.as_os_str().to_string_lossy().to_string()); } - + None } - + async fn get_time_info(repo_path: &Path) -> TimeInfo { let hour = Local::now().hour(); let time_of_day = match hour { @@ -266,14 +271,14 @@ impl WorkStateAnalyzer { 18..=22 => TimeOfDay::Evening, _ => TimeOfDay::Night, }; - + let output = crate::util::process_manager::create_command("git") .arg("log") .arg("-1") .arg("--format=%ct") .current_dir(repo_path) .output(); - + let (minutes_since_last_commit, last_commit_time_desc) = if let Ok(output) = output { if output.status.success() { let timestamp_str = String::from_utf8_lossy(&output.stdout).trim().to_string(); @@ -281,7 +286,7 @@ impl WorkStateAnalyzer { let now = Local::now().timestamp(); let diff_seconds = now - timestamp; let minutes = (diff_seconds / 60) as u64; - + // Don't format time description here, let frontend handle i18n (Some(minutes), None) } else { @@ -293,7 +298,7 @@ impl WorkStateAnalyzer { } else { (None, None) }; - + TimeInfo { minutes_since_last_commit, last_commit_time_desc, @@ -301,4 +306,3 @@ impl WorkStateAnalyzer { } } } - diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs index 24e2938a..865f0ac4 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/mod.rs @@ -1,9 +1,9 @@ -mod openai; mod anthropic; -mod responses; mod gemini; +mod openai; +mod responses; -pub use openai::handle_openai_stream; pub use anthropic::handle_anthropic_stream; -pub use responses::handle_responses_stream; pub use gemini::handle_gemini_stream; +pub use openai::handle_openai_stream; +pub use responses::handle_responses_stream; diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/responses.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/responses.rs index ec2f28ce..7d38aac1 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/responses.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/stream_handler/responses.rs @@ -98,8 +98,9 @@ fn handle_function_call_output_item_done( .and_then(Value::as_str) .unwrap_or_default(); let need_fallback_full = !tc.saw_any_delta; - let need_tail = - tc.saw_any_delta && tc.args_so_far.len() < full_args.len() && full_args.starts_with(&tc.args_so_far); + let need_tail = tc.saw_any_delta + && tc.args_so_far.len() < full_args.len() + && full_args.starts_with(&tc.args_so_far); if need_fallback_full || need_tail { let delta = if need_fallback_full { @@ -215,7 +216,10 @@ pub async fn handle_responses_stream( }; if let Some(api_error_message) = extract_api_error_message(&event_json) { - let error_msg = format!("Responses SSE API error: {}, data: {}", api_error_message, raw); + let error_msg = format!( + "Responses SSE API error: {}, data: {}", + api_error_message, raw + ); error!("{}", error_msg); let _ = tx_event.send(Err(anyhow!(error_msg))); return; @@ -234,7 +238,8 @@ pub async fn handle_responses_stream( match event.kind.as_str() { "response.output_item.added" => { // Track tool calls so we can stream arguments via `response.function_call_arguments.delta`. - if let (Some(output_index), Some(item)) = (event.output_index, event.item.as_ref()) { + if let (Some(output_index), Some(item)) = (event.output_index, event.item.as_ref()) + { if let Some(tc) = InProgressToolCall::from_item_value(item) { if let Some(ref call_id) = tc.call_id { tool_call_index_by_id.insert(call_id.clone(), output_index); @@ -340,28 +345,31 @@ pub async fn handle_responses_stream( && full_args.starts_with(&tc.args_so_far) { let delta = full_args[tc.args_so_far.len()..].to_string(); - if !delta.is_empty() { - tc.args_so_far.push_str(&delta); - let (id, name) = if tc.sent_header { - (None, None) - } else { - tc.sent_header = true; - (tc.call_id.clone(), tc.name.clone()) - }; - let _ = tx_event.send(Ok(UnifiedResponse { - tool_call: Some(crate::types::unified::UnifiedToolCall { - id, - name, - arguments: Some(delta), - }), - ..Default::default() - })); + if !delta.is_empty() { + tc.args_so_far.push_str(&delta); + let (id, name) = if tc.sent_header { + (None, None) + } else { + tc.sent_header = true; + (tc.call_id.clone(), tc.name.clone()) + }; + let _ = tx_event.send(Ok(UnifiedResponse { + tool_call: Some(crate::types::unified::UnifiedToolCall { + id, + name, + arguments: Some(delta), + }), + ..Default::default() + })); + } } } } } - } - match event.response.map(serde_json::from_value::) { + match event + .response + .map(serde_json::from_value::) + { Some(Ok(response)) => { received_finish_reason = true; let _ = tx_event.send(Ok(UnifiedResponse { @@ -372,7 +380,8 @@ pub async fn handle_responses_stream( continue; } Some(Err(e)) => { - let error_msg = format!("Failed to parse response.completed payload: {}", e); + let error_msg = + format!("Failed to parse response.completed payload: {}", e); error!("{}", error_msg); let _ = tx_event.send(Err(anyhow!(error_msg))); return; @@ -539,10 +548,16 @@ mod tests { &mut tool_call_index_by_id, ); - let response = rx_event.try_recv().expect("tool call event").expect("ok response"); + let response = rx_event + .try_recv() + .expect("tool call event") + .expect("ok response"); let tool_call = response.tool_call.expect("tool call"); assert_eq!(tool_call.id.as_deref(), Some("call_1")); assert_eq!(tool_call.name.as_deref(), Some("get_weather")); - assert_eq!(tool_call.arguments.as_deref(), Some("{\"city\":\"Beijing\"}")); + assert_eq!( + tool_call.arguments.as_deref(), + Some("{\"city\":\"Beijing\"}") + ); } } diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/gemini.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/gemini.rs index 3cb810f2..c2e26719 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/gemini.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/gemini.rs @@ -241,7 +241,10 @@ impl GeminiSSEData { } } - fn safety_summary(prompt_feedback: Option<&Value>, safety_ratings: Option<&Value>) -> Option { + fn safety_summary( + prompt_feedback: Option<&Value>, + safety_ratings: Option<&Value>, + ) -> Option { let mut lines = Vec::new(); if let Some(prompt_feedback) = prompt_feedback { diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs index c266edbd..39693a3a 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/mod.rs @@ -1,5 +1,5 @@ -pub mod unified; -pub mod openai; pub mod anthropic; -pub mod responses; pub mod gemini; +pub mod openai; +pub mod responses; +pub mod unified; diff --git a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/responses.rs b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/responses.rs index 6e8a3e00..a12ef79b 100644 --- a/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/responses.rs +++ b/src/crates/core/src/infrastructure/ai/ai_stream_handlers/src/types/responses.rs @@ -174,8 +174,8 @@ mod tests { })) .expect("event"); - let completed: ResponsesCompleted = serde_json::from_value(event.response.expect("response")) - .expect("completed"); + let completed: ResponsesCompleted = + serde_json::from_value(event.response.expect("response")).expect("completed"); assert_eq!(completed.id, "resp_1"); assert_eq!(completed.usage.expect("usage").total_tokens, 14); } diff --git a/src/crates/core/src/infrastructure/ai/client.rs b/src/crates/core/src/infrastructure/ai/client.rs index 35d6bcf7..7d987874 100644 --- a/src/crates/core/src/infrastructure/ai/client.rs +++ b/src/crates/core/src/infrastructure/ai/client.rs @@ -9,12 +9,14 @@ use crate::service::config::ProxyConfig; use crate::util::types::*; use crate::util::JsonChecker; use ai_stream_handlers::{ - handle_anthropic_stream, handle_gemini_stream, handle_openai_stream, handle_responses_stream, UnifiedResponse, + handle_anthropic_stream, handle_gemini_stream, handle_openai_stream, handle_responses_stream, + UnifiedResponse, }; use anyhow::{anyhow, Result}; use futures::StreamExt; use log::{debug, error, info, warn}; use reqwest::{Client, Proxy}; +use serde::Deserialize; use std::collections::HashMap; use tokio::sync::mpsc; @@ -32,6 +34,28 @@ pub struct AIClient { pub config: AIConfig, } +#[derive(Debug, Deserialize)] +struct OpenAIModelsResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct OpenAIModelEntry { + id: String, +} + +#[derive(Debug, Deserialize)] +struct AnthropicModelsResponse { + data: Vec, +} + +#[derive(Debug, Deserialize)] +struct AnthropicModelEntry { + id: String, + #[serde(default)] + display_name: Option, +} + impl AIClient { const TEST_IMAGE_EXPECTED_CODE: &'static str = "BYGR"; const TEST_IMAGE_PNG_BASE64: &'static str = @@ -99,7 +123,10 @@ impl AIClient { } fn is_responses_api_format(api_format: &str) -> bool { - matches!(api_format.to_ascii_lowercase().as_str(), "response" | "responses") + matches!( + api_format.to_ascii_lowercase().as_str(), + "response" | "responses" + ) } fn build_test_connection_extra_body(&self) -> Option { @@ -127,7 +154,113 @@ impl AIClient { } fn is_gemini_api_format(api_format: &str) -> bool { - matches!(api_format.to_ascii_lowercase().as_str(), "gemini" | "google") + matches!( + api_format.to_ascii_lowercase().as_str(), + "gemini" | "google" + ) + } + + fn normalize_base_url_for_discovery(base_url: &str) -> String { + base_url + .trim() + .trim_end_matches('#') + .trim_end_matches('/') + .to_string() + } + + fn resolve_openai_models_url(&self) -> String { + let mut base = Self::normalize_base_url_for_discovery(&self.config.base_url); + + for suffix in ["/chat/completions", "/responses", "/models"] { + if base.ends_with(suffix) { + base.truncate(base.len() - suffix.len()); + break; + } + } + + if base.is_empty() { + return "models".to_string(); + } + + format!("{}/models", base) + } + + fn resolve_anthropic_models_url(&self) -> String { + let mut base = Self::normalize_base_url_for_discovery(&self.config.base_url); + + if base.ends_with("/v1/messages") { + base.truncate(base.len() - "/v1/messages".len()); + return format!("{}/v1/models", base); + } + + if base.ends_with("/v1/models") { + return base; + } + + if base.ends_with("/v1") { + return format!("{}/models", base); + } + + if base.is_empty() { + return "v1/models".to_string(); + } + + format!("{}/v1/models", base) + } + + fn dedupe_remote_models(models: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut deduped = Vec::new(); + + for model in models { + if seen.insert(model.id.clone()) { + deduped.push(model); + } + } + + deduped + } + + async fn list_openai_models(&self) -> Result> { + let url = self.resolve_openai_models_url(); + let response = self + .apply_openai_headers(self.client.get(&url)) + .send() + .await? + .error_for_status()?; + + let payload: OpenAIModelsResponse = response.json().await?; + Ok(Self::dedupe_remote_models( + payload + .data + .into_iter() + .map(|model| RemoteModelInfo { + id: model.id, + display_name: None, + }) + .collect(), + )) + } + + async fn list_anthropic_models(&self) -> Result> { + let url = self.resolve_anthropic_models_url(); + let response = self + .apply_anthropic_headers(self.client.get(&url), &url) + .send() + .await? + .error_for_status()?; + + let payload: AnthropicModelsResponse = response.json().await?; + Ok(Self::dedupe_remote_models( + payload + .data + .into_iter() + .map(|model| RemoteModelInfo { + id: model.id, + display_name: model.display_name, + }) + .collect(), + )) } /// Create an AIClient without proxy (backward compatible) @@ -469,9 +602,11 @@ impl AIClient { fn normalize_gemini_stop_sequences(value: &serde_json::Value) -> Option { match value { - serde_json::Value::String(sequence) => Some(serde_json::Value::Array(vec![ - serde_json::Value::String(sequence.clone()), - ])), + serde_json::Value::String(sequence) => { + Some(serde_json::Value::Array(vec![serde_json::Value::String( + sequence.clone(), + )])) + } serde_json::Value::Array(items) => { let sequences = items .iter() @@ -557,7 +692,9 @@ impl AIClient { Self::insert_gemini_generation_field(request_body, "temperature", temperature); } - let top_p = extra_obj.remove("top_p").or_else(|| extra_obj.remove("topP")); + let top_p = extra_obj + .remove("top_p") + .or_else(|| extra_obj.remove("topP")); if let Some(top_p) = top_p { Self::insert_gemini_generation_field(request_body, "topP", top_p); } @@ -567,11 +704,7 @@ impl AIClient { .and_then(Self::normalize_gemini_stop_sequences) { extra_obj.remove("stop"); - Self::insert_gemini_generation_field( - request_body, - "stopSequences", - stop_sequences, - ); + Self::insert_gemini_generation_field(request_body, "stopSequences", stop_sequences); } if let Some(response_mime_type) = extra_obj @@ -863,21 +996,30 @@ impl AIClient { } if let Some(top_p) = self.config.top_p { - Self::insert_gemini_generation_field(&mut request_body, "topP", serde_json::json!(top_p)); + Self::insert_gemini_generation_field( + &mut request_body, + "topP", + serde_json::json!(top_p), + ); } if self.config.enable_thinking_process { - Self::insert_gemini_generation_field(&mut request_body, "thinkingConfig", serde_json::json!({ - "includeThoughts": true, - })); + Self::insert_gemini_generation_field( + &mut request_body, + "thinkingConfig", + serde_json::json!({ + "includeThoughts": true, + }), + ); } if let Some(tools) = gemini_tools { let tool_names = tools .iter() .flat_map(|tool| { - if let Some(declarations) = - tool.get("functionDeclarations").and_then(|value| value.as_array()) + if let Some(declarations) = tool + .get("functionDeclarations") + .and_then(|value| value.as_array()) { declarations .iter() @@ -903,7 +1045,8 @@ impl AIClient { let has_function_declarations = request_body["tools"] .as_array() .map(|tools| { - tools.iter() + tools + .iter() .any(|tool| tool.get("functionDeclarations").is_some()) }) .unwrap_or(false); @@ -925,9 +1068,7 @@ impl AIClient { for (key, value) in extra_obj { if let Some(request_obj) = request_body.as_object_mut() { - let target = request_obj - .entry(key) - .or_insert(serde_json::Value::Null); + let target = request_obj.entry(key).or_insert(serde_json::Value::Null); Self::merge_json_value(target, value); } } @@ -1321,8 +1462,12 @@ impl AIClient { let (instructions, response_input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); let openai_tools = OpenAIMessageConverter::convert_tools(tools); - let request_body = - self.build_responses_request_body(instructions, response_input, openai_tools, extra_body); + let request_body = self.build_responses_request_body( + instructions, + response_input, + openai_tools, + extra_body, + ); let mut last_error = None; let base_wait_time_ms = 500; @@ -1343,7 +1488,11 @@ impl AIClient { .await .unwrap_or_else(|e| format!("Failed to read error response: {}", e)); error!("Responses API client error {}: {}", status, error_text); - return Err(anyhow!("Responses API client error {}: {}", status, error_text)); + return Err(anyhow!( + "Responses API client error {}: {}", + status, + error_text + )); } if status.is_success() { @@ -1843,6 +1992,17 @@ impl AIClient { } } } + + pub async fn list_models(&self) -> Result> { + match self.get_api_format().to_ascii_lowercase().as_str() { + "openai" | "response" | "responses" => self.list_openai_models().await, + "anthropic" => self.list_anthropic_models().await, + unsupported => Err(anyhow!( + "Listing models is not supported for API format: {}", + unsupported + )), + } + } } #[cfg(test)] @@ -1912,6 +2072,62 @@ mod tests { assert_eq!(extra_body["temperature"], 0.3); } + #[test] + fn resolves_openai_models_url_from_completion_endpoint() { + let client = AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "https://api.openai.com/v1/chat/completions".to_string(), + request_url: "https://api.openai.com/v1/chat/completions".to_string(), + api_key: "test-key".to_string(), + model: "gpt-4.1".to_string(), + format: "openai".to_string(), + context_window: 128000, + max_tokens: Some(8192), + temperature: None, + top_p: None, + enable_thinking_process: false, + support_preserved_thinking: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + custom_request_body: None, + }); + + assert_eq!( + client.resolve_openai_models_url(), + "https://api.openai.com/v1/models" + ); + } + + #[test] + fn resolves_anthropic_models_url_from_messages_endpoint() { + let client = AIClient::new(AIConfig { + name: "test".to_string(), + base_url: "https://api.anthropic.com/v1/messages".to_string(), + request_url: "https://api.anthropic.com/v1/messages".to_string(), + api_key: "test-key".to_string(), + model: "claude-sonnet-4-5".to_string(), + format: "anthropic".to_string(), + context_window: 200000, + max_tokens: Some(8192), + temperature: None, + top_p: None, + enable_thinking_process: false, + support_preserved_thinking: false, + custom_headers: None, + custom_headers_mode: None, + skip_ssl_verify: false, + reasoning_effort: None, + custom_request_body: None, + }); + + assert_eq!( + client.resolve_anthropic_models_url(), + "https://api.anthropic.com/v1/models" + ); + } + #[test] fn build_gemini_request_body_translates_response_format_and_merges_generation_config() { let client = AIClient::new(AIConfig { @@ -1975,7 +2191,10 @@ mod tests { "application/json" ); assert_eq!(request_body["generationConfig"]["candidateCount"], 1); - assert_eq!(request_body["generationConfig"]["stopSequences"], json!(["END"])); + assert_eq!( + request_body["generationConfig"]["stopSequences"], + json!(["END"]) + ); assert_eq!( request_body["generationConfig"]["responseJsonSchema"]["required"], json!(["answer"]) diff --git a/src/crates/core/src/infrastructure/ai/client_factory.rs b/src/crates/core/src/infrastructure/ai/client_factory.rs index 94300f4b..3b70e326 100644 --- a/src/crates/core/src/infrastructure/ai/client_factory.rs +++ b/src/crates/core/src/infrastructure/ai/client_factory.rs @@ -21,6 +21,20 @@ pub struct AIClientFactory { } impl AIClientFactory { + fn resolve_model_reference_in_config( + global_config: &crate::service::config::GlobalConfig, + model_ref: &str, + ) -> Option { + global_config.ai.resolve_model_reference(model_ref) + } + + fn resolve_model_selection_in_config( + global_config: &crate::service::config::GlobalConfig, + model_ref: &str, + ) -> Option { + global_config.ai.resolve_model_selection(model_ref) + } + fn new(config_service: Arc) -> Self { Self { config_service, @@ -63,28 +77,17 @@ impl AIClientFactory { /// Get a client (supports resolving primary/fast) pub async fn get_client_resolved(&self, model_id: &str) -> Result> { + let global_config: crate::service::config::GlobalConfig = + self.config_service.get_config(None).await?; + let resolved_model_id = match model_id { - "primary" => { - let global_config: crate::service::config::GlobalConfig = - self.config_service.get_config(None).await?; - global_config - .ai - .default_models - .primary - .ok_or_else(|| anyhow!("Primary model not configured"))? - } - "fast" => { - let global_config: crate::service::config::GlobalConfig = - self.config_service.get_config(None).await?; - - match global_config.ai.default_models.fast { - Some(fast_id) => fast_id, - None => global_config.ai.default_models.primary.ok_or_else(|| { - anyhow!("Fast model not configured and primary model not configured") - })?, - } - } - _ => model_id.to_string(), + "primary" => Self::resolve_model_selection_in_config(&global_config, "primary") + .ok_or_else(|| anyhow!("Primary model not configured or invalid"))?, + "fast" => Self::resolve_model_selection_in_config(&global_config, "fast").ok_or_else( + || anyhow!("Fast model not configured or invalid, and primary model not configured or invalid"), + )?, + _ => Self::resolve_model_reference_in_config(&global_config, model_id) + .unwrap_or_else(|| model_id.to_string()), }; self.get_or_create_client(&resolved_model_id).await @@ -128,6 +131,15 @@ impl AIClientFactory { } async fn get_or_create_client(&self, model_id: &str) -> Result> { + let global_config: crate::service::config::GlobalConfig = + self.config_service.get_config(None).await?; + let normalized_model_id = match model_id { + "primary" | "fast" => Self::resolve_model_selection_in_config(&global_config, model_id) + .unwrap_or_else(|| model_id.to_string()), + _ => Self::resolve_model_reference_in_config(&global_config, model_id) + .unwrap_or_else(|| model_id.to_string()), + }; + { let cache = match self.client_cache.read() { Ok(cache) => cache, @@ -138,21 +150,22 @@ impl AIClientFactory { poisoned.into_inner() } }; - if let Some(client) = cache.get(model_id) { + if let Some(client) = cache.get(&normalized_model_id) { return Ok(client.clone()); } } - debug!("Creating new AI client: model_id={}", model_id); - - let global_config: crate::service::config::GlobalConfig = - self.config_service.get_config(None).await?; + debug!("Creating new AI client: model_id={}", normalized_model_id); let model_config = global_config .ai .models .iter() - .find(|m| m.id == model_id) - .ok_or_else(|| anyhow!("Model configuration not found: {}", model_id))?; + .find(|m| { + m.id == normalized_model_id + || m.name == normalized_model_id + || m.model_name == normalized_model_id + }) + .ok_or_else(|| anyhow!("Model configuration not found: {}", normalized_model_id))?; let ai_config = AIConfig::try_from(model_config.clone()) .map_err(|e| anyhow!("AI configuration conversion failed: {}", e))?; @@ -175,12 +188,12 @@ impl AIClientFactory { poisoned.into_inner() } }; - cache.insert(model_id.to_string(), client.clone()); + cache.insert(model_config.id.clone(), client.clone()); } debug!( "AI client created: model_id={}, name={}", - model_id, model_config.name + model_config.id, model_config.name ); Ok(client) @@ -257,3 +270,76 @@ pub async fn get_global_ai_client_factory() -> BitFunResult pub async fn initialize_global_ai_client_factory() -> BitFunResult<()> { AIClientFactory::initialize_global().await } + +#[cfg(test)] +mod tests { + use super::AIClientFactory; + use crate::service::config::types::{AIModelConfig, GlobalConfig}; + + fn build_model(id: &str, name: &str, model_name: &str) -> AIModelConfig { + AIModelConfig { + id: id.to_string(), + name: name.to_string(), + model_name: model_name.to_string(), + provider: "anthropic".to_string(), + enabled: true, + ..Default::default() + } + } + + #[test] + fn resolve_model_reference_supports_id_name_and_model_name() { + let mut config = GlobalConfig::default(); + config.ai.models = vec![build_model( + "model-123", + "Primary Chat", + "claude-sonnet-4.5", + )]; + + assert_eq!( + AIClientFactory::resolve_model_reference_in_config(&config, "model-123"), + Some("model-123".to_string()) + ); + assert_eq!( + AIClientFactory::resolve_model_reference_in_config(&config, "Primary Chat"), + Some("model-123".to_string()) + ); + assert_eq!( + AIClientFactory::resolve_model_reference_in_config(&config, "claude-sonnet-4.5"), + Some("model-123".to_string()) + ); + } + + #[test] + fn resolve_fast_selection_falls_back_to_primary_when_fast_missing() { + let mut config = GlobalConfig::default(); + config.ai.models = vec![build_model( + "model-primary", + "Primary Chat", + "claude-sonnet-4.5", + )]; + config.ai.default_models.primary = Some("model-primary".to_string()); + + assert_eq!( + AIClientFactory::resolve_model_selection_in_config(&config, "fast"), + Some("model-primary".to_string()) + ); + } + + #[test] + fn resolve_fast_selection_falls_back_to_primary_when_fast_is_stale() { + let mut config = GlobalConfig::default(); + config.ai.models = vec![build_model( + "model-primary", + "Primary Chat", + "claude-sonnet-4.5", + )]; + config.ai.default_models.primary = Some("model-primary".to_string()); + config.ai.default_models.fast = Some("deleted-fast-model".to_string()); + + assert_eq!( + AIClientFactory::resolve_model_selection_in_config(&config, "fast"), + Some("model-primary".to_string()) + ); + } +} diff --git a/src/crates/core/src/infrastructure/ai/mod.rs b/src/crates/core/src/infrastructure/ai/mod.rs index 544e738e..ae9e7015 100644 --- a/src/crates/core/src/infrastructure/ai/mod.rs +++ b/src/crates/core/src/infrastructure/ai/mod.rs @@ -9,4 +9,6 @@ pub mod providers; pub use ai_stream_handlers; pub use client::{AIClient, StreamResponse}; -pub use client_factory::{AIClientFactory, get_global_ai_client_factory, initialize_global_ai_client_factory}; +pub use client_factory::{ + get_global_ai_client_factory, initialize_global_ai_client_factory, AIClientFactory, +}; diff --git a/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs b/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs index c1684ff5..e01d6710 100644 --- a/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs +++ b/src/crates/core/src/infrastructure/ai/providers/anthropic/mod.rs @@ -5,4 +5,3 @@ pub mod message_converter; pub use message_converter::AnthropicMessageConverter; - diff --git a/src/crates/core/src/infrastructure/ai/providers/gemini/message_converter.rs b/src/crates/core/src/infrastructure/ai/providers/gemini/message_converter.rs index 70000f97..71752bed 100644 --- a/src/crates/core/src/infrastructure/ai/providers/gemini/message_converter.rs +++ b/src/crates/core/src/infrastructure/ai/providers/gemini/message_converter.rs @@ -7,7 +7,10 @@ use serde_json::{json, Map, Value}; pub struct GeminiMessageConverter; impl GeminiMessageConverter { - pub fn convert_messages(messages: Vec, model_name: &str) -> (Option, Vec) { + pub fn convert_messages( + messages: Vec, + model_name: &str, + ) -> (Option, Vec) { let mut system_texts = Vec::new(); let mut contents = Vec::new(); let is_gemini_3 = model_name.contains("gemini-3"); @@ -36,7 +39,11 @@ impl GeminiMessageConverter { .map(|tool_calls| !tool_calls.is_empty()) .unwrap_or(false); - if let Some(content) = msg.content.as_deref().filter(|value| !value.trim().is_empty()) { + if let Some(content) = msg + .content + .as_deref() + .filter(|value| !value.trim().is_empty()) + { if !has_tool_calls { if let Some(signature) = pending_thought_signature.take() { parts.push(json!({ @@ -516,7 +523,9 @@ impl GeminiMessageConverter { Some(Value::String(value)) if value != "null" => (Some(value), false), Some(Value::String(_)) => (None, true), Some(Value::Array(values)) => { - let mut types = values.into_iter().filter_map(|value| value.as_str().map(str::to_string)); + let mut types = values + .into_iter() + .filter_map(|value| value.as_str().map(str::to_string)); let mut nullable = false; let mut selected = None; @@ -565,7 +574,11 @@ impl GeminiMessageConverter { nullable } - fn merge_schema_variants(target: &mut Map, variants: Value, preserve_required: bool) { + fn merge_schema_variants( + target: &mut Map, + variants: Value, + preserve_required: bool, + ) { if let Value::Array(variants) = variants { for variant in variants { if let Value::Object(map) = Self::strip_unsupported_schema_fields(variant) { diff --git a/src/crates/core/src/infrastructure/ai/providers/mod.rs b/src/crates/core/src/infrastructure/ai/providers/mod.rs index d0e806ae..452cfabc 100644 --- a/src/crates/core/src/infrastructure/ai/providers/mod.rs +++ b/src/crates/core/src/infrastructure/ai/providers/mod.rs @@ -2,9 +2,9 @@ //! //! Provides a unified interface for different AI providers -pub mod openai; pub mod anthropic; pub mod gemini; +pub mod openai; pub use anthropic::AnthropicMessageConverter; pub use gemini::GeminiMessageConverter; diff --git a/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs b/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs index 0eb1de14..b4b095d6 100644 --- a/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs +++ b/src/crates/core/src/infrastructure/ai/providers/openai/message_converter.rs @@ -1,20 +1,23 @@ //! OpenAI message format converter -use log::{warn, error}; use crate::util::types::{Message, ToolDefinition}; +use log::{error, warn}; use serde_json::{json, Value}; pub struct OpenAIMessageConverter; impl OpenAIMessageConverter { - pub fn convert_messages_to_responses_input(messages: Vec) -> (Option, Vec) { + pub fn convert_messages_to_responses_input( + messages: Vec, + ) -> (Option, Vec) { let mut instructions = Vec::new(); let mut input = Vec::new(); for msg in messages { match msg.role.as_str() { "system" => { - if let Some(content) = msg.content.filter(|content| !content.trim().is_empty()) { + if let Some(content) = msg.content.filter(|content| !content.trim().is_empty()) + { instructions.push(content); } } @@ -24,7 +27,10 @@ impl OpenAIMessageConverter { } } "assistant" => { - if let Some(content_items) = Self::convert_message_content_to_responses_items(&msg.role, msg.content.as_deref()) { + if let Some(content_items) = Self::convert_message_content_to_responses_items( + &msg.role, + msg.content.as_deref(), + ) { input.push(json!({ "type": "message", "role": "assistant", @@ -45,7 +51,10 @@ impl OpenAIMessageConverter { } } role => { - if let Some(content_items) = Self::convert_message_content_to_responses_items(role, msg.content.as_deref()) { + if let Some(content_items) = Self::convert_message_content_to_responses_items( + role, + msg.content.as_deref(), + ) { input.push(json!({ "type": "message", "role": role, @@ -66,14 +75,17 @@ impl OpenAIMessageConverter { } pub fn convert_messages(messages: Vec) -> Vec { - messages.into_iter() + messages + .into_iter() .map(Self::convert_single_message) .collect() } fn convert_tool_message_to_responses_item(msg: Message) -> Option { let call_id = msg.tool_call_id?; - let output = msg.content.unwrap_or_else(|| "Tool execution completed".to_string()); + let output = msg + .content + .unwrap_or_else(|| "Tool execution completed".to_string()); Some(json!({ "type": "function_call_output", @@ -82,7 +94,10 @@ impl OpenAIMessageConverter { })) } - fn convert_message_content_to_responses_items(role: &str, content: Option<&str>) -> Option> { + fn convert_message_content_to_responses_items( + role: &str, + content: Option<&str>, + ) -> Option> { let content = content?; let text_item_type = Self::responses_text_item_type(role); @@ -118,14 +133,12 @@ impl OpenAIMessageConverter { } } Some("image_url") if role != "assistant" => { - let image_url = item - .get("image_url") - .and_then(|value| { - value - .get("url") - .and_then(Value::as_str) - .or_else(|| value.as_str()) - }); + let image_url = item.get("image_url").and_then(|value| { + value + .get("url") + .and_then(Value::as_str) + .or_else(|| value.as_str()) + }); if let Some(image_url) = image_url { content_items.push(json!({ @@ -172,15 +185,12 @@ impl OpenAIMessageConverter { } else if msg.role == "tool" { openai_msg["content"] = Value::String("Tool execution completed".to_string()); warn!( - "[OpenAI] Tool response content is empty: name={:?}", + "[OpenAI] Tool response content is empty: name={:?}", msg.name ); } else { openai_msg["content"] = Value::String(" ".to_string()); - warn!( - "[OpenAI] Message content is empty: role={}", - msg.role - ); + warn!("[OpenAI] Message content is empty: role={}", msg.role); } } else { if let Ok(parsed) = serde_json::from_str::(&content) { @@ -199,9 +209,9 @@ impl OpenAIMessageConverter { openai_msg["content"] = Value::String(" ".to_string()); } else if msg.role == "tool" { openai_msg["content"] = Value::String("Tool execution completed".to_string()); - + warn!( - "[OpenAI] Tool response message content is empty, set to default: name={:?}", + "[OpenAI] Tool response message content is empty, set to default: name={:?}", msg.name ); } else { @@ -210,7 +220,7 @@ impl OpenAIMessageConverter { msg.role, has_tool_calls ); - + openai_msg["content"] = Value::String(" ".to_string()); } } @@ -300,7 +310,8 @@ mod tests { }, ]; - let (instructions, input) = OpenAIMessageConverter::convert_messages_to_responses_input(messages); + let (instructions, input) = + OpenAIMessageConverter::convert_messages_to_responses_input(messages); assert_eq!(instructions.as_deref(), Some("You are helpful")); assert_eq!(input.len(), 3); @@ -313,18 +324,21 @@ mod tests { fn converts_openai_style_image_content_to_responses_input() { let messages = vec![Message { role: "user".to_string(), - content: Some(json!([ - { - "type": "image_url", - "image_url": { - "url": "data:image/png;base64,abc" + content: Some( + json!([ + { + "type": "image_url", + "image_url": { + "url": "data:image/png;base64,abc" + } + }, + { + "type": "text", + "text": "Describe this image" } - }, - { - "type": "text", - "text": "Describe this image" - } - ]).to_string()), + ]) + .to_string(), + ), reasoning_content: None, thinking_signature: None, tool_calls: None, diff --git a/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs b/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs index 3b1f965c..44ad1060 100644 --- a/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs +++ b/src/crates/core/src/infrastructure/ai/providers/openai/mod.rs @@ -3,4 +3,3 @@ pub mod message_converter; pub use message_converter::OpenAIMessageConverter; - diff --git a/src/crates/core/src/infrastructure/debug_log/http_server.rs b/src/crates/core/src/infrastructure/debug_log/http_server.rs index 5c5b2f26..e894e408 100644 --- a/src/crates/core/src/infrastructure/debug_log/http_server.rs +++ b/src/crates/core/src/infrastructure/debug_log/http_server.rs @@ -3,22 +3,25 @@ //! HTTP server that receives debug logs from web applications. //! This is platform-agnostic and can be started by any application (desktop, CLI, etc.). -use log::{trace, debug, info, warn, error}; -use std::net::SocketAddr; -use std::path::PathBuf; -use std::sync::Arc; -use std::sync::OnceLock; use axum::{ extract::{Path, State}, http::StatusCode, routing::{get, post}, Json, Router, }; +use log::{debug, error, info, trace, warn}; +use std::net::SocketAddr; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::OnceLock; use tokio::sync::RwLock; use tokio_util::sync::CancellationToken; use tower_http::cors::{Any, CorsLayer}; -use super::types::{IngestServerConfig, IngestServerState, IngestLogRequest, IngestResponse, handle_ingest, DEFAULT_INGEST_PORT}; +use super::types::{ + handle_ingest, IngestLogRequest, IngestResponse, IngestServerConfig, IngestServerState, + DEFAULT_INGEST_PORT, +}; static GLOBAL_INGEST_MANAGER: OnceLock> = OnceLock::new(); @@ -36,32 +39,35 @@ impl IngestServerManager { actual_port: Arc::new(RwLock::new(DEFAULT_INGEST_PORT)), } } - + pub fn global() -> &'static Arc { GLOBAL_INGEST_MANAGER.get_or_init(|| Arc::new(IngestServerManager::new())) } - + pub async fn start(&self, config: Option) -> anyhow::Result<()> { self.stop().await; - + let cfg = config.unwrap_or_default(); let base_port = cfg.port; - + let mut listener: Option = None; let mut actual_port = base_port; - + for offset in 0..10u16 { let port = base_port + offset; if let Some(l) = try_bind_port(port).await { listener = Some(l); actual_port = port; if offset > 0 { - info!("Default port {} is occupied, using port {} instead", base_port, port); + info!( + "Default port {} is occupied, using port {} instead", + base_port, port + ); } break; } } - + let listener = match listener { Some(l) => l, None => { @@ -70,38 +76,38 @@ impl IngestServerManager { return Ok(()); } }; - + let mut updated_cfg = cfg; updated_cfg.port = actual_port; - + let state = IngestServerState::new(updated_cfg); let cancel_token = CancellationToken::new(); - + *self.state.write().await = Some(state.clone()); *self.cancel_token.write().await = Some(cancel_token.clone()); *self.actual_port.write().await = actual_port; - + let cors = CorsLayer::new() .allow_origin(Any) .allow_methods(Any) .allow_headers(Any); - + let app = Router::new() .route("/health", get(health_handler)) .route("/ingest/:session_id", post(ingest_handler)) .layer(cors) .with_state(state.clone()); - + *state.is_running.write().await = true; - + let addr = listener.local_addr()?; info!("Debug Log Ingest Server started on http://{}", addr); info!("Debug logs will be written to: /.bitfun/debug.log"); - + let state_clone = state.clone(); tokio::spawn(async move { let server = axum::serve(listener, app); - + tokio::select! { result = server => { if let Err(e) = result { @@ -112,13 +118,13 @@ impl IngestServerManager { info!("Debug Log Ingest Server shutting down"); } } - + *state_clone.is_running.write().await = false; }); - + Ok(()) } - + pub async fn stop(&self) { if let Some(token) = self.cancel_token.write().await.take() { token.cancel(); @@ -127,20 +133,22 @@ impl IngestServerManager { } *self.state.write().await = None; } - + pub async fn restart(&self, config: IngestServerConfig) -> anyhow::Result<()> { - debug!("Restarting Debug Log Ingest Server with new config (port: {}, log_path: {:?})", - config.port, config.log_config.log_path); + debug!( + "Restarting Debug Log Ingest Server with new config (port: {}, log_path: {:?})", + config.port, config.log_config.log_path + ); self.stop().await; self.start(Some(config)).await } - + pub async fn update_log_path(&self, log_path: PathBuf) { if let Some(state) = self.state.read().await.as_ref() { state.update_log_path(log_path).await; } } - + pub async fn update_port(&self, new_port: u16, log_path: PathBuf) -> anyhow::Result<()> { let current_port = *self.actual_port.read().await; if current_port != new_port { @@ -151,11 +159,11 @@ impl IngestServerManager { Ok(()) } } - + pub async fn get_actual_port(&self) -> u16 { *self.actual_port.read().await } - + pub async fn is_running(&self) -> bool { if let Some(state) = self.state.read().await.as_ref() { *state.is_running.read().await @@ -186,30 +194,30 @@ async fn ingest_handler( if request.session_id.is_none() { request.session_id = Some(session_id); } - + let config = state.config.read().await; let log_config = config.log_config.clone(); drop(config); - - match handle_ingest(request.clone(), &log_config).await { - Ok(response) => { - trace!( - "Debug log received: [{}] {} | hypothesis: {:?}", - request.location, - request.message, - request.hypothesis_id - ); - Ok(Json(response)) - } - Err(e) => { - warn!("Failed to ingest log: {}", e); - Err(( - StatusCode::INTERNAL_SERVER_ERROR, - Json(IngestResponse { - success: false, - error: Some(e.to_string()), - }), - )) - } + + match handle_ingest(request.clone(), &log_config).await { + Ok(response) => { + trace!( + "Debug log received: [{}] {} | hypothesis: {:?}", + request.location, + request.message, + request.hypothesis_id + ); + Ok(Json(response)) } + Err(e) => { + warn!("Failed to ingest log: {}", e); + Err(( + StatusCode::INTERNAL_SERVER_ERROR, + Json(IngestResponse { + success: false, + error: Some(e.to_string()), + }), + )) + } + } } diff --git a/src/crates/core/src/infrastructure/debug_log/mod.rs b/src/crates/core/src/infrastructure/debug_log/mod.rs index 744ca895..7da06c56 100644 --- a/src/crates/core/src/infrastructure/debug_log/mod.rs +++ b/src/crates/core/src/infrastructure/debug_log/mod.rs @@ -5,12 +5,12 @@ //! - `types` - Types and handlers for the HTTP ingest server (Config, State, Request, Response) //! - `http_server` - The actual HTTP server implementation (axum-based) -pub mod types; pub mod http_server; +pub mod types; pub use types::{ - IngestServerConfig, IngestServerState, IngestLogRequest, IngestResponse, - handle_ingest, DEFAULT_INGEST_PORT, + handle_ingest, IngestLogRequest, IngestResponse, IngestServerConfig, IngestServerState, + DEFAULT_INGEST_PORT, }; pub use http_server::IngestServerManager; @@ -39,9 +39,8 @@ static DEFAULT_LOG_PATH: LazyLock = LazyLock::new(|| { .join("debug.log") }); -static DEFAULT_INGEST_URL: LazyLock> = LazyLock::new(|| { - std::env::var("BITFUN_DEBUG_INGEST_URL").ok() -}); +static DEFAULT_INGEST_URL: LazyLock> = + LazyLock::new(|| std::env::var("BITFUN_DEBUG_INGEST_URL").ok()); #[derive(Debug, Clone)] pub struct DebugLogConfig { @@ -168,7 +167,11 @@ fn ensure_parent_exists(path: &PathBuf) -> Result<()> { Ok(()) } -pub async fn append_log_async(entry: DebugLogEntry, config: Option, send_http: bool) -> Result<()> { +pub async fn append_log_async( + entry: DebugLogEntry, + config: Option, + send_http: bool, +) -> Result<()> { let cfg = config.unwrap_or_default(); let log_line = build_log_line(entry, &cfg); let log_path = cfg.log_path.clone(); diff --git a/src/crates/core/src/infrastructure/debug_log/types.rs b/src/crates/core/src/infrastructure/debug_log/types.rs index 14199c46..22298118 100644 --- a/src/crates/core/src/infrastructure/debug_log/types.rs +++ b/src/crates/core/src/infrastructure/debug_log/types.rs @@ -108,16 +108,15 @@ pub async fn handle_ingest( request: IngestLogRequest, config: &DebugLogConfig, ) -> Result { - let log_config = - if let Some(workspace_path) = get_global_workspace_service() - .and_then(|service| service.try_get_current_workspace_path()) - { - let mut cfg = config.clone(); - cfg.log_path = workspace_path.join(".bitfun").join("debug.log"); - cfg - } else { - config.clone() - }; + let log_config = if let Some(workspace_path) = + get_global_workspace_service().and_then(|service| service.try_get_current_workspace_path()) + { + let mut cfg = config.clone(); + cfg.log_path = workspace_path.join(".bitfun").join("debug.log"); + cfg + } else { + config.clone() + }; let entry: DebugLogEntry = request.into(); diff --git a/src/crates/core/src/infrastructure/events/event_system.rs b/src/crates/core/src/infrastructure/events/event_system.rs index 4ba00840..9b22e8a5 100644 --- a/src/crates/core/src/infrastructure/events/event_system.rs +++ b/src/crates/core/src/infrastructure/events/event_system.rs @@ -1,12 +1,12 @@ //! Backend event system for tool execution and custom events -use log::{trace, warn, error}; -use crate::util::types::event::ToolExecutionProgressInfo; use crate::infrastructure::events::EventEmitter; +use crate::util::types::event::ToolExecutionProgressInfo; +use anyhow::Result; +use log::{error, trace, warn}; +use serde::{Deserialize, Serialize}; use std::sync::Arc; use tokio::sync::Mutex; -use serde::{Deserialize, Serialize}; -use anyhow::Result; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", content = "value")] @@ -17,9 +17,9 @@ pub enum BackendEvent { session_id: String, questions: serde_json::Value, }, - Custom { - event_name: String, - payload: serde_json::Value + Custom { + event_name: String, + payload: serde_json::Value, }, } @@ -46,10 +46,14 @@ impl BackendEventSystem { if let Some(ref emitter) = *emitter_guard { let event_name = match &event { BackendEvent::Custom { event_name, .. } => event_name.clone(), - BackendEvent::ToolExecutionProgress(_) => "backend-event-toolexecutionprogress".to_string(), - BackendEvent::ToolAwaitingUserInput { .. } => "backend-event-toolawaitinguserinput".to_string(), + BackendEvent::ToolExecutionProgress(_) => { + "backend-event-toolexecutionprogress".to_string() + } + BackendEvent::ToolAwaitingUserInput { .. } => { + "backend-event-toolawaitinguserinput".to_string() + } }; - + let event_data = match &event { BackendEvent::Custom { payload, .. } => payload.clone(), _ => match serde_json::to_value(&event) { @@ -60,7 +64,7 @@ impl BackendEventSystem { } }, }; - + if let Err(e) = emitter.emit(&event_name, event_data).await { warn!("Failed to emit to frontend: {}", e); } @@ -76,12 +80,13 @@ impl Default for BackendEventSystem { } } -static GLOBAL_EVENT_SYSTEM: std::sync::OnceLock> = std::sync::OnceLock::new(); +static GLOBAL_EVENT_SYSTEM: std::sync::OnceLock> = + std::sync::OnceLock::new(); pub fn get_global_event_system() -> Arc { - GLOBAL_EVENT_SYSTEM.get_or_init(|| { - Arc::new(BackendEventSystem::new()) - }).clone() + GLOBAL_EVENT_SYSTEM + .get_or_init(|| Arc::new(BackendEventSystem::new())) + .clone() } pub async fn emit_global_event(event: BackendEvent) -> Result<()> { diff --git a/src/crates/core/src/infrastructure/events/mod.rs b/src/crates/core/src/infrastructure/events/mod.rs index 384f3eaf..5f5d1715 100644 --- a/src/crates/core/src/infrastructure/events/mod.rs +++ b/src/crates/core/src/infrastructure/events/mod.rs @@ -1,9 +1,11 @@ //! Event system module -pub mod event_system; pub mod emitter; +pub mod event_system; -pub use event_system::BackendEventSystem as BackendEventManager; -pub use emitter::EventEmitter; pub use bitfun_transport::TransportEmitter; -pub use event_system::{BackendEvent, BackendEventSystem, get_global_event_system, emit_global_event}; +pub use emitter::EventEmitter; +pub use event_system::BackendEventSystem as BackendEventManager; +pub use event_system::{ + emit_global_event, get_global_event_system, BackendEvent, BackendEventSystem, +}; diff --git a/src/crates/core/src/infrastructure/filesystem/file_watcher.rs b/src/crates/core/src/infrastructure/filesystem/file_watcher.rs index 3b7a0023..2e174c62 100644 --- a/src/crates/core/src/infrastructure/filesystem/file_watcher.rs +++ b/src/crates/core/src/infrastructure/filesystem/file_watcher.rs @@ -180,8 +180,7 @@ impl FileWatcher { loop { match rx.recv_timeout(poll) { Ok(Ok(event)) => { - let ignore = - rt.block_on(Self::should_ignore_event(&event, &watched_paths)); + let ignore = rt.block_on(Self::should_ignore_event(&event, &watched_paths)); if !ignore { if let Some(file_event) = Self::convert_event(&event) { lock_event_buffer(&event_buffer).push(file_event); diff --git a/src/crates/core/src/infrastructure/filesystem/mod.rs b/src/crates/core/src/infrastructure/filesystem/mod.rs index 264da0a4..96b03549 100644 --- a/src/crates/core/src/infrastructure/filesystem/mod.rs +++ b/src/crates/core/src/infrastructure/filesystem/mod.rs @@ -2,33 +2,21 @@ //! //! File operations, file tree building, file watching, and path management. -pub mod file_tree; pub mod file_operations; +pub mod file_tree; pub mod file_watcher; pub mod path_manager; -pub use path_manager::{ - PathManager, - StorageLevel, - CacheType, - get_path_manager_arc, - try_get_path_manager_arc, +pub use file_operations::{ + FileInfo, FileOperationOptions, FileOperationService, FileReadResult, FileWriteResult, }; pub use file_tree::{ - FileTreeService, - FileTreeNode, - FileTreeOptions, - FileTreeStatistics, - FileSearchResult, + FileSearchResult, FileTreeNode, FileTreeOptions, FileTreeService, FileTreeStatistics, SearchMatchType, }; -pub use file_operations::{ - FileOperationService, - FileOperationOptions, - FileInfo, - FileReadResult, - FileWriteResult, -}; -#[cfg(feature = "tauri-support")] -pub use file_watcher::{start_file_watch, stop_file_watch, get_watched_paths}; pub use file_watcher::initialize_file_watcher; +#[cfg(feature = "tauri-support")] +pub use file_watcher::{get_watched_paths, start_file_watch, stop_file_watch}; +pub use path_manager::{ + get_path_manager_arc, try_get_path_manager_arc, CacheType, PathManager, StorageLevel, +}; diff --git a/src/crates/core/src/infrastructure/filesystem/path_manager.rs b/src/crates/core/src/infrastructure/filesystem/path_manager.rs index 526c3d6f..2c9f47db 100644 --- a/src/crates/core/src/infrastructure/filesystem/path_manager.rs +++ b/src/crates/core/src/infrastructure/filesystem/path_manager.rs @@ -75,22 +75,46 @@ impl PathManager { .join(".bitfun") } - /// Get assistant workspace base directory. + /// Get the legacy assistant workspace base directory: ~/.bitfun/ /// /// `override_root` is reserved for future user customization. - pub fn assistant_workspace_base_dir(&self, override_root: Option<&Path>) -> PathBuf { + pub fn legacy_assistant_workspace_base_dir(&self, override_root: Option<&Path>) -> PathBuf { override_root .map(Path::to_path_buf) .unwrap_or_else(|| self.bitfun_home_dir()) } - /// Get the default assistant workspace directory: ~/.bitfun/workspace + /// Get assistant workspace base directory: ~/.bitfun/personal_assistant/ + /// + /// `override_root` is reserved for future user customization. + pub fn assistant_workspace_base_dir(&self, override_root: Option<&Path>) -> PathBuf { + self.legacy_assistant_workspace_base_dir(override_root) + .join("personal_assistant") + } + + /// Get the legacy default assistant workspace directory: ~/.bitfun/workspace + pub fn legacy_default_assistant_workspace_dir(&self, override_root: Option<&Path>) -> PathBuf { + self.legacy_assistant_workspace_base_dir(override_root) + .join("workspace") + } + + /// Get the default assistant workspace directory: ~/.bitfun/personal_assistant/workspace pub fn default_assistant_workspace_dir(&self, override_root: Option<&Path>) -> PathBuf { self.assistant_workspace_base_dir(override_root) .join("workspace") } - /// Get a named assistant workspace directory: ~/.bitfun/workspace- + /// Get a legacy named assistant workspace directory: ~/.bitfun/workspace- + pub fn legacy_assistant_workspace_dir( + &self, + assistant_id: &str, + override_root: Option<&Path>, + ) -> PathBuf { + self.legacy_assistant_workspace_base_dir(override_root) + .join(format!("workspace-{}", assistant_id)) + } + + /// Get a named assistant workspace directory: ~/.bitfun/personal_assistant/workspace- pub fn assistant_workspace_dir( &self, assistant_id: &str, @@ -339,6 +363,7 @@ impl PathManager { pub async fn initialize_user_directories(&self) -> BitFunResult<()> { let dirs = vec![ self.bitfun_home_dir(), + self.assistant_workspace_base_dir(None), self.user_config_dir(), self.user_agents_dir(), self.agent_templates_dir(), @@ -490,10 +515,56 @@ pub fn try_get_path_manager_arc() -> BitFunResult> { let manager = init_global_path_manager()?; match GLOBAL_PATH_MANAGER.set(Arc::clone(&manager)) { Ok(()) => Ok(manager), - Err(_) => Ok(Arc::clone( - GLOBAL_PATH_MANAGER - .get() - .expect("GLOBAL_PATH_MANAGER should be initialized after set failure"), - )), + Err(_) => Ok(Arc::clone(GLOBAL_PATH_MANAGER.get().expect( + "GLOBAL_PATH_MANAGER should be initialized after set failure", + ))), + } +} + +#[cfg(test)] +mod tests { + use super::PathManager; + + #[test] + fn assistant_workspace_paths_use_personal_assistant_subdir() { + let path_manager = PathManager::default(); + let base_dir = path_manager.assistant_workspace_base_dir(None); + + assert_eq!( + base_dir, + path_manager.bitfun_home_dir().join("personal_assistant") + ); + assert_eq!( + path_manager.default_assistant_workspace_dir(None), + base_dir.join("workspace") + ); + assert_eq!( + path_manager.assistant_workspace_dir("demo", None), + base_dir.join("workspace-demo") + ); + assert_eq!( + path_manager.resolve_assistant_workspace_dir(None, None), + base_dir.join("workspace") + ); + assert_eq!( + path_manager.resolve_assistant_workspace_dir(Some("demo"), None), + base_dir.join("workspace-demo") + ); + } + + #[test] + fn legacy_assistant_workspace_paths_remain_at_bitfun_root() { + let path_manager = PathManager::default(); + let legacy_base_dir = path_manager.legacy_assistant_workspace_base_dir(None); + + assert_eq!(legacy_base_dir, path_manager.bitfun_home_dir()); + assert_eq!( + path_manager.legacy_default_assistant_workspace_dir(None), + legacy_base_dir.join("workspace") + ); + assert_eq!( + path_manager.legacy_assistant_workspace_dir("demo", None), + legacy_base_dir.join("workspace-demo") + ); } } diff --git a/src/crates/core/src/infrastructure/storage/cleanup.rs b/src/crates/core/src/infrastructure/storage/cleanup.rs index 0b3869d6..02607949 100644 --- a/src/crates/core/src/infrastructure/storage/cleanup.rs +++ b/src/crates/core/src/infrastructure/storage/cleanup.rs @@ -2,13 +2,13 @@ //! //! Provides storage cleanup policies and scheduling -use log::{debug, info, warn}; -use crate::util::errors::*; use crate::infrastructure::PathManager; +use crate::util::errors::*; +use log::{debug, info, warn}; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; -use std::time::{SystemTime, Duration}; +use std::time::{Duration, SystemTime}; use tokio::fs; -use serde::{Serialize, Deserialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CleanupPolicy { @@ -60,73 +60,76 @@ impl CleanupService { policy, } } - + pub async fn cleanup_all(&self) -> BitFunResult { let mut result = CleanupResult::default(); - + if !self.policy.auto_cleanup_enabled { return Ok(result); } - + info!("Starting cleanup process"); - + if let Ok(temp_result) = self.cleanup_temp_files().await { result.merge(temp_result, "Temporary Files"); } - + if let Ok(log_result) = self.cleanup_old_logs().await { result.merge(log_result, "Old Logs"); } - + if let Ok(session_result) = self.cleanup_old_sessions().await { result.merge(session_result, "Expired Sessions"); } - + if let Ok(cache_result) = self.cleanup_oversized_cache().await { result.merge(cache_result, "Oversized Cache"); } - + info!( "Cleanup completed: {} files, {} dirs, {:.2} MB freed", result.files_deleted, result.directories_deleted, result.bytes_freed as f64 / 1_048_576.0 ); - + Ok(result) } - + async fn cleanup_temp_files(&self) -> BitFunResult { let temp_dir = self.path_manager.temp_dir(); let retention = Duration::from_secs(self.policy.temp_retention_days * 24 * 3600); - + self.cleanup_old_files(&temp_dir, retention).await } - + async fn cleanup_old_logs(&self) -> BitFunResult { let logs_dir = self.path_manager.logs_dir(); let retention = Duration::from_secs(self.policy.log_retention_days * 24 * 3600); - + self.cleanup_old_files(&logs_dir, retention).await } - + async fn cleanup_old_sessions(&self) -> BitFunResult { let mut result = CleanupResult::default(); - + let workspaces_dir = self.path_manager.workspaces_dir(); - + if !workspaces_dir.exists() { return Ok(result); } - + let retention = Duration::from_secs(self.policy.session_retention_days * 24 * 3600); - - let mut read_dir = fs::read_dir(&workspaces_dir).await + + let mut read_dir = fs::read_dir(&workspaces_dir) + .await .map_err(|e| BitFunError::service(format!("Failed to read workspaces: {}", e)))?; - - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read workspace entry: {}", e)))? { - + + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read workspace entry: {}", e)))? + { if entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false) { let session_result = self.cleanup_old_files(&entry.path(), retention).await?; result.files_deleted += session_result.files_deleted; @@ -134,62 +137,72 @@ impl CleanupService { result.bytes_freed += session_result.bytes_freed; } } - + Ok(result) } - + async fn cleanup_oversized_cache(&self) -> BitFunResult { let cache_dir = self.path_manager.cache_root(); let max_size = self.policy.max_cache_size_mb * 1_048_576; - + let current_size = Self::calculate_dir_size(&cache_dir).await?; - + if current_size <= max_size { return Ok(CleanupResult::default()); } - + debug!( "Cache size {:.2} MB exceeds limit {:.2} MB, cleaning up", current_size as f64 / 1_048_576.0, max_size as f64 / 1_048_576.0 ); - + self.cleanup_by_size(&cache_dir, max_size).await } - - async fn cleanup_old_files(&self, dir: &Path, retention: Duration) -> BitFunResult { + + async fn cleanup_old_files( + &self, + dir: &Path, + retention: Duration, + ) -> BitFunResult { let mut result = CleanupResult::default(); - + if !dir.exists() { return Ok(result); } - + let cutoff_time = SystemTime::now() .checked_sub(retention) .unwrap_or(SystemTime::UNIX_EPOCH); - - self.cleanup_recursively(dir, |metadata| { - metadata.modified() - .map(|time| time < cutoff_time) - .unwrap_or(false) - }, &mut result).await?; - + + self.cleanup_recursively( + dir, + |metadata| { + metadata + .modified() + .map(|time| time < cutoff_time) + .unwrap_or(false) + }, + &mut result, + ) + .await?; + Ok(result) } - + async fn cleanup_by_size(&self, dir: &Path, max_size: u64) -> BitFunResult { let mut result = CleanupResult::default(); - + let mut files = Vec::new(); self.collect_files_with_time(dir, &mut files).await?; - + files.sort_by(|a, b| b.1.cmp(&a.1)); - + let mut current_size = 0u64; - + for (path, _, size) in files { current_size += size; - + if current_size > max_size { match fs::remove_file(&path).await { Ok(_) => { @@ -202,10 +215,10 @@ impl CleanupService { } } } - + Ok(result) } - + fn cleanup_recursively<'a, F>( &'a self, dir: &'a Path, @@ -220,19 +233,22 @@ impl CleanupService { Ok(d) => d, Err(_) => return Ok(()), }; - - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? { - + + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? + { let path = entry.path(); let metadata = match entry.metadata().await { Ok(m) => m, Err(_) => continue, }; - + if metadata.is_dir() { - self.cleanup_recursively(&path, should_delete, result).await?; - + self.cleanup_recursively(&path, should_delete, result) + .await?; + if Self::is_empty_dir(&path).await { match fs::remove_dir(&path).await { Ok(_) => { @@ -256,11 +272,11 @@ impl CleanupService { } } } - + Ok(()) }) } - + fn collect_files_with_time<'a>( &'a self, dir: &'a Path, @@ -271,60 +287,64 @@ impl CleanupService { Ok(d) => d, Err(_) => return Ok(()), }; - - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? { - + + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? + { let path = entry.path(); let metadata = match entry.metadata().await { Ok(m) => m, Err(_) => continue, }; - + if metadata.is_dir() { self.collect_files_with_time(&path, files).await?; } else if let Ok(modified) = metadata.modified() { files.push((path, modified, metadata.len())); } } - + Ok(()) }) } - - fn calculate_dir_size(dir: &Path) -> std::pin::Pin> + Send + '_>> { + + fn calculate_dir_size( + dir: &Path, + ) -> std::pin::Pin> + Send + '_>> { Box::pin(async move { let mut total = 0u64; - + let mut read_dir = match fs::read_dir(dir).await { Ok(d) => d, Err(_) => return Ok(0), }; - - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? { - + + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read entry: {}", e)))? + { let metadata = match entry.metadata().await { Ok(m) => m, Err(_) => continue, }; - + if metadata.is_dir() { total += Self::calculate_dir_size(&entry.path()).await?; } else { total += metadata.len(); } } - + Ok(total) }) } - + async fn is_empty_dir(dir: &Path) -> bool { match fs::read_dir(dir).await { - Ok(mut read_dir) => { - read_dir.next_entry().await.ok().flatten().is_none() - } + Ok(mut read_dir) => read_dir.next_entry().await.ok().flatten().is_none(), Err(_) => false, } } @@ -335,7 +355,7 @@ impl CleanupResult { self.files_deleted += other.files_deleted; self.directories_deleted += other.directories_deleted; self.bytes_freed += other.bytes_freed; - + if other.files_deleted > 0 || other.bytes_freed > 0 { self.categories.push(CleanupCategory { name: category_name.to_string(), @@ -349,7 +369,7 @@ impl CleanupResult { #[cfg(test)] mod tests { use super::*; - + #[test] fn test_cleanup_policy_default() { let policy = CleanupPolicy::default(); @@ -358,4 +378,3 @@ mod tests { assert!(policy.auto_cleanup_enabled); } } - diff --git a/src/crates/core/src/infrastructure/storage/mod.rs b/src/crates/core/src/infrastructure/storage/mod.rs index 0c954cb6..85e4c3f2 100644 --- a/src/crates/core/src/infrastructure/storage/mod.rs +++ b/src/crates/core/src/infrastructure/storage/mod.rs @@ -1,9 +1,9 @@ //! Storage system -//! +//! //! Data persistence, cleanup, and storage policies. -pub mod persistence; pub mod cleanup; -pub use cleanup::{CleanupService, CleanupPolicy, CleanupResult}; +pub mod persistence; +pub use cleanup::{CleanupPolicy, CleanupResult, CleanupService}; pub use persistence::{PersistenceService, StorageOptions}; diff --git a/src/crates/core/src/infrastructure/storage/persistence.rs b/src/crates/core/src/infrastructure/storage/persistence.rs index b549d201..4ec5ba77 100644 --- a/src/crates/core/src/infrastructure/storage/persistence.rs +++ b/src/crates/core/src/infrastructure/storage/persistence.rs @@ -2,25 +2,25 @@ //! //! Provides data persistence with JSON support -use log::warn; +use crate::infrastructure::{try_get_path_manager_arc, PathManager}; use crate::util::errors::*; -use crate::infrastructure::{PathManager, try_get_path_manager_arc}; +use log::warn; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; use std::path::{Path, PathBuf}; -use serde::{Serialize, Deserialize}; -use tokio::fs; use std::sync::{Arc, LazyLock}; -use std::collections::HashMap; +use tokio::fs; use tokio::sync::Mutex; /// Global file lock map to prevent concurrent writes to the same file -static FILE_LOCKS: LazyLock>>>> = LazyLock::new(|| { - Mutex::new(HashMap::new()) -}); +static FILE_LOCKS: LazyLock>>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); /// Get or create a lock for the specified file async fn get_file_lock(path: &Path) -> Arc> { let mut locks = FILE_LOCKS.lock().await; - locks.entry(path.to_path_buf()) + locks + .entry(path.to_path_buf()) .or_insert_with(|| Arc::new(Mutex::new(()))) .clone() } @@ -52,45 +52,46 @@ impl Default for StorageOptions { impl PersistenceService { pub async fn new(base_dir: PathBuf) -> BitFunResult { if !base_dir.exists() { - fs::create_dir_all(&base_dir).await - .map_err(|e| BitFunError::service(format!("Failed to create storage directory: {}", e)))?; + fs::create_dir_all(&base_dir).await.map_err(|e| { + BitFunError::service(format!("Failed to create storage directory: {}", e)) + })?; } let path_manager = try_get_path_manager_arc()?; - - Ok(Self { + + Ok(Self { base_dir, path_manager, }) } - + pub async fn new_user_level(path_manager: Arc) -> BitFunResult { let base_dir = path_manager.user_data_dir(); path_manager.ensure_dir(&base_dir).await?; - + Ok(Self { base_dir, path_manager, }) } - + pub async fn new_project_level( path_manager: Arc, workspace_path: PathBuf, ) -> BitFunResult { let base_dir = path_manager.project_root(&workspace_path); path_manager.ensure_dir(&base_dir).await?; - + Ok(Self { base_dir, path_manager, }) } - + pub fn base_dir(&self) -> &Path { &self.base_dir } - + pub fn path_manager(&self) -> &Arc { &self.path_manager } @@ -103,17 +104,18 @@ impl PersistenceService { options: StorageOptions, ) -> BitFunResult<()> { let file_path = self.base_dir.join(format!("{}.json", key)); - + let lock = get_file_lock(&file_path).await; let _guard = lock.lock().await; - + if let Some(parent) = file_path.parent() { if !parent.exists() { - fs::create_dir_all(parent).await - .map_err(|e| BitFunError::service(format!("Failed to create directory {:?}: {}", parent, e)))?; + fs::create_dir_all(parent).await.map_err(|e| { + BitFunError::service(format!("Failed to create directory {:?}: {}", parent, e)) + })?; } } - + if options.create_backup && file_path.exists() { self.create_backup(&file_path, options.backup_count).await?; } @@ -123,17 +125,15 @@ impl PersistenceService { // Use atomic writes: write to a temp file first, then rename to avoid corruption on interruption. let temp_path = file_path.with_extension("json.tmp"); - - fs::write(&temp_path, &json_data).await - .map_err(|e| { - BitFunError::service(format!("Failed to write temp file: {}", e)) - })?; - - fs::rename(&temp_path, &file_path).await - .map_err(|e| { - let _ = std::fs::remove_file(&temp_path); - BitFunError::service(format!("Failed to rename temp file: {}", e)) - })?; + + fs::write(&temp_path, &json_data) + .await + .map_err(|e| BitFunError::service(format!("Failed to write temp file: {}", e)))?; + + fs::rename(&temp_path, &file_path).await.map_err(|e| { + let _ = std::fs::remove_file(&temp_path); + BitFunError::service(format!("Failed to rename temp file: {}", e)) + })?; Ok(()) } @@ -143,12 +143,13 @@ impl PersistenceService { key: &str, ) -> BitFunResult> { let file_path = self.base_dir.join(format!("{}.json", key)); - + if !file_path.exists() { return Ok(None); } - let content = fs::read_to_string(&file_path).await + let content = fs::read_to_string(&file_path) + .await .map_err(|e| BitFunError::service(format!("Failed to read file: {}", e)))?; let data: T = serde_json::from_str(&content) @@ -159,9 +160,10 @@ impl PersistenceService { pub async fn delete(&self, key: &str) -> BitFunResult { let json_path = self.base_dir.join(format!("{}.json", key)); - + if json_path.exists() { - fs::remove_file(&json_path).await + fs::remove_file(&json_path) + .await .map_err(|e| BitFunError::service(format!("Failed to delete JSON file: {}", e)))?; return Ok(true); } @@ -172,11 +174,13 @@ impl PersistenceService { async fn create_backup(&self, file_path: &Path, max_backups: usize) -> BitFunResult<()> { let backup_dir = self.base_dir.join("backups"); if !backup_dir.exists() { - fs::create_dir_all(&backup_dir).await - .map_err(|e| BitFunError::service(format!("Failed to create backup directory: {}", e)))?; + fs::create_dir_all(&backup_dir).await.map_err(|e| { + BitFunError::service(format!("Failed to create backup directory: {}", e)) + })?; } - let file_name = file_path.file_name() + let file_name = file_path + .file_name() .and_then(|n| n.to_str()) .ok_or_else(|| BitFunError::service("Invalid file name".to_string()))?; @@ -184,10 +188,12 @@ impl PersistenceService { let backup_name = format!("{}_{}", timestamp, file_name); let backup_path = backup_dir.join(backup_name); - fs::copy(file_path, &backup_path).await + fs::copy(file_path, &backup_path) + .await .map_err(|e| BitFunError::service(format!("Failed to create backup: {}", e)))?; - self.cleanup_old_backups(&backup_dir, file_name, max_backups).await?; + self.cleanup_old_backups(&backup_dir, file_name, max_backups) + .await?; Ok(()) } @@ -199,12 +205,15 @@ impl PersistenceService { max_backups: usize, ) -> BitFunResult<()> { let mut backups = Vec::new(); - let mut read_dir = fs::read_dir(backup_dir).await + let mut read_dir = fs::read_dir(backup_dir) + .await .map_err(|e| BitFunError::service(format!("Failed to read backup directory: {}", e)))?; - while let Some(entry) = read_dir.next_entry().await - .map_err(|e| BitFunError::service(format!("Failed to read backup entry: {}", e)))? { - + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::service(format!("Failed to read backup entry: {}", e)))? + { if let Some(file_name) = entry.file_name().to_str() { if file_name.ends_with(file_pattern) { if let Ok(metadata) = entry.metadata().await { diff --git a/src/crates/core/src/lib.rs b/src/crates/core/src/lib.rs index bb420fac..62aa467e 100644 --- a/src/crates/core/src/lib.rs +++ b/src/crates/core/src/lib.rs @@ -2,37 +2,34 @@ // BitFun Core Library - Platform-agnostic business logic // Four-layer architecture: Util -> Infrastructure -> Service -> Agentic -pub mod util; // Utility layer - General types, errors, helper functions -pub mod infrastructure; // Infrastructure layer - AI clients, storage, logging, events -pub mod service; // Service layer - Workspace, Config, FileSystem, Terminal, Git -pub mod agentic; // Agentic service layer - Agent system, tool system +pub mod agentic; // Agentic service layer - Agent system, tool system pub mod function_agents; // Function Agents - Function-based agents -pub mod miniapp; // MiniApp - AI-generated instant apps (Zero-Dialect Runtime) -// Re-export debug_log from infrastructure for backward compatibility +pub mod infrastructure; // Infrastructure layer - AI clients, storage, logging, events +pub mod miniapp; +pub mod service; // Service layer - Workspace, Config, FileSystem, Terminal, Git +pub mod util; // Utility layer - General types, errors, helper functions // MiniApp - AI-generated instant apps (Zero-Dialect Runtime) + // Re-export debug_log from infrastructure for backward compatibility pub use infrastructure::debug_log as debug; // Export main types -pub use util::types::*; pub use util::errors::*; +pub use util::types::*; // Export service layer components pub use service::{ - workspace::{WorkspaceService, WorkspaceProvider, WorkspaceManager}, - config::{ConfigService, ConfigManager}, + config::{ConfigManager, ConfigService}, + workspace::{WorkspaceManager, WorkspaceProvider, WorkspaceService}, }; // Export infrastructure components -pub use infrastructure::{ - ai::AIClient, - events::BackendEventManager, -}; +pub use infrastructure::{ai::AIClient, events::BackendEventManager}; // Export Agentic service core types pub use agentic::{ - core::{Session, DialogTurn, ModelRound, Message}, - tools::{Tool, ToolPipeline}, - execution::{ExecutionEngine, StreamProcessor}, + core::{DialogTurn, Message, ModelRound, Session}, events::{AgenticEvent, EventQueue, EventRouter}, + execution::{ExecutionEngine, StreamProcessor}, + tools::{Tool, ToolPipeline}, }; // Export ToolRegistry separately @@ -41,4 +38,3 @@ pub use agentic::tools::registry::ToolRegistry; // Version information pub const VERSION: &str = env!("CARGO_PKG_VERSION"); pub const CORE_NAME: &str = "BitFun Core"; - diff --git a/src/crates/core/src/miniapp/bridge_builder.rs b/src/crates/core/src/miniapp/bridge_builder.rs index 21281ce3..00ecabd7 100644 --- a/src/crates/core/src/miniapp/bridge_builder.rs +++ b/src/crates/core/src/miniapp/bridge_builder.rs @@ -145,19 +145,14 @@ fn escape_js_str(s: &str) -> String { pub fn build_import_map(deps: &[EsmDep]) -> String { let mut imports = serde_json::Map::new(); for dep in deps { - let url = dep.url.clone().unwrap_or_else(|| { - match &dep.version { - Some(v) => format!("https://esm.sh/{}@{}", dep.name, v), - None => format!("https://esm.sh/{}", dep.name), - } + let url = dep.url.clone().unwrap_or_else(|| match &dep.version { + Some(v) => format!("https://esm.sh/{}@{}", dep.name, v), + None => format!("https://esm.sh/{}", dep.name), }); imports.insert(dep.name.clone(), serde_json::Value::String(url)); } let json = serde_json::json!({ "imports": imports }); - format!( - r#""#, - json.to_string() - ) + format!(r#""#, json.to_string()) } /// Build CSP meta content from permissions (net.allow → connect-src). diff --git a/src/crates/core/src/miniapp/compiler.rs b/src/crates/core/src/miniapp/compiler.rs index a2446d72..2272408b 100644 --- a/src/crates/core/src/miniapp/compiler.rs +++ b/src/crates/core/src/miniapp/compiler.rs @@ -47,12 +47,7 @@ pub fn compile( let head_content = format!( "\n{}\n{}\n{}\n{}\n{}\n{}\n", - theme_default_style, - csp_tag, - scroll, - import_map, - bridge_script_tag, - style_tag, + theme_default_style, csp_tag, scroll, import_map, bridge_script_tag, style_tag, ); let html = if source.html.trim().is_empty() { @@ -165,7 +160,8 @@ mod tests { #[test] fn test_inject_into_head() { - let html = r#"x"#; + let html = + r#"x"#; let content = ""; let out = inject_into_head(html, content).unwrap(); assert!(out.contains("")); diff --git a/src/crates/core/src/miniapp/exporter.rs b/src/crates/core/src/miniapp/exporter.rs index 21754e1a..c8cc9abb 100644 --- a/src/crates/core/src/miniapp/exporter.rs +++ b/src/crates/core/src/miniapp/exporter.rs @@ -80,7 +80,11 @@ impl MiniAppExporter { } /// Export the MiniApp to a standalone application. - pub async fn export(&self, _app_id: &str, _options: ExportOptions) -> BitFunResult { + pub async fn export( + &self, + _app_id: &str, + _options: ExportOptions, + ) -> BitFunResult { Err(BitFunError::validation( "Export not yet implemented (skeleton)".to_string(), )) diff --git a/src/crates/core/src/miniapp/js_worker.rs b/src/crates/core/src/miniapp/js_worker.rs index 397a736a..f7cf7673 100644 --- a/src/crates/core/src/miniapp/js_worker.rs +++ b/src/crates/core/src/miniapp/js_worker.rs @@ -44,7 +44,10 @@ impl JsWorker { let stderr = child.stderr.take().ok_or("No stderr")?; let _stdout = child.stdout.take(); - let pending = Arc::new(Mutex::new(HashMap::>>::new())); + let pending = Arc::new(Mutex::new(HashMap::< + String, + oneshot::Sender>, + >::new())); let last_activity = Arc::new(AtomicI64::new( std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -61,14 +64,15 @@ impl JsWorker { if line.is_empty() { continue; } - let _ = last_activity_clone.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |_| { - Some( - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64, - ) - }); + let _ = + last_activity_clone.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |_| { + Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64, + ) + }); let msg: Value = match serde_json::from_str(&line) { Ok(v) => v, Err(_) => continue, @@ -76,10 +80,15 @@ impl JsWorker { let id = msg.get("id").and_then(Value::as_str).map(String::from); if let Some(id) = id { let result = if let Some(err) = msg.get("error") { - let msg = err.get("message").and_then(Value::as_str).unwrap_or("RPC error"); + let msg = err + .get("message") + .and_then(Value::as_str) + .unwrap_or("RPC error"); Err(msg.to_string()) } else { - msg.get("result").cloned().ok_or_else(|| "Missing result".to_string()) + msg.get("result") + .cloned() + .ok_or_else(|| "Missing result".to_string()) }; let mut guard = pending_clone.lock().await; if let Some(tx) = guard.remove(&id) { @@ -98,7 +107,12 @@ impl JsWorker { } /// Send a JSON-RPC request and wait for the response (with timeout). - pub async fn call(&self, method: &str, params: Value, timeout_ms: u64) -> Result { + pub async fn call( + &self, + method: &str, + params: Value, + timeout_ms: u64, + ) -> Result { let id = format!("rpc-{}", uuid::Uuid::new_v4()); let request = serde_json::json!({ "jsonrpc": "2.0", @@ -124,7 +138,10 @@ impl JsWorker { let mut stdin_guard = self.stdin.lock().await; let stdin = stdin_guard.as_mut().ok_or("Worker stdin closed")?; use tokio::io::AsyncWriteExt; - stdin.write_all(line.as_bytes()).await.map_err(|e| e.to_string())?; + stdin + .write_all(line.as_bytes()) + .await + .map_err(|e| e.to_string())?; stdin.flush().await.map_err(|e| e.to_string())?; drop(stdin_guard); diff --git a/src/crates/core/src/miniapp/js_worker_pool.rs b/src/crates/core/src/miniapp/js_worker_pool.rs index 9fb7eaae..42d7fdc8 100644 --- a/src/crates/core/src/miniapp/js_worker_pool.rs +++ b/src/crates/core/src/miniapp/js_worker_pool.rs @@ -2,7 +2,7 @@ use crate::miniapp::js_worker::JsWorker; use crate::miniapp::runtime_detect::{detect_runtime, DetectedRuntime}; -use crate::miniapp::types::{NpmDep, NodePermissions}; +use crate::miniapp::types::{NodePermissions, NpmDep}; use crate::util::errors::{BitFunError, BitFunResult}; use serde_json::Value; use std::path::PathBuf; @@ -38,9 +38,12 @@ impl JsWorkerPool { path_manager: Arc, worker_host_path: PathBuf, ) -> BitFunResult { - let runtime = detect_runtime() - .ok_or_else(|| BitFunError::validation("No JS runtime found (install Bun or Node.js)".to_string()))?; - let workers = Arc::new(Mutex::new(std::collections::HashMap::::new())); + let runtime = detect_runtime().ok_or_else(|| { + BitFunError::validation("No JS runtime found (install Bun or Node.js)".to_string()) + })?; + let workers = Arc::new(Mutex::new( + std::collections::HashMap::::new(), + )); // Background task: evict idle workers every 60s without waiting for a new spawn. let workers_bg = Arc::clone(&workers); @@ -113,21 +116,17 @@ impl JsWorkerPool { let app_dir = self.path_manager.miniapp_dir(app_id); if !app_dir.exists() { - return Err(BitFunError::NotFound(format!("MiniApp dir not found: {}", app_id))); + return Err(BitFunError::NotFound(format!( + "MiniApp dir not found: {}", + app_id + ))); } - let worker = JsWorker::spawn( - &self.runtime, - &self.worker_host_path, - &app_dir, - policy_json, - ) - .await - .map_err(|e| BitFunError::validation(e))?; + let worker = JsWorker::spawn(&self.runtime, &self.worker_host_path, &app_dir, policy_json) + .await + .map_err(|e| BitFunError::validation(e))?; - let _timeout_ms = node_perms - .and_then(|n| n.timeout_ms) - .unwrap_or(30_000); + let _timeout_ms = node_perms.and_then(|n| n.timeout_ms).unwrap_or(30_000); let worker = Arc::new(Mutex::new(worker)); guard.insert( app_id.to_string(), @@ -139,10 +138,7 @@ impl JsWorkerPool { Ok(worker) } - async fn evict_idle( - &self, - guard: &mut std::collections::HashMap, - ) { + async fn evict_idle(&self, guard: &mut std::collections::HashMap) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -167,10 +163,7 @@ impl JsWorkerPool { } } - async fn evict_lru( - &self, - guard: &mut std::collections::HashMap, - ) { + async fn evict_lru(&self, guard: &mut std::collections::HashMap) { let (oldest_id, _) = guard .iter() .map(|(id, entry)| { @@ -204,11 +197,12 @@ impl JsWorkerPool { let worker = self .get_or_spawn(app_id, worker_revision, policy_json, permissions) .await?; - let timeout_ms = permissions - .and_then(|n| n.timeout_ms) - .unwrap_or(30_000); + let timeout_ms = permissions.and_then(|n| n.timeout_ms).unwrap_or(30_000); let guard = worker.lock().await; - guard.call(method, params, timeout_ms).await.map_err(BitFunError::validation) + guard + .call(method, params, timeout_ms) + .await + .map_err(BitFunError::validation) } /// Stop and remove the Worker for the app. @@ -241,11 +235,18 @@ impl JsWorkerPool { } pub fn has_installed_deps(&self, app_id: &str) -> bool { - self.path_manager.miniapp_dir(app_id).join("node_modules").exists() + self.path_manager + .miniapp_dir(app_id) + .join("node_modules") + .exists() } /// Install npm dependencies for the app (bun install or npm/pnpm install). - pub async fn install_deps(&self, app_id: &str, _deps: &[NpmDep]) -> BitFunResult { + pub async fn install_deps( + &self, + app_id: &str, + _deps: &[NpmDep], + ) -> BitFunResult { let app_dir = self.path_manager.miniapp_dir(app_id); let package_json = app_dir.join("package.json"); if !package_json.exists() { diff --git a/src/crates/core/src/miniapp/manager.rs b/src/crates/core/src/miniapp/manager.rs index 4755767d..442665f7 100644 --- a/src/crates/core/src/miniapp/manager.rs +++ b/src/crates/core/src/miniapp/manager.rs @@ -161,20 +161,10 @@ impl MiniAppManager { let id = Uuid::new_v4().to_string(); let now = Utc::now().timestamp_millis(); - let compiled_html = self.compile_source( - &id, - &source, - &permissions, - "dark", - workspace_root, - )?; - let runtime = Self::build_runtime_state( - 1, - now, - &source, - !source.npm_dependencies.is_empty(), - true, - ); + let compiled_html = + self.compile_source(&id, &source, &permissions, "dark", workspace_root)?; + let runtime = + Self::build_runtime_state(1, now, &source, !source.npm_dependencies.is_empty(), true); let app = MiniApp { id: id.clone(), @@ -310,10 +300,7 @@ impl MiniAppManager { /// Get app storage (KV) value. pub async fn get_storage(&self, app_id: &str, key: &str) -> BitFunResult { let storage = self.storage.load_app_storage(app_id).await?; - Ok(storage - .get(key) - .cloned() - .unwrap_or(serde_json::Value::Null)) + Ok(storage.get(key).cloned().unwrap_or(serde_json::Value::Null)) } /// Set app storage (KV) value. @@ -379,13 +366,8 @@ impl MiniAppManager { workspace_root: Option<&Path>, ) -> BitFunResult { let mut app = self.storage.load(app_id).await?; - app.compiled_html = self.compile_source( - app_id, - &app.source, - &app.permissions, - theme, - workspace_root, - )?; + app.compiled_html = + self.compile_source(app_id, &app.source, &app.permissions, theme, workspace_root)?; app.updated_at = Utc::now().timestamp_millis(); Self::ensure_runtime_state(&mut app); app.runtime.ui_recompile_required = false; @@ -405,13 +387,8 @@ impl MiniAppManager { app.version += 1; app.updated_at = Utc::now().timestamp_millis(); - app.compiled_html = self.compile_source( - app_id, - &app.source, - &app.permissions, - theme, - workspace_root, - )?; + app.compiled_html = + self.compile_source(app_id, &app.source, &app.permissions, theme, workspace_root)?; app.runtime = Self::build_runtime_state( app.version, app.updated_at, @@ -502,14 +479,13 @@ impl MiniAppManager { if esm_path.exists() { tokio::fs::copy(&esm_path, dest_source.join("esm_dependencies.json")) .await - .map_err(|e| BitFunError::io(format!("Failed to copy esm_dependencies.json: {}", e)))?; + .map_err(|e| { + BitFunError::io(format!("Failed to copy esm_dependencies.json: {}", e)) + })?; } else { - tokio::fs::write( - dest_source.join("esm_dependencies.json"), - "[]", - ) - .await - .map_err(|_e| BitFunError::io("Failed to write esm_dependencies.json"))?; + tokio::fs::write(dest_source.join("esm_dependencies.json"), "[]") + .await + .map_err(|_e| BitFunError::io("Failed to write esm_dependencies.json"))?; } let pkg_src = src.join("package.json"); diff --git a/src/crates/core/src/miniapp/mod.rs b/src/crates/core/src/miniapp/mod.rs index 74a1d1f5..1922f0af 100644 --- a/src/crates/core/src/miniapp/mod.rs +++ b/src/crates/core/src/miniapp/mod.rs @@ -13,11 +13,13 @@ pub mod types; pub use exporter::{ExportCheckResult, ExportOptions, ExportResult, ExportTarget, MiniAppExporter}; pub use js_worker_pool::{InstallResult, JsWorkerPool}; -pub use manager::{MiniAppManager, initialize_global_miniapp_manager, try_get_global_miniapp_manager}; +pub use manager::{ + initialize_global_miniapp_manager, try_get_global_miniapp_manager, MiniAppManager, +}; pub use permission_policy::resolve_policy; pub use runtime_detect::{DetectedRuntime, RuntimeKind}; pub use storage::MiniAppStorage; pub use types::{ - EsmDep, FsPermissions, MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, MiniAppSource, - NpmDep, NodePermissions, NetPermissions, PathScope, ShellPermissions, + EsmDep, FsPermissions, MiniApp, MiniAppAiContext, MiniAppMeta, MiniAppPermissions, + MiniAppSource, NetPermissions, NodePermissions, NpmDep, PathScope, ShellPermissions, }; diff --git a/src/crates/core/src/miniapp/permission_policy.rs b/src/crates/core/src/miniapp/permission_policy.rs index 2487e60e..a65edd4d 100644 --- a/src/crates/core/src/miniapp/permission_policy.rs +++ b/src/crates/core/src/miniapp/permission_policy.rs @@ -55,9 +55,7 @@ pub fn resolve_policy( let allow = shell .allow .as_ref() - .map(|v| { - Value::Array(v.iter().map(|s| Value::String(s.clone())).collect()) - }) + .map(|v| Value::Array(v.iter().map(|s| Value::String(s.clone())).collect())) .unwrap_or_else(|| Value::Array(Vec::new())); policy.insert("shell".to_string(), serde_json::json!({ "allow": allow })); } @@ -66,9 +64,7 @@ pub fn resolve_policy( let allow = net .allow .as_ref() - .map(|v| { - Value::Array(v.iter().map(|s| Value::String(s.clone())).collect()) - }) + .map(|v| Value::Array(v.iter().map(|s| Value::String(s.clone())).collect())) .unwrap_or_else(|| Value::Array(Vec::new())); policy.insert("net".to_string(), serde_json::json!({ "allow": allow })); } diff --git a/src/crates/core/src/miniapp/runtime_detect.rs b/src/crates/core/src/miniapp/runtime_detect.rs index 4595294f..39fce75c 100644 --- a/src/crates/core/src/miniapp/runtime_detect.rs +++ b/src/crates/core/src/miniapp/runtime_detect.rs @@ -40,9 +40,7 @@ pub fn detect_runtime() -> Option { } fn get_version(executable: &std::path::Path) -> Result { - let out = Command::new(executable) - .arg("--version") - .output()?; + let out = Command::new(executable).arg("--version").output()?; if out.status.success() { let v = String::from_utf8_lossy(&out.stdout); Ok(v.trim().to_string()) diff --git a/src/crates/core/src/miniapp/storage.rs b/src/crates/core/src/miniapp/storage.rs index 9bdf1857..945119f8 100644 --- a/src/crates/core/src/miniapp/storage.rs +++ b/src/crates/core/src/miniapp/storage.rs @@ -59,10 +59,18 @@ impl MiniAppStorage { let dir = self.app_dir(app_id); let source = self.source_dir(app_id); tokio::fs::create_dir_all(&dir).await.map_err(|e| { - BitFunError::io(format!("Failed to create miniapp dir {}: {}", dir.display(), e)) + BitFunError::io(format!( + "Failed to create miniapp dir {}: {}", + dir.display(), + e + )) })?; tokio::fs::create_dir_all(&source).await.map_err(|e| { - BitFunError::io(format!("Failed to create source dir {}: {}", source.display(), e)) + BitFunError::io(format!( + "Failed to create source dir {}: {}", + source.display(), + e + )) })?; Ok(()) } @@ -74,12 +82,14 @@ impl MiniAppStorage { return Ok(Vec::new()); } let mut ids = Vec::new(); - let mut read_dir = tokio::fs::read_dir(&root).await.map_err(|e| { - BitFunError::io(format!("Failed to read miniapps dir: {}", e)) - })?; - while let Some(entry) = read_dir.next_entry().await.map_err(|e| { - BitFunError::io(format!("Failed to read miniapps entry: {}", e)) - })? { + let mut read_dir = tokio::fs::read_dir(&root) + .await + .map_err(|e| BitFunError::io(format!("Failed to read miniapps dir: {}", e)))?; + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::io(format!("Failed to read miniapps entry: {}", e)))? + { let path = entry.path(); if path.is_dir() { if let Some(name) = path.file_name().and_then(|n| n.to_str()) { @@ -136,9 +146,8 @@ impl MiniAppStorage { BitFunError::io(format!("Failed to read meta: {}", e)) } })?; - serde_json::from_str(&content).map_err(|e| { - BitFunError::parse(format!("Invalid meta.json: {}", e)) - }) + serde_json::from_str(&content) + .map_err(|e| BitFunError::parse(format!("Invalid meta.json: {}", e))) } async fn load_source(&self, app_id: &str) -> BitFunResult { @@ -187,9 +196,9 @@ impl MiniAppStorage { if !p.exists() { return Ok(Vec::new()); } - let c = tokio::fs::read_to_string(&p).await.map_err(|e| { - BitFunError::io(format!("Failed to read package.json: {}", e)) - })?; + let c = tokio::fs::read_to_string(&p) + .await + .map_err(|e| BitFunError::io(format!("Failed to read package.json: {}", e)))?; let pkg: serde_json::Value = serde_json::from_str(&c) .map_err(|e| BitFunError::parse(format!("Invalid package.json: {}", e)))?; let empty = serde_json::Map::new(); @@ -225,29 +234,31 @@ impl MiniAppStorage { let meta = MiniAppMeta::from(app); let meta_path = self.meta_path(&app.id); let meta_json = serde_json::to_string_pretty(&meta).map_err(BitFunError::from)?; - tokio::fs::write(&meta_path, meta_json).await.map_err(|e| { - BitFunError::io(format!("Failed to write meta: {}", e)) - })?; + tokio::fs::write(&meta_path, meta_json) + .await + .map_err(|e| BitFunError::io(format!("Failed to write meta: {}", e)))?; let sd = self.source_dir(&app.id); - tokio::fs::write(sd.join(INDEX_HTML), &app.source.html).await.map_err(|e| { - BitFunError::io(format!("Failed to write index.html: {}", e)) - })?; - tokio::fs::write(sd.join(STYLE_CSS), &app.source.css).await.map_err(|e| { - BitFunError::io(format!("Failed to write style.css: {}", e)) - })?; - tokio::fs::write(sd.join(UI_JS), &app.source.ui_js).await.map_err(|e| { - BitFunError::io(format!("Failed to write ui.js: {}", e)) - })?; - tokio::fs::write(sd.join(WORKER_JS), &app.source.worker_js).await.map_err(|e| { - BitFunError::io(format!("Failed to write worker.js: {}", e)) - })?; + tokio::fs::write(sd.join(INDEX_HTML), &app.source.html) + .await + .map_err(|e| BitFunError::io(format!("Failed to write index.html: {}", e)))?; + tokio::fs::write(sd.join(STYLE_CSS), &app.source.css) + .await + .map_err(|e| BitFunError::io(format!("Failed to write style.css: {}", e)))?; + tokio::fs::write(sd.join(UI_JS), &app.source.ui_js) + .await + .map_err(|e| BitFunError::io(format!("Failed to write ui.js: {}", e)))?; + tokio::fs::write(sd.join(WORKER_JS), &app.source.worker_js) + .await + .map_err(|e| BitFunError::io(format!("Failed to write worker.js: {}", e)))?; - let esm_json = - serde_json::to_string_pretty(&app.source.esm_dependencies).map_err(BitFunError::from)?; - tokio::fs::write(sd.join(ESM_DEPS_JSON), esm_json).await.map_err(|e| { - BitFunError::io(format!("Failed to write esm_dependencies.json: {}", e)) - })?; + let esm_json = serde_json::to_string_pretty(&app.source.esm_dependencies) + .map_err(BitFunError::from)?; + tokio::fs::write(sd.join(ESM_DEPS_JSON), esm_json) + .await + .map_err(|e| { + BitFunError::io(format!("Failed to write esm_dependencies.json: {}", e)) + })?; self.write_package_json(&app.id, &app.source.npm_dependencies) .await?; @@ -271,23 +282,28 @@ impl MiniAppStorage { }); let p = self.app_dir(app_id).join(PACKAGE_JSON); let json = serde_json::to_string_pretty(&pkg).map_err(BitFunError::from)?; - tokio::fs::write(&p, json).await.map_err(|e| { - BitFunError::io(format!("Failed to write package.json: {}", e)) - })?; + tokio::fs::write(&p, json) + .await + .map_err(|e| BitFunError::io(format!("Failed to write package.json: {}", e)))?; Ok(()) } /// Save a version snapshot (for rollback). - pub async fn save_version(&self, app_id: &str, version: u32, app: &MiniApp) -> BitFunResult<()> { + pub async fn save_version( + &self, + app_id: &str, + version: u32, + app: &MiniApp, + ) -> BitFunResult<()> { let versions_dir = self.app_dir(app_id).join(VERSIONS_DIR); - tokio::fs::create_dir_all(&versions_dir).await.map_err(|e| { - BitFunError::io(format!("Failed to create versions dir: {}", e)) - })?; + tokio::fs::create_dir_all(&versions_dir) + .await + .map_err(|e| BitFunError::io(format!("Failed to create versions dir: {}", e)))?; let path = self.version_path(app_id, version); let json = serde_json::to_string_pretty(app).map_err(BitFunError::from)?; - tokio::fs::write(&path, json).await.map_err(|e| { - BitFunError::io(format!("Failed to write version file: {}", e)) - })?; + tokio::fs::write(&path, json) + .await + .map_err(|e| BitFunError::io(format!("Failed to write version file: {}", e)))?; Ok(()) } @@ -297,9 +313,9 @@ impl MiniAppStorage { if !p.exists() { return Ok(serde_json::json!({})); } - let c = tokio::fs::read_to_string(&p).await.map_err(|e| { - BitFunError::io(format!("Failed to read storage: {}", e)) - })?; + let c = tokio::fs::read_to_string(&p) + .await + .map_err(|e| BitFunError::io(format!("Failed to read storage: {}", e)))?; Ok(serde_json::from_str(&c).unwrap_or_else(|_| serde_json::json!({}))) } @@ -312,15 +328,15 @@ impl MiniAppStorage { ) -> BitFunResult<()> { self.ensure_app_dir(app_id).await?; let mut current = self.load_app_storage(app_id).await?; - let obj = current.as_object_mut().ok_or_else(|| { - BitFunError::validation("App storage is not an object".to_string()) - })?; + let obj = current + .as_object_mut() + .ok_or_else(|| BitFunError::validation("App storage is not an object".to_string()))?; obj.insert(key.to_string(), value); let p = self.storage_path(app_id); let json = serde_json::to_string_pretty(¤t).map_err(BitFunError::from)?; - tokio::fs::write(&p, json).await.map_err(|e| { - BitFunError::io(format!("Failed to write storage: {}", e)) - })?; + tokio::fs::write(&p, json) + .await + .map_err(|e| BitFunError::io(format!("Failed to write storage: {}", e)))?; Ok(()) } @@ -328,9 +344,9 @@ impl MiniAppStorage { pub async fn delete(&self, app_id: &str) -> BitFunResult<()> { let dir = self.app_dir(app_id); if dir.exists() { - tokio::fs::remove_dir_all(&dir).await.map_err(|e| { - BitFunError::io(format!("Failed to delete miniapp dir: {}", e)) - })?; + tokio::fs::remove_dir_all(&dir) + .await + .map_err(|e| BitFunError::io(format!("Failed to delete miniapp dir: {}", e)))?; } Ok(()) } @@ -342,12 +358,14 @@ impl MiniAppStorage { return Ok(Vec::new()); } let mut versions = Vec::new(); - let mut read_dir = tokio::fs::read_dir(&vdir).await.map_err(|e| { - BitFunError::io(format!("Failed to read versions dir: {}", e)) - })?; - while let Some(entry) = read_dir.next_entry().await.map_err(|e| { - BitFunError::io(format!("Failed to read versions entry: {}", e)) - })? { + let mut read_dir = tokio::fs::read_dir(&vdir) + .await + .map_err(|e| BitFunError::io(format!("Failed to read versions dir: {}", e)))?; + while let Some(entry) = read_dir + .next_entry() + .await + .map_err(|e| BitFunError::io(format!("Failed to read versions entry: {}", e)))? + { let name = entry.file_name(); let name = name.to_string_lossy(); if name.starts_with('v') && name.ends_with(".json") { @@ -370,8 +388,7 @@ impl MiniAppStorage { BitFunError::io(format!("Failed to read version: {}", e)) } })?; - serde_json::from_str(&c).map_err(|e| { - BitFunError::parse(format!("Invalid version file: {}", e)) - }) + serde_json::from_str(&c) + .map_err(|e| BitFunError::parse(format!("Invalid version file: {}", e))) } } diff --git a/src/crates/core/src/service/ai_memory/manager.rs b/src/crates/core/src/service/ai_memory/manager.rs index 9a786225..fee92d23 100644 --- a/src/crates/core/src/service/ai_memory/manager.rs +++ b/src/crates/core/src/service/ai_memory/manager.rs @@ -26,9 +26,9 @@ impl AIMemoryManager { let storage_path = path_manager.user_data_dir().join("ai_memories.json"); if let Some(parent) = storage_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| BitFunError::io(format!("Failed to create memory storage directory: {}", e)))?; + fs::create_dir_all(parent).await.map_err(|e| { + BitFunError::io(format!("Failed to create memory storage directory: {}", e)) + })?; } let storage = if storage_path.exists() { @@ -53,9 +53,9 @@ impl AIMemoryManager { let storage_path = workspace_path.join(".bitfun").join("ai_memories.json"); if let Some(parent) = storage_path.parent() { - fs::create_dir_all(parent) - .await - .map_err(|e| BitFunError::io(format!("Failed to create memory storage directory: {}", e)))?; + fs::create_dir_all(parent).await.map_err(|e| { + BitFunError::io(format!("Failed to create memory storage directory: {}", e)) + })?; } let storage = if storage_path.exists() { @@ -77,8 +77,9 @@ impl AIMemoryManager { .await .map_err(|e| BitFunError::io(format!("Failed to read memory storage file: {}", e)))?; - let storage: MemoryStorage = serde_json::from_str(&content) - .map_err(|e| BitFunError::Deserialization(format!("Failed to deserialize memory storage: {}", e)))?; + let storage: MemoryStorage = serde_json::from_str(&content).map_err(|e| { + BitFunError::Deserialization(format!("Failed to deserialize memory storage: {}", e)) + })?; debug!("Loaded {} memory points from disk", storage.memories.len()); Ok(storage) @@ -87,8 +88,9 @@ impl AIMemoryManager { /// Saves storage to disk. async fn save_storage(&self) -> BitFunResult<()> { let storage = self.storage.read().await; - let content = serde_json::to_string_pretty(&*storage) - .map_err(|e| BitFunError::serialization(format!("Failed to serialize memory storage: {}", e)))?; + let content = serde_json::to_string_pretty(&*storage).map_err(|e| { + BitFunError::serialization(format!("Failed to serialize memory storage: {}", e)) + })?; fs::write(&self.storage_path, content) .await diff --git a/src/crates/core/src/service/ai_rules/service.rs b/src/crates/core/src/service/ai_rules/service.rs index 131fe6c2..19dcded7 100644 --- a/src/crates/core/src/service/ai_rules/service.rs +++ b/src/crates/core/src/service/ai_rules/service.rs @@ -353,7 +353,10 @@ impl AIRulesService { Ok(Self::calculate_stats(&rules)) } - async fn load_project_rules_for_workspace(&self, workspace: &Path) -> BitFunResult> { + async fn load_project_rules_for_workspace( + &self, + workspace: &Path, + ) -> BitFunResult> { let mut all_rules = Vec::new(); let mut loaded_names = std::collections::HashSet::new(); @@ -456,10 +459,16 @@ The rules section has a number of possible rules/memories/context that you shoul prompt } - pub async fn build_system_prompt_for(&self, workspace_root: Option<&Path>) -> BitFunResult { + pub async fn build_system_prompt_for( + &self, + workspace_root: Option<&Path>, + ) -> BitFunResult { let user_rules = self.user_rules.read().await.clone(); let project_rules = match workspace_root { - Some(workspace_root) => self.load_project_rules_for_workspace(workspace_root).await?, + Some(workspace_root) => { + self.load_project_rules_for_workspace(workspace_root) + .await? + } None => Vec::new(), }; @@ -485,7 +494,10 @@ The rules section has a number of possible rules/memories/context that you shoul let project_rules = match self.load_project_rules_for_workspace(workspace_path).await { Ok(rules) => rules, Err(e) => { - warn!("Failed to load project rules for file '{}': {}", file_path, e); + warn!( + "Failed to load project rules for file '{}': {}", + file_path, e + ); return FileRulesResult { matched_count: 0, formatted_content: None, diff --git a/src/crates/core/src/service/bootstrap/bootstrap.rs b/src/crates/core/src/service/bootstrap/bootstrap.rs index 73747dc2..dd2c058d 100644 --- a/src/crates/core/src/service/bootstrap/bootstrap.rs +++ b/src/crates/core/src/service/bootstrap/bootstrap.rs @@ -35,15 +35,14 @@ async fn ensure_markdown_placeholder(path: &Path, content: &str) -> BitFunResult Ok(true) } -pub(crate) async fn initialize_workspace_persona_files( - workspace_root: &Path, -) -> BitFunResult<()> { +pub(crate) async fn initialize_workspace_persona_files(workspace_root: &Path) -> BitFunResult<()> { let bootstrap_path = workspace_root.join(BOOTSTRAP_FILE_NAME); let soul_path = workspace_root.join(SOUL_FILE_NAME); let user_path = workspace_root.join(USER_FILE_NAME); let identity_path = workspace_root.join(IDENTITY_FILE_NAME); - let created_bootstrap = ensure_markdown_placeholder(&bootstrap_path, BOOTSTRAP_TEMPLATE).await?; + let created_bootstrap = + ensure_markdown_placeholder(&bootstrap_path, BOOTSTRAP_TEMPLATE).await?; let created_soul = ensure_markdown_placeholder(&soul_path, SOUL_TEMPLATE).await?; let created_user = ensure_markdown_placeholder(&user_path, USER_TEMPLATE).await?; let created_identity = ensure_markdown_placeholder(&identity_path, IDENTITY_TEMPLATE).await?; @@ -116,9 +115,7 @@ pub(crate) async fn ensure_workspace_persona_files_for_prompt( Ok(()) } -pub async fn reset_workspace_persona_files_to_default( - workspace_root: &Path, -) -> BitFunResult<()> { +pub async fn reset_workspace_persona_files_to_default(workspace_root: &Path) -> BitFunResult<()> { let persona_templates = [ (BOOTSTRAP_FILE_NAME, BOOTSTRAP_TEMPLATE), (SOUL_FILE_NAME, SOUL_TEMPLATE), @@ -129,13 +126,15 @@ pub async fn reset_workspace_persona_files_to_default( for (file_name, template) in persona_templates { let file_path = workspace_root.join(file_name); let normalized_content = normalize_line_endings(template); - fs::write(&file_path, normalized_content).await.map_err(|e| { - BitFunError::service(format!( - "Failed to reset persona file '{}': {}", - file_path.display(), - e - )) - })?; + fs::write(&file_path, normalized_content) + .await + .map_err(|e| { + BitFunError::service(format!( + "Failed to reset persona file '{}': {}", + file_path.display(), + e + )) + })?; } debug!( diff --git a/src/crates/core/src/service/bootstrap/mod.rs b/src/crates/core/src/service/bootstrap/mod.rs index 039e9fc5..e29cced9 100644 --- a/src/crates/core/src/service/bootstrap/mod.rs +++ b/src/crates/core/src/service/bootstrap/mod.rs @@ -1,7 +1,4 @@ mod bootstrap; -pub(crate) use bootstrap::{ - build_workspace_persona_prompt, - initialize_workspace_persona_files, -}; -pub use bootstrap::reset_workspace_persona_files_to_default; \ No newline at end of file +pub use bootstrap::reset_workspace_persona_files_to_default; +pub(crate) use bootstrap::{build_workspace_persona_prompt, initialize_workspace_persona_files}; diff --git a/src/crates/core/src/service/config/mod.rs b/src/crates/core/src/service/config/mod.rs index 77494b89..032e6540 100644 --- a/src/crates/core/src/service/config/mod.rs +++ b/src/crates/core/src/service/config/mod.rs @@ -10,7 +10,6 @@ pub mod service; pub mod tool_config_sync; pub mod types; - pub use factory::ConfigFactory; pub use global::{ get_global_config_service, initialize_global_config, reload_global_config, diff --git a/src/crates/core/src/service/config/providers.rs b/src/crates/core/src/service/config/providers.rs index 0bff49c0..4a68d2ca 100644 --- a/src/crates/core/src/service/config/providers.rs +++ b/src/crates/core/src/service/config/providers.rs @@ -83,6 +83,7 @@ impl ConfigProvider for AIConfigProvider { for (agent_name, model_id) in &ai_config.agent_models { if !ai_config.models.iter().any(|m| m.id == *model_id) + && model_id != "auto" && model_id != "primary" && model_id != "fast" { diff --git a/src/crates/core/src/service/config/types.rs b/src/crates/core/src/service/config/types.rs index 73176987..94e3cd5e 100644 --- a/src/crates/core/src/service/config/types.rs +++ b/src/crates/core/src/service/config/types.rs @@ -417,6 +417,45 @@ pub struct AIConfig { pub known_tools: Vec, } +impl AIConfig { + /// Resolves a configured model reference by `id`, `name`, or `model_name`. + pub fn resolve_model_reference(&self, model_ref: &str) -> Option { + self.models + .iter() + .find(|m| m.id == model_ref || m.name == model_ref || m.model_name == model_ref) + .map(|m| m.id.clone()) + } + + /// Resolves a model selector value. + /// + /// Special values: + /// - `primary`: must resolve to a valid primary model + /// - `fast`: first tries the configured fast model, then falls back to primary + /// + /// Regular values are resolved by `id`, `name`, or `model_name`. + pub fn resolve_model_selection(&self, model_ref: &str) -> Option { + match model_ref { + "primary" => self + .default_models + .primary + .as_deref() + .and_then(|value| self.resolve_model_reference(value)), + "fast" => self + .default_models + .fast + .as_deref() + .and_then(|value| self.resolve_model_reference(value)) + .or_else(|| { + self.default_models + .primary + .as_deref() + .and_then(|value| self.resolve_model_reference(value)) + }), + _ => self.resolve_model_reference(model_ref), + } + } +} + /// Mode configuration (tool configuration per mode). /// /// Model mapping has moved to `AIConfig.agent_models`, keyed by `mode_id`. @@ -713,7 +752,7 @@ pub struct AIModelConfig { /// Stored by the frontend when config is saved; falls back to base_url if absent. #[serde(default)] pub request_url: Option, - + pub api_key: String, /// Context window size (total token limit for input + output). pub context_window: Option, diff --git a/src/crates/core/src/service/git/git_utils.rs b/src/crates/core/src/service/git/git_utils.rs index 29b665d6..b23bc298 100644 --- a/src/crates/core/src/service/git/git_utils.rs +++ b/src/crates/core/src/service/git/git_utils.rs @@ -100,7 +100,10 @@ pub fn status_to_string(status: Status) -> String { const UNTRACKED_RECURSE_THRESHOLD: usize = 200; /// Collects file statuses from a `StatusOptions` scan. -fn collect_statuses(repo: &Repository, recurse_untracked: bool) -> Result, GitError> { +fn collect_statuses( + repo: &Repository, + recurse_untracked: bool, +) -> Result, GitError> { let mut status_options = StatusOptions::new(); status_options.include_untracked(true); status_options.include_ignored(false); diff --git a/src/crates/core/src/service/lsp/global.rs b/src/crates/core/src/service/lsp/global.rs index be13530e..d02e673f 100644 --- a/src/crates/core/src/service/lsp/global.rs +++ b/src/crates/core/src/service/lsp/global.rs @@ -2,8 +2,8 @@ //! //! Uses a global singleton to avoid adding dependencies to `AppState`. -use log::{info, warn}; use crate::infrastructure::try_get_path_manager_arc; +use log::{info, warn}; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, OnceLock}; diff --git a/src/crates/core/src/service/lsp/manager.rs b/src/crates/core/src/service/lsp/manager.rs index 2898944d..9092579b 100644 --- a/src/crates/core/src/service/lsp/manager.rs +++ b/src/crates/core/src/service/lsp/manager.rs @@ -1,6 +1,5 @@ //! LSP protocol-layer manager - use anyhow::{anyhow, Result}; use log::{debug, error, info, warn}; use std::collections::HashMap; @@ -202,7 +201,6 @@ impl LspManager { Ok(()) } - /// Returns whether the server is running. pub async fn is_server_running(&self, language: &str) -> bool { let processes = self.processes.read().await; @@ -277,7 +275,6 @@ impl LspManager { self.shutdown().await } - /// Document open notification (protocol-only; does not include startup logic). pub async fn did_open(&self, language: &str, uri: &str, text: &str) -> Result<()> { let process = self.get_process(language).await?; diff --git a/src/crates/core/src/service/mcp/adapter/resource.rs b/src/crates/core/src/service/mcp/adapter/resource.rs index 0ad37713..beb4d6e9 100644 --- a/src/crates/core/src/service/mcp/adapter/resource.rs +++ b/src/crates/core/src/service/mcp/adapter/resource.rs @@ -30,10 +30,12 @@ impl ResourceAdapter { /// Converts MCP resource content to plain text. Binary (blob) content is summarized. pub fn to_text(content: &MCPResourceContent) -> String { - let text = content - .content - .as_deref() - .unwrap_or_else(|| content.blob.as_ref().map_or("(empty)", |_| "(binary content)")); + let text = content.content.as_deref().unwrap_or_else(|| { + content + .blob + .as_ref() + .map_or("(empty)", |_| "(binary content)") + }); format!("Resource: {}\n\n{}\n", content.uri, text) } diff --git a/src/crates/core/src/service/mcp/adapter/tool.rs b/src/crates/core/src/service/mcp/adapter/tool.rs index ab2c58e4..1c4e56b7 100644 --- a/src/crates/core/src/service/mcp/adapter/tool.rs +++ b/src/crates/core/src/service/mcp/adapter/tool.rs @@ -134,9 +134,16 @@ impl Tool for MCPToolWrapper { .. } => format!("[Audio: {}]", mime_type), crate::service::mcp::protocol::MCPToolResultContent::ResourceLink { - uri, name, .. - } => name.as_ref().map_or_else(|| uri.clone(), |n| format!("[Resource: {} ({})]", n, uri)), - crate::service::mcp::protocol::MCPToolResultContent::Resource { resource } => { + uri, + name, + .. + } => name.as_ref().map_or_else( + || uri.clone(), + |n| format!("[Resource: {} ({})]", n, uri), + ), + crate::service::mcp::protocol::MCPToolResultContent::Resource { + resource, + } => { format!("[Resource: {}]", resource.uri) } }) diff --git a/src/crates/core/src/service/mcp/protocol/types.rs b/src/crates/core/src/service/mcp/protocol/types.rs index 3b506d19..08ad55bb 100644 --- a/src/crates/core/src/service/mcp/protocol/types.rs +++ b/src/crates/core/src/service/mcp/protocol/types.rs @@ -194,7 +194,12 @@ pub struct MCPResourceContentMeta { pub struct MCPResourceContent { pub uri: String, /// Text or HTML content. Serialized as `text` per MCP spec; accepts `text` or `content` when deserializing. - #[serde(default, alias = "text", rename = "text", skip_serializing_if = "Option::is_none")] + #[serde( + default, + alias = "text", + rename = "text", + skip_serializing_if = "Option::is_none" + )] pub content: Option, /// Base64-encoded binary content (MCP spec). Used for video, images, etc. #[serde(skip_serializing_if = "Option::is_none")] @@ -274,11 +279,19 @@ impl MCPPromptMessageContent { pub fn text_or_placeholder(&self) -> String { match self { MCPPromptMessageContent::Plain(s) => s.clone(), - MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Text { text }) => text.clone(), - MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Image { mime_type, .. }) => { + MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Text { text }) => { + text.clone() + } + MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Image { + mime_type, + .. + }) => { format!("[Image: {}]", mime_type) } - MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Audio { mime_type, .. }) => { + MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Audio { + mime_type, + .. + }) => { format!("[Audio: {}]", mime_type) } MCPPromptMessageContent::Block(MCPPromptMessageContentBlock::Resource { resource }) => { diff --git a/src/crates/core/src/service/mod.rs b/src/crates/core/src/service/mod.rs index f44a174e..2b9c91ed 100644 --- a/src/crates/core/src/service/mod.rs +++ b/src/crates/core/src/service/mod.rs @@ -2,8 +2,8 @@ //! //! Contains core business logic: Workspace, Config, FileSystem, Git, Agentic, AIRules, MCP. -pub mod ai_memory; // AI memory point management pub(crate) mod agent_memory; // Agent memory prompt helpers +pub mod ai_memory; // AI memory point management pub mod ai_rules; // AI rules management pub(crate) mod bootstrap; // Workspace persona bootstrap helpers pub mod config; // Config management @@ -14,11 +14,11 @@ pub mod i18n; // I18n service pub mod lsp; // LSP (Language Server Protocol) system pub mod mcp; // MCP (Model Context Protocol) system pub mod project_context; // Project context management +pub mod remote_connect; // Remote Connect (phone → desktop) pub mod runtime; // Managed runtime and capability management pub mod session; // Session persistence pub mod snapshot; // Snapshot-based change tracking pub mod system; // System command detection and execution -pub mod remote_connect; // Remote Connect (phone → desktop) pub mod token_usage; // Token usage tracking pub mod workspace; // Workspace management // Diff calculation and merge service diff --git a/src/crates/core/src/service/remote_connect/bot/command_router.rs b/src/crates/core/src/service/remote_connect/bot/command_router.rs index 1b5920ea..e33bac36 100644 --- a/src/crates/core/src/service/remote_connect/bot/command_router.rs +++ b/src/crates/core/src/service/remote_connect/bot/command_router.rs @@ -13,6 +13,20 @@ use std::sync::Arc; // ── Per-chat state ────────────────────────────────────────────────── +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +pub enum BotLanguage { + #[serde(rename = "zh-CN")] + ZhCN, + #[serde(rename = "en-US")] + EnUS, +} + +impl BotLanguage { + pub fn is_chinese(self) -> bool { + matches!(self, Self::ZhCN) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BotChatState { pub chat_id: String, @@ -42,6 +56,17 @@ impl BotChatState { } } +pub async fn current_bot_language() -> BotLanguage { + if let Some(service) = crate::service::get_global_i18n_service().await { + match service.get_current_locale().await { + crate::service::LocaleId::ZhCN => BotLanguage::ZhCN, + crate::service::LocaleId::EnUS => BotLanguage::EnUS, + } + } else { + BotLanguage::ZhCN + } +} + #[derive(Debug, Clone)] pub enum PendingAction { SelectWorkspace { @@ -94,13 +119,11 @@ pub struct BotInteractiveRequest { pub pending_action: PendingAction, } -pub type BotInteractionHandler = Arc< - dyn Fn(BotInteractiveRequest) -> Pin + Send>> + Send + Sync, ->; +pub type BotInteractionHandler = + Arc Pin + Send>> + Send + Sync>; -pub type BotMessageSender = Arc< - dyn Fn(String) -> Pin + Send>> + Send + Sync, ->; +pub type BotMessageSender = + Arc Pin + Send>> + Send + Sync>; pub struct ForwardRequest { pub session_id: String, @@ -207,60 +230,157 @@ pub fn parse_command(text: &str) -> BotCommand { // ── Static messages ───────────────────────────────────────────────── -pub const WELCOME_MESSAGE: &str = "\ +pub fn welcome_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "\ +欢迎使用 BitFun! + +要连接你的 BitFun 桌面端,请发送 BitFun Remote Connect 面板里显示的 6 位配对码。 + +如果你还没有配对码,请打开 BitFun Desktop -> Remote Connect -> Telegram/飞书机器人,复制 6 位配对码并发送到这里。" + } else { + "\ Welcome to BitFun! To connect your BitFun desktop app, please enter the 6-digit pairing code shown in your BitFun Remote Connect panel. -Need a pairing code? Open BitFun Desktop -> Remote Connect -> Telegram/Feishu Bot -> copy the 6-digit code and send it here."; +Need a pairing code? Open BitFun Desktop -> Remote Connect -> Telegram/Feishu Bot -> copy the 6-digit code and send it here." + } +} -pub const HELP_MESSAGE: &str = "\ +pub fn help_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "\ +可用命令: +/switch_workspace - 列出并切换工作区 +/resume_session - 恢复已有会话 +/new_code_session - 创建新的编码会话 +/new_cowork_session - 创建新的协作会话 +/cancel_task - 取消当前任务 +/help - 显示帮助信息" + } else { + "\ Available commands: /switch_workspace - List and switch workspaces /resume_session - Resume an existing session /new_code_session - Create a new coding session /new_cowork_session - Create a new cowork session /cancel_task - Cancel the current task -/help - Show this help message"; +/help - Show this help message" + } +} + +pub fn paired_success_message(language: BotLanguage) -> String { + if language.is_chinese() { + format!("配对成功!BitFun 已连接。\n\n{}", help_message(language)) + } else { + format!( + "Pairing successful! BitFun is now connected.\n\n{}", + help_message(language) + ) + } +} + +fn label_switch_workspace(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "切换工作区" + } else { + "Switch Workspace" + } +} + +fn label_resume_session(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "恢复会话" + } else { + "Resume Session" + } +} + +fn label_new_code_session(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "新建编码会话" + } else { + "New Code Session" + } +} + +fn label_new_cowork_session(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "新建协作会话" + } else { + "New Cowork Session" + } +} -pub fn paired_success_message() -> String { - format!( - "Pairing successful! BitFun is now connected.\n\n{}", - HELP_MESSAGE - ) +fn label_cancel_task(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "取消任务" + } else { + "Cancel Task" + } } -pub fn main_menu_actions() -> Vec { +fn label_next_page(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "下一页" + } else { + "Next Page" + } +} + +fn other_label(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "其他" + } else { + "Other" + } +} + +pub fn main_menu_actions(language: BotLanguage) -> Vec { vec![ - BotAction::primary("Switch Workspace", "/switch_workspace"), - BotAction::secondary("Resume Session", "/resume_session"), - BotAction::secondary("New Code Session", "/new_code_session"), - BotAction::secondary("New Cowork Session", "/new_cowork_session"), - BotAction::secondary("Help (send /help for menu)", "/help"), + BotAction::primary(label_switch_workspace(language), "/switch_workspace"), + BotAction::secondary(label_resume_session(language), "/resume_session"), + BotAction::secondary(label_new_code_session(language), "/new_code_session"), + BotAction::secondary(label_new_cowork_session(language), "/new_cowork_session"), + BotAction::secondary( + if language.is_chinese() { + "帮助(发送 /help 查看菜单)" + } else { + "Help (send /help for menu)" + }, + "/help", + ), ] } -fn workspace_required_actions() -> Vec { - vec![BotAction::primary("Switch Workspace", "/switch_workspace")] +fn workspace_required_actions(language: BotLanguage) -> Vec { + vec![BotAction::primary( + label_switch_workspace(language), + "/switch_workspace", + )] } -fn session_entry_actions() -> Vec { +fn session_entry_actions(language: BotLanguage) -> Vec { vec![ - BotAction::primary("Resume Session", "/resume_session"), - BotAction::secondary("New Code Session", "/new_code_session"), - BotAction::secondary("New Cowork Session", "/new_cowork_session"), + BotAction::primary(label_resume_session(language), "/resume_session"), + BotAction::secondary(label_new_code_session(language), "/new_code_session"), + BotAction::secondary(label_new_cowork_session(language), "/new_cowork_session"), ] } -fn new_session_actions() -> Vec { +fn new_session_actions(language: BotLanguage) -> Vec { vec![ - BotAction::primary("New Code Session", "/new_code_session"), - BotAction::secondary("New Cowork Session", "/new_cowork_session"), + BotAction::primary(label_new_code_session(language), "/new_code_session"), + BotAction::secondary(label_new_cowork_session(language), "/new_cowork_session"), ] } -fn cancel_task_actions(command: impl Into) -> Vec { - vec![BotAction::secondary("Cancel Task", command.into())] +fn cancel_task_actions(language: BotLanguage, command: impl Into) -> Vec { + vec![BotAction::secondary( + label_cancel_task(language), + command.into(), + )] } // ── Main dispatch ─────────────────────────────────────────────────── @@ -270,88 +390,95 @@ pub async fn handle_command( cmd: BotCommand, images: Vec, ) -> HandleResult { + let language = current_bot_language().await; let image_contexts: Vec = - super::super::remote_server::images_to_contexts( - if images.is_empty() { None } else { Some(&images) }, - ); + super::super::remote_server::images_to_contexts(if images.is_empty() { + None + } else { + Some(&images) + }); match cmd { BotCommand::Start | BotCommand::Help => { if state.paired { HandleResult { - reply: HELP_MESSAGE.to_string(), - actions: main_menu_actions(), + reply: help_message(language).to_string(), + actions: main_menu_actions(language), forward_to_session: None, } } else { HandleResult { - reply: WELCOME_MESSAGE.to_string(), + reply: welcome_message(language).to_string(), actions: vec![], forward_to_session: None, } } } BotCommand::PairingCode(_) => HandleResult { - reply: "Pairing codes are handled automatically. If you need to re-pair, \ - please restart the connection from BitFun Desktop." - .to_string(), + reply: if language.is_chinese() { + "配对码会自动处理。如果你需要重新配对,请在 BitFun Desktop 中重新启动连接。" + .to_string() + } else { + "Pairing codes are handled automatically. If you need to re-pair, please restart the connection from BitFun Desktop." + .to_string() + }, actions: vec![], forward_to_session: None, }, BotCommand::SwitchWorkspace => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_switch_workspace(state).await } BotCommand::ResumeSession => { if !state.paired { - return not_paired(); + return not_paired(language); } if state.current_workspace.is_none() { - return need_workspace(); + return need_workspace(language); } handle_resume_session(state, 0).await } BotCommand::NewCodeSession => { if !state.paired { - return not_paired(); + return not_paired(language); } if state.current_workspace.is_none() { - return need_workspace(); + return need_workspace(language); } handle_new_session(state, "agentic").await } BotCommand::NewCoworkSession => { if !state.paired { - return not_paired(); + return not_paired(language); } if state.current_workspace.is_none() { - return need_workspace(); + return need_workspace(language); } handle_new_session(state, "Cowork").await } BotCommand::CancelTask(turn_id) => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_cancel_task(state, turn_id.as_deref()).await } BotCommand::NumberSelection(n) => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_number_selection(state, n).await } BotCommand::NextPage => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_next_page(state).await } BotCommand::ChatMessage(msg) => { if !state.paired { - return not_paired(); + return not_paired(language); } handle_chat_message(state, &msg, image_contexts).await } @@ -360,19 +487,27 @@ pub async fn handle_command( // ── Helpers ───────────────────────────────────────────────────────── -fn not_paired() -> HandleResult { +fn not_paired(language: BotLanguage) -> HandleResult { HandleResult { - reply: "Not connected to BitFun Desktop. Please enter the 6-digit pairing code first." - .to_string(), + reply: if language.is_chinese() { + "尚未连接到 BitFun Desktop。请先发送 6 位配对码。".to_string() + } else { + "Not connected to BitFun Desktop. Please enter the 6-digit pairing code first." + .to_string() + }, actions: vec![], forward_to_session: None, } } -fn need_workspace() -> HandleResult { +fn need_workspace(language: BotLanguage) -> HandleResult { HandleResult { - reply: "No workspace selected. Use /switch_workspace first.".to_string(), - actions: workspace_required_actions(), + reply: if language.is_chinese() { + "尚未选择工作区。请先使用 /switch_workspace。".to_string() + } else { + "No workspace selected. Use /switch_workspace first.".to_string() + }, + actions: workspace_required_actions(language), forward_to_session: None, } } @@ -400,15 +535,13 @@ fn numbered_actions(labels: &[String]) -> Vec { .iter() .enumerate() .map(|(idx, label)| { - BotAction::secondary( - truncate_action_label(label, 28), - (idx + 1).to_string(), - ) + BotAction::secondary(truncate_action_label(label, 28), (idx + 1).to_string()) }) .collect() } fn build_question_prompt( + language: BotLanguage, tool_id: String, questions: Vec, current_index: usize, @@ -419,9 +552,14 @@ fn build_question_prompt( let question = &questions[current_index]; let mut actions = Vec::new(); let mut reply = format!( - "Question {}/{}\n", + "{} {}/{}\n", + if language.is_chinese() { + "问题" + } else { + "Question" + }, current_index + 1, - questions.len() + questions.len(), ); if !question.header.is_empty() { reply.push_str(&format!("{}\n", question.header)); @@ -431,21 +569,34 @@ fn build_question_prompt( reply.push_str(&format!("{}\n", question_option_line(idx, option))); } reply.push_str(&format!( - "{}. Other\n\n", - question.options.len() + 1 + "{}. {}\n\n", + question.options.len() + 1, + other_label(language), )); if awaiting_custom_text { - reply.push_str("Please type your custom answer."); + reply.push_str(if language.is_chinese() { + "请输入你的自定义答案。" + } else { + "Please type your custom answer." + }); } else if question.multi_select { - reply.push_str("Reply with one or more option numbers, separated by commas. Example: 1,3"); + reply.push_str(if language.is_chinese() { + "请回复一个或多个选项编号,用逗号分隔,例如:1,3" + } else { + "Reply with one or more option numbers, separated by commas. Example: 1,3" + }); } else { - reply.push_str("Reply with a single option number."); + reply.push_str(if language.is_chinese() { + "请回复单个选项编号。" + } else { + "Reply with a single option number." + }); let mut labels: Vec = question .options .iter() .map(|option| option.label.clone()) .collect(); - labels.push("Other".to_string()); + labels.push(other_label(language).to_string()); actions = numbered_actions(&labels); } @@ -482,12 +633,17 @@ fn parse_question_numbers(input: &str) -> Option> { async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { use crate::service::workspace::get_global_workspace_service; + let language = current_bot_language().await; let ws_service = match get_global_workspace_service() { Some(s) => s, None => { return HandleResult { - reply: "Workspace service not available.".to_string(), + reply: if language.is_chinese() { + "工作区服务不可用。".to_string() + } else { + "Workspace service not available.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -497,8 +653,11 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { let workspaces = ws_service.get_recent_workspaces().await; if workspaces.is_empty() { return HandleResult { - reply: "No workspaces found. Please open a project in BitFun Desktop first." - .to_string(), + reply: if language.is_chinese() { + "未找到工作区。请先在 BitFun Desktop 中打开一个项目。".to_string() + } else { + "No workspaces found. Please open a project in BitFun Desktop first.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -506,16 +665,32 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { let effective_current: Option<&str> = state.current_workspace.as_deref(); - let mut text = String::from("Select a workspace:\n\n"); + let mut text = if language.is_chinese() { + String::from("请选择工作区:\n\n") + } else { + String::from("Select a workspace:\n\n") + }; let mut options: Vec<(String, String)> = Vec::new(); for (i, ws) in workspaces.iter().enumerate() { let path = ws.root_path.to_string_lossy().to_string(); let is_current = effective_current == Some(path.as_str()); - let marker = if is_current { " [current]" } else { "" }; + let marker = if is_current { + if language.is_chinese() { + " [当前]" + } else { + " [current]" + } + } else { + "" + }; text.push_str(&format!("{}. {}{}\n {}\n", i + 1, ws.name, marker, path)); options.push((path, ws.name.clone())); } - text.push_str("\nReply with the workspace number."); + text.push_str(if language.is_chinese() { + "\n请回复工作区编号。" + } else { + "\nReply with the workspace number." + }); let action_labels: Vec = options.iter().map(|(_, name)| name.clone()).collect(); state.pending_action = Some(PendingAction::SelectWorkspace { options }); @@ -529,10 +704,11 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleResult { use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::PathManager; + let language = current_bot_language().await; let ws_path = match &state.current_workspace { Some(p) => std::path::PathBuf::from(p), - None => return need_workspace(), + None => return need_workspace(language), }; let page_size = 10usize; @@ -542,7 +718,11 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR Ok(pm) => std::sync::Arc::new(pm), Err(e) => { return HandleResult { - reply: format!("Failed to load sessions: {e}"), + reply: if language.is_chinese() { + format!("加载会话失败:{e}") + } else { + format!("Failed to load sessions: {e}") + }, actions: vec![], forward_to_session: None, }; @@ -553,7 +733,11 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR Ok(store) => store, Err(e) => { return HandleResult { - reply: format!("Failed to load sessions: {e}"), + reply: if language.is_chinese() { + format!("加载会话失败:{e}") + } else { + format!("Failed to load sessions: {e}") + }, actions: vec![], forward_to_session: None, }; @@ -564,7 +748,11 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR Ok(m) => m, Err(e) => { return HandleResult { - reply: format!("Failed to list sessions: {e}"), + reply: if language.is_chinese() { + format!("列出会话失败:{e}") + } else { + format!("Failed to list sessions: {e}") + }, actions: vec![], forward_to_session: None, }; @@ -573,10 +761,14 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR if all_meta.is_empty() { return HandleResult { - reply: "No sessions found in this workspace. Use /new_code_session or \ - /new_cowork_session to create one." - .to_string(), - actions: new_session_actions(), + reply: if language.is_chinese() { + "当前工作区没有会话。请使用 /new_code_session 或 /new_cowork_session 创建一个。" + .to_string() + } else { + "No sessions found in this workspace. Use /new_code_session or /new_cowork_session to create one." + .to_string() + }, + actions: new_session_actions(language), forward_to_session: None, }; } @@ -588,23 +780,53 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR let ws_name = ws_path .file_name() .map(|n| n.to_string_lossy().to_string()) - .unwrap_or_else(|| "Unknown".to_string()); + .unwrap_or_else(|| { + if language.is_chinese() { + "未知".to_string() + } else { + "Unknown".to_string() + } + }); - let mut text = format!("Sessions in {} (page {}):\n\n", ws_name, page + 1); + let mut text = if language.is_chinese() { + format!("{} 中的会话(第 {} 页):\n\n", ws_name, page + 1) + } else { + format!("Sessions in {} (page {}):\n\n", ws_name, page + 1) + }; let mut options: Vec<(String, String)> = Vec::new(); for (i, s) in sessions.iter().enumerate() { let is_current = state.current_session_id.as_deref() == Some(&s.session_id); - let marker = if is_current { " [current]" } else { "" }; + let marker = if is_current { + if language.is_chinese() { + " [当前]" + } else { + " [current]" + } + } else { + "" + }; let ts = chrono::DateTime::from_timestamp(s.last_active_at as i64 / 1000, 0) .map(|dt| dt.format("%m-%d %H:%M").to_string()) .unwrap_or_default(); let turn_count = s.turn_count; let msg_hint = if turn_count == 0 { - "no messages".to_string() + if language.is_chinese() { + "无消息".to_string() + } else { + "no messages".to_string() + } } else if turn_count == 1 { - "1 message".to_string() + if language.is_chinese() { + "1 条消息".to_string() + } else { + "1 message".to_string() + } } else { - format!("{turn_count} messages") + if language.is_chinese() { + format!("{turn_count} 条消息") + } else { + format!("{turn_count} messages") + } }; text.push_str(&format!( "{}. [{}] {}{}\n {} · {}\n", @@ -618,19 +840,31 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR options.push((s.session_id.clone(), s.session_name.clone())); } if has_more { - text.push_str("\n0 - Next page\n"); + text.push_str(if language.is_chinese() { + "\n0 - 下一页\n" + } else { + "\n0 - Next page\n" + }); } - text.push_str("\nReply with the session number."); + text.push_str(if language.is_chinese() { + "\n请回复会话编号。" + } else { + "\nReply with the session number." + }); - state.pending_action = Some(PendingAction::SelectSession { options, page, has_more }); + state.pending_action = Some(PendingAction::SelectSession { + options, + page, + has_more, + }); let mut action_labels: Vec = sessions .iter() .map(|session| format!("[{}] {}", session.agent_type, session.session_name)) .collect(); let mut actions = numbered_actions(&action_labels); if has_more { - action_labels.push("Next Page".to_string()); - actions.push(BotAction::secondary("Next Page", "0")); + action_labels.push(label_next_page(language).to_string()); + actions.push(BotAction::secondary(label_next_page(language), "0")); } HandleResult { reply: text, @@ -642,12 +876,17 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> HandleResult { use crate::agentic::coordination::get_global_coordinator; use crate::agentic::core::SessionConfig; + let language = current_bot_language().await; let coordinator = match get_global_coordinator() { Some(c) => c, None => { return HandleResult { - reply: "BitFun session system not ready.".to_string(), + reply: if language.is_chinese() { + "BitFun 会话系统尚未就绪。".to_string() + } else { + "BitFun session system not ready.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -656,13 +895,29 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl let ws_path = state.current_workspace.clone(); let session_name = match agent_type { - "Cowork" => "Remote Cowork Session", - _ => "Remote Code Session", + "Cowork" => { + if language.is_chinese() { + "远程协作会话" + } else { + "Remote Cowork Session" + } + } + _ => { + if language.is_chinese() { + "远程编码会话" + } else { + "Remote Code Session" + } + } }; let Some(workspace_path) = ws_path.clone() else { return HandleResult { - reply: "Please select a workspace first.".to_string(), + reply: if language.is_chinese() { + "请先选择工作区。".to_string() + } else { + "Please select a workspace first.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -685,23 +940,41 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl let session_id = session.session_id.clone(); state.current_session_id = Some(session_id.clone()); let label = if agent_type == "Cowork" { - "cowork" + if language.is_chinese() { + "协作" + } else { + "cowork" + } } else { - "coding" + if language.is_chinese() { + "编码" + } else { + "coding" + } }; let workspace = workspace_path.as_str(); HandleResult { - reply: format!( - "Created new {} session: {}\nWorkspace: {}\n\n\ - You can now send messages to interact with the AI agent.", - label, session_name, workspace - ), + reply: if language.is_chinese() { + format!( + "已创建新的{}会话:{}\n工作区:{}\n\n你现在可以发送消息与 AI 助手交互。", + label, session_name, workspace + ) + } else { + format!( + "Created new {} session: {}\nWorkspace: {}\n\nYou can now send messages to interact with the AI agent.", + label, session_name, workspace + ) + }, actions: vec![], forward_to_session: None, } } Err(e) => HandleResult { - reply: format!("Failed to create session: {e}"), + reply: if language.is_chinese() { + format!("创建会话失败:{e}") + } else { + format!("Failed to create session: {e}") + }, actions: vec![], forward_to_session: None, }, @@ -709,15 +982,38 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl } async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleResult { + let language = current_bot_language().await; let pending = state.pending_action.take(); match pending { Some(PendingAction::SelectWorkspace { options }) => { if n < 1 || n > options.len() { state.pending_action = Some(PendingAction::SelectWorkspace { options }); return HandleResult { - reply: format!("Invalid selection. Please enter 1-{}.", state.pending_action.as_ref() - .map(|a| match a { PendingAction::SelectWorkspace { options } => options.len(), _ => 0 }) - .unwrap_or(0)), + reply: if language.is_chinese() { + format!( + "无效选择。请输入 1-{}。", + state + .pending_action + .as_ref() + .map(|a| match a { + PendingAction::SelectWorkspace { options } => options.len(), + _ => 0, + }) + .unwrap_or(0) + ) + } else { + format!( + "Invalid selection. Please enter 1-{}.", + state + .pending_action + .as_ref() + .map(|a| match a { + PendingAction::SelectWorkspace { options } => options.len(), + _ => 0, + }) + .unwrap_or(0) + ) + }, actions: vec![], forward_to_session: None, }; @@ -738,7 +1034,11 @@ async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleRe has_more, }); return HandleResult { - reply: format!("Invalid selection. Please enter 1-{max}."), + reply: if language.is_chinese() { + format!("无效选择。请输入 1-{max}。") + } else { + format!("Invalid selection. Please enter 1-{max}.") + }, actions: vec![], forward_to_session: None, }; @@ -772,12 +1072,17 @@ async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleRe async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> HandleResult { use crate::service::workspace::get_global_workspace_service; + let language = current_bot_language().await; let ws_service = match get_global_workspace_service() { Some(s) => s, None => { return HandleResult { - reply: "Workspace service not available.".to_string(), + reply: if language.is_chinese() { + "工作区服务不可用。".to_string() + } else { + "Workspace service not available.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -800,11 +1105,11 @@ async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> H info!("Bot switched workspace to: {path}"); let session_count = count_workspace_sessions(path).await; - let reply = build_workspace_switched_reply(name, session_count); + let reply = build_workspace_switched_reply(language, name, session_count); let actions = if session_count > 0 { - session_entry_actions() + session_entry_actions(language) } else { - new_session_actions() + new_session_actions(language) }; HandleResult { reply, @@ -813,7 +1118,11 @@ async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> H } } Err(e) => HandleResult { - reply: format!("Failed to switch workspace: {e}"), + reply: if language.is_chinese() { + format!("切换工作区失败:{e}") + } else { + format!("Failed to switch workspace: {e}") + }, actions: vec![], forward_to_session: None, }, @@ -840,22 +1149,47 @@ async fn count_workspace_sessions(workspace_path: &str) -> usize { .unwrap_or(0) } -fn build_workspace_switched_reply(name: &str, session_count: usize) -> String { - let mut reply = format!("Switched to workspace: {name}\n\n"); +fn build_workspace_switched_reply( + language: BotLanguage, + name: &str, + session_count: usize, +) -> String { + let mut reply = if language.is_chinese() { + format!("已切换到工作区:{name}\n\n") + } else { + format!("Switched to workspace: {name}\n\n") + }; if session_count > 0 { - let s = if session_count == 1 { "" } else { "s" }; - reply.push_str(&format!( - "This workspace has {session_count} existing session{s}. What would you like to do?\n\n\ - /resume_session - Resume an existing session\n\ - /new_code_session - Start a new coding session\n\ - /new_cowork_session - Start a new cowork session" - )); + if language.is_chinese() { + reply.push_str(&format!( + "这个工作区已有 {session_count} 个会话。你想做什么?\n\n\ + /resume_session - 恢复已有会话\n\ + /new_code_session - 开始新的编码会话\n\ + /new_cowork_session - 开始新的协作会话" + )); + } else { + let s = if session_count == 1 { "" } else { "s" }; + reply.push_str(&format!( + "This workspace has {session_count} existing session{s}. What would you like to do?\n\n\ + /resume_session - Resume an existing session\n\ + /new_code_session - Start a new coding session\n\ + /new_cowork_session - Start a new cowork session" + )); + } } else { - reply.push_str( - "No sessions found in this workspace. What would you like to do?\n\n\ - /new_code_session - Start a new coding session\n\ - /new_cowork_session - Start a new cowork session", - ); + if language.is_chinese() { + reply.push_str( + "这个工作区还没有会话。你想做什么?\n\n\ + /new_code_session - 开始新的编码会话\n\ + /new_cowork_session - 开始新的协作会话", + ); + } else { + reply.push_str( + "No sessions found in this workspace. What would you like to do?\n\n\ + /new_code_session - Start a new coding session\n\ + /new_cowork_session - Start a new cowork session", + ); + } } reply } @@ -865,20 +1199,43 @@ async fn select_session( session_id: &str, session_name: &str, ) -> HandleResult { + let language = current_bot_language().await; state.current_session_id = Some(session_id.to_string()); info!("Bot resumed session: {session_id}"); let last_pair = load_last_dialog_pair_from_turns(state.current_workspace.as_deref(), session_id).await; - let mut reply = format!("Resumed session: {session_name}\n\n"); + let mut reply = if language.is_chinese() { + format!("已恢复会话:{session_name}\n\n") + } else { + format!("Resumed session: {session_name}\n\n") + }; if let Some((user_text, assistant_text)) = last_pair { - reply.push_str("— Last conversation —\n"); - reply.push_str(&format!("You: {user_text}\n\n")); - reply.push_str(&format!("AI: {assistant_text}\n\n")); - reply.push_str("You can continue the conversation."); + reply.push_str(if language.is_chinese() { + "— 最近一次对话 —\n" + } else { + "— Last conversation —\n" + }); + reply.push_str(&format!( + "{}: {user_text}\n\n", + if language.is_chinese() { "你" } else { "You" } + )); + reply.push_str(&format!( + "{}: {assistant_text}\n\n", + if language.is_chinese() { "AI" } else { "AI" } + )); + reply.push_str(if language.is_chinese() { + "你可以继续对话。" + } else { + "You can continue the conversation." + }); } else { - reply.push_str("You can now send messages to interact with the AI agent."); + reply.push_str(if language.is_chinese() { + "你现在可以发送消息与 AI 助手交互。" + } else { + "You can now send messages to interact with the AI agent." + }); } HandleResult { @@ -979,33 +1336,43 @@ async fn handle_cancel_task( requested_turn_id: Option<&str>, ) -> HandleResult { use crate::service::remote_connect::remote_server::get_or_init_global_dispatcher; + let language = current_bot_language().await; let session_id = match state.current_session_id.clone() { Some(id) => id, None => { return HandleResult { - reply: "No active session to cancel.".to_string(), - actions: session_entry_actions(), + reply: if language.is_chinese() { + "当前没有可取消的活动会话。".to_string() + } else { + "No active session to cancel.".to_string() + }, + actions: session_entry_actions(language), forward_to_session: None, }; } }; let dispatcher = get_or_init_global_dispatcher(); - match dispatcher - .cancel_task(&session_id, requested_turn_id) - .await - { + match dispatcher.cancel_task(&session_id, requested_turn_id).await { Ok(_) => { state.pending_action = None; HandleResult { - reply: "Cancellation requested for the current task.".to_string(), + reply: if language.is_chinese() { + "已请求取消当前任务。".to_string() + } else { + "Cancellation requested for the current task.".to_string() + }, actions: vec![], forward_to_session: None, } } Err(e) => HandleResult { - reply: format!("Failed to cancel task: {e}"), + reply: if language.is_chinese() { + format!("取消任务失败:{e}") + } else { + format!("Failed to cancel task: {e}") + }, actions: vec![], forward_to_session: None, }, @@ -1064,9 +1431,14 @@ async fn handle_question_reply( pending_answer: Option, message: &str, ) -> HandleResult { + let language = current_bot_language().await; let Some(question) = questions.get(current_index).cloned() else { return HandleResult { - reply: "Question state is invalid.".to_string(), + reply: if language.is_chinese() { + "问题状态无效。".to_string() + } else { + "Question state is invalid.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -1085,7 +1457,11 @@ async fn handle_question_reply( pending_answer, ); return HandleResult { - reply: "Custom answer cannot be empty. Please type your custom answer.".to_string(), + reply: if language.is_chinese() { + "自定义答案不能为空。请输入你的自定义答案。".to_string() + } else { + "Custom answer cannot be empty. Please type your custom answer.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -1119,9 +1495,17 @@ async fn handle_question_reply( ); return HandleResult { reply: if question.multi_select { - "Invalid input. Reply with option numbers like `1,3`.".to_string() + if language.is_chinese() { + "输入无效。请回复选项编号,例如 `1,3`。".to_string() + } else { + "Invalid input. Reply with option numbers like `1,3`.".to_string() + } } else { - "Invalid input. Reply with a single option number.".to_string() + if language.is_chinese() { + "输入无效。请回复单个选项编号。".to_string() + } else { + "Invalid input. Reply with a single option number.".to_string() + } }, actions: vec![], forward_to_session: None, @@ -1140,7 +1524,11 @@ async fn handle_question_reply( None, ); return HandleResult { - reply: "Please reply with a single option number.".to_string(), + reply: if language.is_chinese() { + "请回复单个选项编号。".to_string() + } else { + "Please reply with a single option number.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -1152,11 +1540,9 @@ async fn handle_question_reply( for selection in selections { if selection == other_index { includes_other = true; - labels.push(Value::String("Other".to_string())); + labels.push(Value::String(other_label(language).to_string())); } else if selection >= 1 && selection <= question.options.len() { - labels.push(Value::String( - question.options[selection - 1].label.clone(), - )); + labels.push(Value::String(question.options[selection - 1].label.clone())); } else { restore_question_pending_action( state, @@ -1169,7 +1555,13 @@ async fn handle_question_reply( ); return HandleResult { reply: format!( - "Invalid selection. Please choose between 1 and {}.", + "{} 1 {} {}。", + if language.is_chinese() { + "无效选择。请选择" + } else { + "Invalid selection. Please choose between" + }, + if language.is_chinese() { "到" } else { "and" }, other_index ), actions: vec![], @@ -1195,7 +1587,11 @@ async fn handle_question_reply( pending_answer, ); return HandleResult { - reply: "Please type your custom answer for `Other`.".to_string(), + reply: if language.is_chinese() { + format!("请为“{}”输入你的自定义答案。", other_label(language)) + } else { + "Please type your custom answer for `Other`.".to_string() + }, actions: vec![], forward_to_session: None, }; @@ -1210,6 +1606,7 @@ async fn handle_question_reply( if current_index + 1 < questions.len() { let prompt = build_question_prompt( + language, tool_id, questions, current_index + 1, @@ -1245,10 +1642,17 @@ async fn handle_question_reply( }; } - submit_question_answers(&tool_id, &answers).await + let mut result = submit_question_answers(&tool_id, &answers).await; + if language.is_chinese() + && result.reply == "Answers submitted. Waiting for the assistant to continue..." + { + result.reply = "答案已提交,等待助手继续...".to_string(); + } + result } async fn handle_next_page(state: &mut BotChatState) -> HandleResult { + let language = current_bot_language().await; let pending = state.pending_action.take(); match pending { Some(PendingAction::SelectSession { page, has_more, .. }) if has_more => { @@ -1257,7 +1661,11 @@ async fn handle_next_page(state: &mut BotChatState) -> HandleResult { Some(action) => { state.pending_action = Some(action); HandleResult { - reply: "No more pages available.".to_string(), + reply: if language.is_chinese() { + "没有更多页面了。".to_string() + } else { + "No more pages available.".to_string() + }, actions: vec![], forward_to_session: None, } @@ -1271,6 +1679,7 @@ async fn handle_chat_message( message: &str, image_contexts: Vec, ) -> HandleResult { + let language = current_bot_language().await; if let Some(PendingAction::AskUserQuestion { tool_id, questions, @@ -1295,15 +1704,28 @@ async fn handle_chat_message( if let Some(pending) = state.pending_action.clone() { return match pending { PendingAction::SelectWorkspace { .. } => HandleResult { - reply: "Please reply with the workspace number.".to_string(), + reply: if language.is_chinese() { + "请回复工作区编号。".to_string() + } else { + "Please reply with the workspace number.".to_string() + }, actions: vec![], forward_to_session: None, }, PendingAction::SelectSession { has_more, .. } => HandleResult { reply: if has_more { - "Please reply with the session number, or `0` for the next page.".to_string() + if language.is_chinese() { + "请回复会话编号,或回复 `0` 查看下一页。".to_string() + } else { + "Please reply with the session number, or `0` for the next page." + .to_string() + } } else { - "Please reply with the session number.".to_string() + if language.is_chinese() { + "请回复会话编号。".to_string() + } else { + "Please reply with the session number.".to_string() + } }, actions: vec![], forward_to_session: None, @@ -1314,17 +1736,25 @@ async fn handle_chat_message( if state.current_workspace.is_none() { return HandleResult { - reply: "No workspace selected. Use /switch_workspace to select one first.".to_string(), - actions: workspace_required_actions(), + reply: if language.is_chinese() { + "尚未选择工作区。请先使用 /switch_workspace 选择工作区。".to_string() + } else { + "No workspace selected. Use /switch_workspace to select one first.".to_string() + }, + actions: workspace_required_actions(language), forward_to_session: None, }; } if state.current_session_id.is_none() { return HandleResult { - reply: "No active session. Use /resume_session to resume one or \ - /new_code_session /new_cowork_session to create a new one." - .to_string(), - actions: session_entry_actions(), + reply: if language.is_chinese() { + "当前没有活动会话。请使用 /resume_session 恢复已有会话,或使用 /new_code_session /new_cowork_session 创建新会话。" + .to_string() + } else { + "No active session. Use /resume_session to resume one or /new_code_session /new_cowork_session to create a new one." + .to_string() + }, + actions: session_entry_actions(language), forward_to_session: None, }; } @@ -1334,10 +1764,19 @@ async fn handle_chat_message( let cancel_command = format!("/cancel_task {}", turn_id); HandleResult { reply: format!( - "Processing your message...\n\nIf needed, send `{}` to stop this request.", - cancel_command + "{}\n\n{}", + if language.is_chinese() { + "正在处理你的消息..." + } else { + "Processing your message..." + }, + if language.is_chinese() { + format!("如需停止本次请求,请发送 `{}`。", cancel_command) + } else { + format!("If needed, send `{}` to stop this request.", cancel_command) + } ), - actions: cancel_task_actions(cancel_command), + actions: cancel_task_actions(language, cancel_command), forward_to_session: Some(ForwardRequest { session_id, content: message.to_string(), @@ -1366,6 +1805,7 @@ pub async fn execute_forwarded_turn( use crate::service::remote_connect::remote_server::{ get_or_init_global_dispatcher, TrackerEvent, }; + let language = current_bot_language().await; let dispatcher = get_or_init_global_dispatcher(); @@ -1379,18 +1819,22 @@ pub async fn execute_forwarded_turn( Some(&forward.agent_type), forward.image_contexts, DialogTriggerSource::Bot, - Some(forward.turn_id), + Some(forward.turn_id.clone()), ) .await { - let msg = format!("Failed to send message: {e}"); + let msg = if language.is_chinese() { + format!("发送消息失败:{e}") + } else { + format!("Failed to send message: {e}") + }; return ForwardedTurnResult { display_text: msg.clone(), full_text: msg, }; } - let result = tokio::time::timeout(std::time::Duration::from_secs(300), async { + let result = tokio::time::timeout(std::time::Duration::from_secs(3600), async { let mut response = String::new(); loop { match event_rx.recv().await { @@ -1409,6 +1853,7 @@ pub async fn execute_forwarded_turn( serde_json::from_value::>(questions_value) { let request = build_question_prompt( + language, tool_id, questions, 0, @@ -1424,14 +1869,22 @@ pub async fn execute_forwarded_turn( } TrackerEvent::TurnCompleted => break, TrackerEvent::TurnFailed(e) => { - let msg = format!("Error: {e}"); + let msg = if language.is_chinese() { + format!("错误:{e}") + } else { + format!("Error: {e}") + }; return ForwardedTurnResult { display_text: msg.clone(), full_text: msg, }; } TrackerEvent::TurnCancelled => { - let msg = "Task was cancelled.".to_string(); + let msg = if language.is_chinese() { + "任务已取消。".to_string() + } else { + "Task was cancelled.".to_string() + }; return ForwardedTurnResult { display_text: msg.clone(), full_text: msg, @@ -1452,7 +1905,11 @@ pub async fn execute_forwarded_turn( // response — it is maintained directly from AgenticEvent and is not // subject to broadcast channel lag. let full_text = tracker.accumulated_text(); - let full_text = if full_text.is_empty() { response } else { full_text }; + let full_text = if full_text.is_empty() { + response + } else { + full_text + }; let mut display_text = full_text.clone(); const MAX_BOT_MSG_LEN: usize = 4000; @@ -1467,7 +1924,11 @@ pub async fn execute_forwarded_turn( ForwardedTurnResult { display_text: if display_text.is_empty() { - "(No response)".to_string() + if language.is_chinese() { + "(无回复)".to_string() + } else { + "(No response)".to_string() + } } else { display_text }, @@ -1477,7 +1938,11 @@ pub async fn execute_forwarded_turn( .await; result.unwrap_or_else(|_| ForwardedTurnResult { - display_text: "Response timed out after 5 minutes.".to_string(), + display_text: if language.is_chinese() { + "等待 1 小时后响应超时。".to_string() + } else { + "Response timed out after 1 hour.".to_string() + }, full_text: String::new(), }) } diff --git a/src/crates/core/src/service/remote_connect/bot/feishu.rs b/src/crates/core/src/service/remote_connect/bot/feishu.rs index 8c745849..49cb2416 100644 --- a/src/crates/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -14,9 +14,10 @@ use tokio::sync::RwLock; use tokio_tungstenite::tungstenite::Message as WsMessage; use super::command_router::{ - execute_forwarded_turn, handle_command, main_menu_actions, paired_success_message, - parse_command, BotAction, BotActionStyle, BotChatState, BotInteractionHandler, - BotInteractiveRequest, BotMessageSender, HandleResult, WELCOME_MESSAGE, + current_bot_language, execute_forwarded_turn, handle_command, main_menu_actions, + paired_success_message, parse_command, welcome_message, BotAction, BotActionStyle, + BotChatState, BotInteractionHandler, BotInteractiveRequest, BotLanguage, BotMessageSender, + HandleResult, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; @@ -268,6 +269,54 @@ struct ParsedMessage { } impl FeishuBot { + fn invalid_pairing_code_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "配对码无效或已过期,请重试。" + } else { + "Invalid or expired pairing code. Please try again." + } + } + + fn enter_pairing_code_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "请输入 BitFun Desktop 中显示的 6 位配对码。" + } else { + "Please enter the 6-digit pairing code from BitFun Desktop." + } + } + + fn unsupported_message_type_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "暂不支持这种消息类型,请发送文本或图片。" + } else { + "This message type is not supported. Please send text or images." + } + } + + fn expired_download_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "这个下载链接已过期,请重新让助手发送一次。" + } else { + "This download link has expired. Please ask the agent again." + } + } + + fn sending_file_message(language: BotLanguage, file_name: &str) -> String { + if language.is_chinese() { + format!("正在发送“{file_name}”……") + } else { + format!("Sending \"{file_name}\"…") + } + } + + fn send_file_failed_message(language: BotLanguage, file_name: &str, error: &str) -> String { + if language.is_chinese() { + format!("无法发送“{file_name}”:{error}") + } else { + format!("Could not send \"{file_name}\": {error}") + } + } + pub fn new(config: FeishuConfig) -> Self { Self { config, @@ -481,12 +530,13 @@ impl FeishuBot { pub async fn send_action_card( &self, chat_id: &str, + language: BotLanguage, content: &str, actions: &[BotAction], ) -> Result<()> { let token = self.get_access_token().await?; let client = reqwest::Client::new(); - let card = Self::build_action_card(chat_id, content, actions); + let card = Self::build_action_card(chat_id, language, content, actions); let resp = client .post("https://open.feishu.cn/open-apis/im/v1/messages") .query(&[("receive_id_type", "chat_id")]) @@ -508,10 +558,11 @@ impl FeishuBot { } async fn send_handle_result(&self, chat_id: &str, result: &HandleResult) -> Result<()> { + let language = current_bot_language().await; if result.actions.is_empty() { self.send_message(chat_id, &result.reply).await } else { - self.send_action_card(chat_id, &result.reply, &result.actions) + self.send_action_card(chat_id, language, &result.reply, &result.actions) .await } } @@ -624,20 +675,18 @@ impl FeishuBot { /// upload it to Feishu. Sends a plain-text error if the token has expired /// or the transfer fails. async fn handle_download_request(&self, chat_id: &str, token: &str) { - let path = { + let (path, language) = { let mut states = self.chat_states.write().await; - states - .get_mut(chat_id) - .and_then(|s| s.pending_files.remove(token)) + let state = states.get_mut(chat_id); + let language = current_bot_language().await; + let path = state.and_then(|s| s.pending_files.remove(token)); + (path, language) }; match path { None => { let _ = self - .send_message( - chat_id, - "This download link has expired. Please ask the agent again.", - ) + .send_message(chat_id, Self::expired_download_message(language)) .await; } Some(path) => { @@ -647,7 +696,7 @@ impl FeishuBot { .unwrap_or("file") .to_string(); let _ = self - .send_message(chat_id, &format!("Sending \"{file_name}\"…")) + .send_message(chat_id, &Self::sending_file_message(language, &file_name)) .await; match self.send_file_to_feishu_chat(chat_id, &path).await { Ok(()) => info!("Sent file to Feishu chat {chat_id}: {path}"), @@ -656,7 +705,11 @@ impl FeishuBot { let _ = self .send_message( chat_id, - &format!("⚠️ Could not send \"{file_name}\": {e}"), + &Self::send_file_failed_message( + language, + &file_name, + &e.to_string(), + ), ) .await; } @@ -665,8 +718,13 @@ impl FeishuBot { } } - fn build_action_card(chat_id: &str, content: &str, actions: &[BotAction]) -> serde_json::Value { - let body = Self::card_body_text(content); + fn build_action_card( + chat_id: &str, + language: BotLanguage, + content: &str, + actions: &[BotAction], + ) -> serde_json::Value { + let body = Self::card_body_text(language, content); let mut elements = vec![serde_json::json!({ "tag": "markdown", "content": body, @@ -714,7 +772,7 @@ impl FeishuBot { }) } - fn card_body_text(content: &str) -> String { + fn card_body_text(language: BotLanguage, content: &str) -> String { let mut removed_command_lines = false; let mut lines = Vec::new(); @@ -725,12 +783,14 @@ impl FeishuBot { continue; } if trimmed.contains("/cancel_task ") { - lines.push( - "If needed, use the Cancel Task button below to stop this request.".to_string(), - ); + lines.push(if language.is_chinese() { + "如需停止本次请求,请使用下方的“取消任务”按钮。".to_string() + } else { + "If needed, use the Cancel Task button below to stop this request.".to_string() + }); continue; } - lines.push(Self::replace_command_tokens(line)); + lines.push(Self::replace_command_tokens(language, line)); } let mut body = lines.join("\n").trim().to_string(); @@ -738,24 +798,74 @@ impl FeishuBot { if !body.is_empty() { body.push_str("\n\n"); } - body.push_str("Choose an action below."); + body.push_str(if language.is_chinese() { + "请选择下方操作。" + } else { + "Choose an action below." + }); } if body.is_empty() { - "Choose an action below.".to_string() + if language.is_chinese() { + "请选择下方操作。".to_string() + } else { + "Choose an action below.".to_string() + } } else { body } } - fn replace_command_tokens(line: &str) -> String { + fn replace_command_tokens(language: BotLanguage, line: &str) -> String { let replacements = [ - ("/switch_workspace", "Switch Workspace"), - ("/resume_session", "Resume Session"), - ("/new_code_session", "New Code Session"), - ("/new_cowork_session", "New Cowork Session"), - ("/cancel_task", "Cancel Task"), - ("/help", "Help"), + ( + "/switch_workspace", + if language.is_chinese() { + "切换工作区" + } else { + "Switch Workspace" + }, + ), + ( + "/resume_session", + if language.is_chinese() { + "恢复会话" + } else { + "Resume Session" + }, + ), + ( + "/new_code_session", + if language.is_chinese() { + "新建编码会话" + } else { + "New Code Session" + }, + ), + ( + "/new_cowork_session", + if language.is_chinese() { + "新建协作会话" + } else { + "New Cowork Session" + }, + ), + ( + "/cancel_task", + if language.is_chinese() { + "取消任务" + } else { + "Cancel Task" + }, + ), + ( + "/help", + if language.is_chinese() { + "帮助" + } else { + "Help" + }, + ), ]; replacements @@ -888,6 +998,7 @@ impl FeishuBot { } /// Backward-compatible wrapper: returns (chat_id, text) only for text/post with text content. + #[cfg(test)] fn parse_message_event(event: &serde_json::Value) -> Option<(String, String)> { let parsed = Self::parse_message_event_full(event)?; if parsed.text.is_empty() { @@ -1026,17 +1137,22 @@ impl FeishuBot { .send(WsMessage::Binary(pb::encode_frame(&resp_frame))) .await; - if let Some((chat_id, msg_text)) = Self::parse_message_event(&event) { + if let Some(parsed) = Self::parse_message_event_full(&event) { + let language = current_bot_language().await; + let chat_id = parsed.chat_id; + let msg_text = parsed.text; let trimmed = msg_text.trim(); if trimmed == "/start" { - self.send_message(&chat_id, WELCOME_MESSAGE).await.ok(); + self.send_message(&chat_id, welcome_message(language)) + .await + .ok(); } else if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { info!("Feishu pairing successful, chat_id={chat_id}"); let result = HandleResult { - reply: paired_success_message(), - actions: main_menu_actions(), + reply: paired_success_message(language), + actions: main_menu_actions(language), forward_to_session: None, }; self.send_handle_result(&chat_id, &result).await.ok(); @@ -1051,28 +1167,20 @@ impl FeishuBot { return Some(chat_id); } else { - self.send_message( - &chat_id, - "Invalid or expired pairing code. Please try again.", - ) - .await - .ok(); + self.send_message(&chat_id, Self::invalid_pairing_code_message(language)) + .await + .ok(); } } else { - self.send_message( - &chat_id, - "Please enter the 6-digit pairing code from BitFun Desktop.", - ) - .await - .ok(); + self.send_message(&chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); } } else if let Some(chat_id) = Self::extract_message_chat_id(&event) { - self.send_message( - &chat_id, - "Only text messages are supported. Please send the 6-digit pairing code as text.", - ) - .await - .ok(); + let language = current_bot_language().await; + self.send_message(&chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); } None } @@ -1242,6 +1350,7 @@ impl FeishuBot { let bot = self.clone(); tokio::spawn(async move { const MAX_IMAGES: usize = 5; + let language = current_bot_language().await; let truncated = parsed.image_keys.len() > MAX_IMAGES; let keys_to_use = if truncated { &parsed.image_keys[..MAX_IMAGES] @@ -1255,30 +1364,60 @@ impl FeishuBot { }; if truncated { let msg = format!( - "⚠️ Only the first {} images will be processed; the remaining {} were discarded.", + "{} {} {}", + if language.is_chinese() { + "仅会处理前" + } else { + "Only the first" + }, MAX_IMAGES, - parsed.image_keys.len() - MAX_IMAGES, + if language.is_chinese() { + format!( + "张图片,其余 {} 张已丢弃。", + parsed.image_keys.len() - MAX_IMAGES + ) + } else { + format!( + "images will be processed; the remaining {} were discarded.", + parsed.image_keys.len() - MAX_IMAGES + ) + }, ); bot.send_message(&parsed.chat_id, &msg).await.ok(); } let text = if parsed.text.is_empty() && !images.is_empty() { - "[User sent an image]".to_string() + if language.is_chinese() { + "[用户发送了一张图片]".to_string() + } else { + "[User sent an image]".to_string() + } } else { parsed.text }; - bot.handle_incoming_message(&parsed.chat_id, &text, images).await; + bot.handle_incoming_message( + &parsed.chat_id, + &text, + images, + ) + .await; }); } else if let Some((chat_id, cmd)) = Self::parse_card_action_event(&event) { let bot = self.clone(); tokio::spawn(async move { - bot.handle_incoming_message(&chat_id, &cmd, vec![]).await; + bot.handle_incoming_message( + &chat_id, + &cmd, + vec![], + ) + .await; }); } else if let Some(chat_id) = Self::extract_message_chat_id(&event) { let bot = self.clone(); tokio::spawn(async move { + let language = current_bot_language().await; bot.send_message( &chat_id, - "This message type is not supported. Please send text or images.", + Self::unsupported_message_type_message(language), ).await.ok(); }); } @@ -1332,40 +1471,37 @@ impl FeishuBot { s.paired = true; s }); + let language = current_bot_language().await; if !state.paired { let trimmed = text.trim(); if trimmed == "/start" { - self.send_message(chat_id, WELCOME_MESSAGE).await.ok(); + self.send_message(chat_id, welcome_message(language)) + .await + .ok(); return; } if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { state.paired = true; let result = HandleResult { - reply: paired_success_message(), - actions: main_menu_actions(), + reply: paired_success_message(language), + actions: main_menu_actions(language), forward_to_session: None, }; self.send_handle_result(chat_id, &result).await.ok(); self.persist_chat_state(chat_id, state).await; return; } else { - self.send_message( - chat_id, - "Invalid or expired pairing code. Please try again.", - ) - .await - .ok(); + self.send_message(chat_id, Self::invalid_pairing_code_message(language)) + .await + .ok(); return; } } - self.send_message( - chat_id, - "Please enter the 6-digit pairing code from BitFun Desktop.", - ) - .await - .ok(); + self.send_message(chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); return; } @@ -1459,6 +1595,7 @@ impl FeishuBot { #[cfg(test)] mod tests { use super::FeishuBot; + use crate::service::remote_connect::bot::command_router::BotLanguage; #[test] fn parse_text_message_event() { @@ -1507,6 +1644,7 @@ mod tests { #[test] fn card_body_removes_slash_command_list() { let body = FeishuBot::card_body_text( + BotLanguage::EnUS, "Available commands:\n/switch_workspace - List and switch workspaces\n/help - Show this help message", ); diff --git a/src/crates/core/src/service/remote_connect/bot/mod.rs b/src/crates/core/src/service/remote_connect/bot/mod.rs index 7aebaca2..9f6784ba 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -280,8 +280,6 @@ const CODE_FILE_EXTENSIONS: &[&str] = &[ "swift", "vue", "svelte", - "html", - "htm", "css", "scss", "less", @@ -319,11 +317,22 @@ const CODE_FILE_EXTENSIONS: &[&str] = &[ "log", ]; +/// Extensions that should be treated as downloadable when referenced via +/// relative markdown links (matches mobile-web `DOWNLOADABLE_EXTENSIONS`). +const DOWNLOADABLE_EXTENSIONS: &[&str] = &[ + "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "odt", "ods", "odp", "rtf", "pages", + "numbers", "key", "png", "jpg", "jpeg", "gif", "bmp", "svg", "webp", "ico", "tiff", "tif", + "zip", "tar", "gz", "bz2", "7z", "rar", "dmg", "iso", "xz", "mp3", "wav", "ogg", "flac", "aac", + "m4a", "wma", "mp4", "avi", "mkv", "mov", "webm", "wmv", "flv", "csv", "tsv", "sqlite", "db", + "parquet", "epub", "mobi", "apk", "ipa", "exe", "msi", "deb", "rpm", "ttf", "otf", "woff", + "woff2", +]; + /// Check whether a bare file path (no protocol prefix) should be treated as /// a downloadable file based on its extension. /// -/// Only absolute local file paths are accepted in multi-workspace mode. -/// Code/config source files are filtered out even when absolute. +/// Absolute local file paths exclude source/config files. Relative links +/// are allowed when they point to known downloadable file types. fn is_downloadable_by_extension(file_path: &str) -> bool { let ext = std::path::Path::new(file_path) .extension() @@ -338,7 +347,7 @@ fn is_downloadable_by_extension(file_path: &str) -> bool { if is_absolute { !CODE_FILE_EXTENSIONS.contains(&ext.as_str()) } else { - false + DOWNLOADABLE_EXTENSIONS.contains(&ext.as_str()) } } @@ -524,7 +533,10 @@ const REMOTE_CONNECT_PERSISTENCE_FILENAME: &str = "remote_connect_persistence.js const LEGACY_BOT_PERSISTENCE_FILENAME: &str = "bot_connections.json"; pub fn bot_persistence_path() -> Option { - dirs::home_dir().map(|home| home.join(".bitfun").join(REMOTE_CONNECT_PERSISTENCE_FILENAME)) + dirs::home_dir().map(|home| { + home.join(".bitfun") + .join(REMOTE_CONNECT_PERSISTENCE_FILENAME) + }) } fn legacy_bot_persistence_path() -> Option { diff --git a/src/crates/core/src/service/remote_connect/bot/telegram.rs b/src/crates/core/src/service/remote_connect/bot/telegram.rs index fc566382..92d152c5 100644 --- a/src/crates/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -12,9 +12,9 @@ use std::sync::Arc; use tokio::sync::RwLock; use super::command_router::{ - execute_forwarded_turn, handle_command, paired_success_message, parse_command, BotAction, - BotChatState, BotInteractionHandler, BotInteractiveRequest, BotMessageSender, HandleResult, - WELCOME_MESSAGE, + current_bot_language, execute_forwarded_turn, handle_command, paired_success_message, + parse_command, welcome_message, BotAction, BotChatState, BotInteractionHandler, + BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; use crate::service::remote_connect::remote_server::ImageAttachment; @@ -37,6 +37,54 @@ struct PendingPairing { } impl TelegramBot { + fn expired_download_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "这个下载链接已过期,请重新让助手发送一次。" + } else { + "This download link has expired. Please ask the agent again." + } + } + + fn sending_file_message(language: BotLanguage, file_name: &str) -> String { + if language.is_chinese() { + format!("正在发送“{file_name}”……") + } else { + format!("Sending \"{file_name}\"…") + } + } + + fn send_file_failed_message(language: BotLanguage, file_name: &str, error: &str) -> String { + if language.is_chinese() { + format!("无法发送“{file_name}”:{error}") + } else { + format!("Could not send \"{file_name}\": {error}") + } + } + + fn invalid_pairing_code_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "配对码无效或已过期,请重试。" + } else { + "Invalid or expired pairing code. Please try again." + } + } + + fn enter_pairing_code_message(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "请输入 BitFun Desktop 中显示的 6 位配对码。" + } else { + "Please enter the 6-digit pairing code from BitFun Desktop." + } + } + + fn cancel_button_hint(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "如需停止本次请求,请点击下方的“取消任务”按钮。" + } else { + "If needed, tap the Cancel Task button below to stop this request." + } + } + pub fn new(config: TelegramConfig) -> Self { Self { config, @@ -185,20 +233,18 @@ impl TelegramBot { /// send it. Sends a plain-text error if the token has expired or the /// transfer fails. async fn handle_download_request(&self, chat_id: i64, token: &str) { - let path = { + let (path, language) = { let mut states = self.chat_states.write().await; - states - .get_mut(&chat_id) - .and_then(|s| s.pending_files.remove(token)) + let state = states.get_mut(&chat_id); + let language = current_bot_language().await; + let path = state.and_then(|s| s.pending_files.remove(token)); + (path, language) }; match path { None => { let _ = self - .send_message( - chat_id, - "This download link has expired. Please ask the agent again.", - ) + .send_message(chat_id, Self::expired_download_message(language)) .await; } Some(path) => { @@ -208,7 +254,7 @@ impl TelegramBot { .unwrap_or("file") .to_string(); let _ = self - .send_message(chat_id, &format!("Sending \"{file_name}\"…")) + .send_message(chat_id, &Self::sending_file_message(language, &file_name)) .await; match self.send_file_as_document(chat_id, &path).await { Ok(()) => info!("Sent file to Telegram chat {chat_id}: {path}"), @@ -217,7 +263,11 @@ impl TelegramBot { let _ = self .send_message( chat_id, - &format!("⚠️ Could not send \"{file_name}\": {e}"), + &Self::send_file_failed_message( + language, + &file_name, + &e.to_string(), + ), ) .await; } @@ -242,7 +292,8 @@ impl TelegramBot { /// text is replaced with a friendlier prompt, and a Cancel Task button is /// added via the inline keyboard. async fn send_handle_result(&self, chat_id: i64, result: &HandleResult) { - let text = Self::clean_reply_text(&result.reply, !result.actions.is_empty()); + let language = current_bot_language().await; + let text = Self::clean_reply_text(language, &result.reply, !result.actions.is_empty()); if result.actions.is_empty() { self.send_message(chat_id, &text).await.ok(); } else { @@ -258,7 +309,7 @@ impl TelegramBot { /// Remove raw `/cancel_task ` instruction lines and replace them /// with a short hint that the button below can be used instead. - fn clean_reply_text(text: &str, has_actions: bool) -> String { + fn clean_reply_text(language: BotLanguage, text: &str, has_actions: bool) -> String { let mut lines: Vec = Vec::new(); let mut replaced_cancel = false; @@ -266,10 +317,7 @@ impl TelegramBot { let trimmed = line.trim(); if trimmed.contains("/cancel_task ") { if has_actions && !replaced_cancel { - lines.push( - "If needed, tap the Cancel Task button below to stop this request." - .to_string(), - ); + lines.push(Self::cancel_button_hint(language).to_string()); replaced_cancel = true; } continue; @@ -417,7 +465,6 @@ impl TelegramBot { let cq_id = cq["id"].as_str().unwrap_or("").to_string(); let chat_id = cq.pointer("/message/chat/id").and_then(|v| v.as_i64()); let data = cq["data"].as_str().map(|s| s.trim().to_string()); - if let (Some(chat_id), Some(data)) = (chat_id, data) { // Answer the callback query to dismiss the button spinner. self.answer_callback_query(&cq_id).await; @@ -492,16 +539,19 @@ impl TelegramBot { Ok(messages) => { for (chat_id, text, _images) in messages { let trimmed = text.trim(); + let language = current_bot_language().await; if trimmed == "/start" { - self.send_message(chat_id, WELCOME_MESSAGE).await.ok(); + self.send_message(chat_id, welcome_message(language)) + .await + .ok(); continue; } if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { info!("Telegram pairing successful, chat_id={chat_id}"); - let success_msg = paired_success_message(); + let success_msg = paired_success_message(language); self.send_message(chat_id, &success_msg).await.ok(); self.set_bot_commands().await.ok(); @@ -517,18 +567,15 @@ impl TelegramBot { } else { self.send_message( chat_id, - "Invalid or expired pairing code. Please try again.", + Self::invalid_pairing_code_message(language), ) .await .ok(); } } else { - self.send_message( - chat_id, - "Please enter the 6-digit pairing code from BitFun Desktop.", - ) - .await - .ok(); + self.send_message(chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); } } } @@ -589,37 +636,34 @@ impl TelegramBot { s.paired = true; s }); + let language = current_bot_language().await; if !state.paired { let trimmed = text.trim(); if trimmed == "/start" { - self.send_message(chat_id, WELCOME_MESSAGE).await.ok(); + self.send_message(chat_id, welcome_message(language)) + .await + .ok(); return; } if trimmed.len() == 6 && trimmed.chars().all(|c| c.is_ascii_digit()) { if self.verify_pairing_code(trimmed).await { state.paired = true; - let msg = paired_success_message(); + let msg = paired_success_message(language); self.send_message(chat_id, &msg).await.ok(); self.set_bot_commands().await.ok(); self.persist_chat_state(chat_id, state).await; return; } else { - self.send_message( - chat_id, - "Invalid or expired pairing code. Please try again.", - ) - .await - .ok(); + self.send_message(chat_id, Self::invalid_pairing_code_message(language)) + .await + .ok(); return; } } - self.send_message( - chat_id, - "Please enter the 6-digit pairing code from BitFun Desktop.", - ) - .await - .ok(); + self.send_message(chat_id, Self::enter_pairing_code_message(language)) + .await + .ok(); return; } diff --git a/src/crates/core/src/service/remote_connect/embedded_relay.rs b/src/crates/core/src/service/remote_connect/embedded_relay.rs index 71ce2640..505f8d9b 100644 --- a/src/crates/core/src/service/remote_connect/embedded_relay.rs +++ b/src/crates/core/src/service/remote_connect/embedded_relay.rs @@ -32,8 +32,8 @@ pub async fn start_embedded_relay( if let Some(dir) = static_dir { info!("Embedded relay: serving static files from {dir}"); - let serve_dir = tower_http::services::ServeDir::new(dir) - .append_index_html_on_directories(true); + let serve_dir = + tower_http::services::ServeDir::new(dir).append_index_html_on_directories(true); let static_app = axum::Router::<()>::new() .fallback_service(serve_dir) .layer(axum::middleware::from_fn(static_cache_headers)); diff --git a/src/crates/core/src/service/remote_connect/mod.rs b/src/crates/core/src/service/remote_connect/mod.rs index 315c0a74..09083b22 100644 --- a/src/crates/core/src/service/remote_connect/mod.rs +++ b/src/crates/core/src/service/remote_connect/mod.rs @@ -201,10 +201,9 @@ impl RemoteConnectService { message: String, ) { let server = RemoteServer::new(*shared_secret); - if let Ok((enc, nonce)) = server.encrypt_response( - &remote_server::RemoteResponse::Error { message }, - None, - ) { + if let Ok((enc, nonce)) = + server.encrypt_response(&remote_server::RemoteResponse::Error { message }, None) + { if let Some(ref client) = *relay_arc.read().await { let _ = client .send_relay_response(correlation_id, &enc, &nonce) @@ -375,7 +374,13 @@ impl RemoteConnectService { _ => self.config.web_app_url.clone(), }; - let qr_url = QrGenerator::build_url(&qr_payload, &web_app_url); + let client_language = if let Some(service) = crate::service::get_global_i18n_service().await + { + service.get_current_locale().await.as_str().to_string() + } else { + crate::service::LocaleId::ZhCN.as_str().to_string() + }; + let qr_url = QrGenerator::build_url(&qr_payload, &web_app_url, &client_language); let qr_svg = QrGenerator::generate_svg_from_url(&qr_url)?; let qr_data = QrGenerator::generate_png_base64_from_url(&qr_url)?; @@ -407,9 +412,7 @@ impl RemoteConnectService { { if let Some(ref client) = *relay_arc.read().await { let _ = client - .send_relay_response( - &correlation_id, &enc, &nonce, - ) + .send_relay_response(&correlation_id, &enc, &nonce) .await; } } @@ -438,9 +441,7 @@ impl RemoteConnectService { .encrypt_response(&response, request_id.as_deref()) { Ok((enc, resp_nonce)) => { - if let Some(ref client) = - *relay_arc.read().await - { + if let Some(ref client) = *relay_arc.read().await { let _ = client .send_relay_response( &correlation_id, @@ -646,8 +647,7 @@ impl RemoteConnectService { } }); - *self.bot_telegram_handle.write().await = - Some(BotHandle { stop_tx }); + *self.bot_telegram_handle.write().await = Some(BotHandle { stop_tx }); "https://t.me/BotFather".to_string() } @@ -667,12 +667,11 @@ impl RemoteConnectService { handle.stop(); } - let fs_bot = Arc::new(bot::feishu::FeishuBot::new( - bot::feishu::FeishuConfig { + let fs_bot = + Arc::new(bot::feishu::FeishuBot::new(bot::feishu::FeishuConfig { app_id: app_id.clone(), app_secret: app_secret.clone(), - }, - )); + })); fs_bot.register_pairing(&pairing_code).await?; let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); @@ -756,8 +755,7 @@ impl RemoteConnectService { let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); *self.telegram_bot.write().await = Some(tg_bot.clone()); - *self.bot_connected_info.write().await = - Some(format!("Telegram({chat_id})")); + *self.bot_connected_info.write().await = Some(format!("Telegram({chat_id})")); let bot_for_loop = tg_bot.clone(); tokio::spawn(async move { @@ -776,12 +774,10 @@ impl RemoteConnectService { handle.stop(); } - let fs_bot = Arc::new(bot::feishu::FeishuBot::new( - bot::feishu::FeishuConfig { - app_id: app_id.clone(), - app_secret: app_secret.clone(), - }, - )); + let fs_bot = Arc::new(bot::feishu::FeishuBot::new(bot::feishu::FeishuConfig { + app_id: app_id.clone(), + app_secret: app_secret.clone(), + })); fs_bot .restore_chat_state(&saved.chat_id, saved.chat_state.clone()) @@ -791,8 +787,7 @@ impl RemoteConnectService { *self.feishu_bot.write().await = Some(fs_bot.clone()); let cid = saved.chat_id.clone(); - *self.bot_connected_info.write().await = - Some(format!("Feishu({cid})")); + *self.bot_connected_info.write().await = Some(format!("Feishu({cid})")); let bot_for_loop = fs_bot.clone(); tokio::spawn(async move { @@ -962,9 +957,10 @@ async fn upload_mobile_web(relay_url: &str, room_id: &str, web_dir: &str) -> Res match check_result { Ok(resp) if resp.status().is_success() => { - let body: serde_json::Value = resp.json().await.map_err(|e| { - anyhow::anyhow!("parse check-web-files response: {e}") - })?; + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| anyhow::anyhow!("parse check-web-files response: {e}"))?; let needed: Vec = body["needed"] .as_array() .map(|arr| { @@ -977,9 +973,7 @@ async fn upload_mobile_web(relay_url: &str, room_id: &str, web_dir: &str) -> Res let existing = body["existing_count"].as_u64().unwrap_or(0); let total = body["total_count"].as_u64().unwrap_or(0); if needed.is_empty() { - info!( - "All {total} files already exist on relay server, no upload needed" - ); + info!("All {total} files already exist on relay server, no upload needed"); return Ok(()); } @@ -1017,8 +1011,7 @@ async fn upload_needed_files( use base64::{engine::general_purpose::STANDARD as B64, Engine}; use std::collections::HashMap; - let needed_set: std::collections::HashSet<&str> = - needed.iter().map(|s| s.as_str()).collect(); + let needed_set: std::collections::HashSet<&str> = needed.iter().map(|s| s.as_str()).collect(); let mut files_payload: Vec<(String, serde_json::Value, usize)> = Vec::new(); for f in all_files { diff --git a/src/crates/core/src/service/remote_connect/ngrok.rs b/src/crates/core/src/service/remote_connect/ngrok.rs index 07806a3f..291a1801 100644 --- a/src/crates/core/src/service/remote_connect/ngrok.rs +++ b/src/crates/core/src/service/remote_connect/ngrok.rs @@ -133,7 +133,10 @@ pub async fn start_ngrok_tunnel(local_port: u16) -> Result { "An ngrok process is already running (PID: {}).\n\ Please stop the existing ngrok process before starting a new tunnel,\n\ or use the existing tunnel directly.", - pids.iter().map(|p| p.to_string()).collect::>().join(", ") + pids.iter() + .map(|p| p.to_string()) + .collect::>() + .join(", ") )); } diff --git a/src/crates/core/src/service/remote_connect/pairing.rs b/src/crates/core/src/service/remote_connect/pairing.rs index befd0948..cb061b9f 100644 --- a/src/crates/core/src/service/remote_connect/pairing.rs +++ b/src/crates/core/src/service/remote_connect/pairing.rs @@ -241,10 +241,7 @@ mod tests { let mut protocol = PairingProtocol::new(device); // Step 1: Desktop initiates - let qr = protocol - .initiate("wss://relay.example.com") - .await - .unwrap(); + let qr = protocol.initiate("wss://relay.example.com").await.unwrap(); assert_eq!(protocol.state().await, PairingState::WaitingForScan); assert!(!qr.room_id.is_empty()); diff --git a/src/crates/core/src/service/remote_connect/qr_generator.rs b/src/crates/core/src/service/remote_connect/qr_generator.rs index 0a792173..f8549779 100644 --- a/src/crates/core/src/service/remote_connect/qr_generator.rs +++ b/src/crates/core/src/service/remote_connect/qr_generator.rs @@ -12,13 +12,13 @@ impl QrGenerator { /// Build the URL that the QR code points to. /// `web_app_url` = where the mobile web app is hosted. /// `payload.url` = the relay server that the mobile WebSocket should connect to. - pub fn build_url(payload: &QrPayload, web_app_url: &str) -> String { + pub fn build_url(payload: &QrPayload, web_app_url: &str, language: &str) -> String { let relay_ws = payload .url .replace("https://", "wss://") .replace("http://", "ws://"); format!( - "{web_app}/#/pair?room={room}&did={did}&pk={pk}&dn={dn}&relay={relay}&v={v}", + "{web_app}/#/pair?room={room}&did={did}&pk={pk}&dn={dn}&relay={relay}&v={v}&lang={lang}", web_app = web_app_url.trim_end_matches('/'), room = urlencoding::encode(&payload.room_id), did = urlencoding::encode(&payload.device_id), @@ -26,6 +26,7 @@ impl QrGenerator { dn = urlencoding::encode(&payload.device_name), relay = urlencoding::encode(&relay_ws), v = payload.version, + lang = urlencoding::encode(language), ) } @@ -58,3 +59,24 @@ impl QrGenerator { Ok(svg) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::service::remote_connect::pairing::QrPayload; + + #[test] + fn build_url_includes_language_parameter() { + let payload = QrPayload { + room_id: "room_123".to_string(), + url: "https://relay.example.com".to_string(), + device_id: "device_123".to_string(), + device_name: "BitFun Desktop".to_string(), + public_key: "public_key_value".to_string(), + version: 1, + }; + + let url = QrGenerator::build_url(&payload, "https://mobile.example.com", "en-US"); + assert!(url.contains("lang=en-US")); + } +} diff --git a/src/crates/core/src/service/remote_connect/relay_client.rs b/src/crates/core/src/service/remote_connect/relay_client.rs index f2198719..eb29ece0 100644 --- a/src/crates/core/src/service/remote_connect/relay_client.rs +++ b/src/crates/core/src/service/remote_connect/relay_client.rs @@ -16,9 +16,8 @@ use std::sync::Arc; use tokio::sync::{mpsc, RwLock}; use tokio_tungstenite::tungstenite::Message; -type WsStream = tokio_tungstenite::WebSocketStream< - tokio_tungstenite::MaybeTlsStream, ->; +type WsStream = + tokio_tungstenite::WebSocketStream>; /// Messages in the relay protocol (both directions). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -403,9 +402,8 @@ async fn dial(ws_url: &str) -> Result { max_write_buffer_size: 64 * 1024 * 1024, ..Default::default() }; - let (stream, _) = - tokio_tungstenite::connect_async_with_config(ws_url, Some(config), false) - .await - .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; + let (stream, _) = tokio_tungstenite::connect_async_with_config(ws_url, Some(config), false) + .await + .map_err(|e| anyhow!("dial {ws_url}: {e}"))?; Ok(stream) } diff --git a/src/crates/core/src/service/remote_connect/remote_server.rs b/src/crates/core/src/service/remote_connect/remote_server.rs index 281de076..037681b2 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -1529,6 +1529,7 @@ impl RemoteExecutionDispatcher { .start_dialog_turn( session_id.to_string(), content.clone(), + None, Some(turn_id.clone()), resolved_agent_type, binding_workspace.clone(), @@ -1541,6 +1542,7 @@ impl RemoteExecutionDispatcher { .start_dialog_turn_with_image_contexts( session_id.to_string(), content.clone(), + None, image_contexts, Some(turn_id.clone()), resolved_agent_type, diff --git a/src/crates/core/src/service/snapshot/manager.rs b/src/crates/core/src/service/snapshot/manager.rs index 099dec47..208da6e2 100644 --- a/src/crates/core/src/service/snapshot/manager.rs +++ b/src/crates/core/src/service/snapshot/manager.rs @@ -508,14 +508,11 @@ impl WrappedTool { Err(e) => return Err(crate::util::errors::BitFunError::Tool(e.to_string())), }; - let snapshot_workspace = context - .workspace_root() - .map(PathBuf::from) - .ok_or_else(|| { - crate::util::errors::BitFunError::Tool( - "workspace is required in ToolUseContext for snapshot tracking".to_string(), - ) - })?; + let snapshot_workspace = context.workspace_root().map(PathBuf::from).ok_or_else(|| { + crate::util::errors::BitFunError::Tool( + "workspace is required in ToolUseContext for snapshot tracking".to_string(), + ) + })?; let snapshot_manager = get_or_create_snapshot_manager(snapshot_workspace.clone(), None) .await @@ -636,9 +633,9 @@ pub async fn get_or_create_snapshot_manager( let manager = Arc::new(SnapshotManager::new(workspace_dir.clone(), config).await?); { - let mut managers = snapshot_managers() - .write() - .map_err(|_| SnapshotError::ConfigError("Snapshot manager store lock poisoned".to_string()))?; + let mut managers = snapshot_managers().write().map_err(|_| { + SnapshotError::ConfigError("Snapshot manager store lock poisoned".to_string()) + })?; if let Some(existing) = managers.get(&workspace_dir) { return Ok(existing.clone()); } @@ -655,7 +652,9 @@ pub fn get_snapshot_manager_for_workspace(workspace_dir: &Path) -> Option SnapshotResult> { +pub fn ensure_snapshot_manager_for_workspace( + workspace_dir: &Path, +) -> SnapshotResult> { get_snapshot_manager_for_workspace(workspace_dir).ok_or_else(|| { SnapshotError::ConfigError(format!( "Snapshot manager not initialized for workspace: {}", diff --git a/src/crates/core/src/service/snapshot/service.rs b/src/crates/core/src/service/snapshot/service.rs index 31a3494b..9c386b49 100644 --- a/src/crates/core/src/service/snapshot/service.rs +++ b/src/crates/core/src/service/snapshot/service.rs @@ -296,7 +296,11 @@ impl SnapshotService { Ok(()) } - pub async fn reject_file(&self, session_id: &str, file_path: &Path) -> SnapshotResult> { + pub async fn reject_file( + &self, + session_id: &str, + file_path: &Path, + ) -> SnapshotResult> { self.ensure_initialized().await?; self.validate_file_path(file_path).await?; diff --git a/src/crates/core/src/service/snapshot/snapshot_core.rs b/src/crates/core/src/service/snapshot/snapshot_core.rs index 867e9319..734bef1e 100644 --- a/src/crates/core/src/service/snapshot/snapshot_core.rs +++ b/src/crates/core/src/service/snapshot/snapshot_core.rs @@ -261,10 +261,9 @@ impl SnapshotCore { .turns .get_mut(&turn_index) .ok_or_else(|| SnapshotError::ConfigError("turn not found".to_string()))?; - let op = turn - .operations - .get_mut(seq) - .ok_or_else(|| SnapshotError::ConfigError("seq_in_turn out of bounds".to_string()))?; + let op = turn.operations.get_mut(seq).ok_or_else(|| { + SnapshotError::ConfigError("seq_in_turn out of bounds".to_string()) + })?; op.tool_context.execution_time_ms = execution_time_ms; @@ -291,10 +290,9 @@ impl SnapshotCore { .turns .get_mut(&turn_index) .ok_or_else(|| SnapshotError::ConfigError("turn not found".to_string()))?; - let op = turn - .operations - .get_mut(seq) - .ok_or_else(|| SnapshotError::ConfigError("seq_in_turn out of bounds".to_string()))?; + let op = turn.operations.get_mut(seq).ok_or_else(|| { + SnapshotError::ConfigError("seq_in_turn out of bounds".to_string()) + })?; op.diff_summary = diff_summary; session.last_updated = SystemTime::now(); diff --git a/src/crates/core/src/service/snapshot/snapshot_system.rs b/src/crates/core/src/service/snapshot/snapshot_system.rs index 22062f30..f6e4efda 100644 --- a/src/crates/core/src/service/snapshot/snapshot_system.rs +++ b/src/crates/core/src/service/snapshot/snapshot_system.rs @@ -509,8 +509,9 @@ impl FileSnapshotSystem { /// Gets snapshot content (string), read directly from disk. pub async fn get_snapshot_content(&self, snapshot_id: &str) -> SnapshotResult { let content_bytes = self.restore_snapshot_content(snapshot_id).await?; - String::from_utf8(content_bytes) - .map_err(|e| SnapshotError::ConfigError(format!("Snapshot content is not valid UTF-8: {}", e))) + String::from_utf8(content_bytes).map_err(|e| { + SnapshotError::ConfigError(format!("Snapshot content is not valid UTF-8: {}", e)) + }) } /// Restores snapshot content (read directly from disk, without using in-memory cache). diff --git a/src/crates/core/src/service/terminal/src/pty/process.rs b/src/crates/core/src/service/terminal/src/pty/process.rs index 2f275241..8eaff0b9 100644 --- a/src/crates/core/src/service/terminal/src/pty/process.rs +++ b/src/crates/core/src/service/terminal/src/pty/process.rs @@ -19,7 +19,7 @@ use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; -use log::{debug, error, warn}; +use log::{error, warn}; use portable_pty::{native_pty_system, CommandBuilder, PtySize}; use tokio::sync::mpsc; diff --git a/src/crates/core/src/service/terminal/src/shell/integration.rs b/src/crates/core/src/service/terminal/src/shell/integration.rs index 527d2874..0bf6b317 100644 --- a/src/crates/core/src/service/terminal/src/shell/integration.rs +++ b/src/crates/core/src/service/terminal/src/shell/integration.rs @@ -225,10 +225,7 @@ impl ShellIntegration { seq, OscSequence::CommandFinished { .. } | OscSequence::PromptStart ); - if should_flush - && !plain_output.is_empty() - && self.should_collect() - { + if should_flush && !plain_output.is_empty() && self.should_collect() { self.output_buffer.push_str(&plain_output); if let Some(cmd_id) = &self.current_command_id { events.push(ShellIntegrationEvent::OutputData { diff --git a/src/crates/core/src/service/token_usage/types.rs b/src/crates/core/src/service/token_usage/types.rs index 438837ac..25b974ca 100644 --- a/src/crates/core/src/service/token_usage/types.rs +++ b/src/crates/core/src/service/token_usage/types.rs @@ -77,7 +77,10 @@ pub enum TimeRange { ThisWeek, ThisMonth, All, - Custom { start: DateTime, end: DateTime }, + Custom { + start: DateTime, + end: DateTime, + }, } /// Query parameters for token usage diff --git a/src/crates/core/src/service/workspace/manager.rs b/src/crates/core/src/service/workspace/manager.rs index 243a632b..6788b10c 100644 --- a/src/crates/core/src/service/workspace/manager.rs +++ b/src/crates/core/src/service/workspace/manager.rs @@ -66,15 +66,21 @@ struct WorkspaceIdentityFrontmatter { } impl WorkspaceIdentity { - pub(crate) async fn load_from_workspace_root(workspace_root: &Path) -> Result, String> { + pub(crate) async fn load_from_workspace_root( + workspace_root: &Path, + ) -> Result, String> { let identity_path = workspace_root.join(IDENTITY_FILE_NAME); if !identity_path.exists() { return Ok(None); } - let content = fs::read_to_string(&identity_path) - .await - .map_err(|e| format!("Failed to read identity file '{}': {}", identity_path.display(), e))?; + let content = fs::read_to_string(&identity_path).await.map_err(|e| { + format!( + "Failed to read identity file '{}': {}", + identity_path.display(), + e + ) + })?; let identity = Self::from_markdown(&content)?; if identity.is_empty() { @@ -157,7 +163,11 @@ pub struct WorkspaceInfo { pub workspace_type: WorkspaceType, #[serde(rename = "workspaceKind", default)] pub workspace_kind: WorkspaceKind, - #[serde(rename = "assistantId", default, skip_serializing_if = "Option::is_none")] + #[serde( + rename = "assistantId", + default, + skip_serializing_if = "Option::is_none" + )] pub assistant_id: Option, pub status: WorkspaceStatus, pub languages: Vec, @@ -313,7 +323,10 @@ impl WorkspaceInfo { }; if self.workspace_kind == WorkspaceKind::Assistant { - if let Some(name) = identity.as_ref().and_then(|identity| identity.name.as_ref()) { + if let Some(name) = identity + .as_ref() + .and_then(|identity| identity.name.as_ref()) + { self.name = name.clone(); } } @@ -698,7 +711,10 @@ impl WorkspaceManager { .insert(workspace_id.clone(), workspace.clone()); self.ensure_workspace_open(&workspace_id); if options.auto_set_current { - self.set_current_workspace_with_recent_policy(workspace_id.clone(), options.add_to_recent)?; + self.set_current_workspace_with_recent_policy( + workspace_id.clone(), + options.add_to_recent, + )?; } else { self.touch_workspace_access(&workspace_id, options.add_to_recent); } @@ -750,7 +766,11 @@ impl WorkspaceManager { /// Sets the active workspace among already opened workspaces. pub fn set_active_workspace(&mut self, workspace_id: &str) -> BitFunResult<()> { - if !self.opened_workspace_ids.iter().any(|id| id == workspace_id) { + if !self + .opened_workspace_ids + .iter() + .any(|id| id == workspace_id) + { return Err(BitFunError::service(format!( "Workspace is not opened: {}", workspace_id @@ -961,7 +981,8 @@ impl WorkspaceManager { /// Ensures a workspace stays in the opened list. fn ensure_workspace_open(&mut self, workspace_id: &str) { self.opened_workspace_ids.retain(|id| id != workspace_id); - self.opened_workspace_ids.insert(0, workspace_id.to_string()); + self.opened_workspace_ids + .insert(0, workspace_id.to_string()); } /// Returns manager statistics. diff --git a/src/crates/core/src/service/workspace/mod.rs b/src/crates/core/src/service/workspace/mod.rs index b25260a5..4cbfcc87 100644 --- a/src/crates/core/src/service/workspace/mod.rs +++ b/src/crates/core/src/service/workspace/mod.rs @@ -18,9 +18,8 @@ pub use context_generator::{ pub use factory::WorkspaceFactory; pub use identity_watch::WorkspaceIdentityWatchService; pub use manager::{ - GitInfo, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceManager, - WorkspaceManagerConfig, - WorkspaceManagerStatistics, WorkspaceKind, WorkspaceOpenOptions, WorkspaceStatistics, + GitInfo, ScanOptions, WorkspaceIdentity, WorkspaceInfo, WorkspaceKind, WorkspaceManager, + WorkspaceManagerConfig, WorkspaceManagerStatistics, WorkspaceOpenOptions, WorkspaceStatistics, WorkspaceStatus, WorkspaceSummary, WorkspaceType, }; pub use provider::{WorkspaceCleanupResult, WorkspaceProvider, WorkspaceSystemSummary}; diff --git a/src/crates/core/src/service/workspace/service.rs b/src/crates/core/src/service/workspace/service.rs index edbb696a..5544fe04 100644 --- a/src/crates/core/src/service/workspace/service.rs +++ b/src/crates/core/src/service/workspace/service.rs @@ -116,6 +116,13 @@ impl WorkspaceService { warn!("Failed to load workspace history on startup: {}", e); } + if let Err(e) = service.remap_legacy_assistant_workspace_records().await { + warn!( + "Failed to remap legacy assistant workspace records on startup: {}", + e + ); + } + if let Err(e) = service.ensure_assistant_workspaces().await { warn!("Failed to ensure assistant workspaces on startup: {}", e); } @@ -180,7 +187,9 @@ impl WorkspaceService { })?; } - let mut workspace = self.open_workspace_with_options(path, options.clone()).await?; + let mut workspace = self + .open_workspace_with_options(path, options.clone()) + .await?; if let Some(description) = options.description { workspace.description = Some(description); @@ -522,21 +531,24 @@ impl WorkspaceService { return Ok(None); } - let updated_identity = match WorkspaceIdentity::load_from_workspace_root(&workspace.root_path).await { - Ok(identity) => identity, - Err(error) => { - warn!( - "Failed to refresh workspace identity: workspace_id={} path={} error={}", - workspace_id, - workspace.root_path.display(), - error - ); - return Ok(None); - } - }; + let updated_identity = + match WorkspaceIdentity::load_from_workspace_root(&workspace.root_path).await { + Ok(identity) => identity, + Err(error) => { + warn!( + "Failed to refresh workspace identity: workspace_id={} path={} error={}", + workspace_id, + workspace.root_path.display(), + error + ); + return Ok(None); + } + }; - let changed_fields = - WorkspaceIdentity::collect_changed_fields(workspace.identity.as_ref(), updated_identity.as_ref()); + let changed_fields = WorkspaceIdentity::collect_changed_fields( + workspace.identity.as_ref(), + updated_identity.as_ref(), + ); let fallback_name = Self::assistant_display_name(workspace.assistant_id.as_deref()); let updated_name = updated_identity .as_ref() @@ -552,7 +564,9 @@ impl WorkspaceService { let workspace = manager .get_workspaces_mut() .get_mut(workspace_id) - .ok_or_else(|| BitFunError::service(format!("Workspace not found: {}", workspace_id)))?; + .ok_or_else(|| { + BitFunError::service(format!("Workspace not found: {}", workspace_id)) + })?; workspace.identity = updated_identity.clone(); workspace.name = updated_name.clone(); @@ -1009,6 +1023,198 @@ impl WorkspaceService { }) } + fn legacy_assistant_descriptor_from_path( + &self, + path: &Path, + ) -> Option { + let default_workspace = self + .path_manager + .legacy_default_assistant_workspace_dir(None); + if path == default_workspace { + return Some(AssistantWorkspaceDescriptor { + path: path.to_path_buf(), + assistant_id: None, + display_name: Self::assistant_display_name(None), + }); + } + + let assistant_root = self.path_manager.legacy_assistant_workspace_base_dir(None); + if path.parent()? != assistant_root { + return None; + } + + let file_name = path.file_name()?.to_string_lossy(); + let assistant_id = file_name.strip_prefix("workspace-")?; + if assistant_id.trim().is_empty() { + return None; + } + + Some(AssistantWorkspaceDescriptor { + path: path.to_path_buf(), + assistant_id: Some(assistant_id.to_string()), + display_name: Self::assistant_display_name(Some(assistant_id)), + }) + } + + async fn remap_legacy_assistant_workspace_records(&self) -> BitFunResult<()> { + let mut changed = false; + let mut manager = self.manager.write().await; + + for workspace in manager.get_workspaces_mut().values_mut() { + let Some(descriptor) = self.legacy_assistant_descriptor_from_path(&workspace.root_path) + else { + continue; + }; + let new_path = self + .path_manager + .resolve_assistant_workspace_dir(descriptor.assistant_id.as_deref(), None); + + if workspace.root_path != new_path { + info!( + "Remap legacy assistant workspace record: workspace_id={}, from={}, to={}", + workspace.id, + workspace.root_path.display(), + new_path.display() + ); + workspace.root_path = new_path; + changed = true; + } + + if workspace.workspace_kind != WorkspaceKind::Assistant { + workspace.workspace_kind = WorkspaceKind::Assistant; + changed = true; + } + + if workspace.assistant_id != descriptor.assistant_id { + workspace.assistant_id = descriptor.assistant_id.clone(); + changed = true; + } + } + + drop(manager); + + if changed { + self.save_workspace_data().await?; + } + + Ok(()) + } + + async fn migrate_legacy_assistant_workspaces(&self) -> BitFunResult<()> { + let assistant_root = self.path_manager.assistant_workspace_base_dir(None); + fs::create_dir_all(&assistant_root).await.map_err(|e| { + BitFunError::service(format!( + "Failed to create assistant workspace root '{}': {}", + assistant_root.display(), + e + )) + })?; + + let legacy_root = self.path_manager.legacy_assistant_workspace_base_dir(None); + let default_legacy_workspace = self + .path_manager + .legacy_default_assistant_workspace_dir(None); + let default_workspace = self.path_manager.default_assistant_workspace_dir(None); + + if fs::try_exists(&default_legacy_workspace) + .await + .map_err(|e| { + BitFunError::service(format!( + "Failed to inspect legacy assistant workspace '{}': {}", + default_legacy_workspace.display(), + e + )) + })? + && !fs::try_exists(&default_workspace).await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect assistant workspace '{}': {}", + default_workspace.display(), + e + )) + })? + { + fs::rename(&default_legacy_workspace, &default_workspace) + .await + .map_err(|e| { + BitFunError::service(format!( + "Failed to migrate assistant workspace '{}' to '{}': {}", + default_legacy_workspace.display(), + default_workspace.display(), + e + )) + })?; + info!( + "Migrated default assistant workspace: from={}, to={}", + default_legacy_workspace.display(), + default_workspace.display() + ); + } + + let mut entries = fs::read_dir(&legacy_root).await.map_err(|e| { + BitFunError::service(format!( + "Failed to read legacy assistant workspace root '{}': {}", + legacy_root.display(), + e + )) + })?; + + while let Some(entry) = entries.next_entry().await.map_err(|e| { + BitFunError::service(format!( + "Failed to iterate legacy assistant workspace root '{}': {}", + legacy_root.display(), + e + )) + })? { + let file_type = entry.file_type().await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect legacy assistant workspace entry '{}': {}", + entry.path().display(), + e + )) + })?; + if !file_type.is_dir() { + continue; + } + + let file_name = entry.file_name().to_string_lossy().to_string(); + let Some(assistant_id) = file_name.strip_prefix("workspace-") else { + continue; + }; + if assistant_id.trim().is_empty() { + continue; + } + + let target_path = self + .path_manager + .assistant_workspace_dir(assistant_id, None); + if fs::try_exists(&target_path).await.map_err(|e| { + BitFunError::service(format!( + "Failed to inspect assistant workspace '{}': {}", + target_path.display(), + e + )) + })? { + continue; + } + + fs::rename(entry.path(), &target_path).await.map_err(|e| { + BitFunError::service(format!( + "Failed to migrate assistant workspace '{}' to '{}': {}", + file_name, + target_path.display(), + e + )) + })?; + info!( + "Migrated named assistant workspace: assistant_id={}, to={}", + assistant_id, + target_path.display() + ); + } + + Ok(()) + } + fn normalize_workspace_options_for_path( &self, path: &Path, @@ -1016,8 +1222,9 @@ impl WorkspaceService { ) -> WorkspaceCreateOptions { if options.workspace_kind == WorkspaceKind::Assistant { if options.display_name.is_none() { - options.display_name = - Some(Self::assistant_display_name(options.assistant_id.as_deref())); + options.display_name = Some(Self::assistant_display_name( + options.assistant_id.as_deref(), + )); } return options; } @@ -1035,7 +1242,11 @@ impl WorkspaceService { options } - async fn discover_assistant_workspaces(&self) -> BitFunResult> { + async fn discover_assistant_workspaces( + &self, + ) -> BitFunResult> { + self.migrate_legacy_assistant_workspaces().await?; + let assistant_root = self.path_manager.assistant_workspace_base_dir(None); fs::create_dir_all(&assistant_root).await.map_err(|e| { BitFunError::service(format!( @@ -1127,7 +1338,8 @@ impl WorkspaceService { ..Default::default() }; - self.open_workspace_with_options(descriptor.path, options).await?; + self.open_workspace_with_options(descriptor.path, options) + .await?; has_current_workspace = true; } diff --git a/src/crates/core/src/util/errors.rs b/src/crates/core/src/util/errors.rs index 753bd99d..7db157db 100644 --- a/src/crates/core/src/util/errors.rs +++ b/src/crates/core/src/util/errors.rs @@ -124,11 +124,11 @@ impl BitFunError { pub fn validation>(msg: T) -> Self { Self::Validation(msg.into()) } - + pub fn ai>(msg: T) -> Self { Self::AIClient(msg.into()) } - + pub fn parse>(msg: T) -> Self { Self::Deserialization(msg.into()) } @@ -179,4 +179,4 @@ impl From for BitFunError { fn from(error: tokio::sync::AcquireError) -> Self { BitFunError::Semaphore(error.to_string()) } -} \ No newline at end of file +} diff --git a/src/crates/core/src/util/token_counter.rs b/src/crates/core/src/util/token_counter.rs index 70e257f1..fcc94f52 100644 --- a/src/crates/core/src/util/token_counter.rs +++ b/src/crates/core/src/util/token_counter.rs @@ -55,9 +55,7 @@ impl TokenCounter { } pub fn estimate_messages_tokens(messages: &[Message]) -> usize { - let mut total: usize = messages.iter() - .map(Self::estimate_message_tokens) - .sum(); + let mut total: usize = messages.iter().map(Self::estimate_message_tokens).sum(); total += 3; diff --git a/src/crates/core/src/util/types/ai.rs b/src/crates/core/src/util/types/ai.rs index 0cab0dbf..3735f04a 100644 --- a/src/crates/core/src/util/types/ai.rs +++ b/src/crates/core/src/util/types/ai.rs @@ -48,3 +48,13 @@ pub struct ConnectionTestResult { #[serde(skip_serializing_if = "Option::is_none")] pub error_details: Option, } + +/// Remote model info discovered from a provider API. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RemoteModelInfo { + /// Provider model identifier (used as the actual model_name). + pub id: String, + /// Optional human-readable display name returned by the provider. + #[serde(skip_serializing_if = "Option::is_none")] + pub display_name: Option, +} diff --git a/src/crates/core/src/util/types/config.rs b/src/crates/core/src/util/types/config.rs index 9158e6e5..2abae95d 100644 --- a/src/crates/core/src/util/types/config.rs +++ b/src/crates/core/src/util/types/config.rs @@ -1,5 +1,5 @@ -use log::warn; use crate::service::config::types::AIModelConfig; +use log::warn; use serde::{Deserialize, Serialize}; fn append_endpoint(base_url: &str, endpoint: &str) -> String { @@ -151,7 +151,10 @@ impl TryFrom for AIConfig { match serde_json::from_str::(body_str) { Ok(value) => Some(value), Err(e) => { - warn!("Failed to parse custom_request_body: {}, config: {}", e, other.name); + warn!( + "Failed to parse custom_request_body: {}, config: {}", + e, other.name + ); None } } @@ -163,7 +166,9 @@ impl TryFrom for AIConfig { let request_url = other .request_url .filter(|u| !u.is_empty()) - .unwrap_or_else(|| resolve_request_url(&other.base_url, &other.provider, &other.model_name)); + .unwrap_or_else(|| { + resolve_request_url(&other.base_url, &other.provider, &other.model_name) + }); Ok(AIConfig { name: other.name.clone(), diff --git a/src/crates/core/src/util/types/mod.rs b/src/crates/core/src/util/types/mod.rs index b2a0b593..6fe35066 100644 --- a/src/crates/core/src/util/types/mod.rs +++ b/src/crates/core/src/util/types/mod.rs @@ -1,13 +1,13 @@ -pub mod core; pub mod ai; pub mod config; +pub mod core; +pub mod event; pub mod message; pub mod tool; -pub mod event; -pub use core::*; pub use ai::*; pub use config::*; +pub use core::*; +pub use event::*; pub use message::*; pub use tool::*; -pub use event::*; diff --git a/src/crates/transport/src/adapters/cli.rs b/src/crates/transport/src/adapters/cli.rs index 3eff0780..6fd53bd2 100644 --- a/src/crates/transport/src/adapters/cli.rs +++ b/src/crates/transport/src/adapters/cli.rs @@ -1,13 +1,12 @@ /// CLI transport adapter /// /// Uses tokio::mpsc channel to send events to CLI TUI renderer - use crate::traits::{TextChunk, ToolEventPayload, TransportAdapter}; use async_trait::async_trait; +use bitfun_events::AgenticEvent; use serde::{Deserialize, Serialize}; use std::fmt; use tokio::sync::mpsc; -use bitfun_events::AgenticEvent; /// CLI event type (for TUI rendering) #[derive(Debug, Clone, Serialize, Deserialize)] @@ -50,7 +49,7 @@ impl CliTransportAdapter { pub fn new(tx: mpsc::UnboundedSender) -> Self { Self { tx } } - + /// Create channel and get receiver (for creating TUI renderer) pub fn create_channel() -> (Self, mpsc::UnboundedReceiver) { let (tx, rx) = mpsc::unbounded_channel(); @@ -70,77 +69,109 @@ impl fmt::Debug for CliTransportAdapter { impl TransportAdapter for CliTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { let cli_event = match event { - AgenticEvent::TextChunk { session_id, turn_id, round_id, text, .. } => { - CliEvent::TextChunk(TextChunk { - session_id, - turn_id, - round_id, - text, - timestamp: chrono::Utc::now().timestamp_millis(), - }) - } - AgenticEvent::DialogTurnStarted { session_id, turn_id, .. } => { - CliEvent::DialogTurnStarted { session_id, turn_id } - } - AgenticEvent::DialogTurnCompleted { session_id, turn_id, .. } => { - CliEvent::DialogTurnCompleted { session_id, turn_id } - } + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + text, + .. + } => CliEvent::TextChunk(TextChunk { + session_id, + turn_id, + round_id, + text, + timestamp: chrono::Utc::now().timestamp_millis(), + }), + AgenticEvent::DialogTurnStarted { + session_id, + turn_id, + .. + } => CliEvent::DialogTurnStarted { + session_id, + turn_id, + }, + AgenticEvent::DialogTurnCompleted { + session_id, + turn_id, + .. + } => CliEvent::DialogTurnCompleted { + session_id, + turn_id, + }, _ => return Ok(()), }; - - self.tx.send(cli_event).map_err(|e| { - anyhow::anyhow!("Failed to send CLI event: {}", e) - })?; - + + self.tx + .send(cli_event) + .map_err(|e| anyhow::anyhow!("Failed to send CLI event: {}", e))?; + Ok(()) } - + async fn emit_text_chunk(&self, _session_id: &str, chunk: TextChunk) -> anyhow::Result<()> { - self.tx.send(CliEvent::TextChunk(chunk)).map_err(|e| { - anyhow::anyhow!("Failed to send text chunk: {}", e) - })?; + self.tx + .send(CliEvent::TextChunk(chunk)) + .map_err(|e| anyhow::anyhow!("Failed to send text chunk: {}", e))?; Ok(()) } - - async fn emit_tool_event(&self, _session_id: &str, event: ToolEventPayload) -> anyhow::Result<()> { - self.tx.send(CliEvent::ToolEvent(event)).map_err(|e| { - anyhow::anyhow!("Failed to send tool event: {}", e) - })?; + + async fn emit_tool_event( + &self, + _session_id: &str, + event: ToolEventPayload, + ) -> anyhow::Result<()> { + self.tx + .send(CliEvent::ToolEvent(event)) + .map_err(|e| anyhow::anyhow!("Failed to send tool event: {}", e))?; Ok(()) } - - async fn emit_stream_start(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { - self.tx.send(CliEvent::StreamStart { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - round_id: round_id.to_string(), - }).map_err(|e| { - anyhow::anyhow!("Failed to send stream start: {}", e) - })?; + + async fn emit_stream_start( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { + self.tx + .send(CliEvent::StreamStart { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_id: round_id.to_string(), + }) + .map_err(|e| anyhow::anyhow!("Failed to send stream start: {}", e))?; Ok(()) } - - async fn emit_stream_end(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { - self.tx.send(CliEvent::StreamEnd { - session_id: session_id.to_string(), - turn_id: turn_id.to_string(), - round_id: round_id.to_string(), - }).map_err(|e| { - anyhow::anyhow!("Failed to send stream end: {}", e) - })?; + + async fn emit_stream_end( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { + self.tx + .send(CliEvent::StreamEnd { + session_id: session_id.to_string(), + turn_id: turn_id.to_string(), + round_id: round_id.to_string(), + }) + .map_err(|e| anyhow::anyhow!("Failed to send stream end: {}", e))?; Ok(()) } - - async fn emit_generic(&self, event_name: &str, payload: serde_json::Value) -> anyhow::Result<()> { - self.tx.send(CliEvent::Generic { - event_name: event_name.to_string(), - payload, - }).map_err(|e| { - anyhow::anyhow!("Failed to send generic event: {}", e) - })?; + + async fn emit_generic( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> anyhow::Result<()> { + self.tx + .send(CliEvent::Generic { + event_name: event_name.to_string(), + payload, + }) + .map_err(|e| anyhow::anyhow!("Failed to send generic event: {}", e))?; Ok(()) } - + fn adapter_type(&self) -> &str { "cli" } diff --git a/src/crates/transport/src/adapters/mod.rs b/src/crates/transport/src/adapters/mod.rs index dc7e803d..c34e4ed7 100644 --- a/src/crates/transport/src/adapters/mod.rs +++ b/src/crates/transport/src/adapters/mod.rs @@ -1,5 +1,4 @@ /// Transport adapters for different platforms - pub mod cli; pub mod websocket; diff --git a/src/crates/transport/src/adapters/tauri.rs b/src/crates/transport/src/adapters/tauri.rs index d810909f..b8eb0869 100644 --- a/src/crates/transport/src/adapters/tauri.rs +++ b/src/crates/transport/src/adapters/tauri.rs @@ -1,4 +1,3 @@ -use log::warn; /// Tauri transport adapter /// /// Uses Tauri's app.emit() system to send events to frontend @@ -7,9 +6,10 @@ use log::warn; #[cfg(feature = "tauri-adapter")] use crate::traits::{TextChunk, ToolEventPayload, TransportAdapter}; use async_trait::async_trait; +use bitfun_events::AgenticEvent; +use log::warn; use serde_json::json; use std::fmt; -use bitfun_events::AgenticEvent; #[cfg(feature = "tauri-adapter")] use tauri::{AppHandle, Emitter}; @@ -41,229 +41,422 @@ impl fmt::Debug for TauriTransportAdapter { impl TransportAdapter for TauriTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { match event { - AgenticEvent::SessionCreated { session_id, session_name, agent_type, workspace_path } => { - self.app_handle.emit("agentic://session-created", json!({ - "sessionId": session_id, - "sessionName": session_name, - "agentType": agent_type, - "workspacePath": workspace_path, - }))?; + AgenticEvent::SessionCreated { + session_id, + session_name, + agent_type, + workspace_path, + } => { + self.app_handle.emit( + "agentic://session-created", + json!({ + "sessionId": session_id, + "sessionName": session_name, + "agentType": agent_type, + "workspacePath": workspace_path, + }), + )?; } AgenticEvent::SessionDeleted { session_id } => { - self.app_handle.emit("agentic://session-deleted", json!({ - "sessionId": session_id, - }))?; + self.app_handle.emit( + "agentic://session-deleted", + json!({ + "sessionId": session_id, + }), + )?; } - AgenticEvent::ImageAnalysisStarted { session_id, image_count, user_input, image_metadata } => { - self.app_handle.emit("agentic://image-analysis-started", json!({ - "sessionId": session_id, - "imageCount": image_count, - "userInput": user_input, - "imageMetadata": image_metadata, - }))?; + AgenticEvent::ImageAnalysisStarted { + session_id, + image_count, + user_input, + image_metadata, + } => { + self.app_handle.emit( + "agentic://image-analysis-started", + json!({ + "sessionId": session_id, + "imageCount": image_count, + "userInput": user_input, + "imageMetadata": image_metadata, + }), + )?; } - AgenticEvent::ImageAnalysisCompleted { session_id, success, duration_ms } => { - self.app_handle.emit("agentic://image-analysis-completed", json!({ - "sessionId": session_id, - "success": success, - "durationMs": duration_ms, - }))?; + AgenticEvent::ImageAnalysisCompleted { + session_id, + success, + duration_ms, + } => { + self.app_handle.emit( + "agentic://image-analysis-completed", + json!({ + "sessionId": session_id, + "success": success, + "durationMs": duration_ms, + }), + )?; } - AgenticEvent::DialogTurnStarted { session_id, turn_id, turn_index, user_input, original_user_input, user_message_metadata, subagent_parent_info } => { - self.app_handle.emit("agentic://dialog-turn-started", json!({ - "sessionId": session_id, - "turnId": turn_id, - "turnIndex": turn_index, - "userInput": user_input, - "originalUserInput": original_user_input, - "userMessageMetadata": user_message_metadata, - "subagentParentInfo": subagent_parent_info, - }))?; + AgenticEvent::DialogTurnStarted { + session_id, + turn_id, + turn_index, + user_input, + original_user_input, + user_message_metadata, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://dialog-turn-started", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "turnIndex": turn_index, + "userInput": user_input, + "originalUserInput": original_user_input, + "userMessageMetadata": user_message_metadata, + "subagentParentInfo": subagent_parent_info, + }), + )?; } - AgenticEvent::ModelRoundStarted { session_id, turn_id, round_id, .. } => { - self.app_handle.emit("agentic://model-round-started", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - }))?; + AgenticEvent::ModelRoundStarted { + session_id, + turn_id, + round_id, + .. + } => { + self.app_handle.emit( + "agentic://model-round-started", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + }), + )?; } - AgenticEvent::TextChunk { session_id, turn_id, round_id, text, subagent_parent_info } => { - self.app_handle.emit("agentic://text-chunk", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "text": text, - "subagentParentInfo": subagent_parent_info, - }))?; + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + text, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://text-chunk", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "text": text, + "subagentParentInfo": subagent_parent_info, + }), + )?; } - AgenticEvent::ThinkingChunk { session_id, turn_id, round_id, content, subagent_parent_info } => { - self.app_handle.emit("agentic://text-chunk", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "text": content, - "contentType": "thinking", - "subagentParentInfo": subagent_parent_info, - }))?; + AgenticEvent::ThinkingChunk { + session_id, + turn_id, + round_id, + content, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://text-chunk", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "text": content, + "contentType": "thinking", + "subagentParentInfo": subagent_parent_info, + }), + )?; } - AgenticEvent::ToolEvent { session_id, turn_id, tool_event, subagent_parent_info } => { - self.app_handle.emit("agentic://tool-event", json!({ - "sessionId": session_id, - "turnId": turn_id, - "toolEvent": tool_event, - "subagentParentInfo": subagent_parent_info, - }))?; + AgenticEvent::ToolEvent { + session_id, + turn_id, + tool_event, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://tool-event", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "toolEvent": tool_event, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::DialogTurnCompleted { + session_id, + turn_id, + subagent_parent_info, + .. + } => { + self.app_handle.emit( + "agentic://dialog-turn-completed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::SessionTitleGenerated { + session_id, + title, + method, + } => { + self.app_handle.emit( + "session_title_generated", + json!({ + "sessionId": session_id, + "title": title, + "method": method, + "timestamp": chrono::Utc::now().timestamp_millis(), + }), + )?; + } + AgenticEvent::DialogTurnCancelled { + session_id, + turn_id, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://dialog-turn-cancelled", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::DialogTurnFailed { + session_id, + turn_id, + error, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://dialog-turn-failed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "error": error, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::TokenUsageUpdated { + session_id, + turn_id, + model_id, + input_tokens, + output_tokens, + total_tokens, + max_context_tokens, + is_subagent, + } => { + self.app_handle.emit( + "agentic://token-usage-updated", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "modelId": model_id, + "inputTokens": input_tokens, + "outputTokens": output_tokens, + "totalTokens": total_tokens, + "maxContextTokens": max_context_tokens, + "isSubagent": is_subagent, + }), + )?; + } + AgenticEvent::ContextCompressionStarted { + session_id, + turn_id, + subagent_parent_info, + compression_id, + trigger, + tokens_before, + context_window, + threshold, + } => { + self.app_handle.emit( + "agentic://context-compression-started", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "compressionId": compression_id, + "trigger": trigger, + "tokensBefore": tokens_before, + "contextWindow": context_window, + "threshold": threshold, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::ContextCompressionCompleted { + session_id, + turn_id, + subagent_parent_info, + compression_id, + compression_count, + tokens_before, + tokens_after, + compression_ratio, + duration_ms, + has_summary, + } => { + self.app_handle.emit( + "agentic://context-compression-completed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "compressionId": compression_id, + "compressionCount": compression_count, + "tokensBefore": tokens_before, + "tokensAfter": tokens_after, + "compressionRatio": compression_ratio, + "durationMs": duration_ms, + "hasSummary": has_summary, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::ContextCompressionFailed { + session_id, + turn_id, + subagent_parent_info, + compression_id, + error, + } => { + self.app_handle.emit( + "agentic://context-compression-failed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "compressionId": compression_id, + "error": error, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + AgenticEvent::SessionStateChanged { + session_id, + new_state, + } => { + self.app_handle.emit( + "agentic://session-state-changed", + json!({ + "sessionId": session_id, + "newState": new_state, + }), + )?; + } + AgenticEvent::ModelRoundCompleted { + session_id, + turn_id, + round_id, + has_tool_calls, + subagent_parent_info, + } => { + self.app_handle.emit( + "agentic://model-round-completed", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + "hasToolCalls": has_tool_calls, + "subagentParentInfo": subagent_parent_info, + }), + )?; + } + _ => { + warn!("Unhandled AgenticEvent type in TauriAdapter"); } - AgenticEvent::DialogTurnCompleted { session_id, turn_id, subagent_parent_info, .. } => { - self.app_handle.emit("agentic://dialog-turn-completed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::SessionTitleGenerated { session_id, title, method } => { - self.app_handle.emit("session_title_generated", json!({ - "sessionId": session_id, - "title": title, - "method": method, - "timestamp": chrono::Utc::now().timestamp_millis(), - }))?; - } - AgenticEvent::DialogTurnCancelled { session_id, turn_id, subagent_parent_info } => { - self.app_handle.emit("agentic://dialog-turn-cancelled", json!({ - "sessionId": session_id, - "turnId": turn_id, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::DialogTurnFailed { session_id, turn_id, error, subagent_parent_info } => { - self.app_handle.emit("agentic://dialog-turn-failed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "error": error, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::TokenUsageUpdated { session_id, turn_id, model_id, input_tokens, output_tokens, total_tokens, max_context_tokens, is_subagent } => { - self.app_handle.emit("agentic://token-usage-updated", json!({ - "sessionId": session_id, - "turnId": turn_id, - "modelId": model_id, - "inputTokens": input_tokens, - "outputTokens": output_tokens, - "totalTokens": total_tokens, - "maxContextTokens": max_context_tokens, - "isSubagent": is_subagent, - }))?; - } - AgenticEvent::ContextCompressionStarted { session_id, turn_id, subagent_parent_info, compression_id, trigger, tokens_before, context_window, threshold } => { - self.app_handle.emit("agentic://context-compression-started", json!({ - "sessionId": session_id, - "turnId": turn_id, - "compressionId": compression_id, - "trigger": trigger, - "tokensBefore": tokens_before, - "contextWindow": context_window, - "threshold": threshold, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::ContextCompressionCompleted { session_id, turn_id, subagent_parent_info, compression_id, compression_count, tokens_before, tokens_after, compression_ratio, duration_ms, has_summary } => { - self.app_handle.emit("agentic://context-compression-completed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "compressionId": compression_id, - "compressionCount": compression_count, - "tokensBefore": tokens_before, - "tokensAfter": tokens_after, - "compressionRatio": compression_ratio, - "durationMs": duration_ms, - "hasSummary": has_summary, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::ContextCompressionFailed { session_id, turn_id, subagent_parent_info, compression_id, error } => { - self.app_handle.emit("agentic://context-compression-failed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "compressionId": compression_id, - "error": error, - "subagentParentInfo": subagent_parent_info, - }))?; - } - AgenticEvent::SessionStateChanged { session_id, new_state } => { - self.app_handle.emit("agentic://session-state-changed", json!({ - "sessionId": session_id, - "newState": new_state, - }))?; - } - AgenticEvent::ModelRoundCompleted { session_id, turn_id, round_id, has_tool_calls, subagent_parent_info } => { - self.app_handle.emit("agentic://model-round-completed", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - "hasToolCalls": has_tool_calls, - "subagentParentInfo": subagent_parent_info, - }))?; - } - _ => { - warn!("Unhandled AgenticEvent type in TauriAdapter"); - } } Ok(()) } - + async fn emit_text_chunk(&self, _session_id: &str, chunk: TextChunk) -> anyhow::Result<()> { - self.app_handle.emit("agentic://text-chunk", json!({ - "sessionId": chunk.session_id, - "turnId": chunk.turn_id, - "roundId": chunk.round_id, - "text": chunk.text, - "timestamp": chunk.timestamp, - }))?; + self.app_handle.emit( + "agentic://text-chunk", + json!({ + "sessionId": chunk.session_id, + "turnId": chunk.turn_id, + "roundId": chunk.round_id, + "text": chunk.text, + "timestamp": chunk.timestamp, + }), + )?; Ok(()) } - - async fn emit_tool_event(&self, _session_id: &str, event: ToolEventPayload) -> anyhow::Result<()> { - self.app_handle.emit("agentic://tool-event", json!({ - "sessionId": event.session_id, - "turnId": event.turn_id, - "toolEvent": { - "tool_id": event.tool_id, - "tool_name": event.tool_name, - "event_type": event.event_type, - "params": event.params, - "result": event.result, - "error": event.error, - "duration_ms": event.duration_ms, - } - }))?; + + async fn emit_tool_event( + &self, + _session_id: &str, + event: ToolEventPayload, + ) -> anyhow::Result<()> { + self.app_handle.emit( + "agentic://tool-event", + json!({ + "sessionId": event.session_id, + "turnId": event.turn_id, + "toolEvent": { + "tool_id": event.tool_id, + "tool_name": event.tool_name, + "event_type": event.event_type, + "params": event.params, + "result": event.result, + "error": event.error, + "duration_ms": event.duration_ms, + } + }), + )?; Ok(()) } - - async fn emit_stream_start(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { - self.app_handle.emit("agentic://stream-start", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - }))?; + + async fn emit_stream_start( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { + self.app_handle.emit( + "agentic://stream-start", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + }), + )?; Ok(()) } - - async fn emit_stream_end(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { - self.app_handle.emit("agentic://stream-end", json!({ - "sessionId": session_id, - "turnId": turn_id, - "roundId": round_id, - }))?; + + async fn emit_stream_end( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { + self.app_handle.emit( + "agentic://stream-end", + json!({ + "sessionId": session_id, + "turnId": turn_id, + "roundId": round_id, + }), + )?; Ok(()) } - - async fn emit_generic(&self, event_name: &str, payload: serde_json::Value) -> anyhow::Result<()> { + + async fn emit_generic( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> anyhow::Result<()> { self.app_handle.emit(event_name, payload)?; Ok(()) } - + fn adapter_type(&self) -> &str { "tauri" } diff --git a/src/crates/transport/src/adapters/websocket.rs b/src/crates/transport/src/adapters/websocket.rs index 892afb0c..889ad219 100644 --- a/src/crates/transport/src/adapters/websocket.rs +++ b/src/crates/transport/src/adapters/websocket.rs @@ -1,13 +1,12 @@ /// WebSocket transport adapter /// /// Used for Web Server version, pushes events to browser via WebSocket - use crate::traits::{TextChunk, ToolEventPayload, TransportAdapter}; use async_trait::async_trait; +use bitfun_events::AgenticEvent; use serde_json::json; use std::fmt; use tokio::sync::mpsc; -use bitfun_events::AgenticEvent; /// WebSocket message type #[derive(Debug, Clone)] @@ -28,13 +27,13 @@ impl WebSocketTransportAdapter { pub fn new(tx: mpsc::UnboundedSender) -> Self { Self { tx } } - + /// Send JSON message fn send_json(&self, value: serde_json::Value) -> anyhow::Result<()> { let json_str = serde_json::to_string(&value)?; - self.tx.send(WsMessage::Text(json_str)).map_err(|e| { - anyhow::anyhow!("Failed to send WebSocket message: {}", e) - })?; + self.tx + .send(WsMessage::Text(json_str)) + .map_err(|e| anyhow::anyhow!("Failed to send WebSocket message: {}", e))?; Ok(()) } } @@ -51,7 +50,12 @@ impl fmt::Debug for WebSocketTransportAdapter { impl TransportAdapter for WebSocketTransportAdapter { async fn emit_event(&self, _session_id: &str, event: AgenticEvent) -> anyhow::Result<()> { let message = match event { - AgenticEvent::ImageAnalysisStarted { session_id, image_count, user_input, image_metadata } => { + AgenticEvent::ImageAnalysisStarted { + session_id, + image_count, + user_input, + image_metadata, + } => { json!({ "type": "image-analysis-started", "sessionId": session_id, @@ -60,7 +64,11 @@ impl TransportAdapter for WebSocketTransportAdapter { "imageMetadata": image_metadata, }) } - AgenticEvent::ImageAnalysisCompleted { session_id, success, duration_ms } => { + AgenticEvent::ImageAnalysisCompleted { + session_id, + success, + duration_ms, + } => { json!({ "type": "image-analysis-completed", "sessionId": session_id, @@ -68,7 +76,14 @@ impl TransportAdapter for WebSocketTransportAdapter { "durationMs": duration_ms, }) } - AgenticEvent::DialogTurnStarted { session_id, turn_id, turn_index, original_user_input, user_message_metadata, .. } => { + AgenticEvent::DialogTurnStarted { + session_id, + turn_id, + turn_index, + original_user_input, + user_message_metadata, + .. + } => { json!({ "type": "dialog-turn-started", "sessionId": session_id, @@ -78,7 +93,12 @@ impl TransportAdapter for WebSocketTransportAdapter { "userMessageMetadata": user_message_metadata, }) } - AgenticEvent::ModelRoundStarted { session_id, turn_id, round_id, .. } => { + AgenticEvent::ModelRoundStarted { + session_id, + turn_id, + round_id, + .. + } => { json!({ "type": "model-round-started", "sessionId": session_id, @@ -86,7 +106,13 @@ impl TransportAdapter for WebSocketTransportAdapter { "roundId": round_id, }) } - AgenticEvent::TextChunk { session_id, turn_id, round_id, text, .. } => { + AgenticEvent::TextChunk { + session_id, + turn_id, + round_id, + text, + .. + } => { json!({ "type": "text-chunk", "sessionId": session_id, @@ -95,7 +121,12 @@ impl TransportAdapter for WebSocketTransportAdapter { "text": text, }) } - AgenticEvent::ToolEvent { session_id, turn_id, tool_event, .. } => { + AgenticEvent::ToolEvent { + session_id, + turn_id, + tool_event, + .. + } => { json!({ "type": "tool-event", "sessionId": session_id, @@ -103,7 +134,11 @@ impl TransportAdapter for WebSocketTransportAdapter { "toolEvent": tool_event, }) } - AgenticEvent::DialogTurnCompleted { session_id, turn_id, .. } => { + AgenticEvent::DialogTurnCompleted { + session_id, + turn_id, + .. + } => { json!({ "type": "dialog-turn-completed", "sessionId": session_id, @@ -112,11 +147,11 @@ impl TransportAdapter for WebSocketTransportAdapter { } _ => return Ok(()), }; - + self.send_json(message)?; Ok(()) } - + async fn emit_text_chunk(&self, _session_id: &str, chunk: TextChunk) -> anyhow::Result<()> { self.send_json(json!({ "type": "text-chunk", @@ -128,8 +163,12 @@ impl TransportAdapter for WebSocketTransportAdapter { }))?; Ok(()) } - - async fn emit_tool_event(&self, _session_id: &str, event: ToolEventPayload) -> anyhow::Result<()> { + + async fn emit_tool_event( + &self, + _session_id: &str, + event: ToolEventPayload, + ) -> anyhow::Result<()> { self.send_json(json!({ "type": "tool-event", "sessionId": event.session_id, @@ -146,8 +185,13 @@ impl TransportAdapter for WebSocketTransportAdapter { }))?; Ok(()) } - - async fn emit_stream_start(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { + + async fn emit_stream_start( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { self.send_json(json!({ "type": "stream-start", "sessionId": session_id, @@ -156,8 +200,13 @@ impl TransportAdapter for WebSocketTransportAdapter { }))?; Ok(()) } - - async fn emit_stream_end(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()> { + + async fn emit_stream_end( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()> { self.send_json(json!({ "type": "stream-end", "sessionId": session_id, @@ -166,15 +215,19 @@ impl TransportAdapter for WebSocketTransportAdapter { }))?; Ok(()) } - - async fn emit_generic(&self, event_name: &str, payload: serde_json::Value) -> anyhow::Result<()> { + + async fn emit_generic( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> anyhow::Result<()> { self.send_json(json!({ "type": event_name, "payload": payload, }))?; Ok(()) } - + fn adapter_type(&self) -> &str { "websocket" } diff --git a/src/crates/transport/src/emitter.rs b/src/crates/transport/src/emitter.rs index d7365ff0..4409cf71 100644 --- a/src/crates/transport/src/emitter.rs +++ b/src/crates/transport/src/emitter.rs @@ -1,11 +1,10 @@ +use crate::TransportAdapter; +use async_trait::async_trait; +use bitfun_events::EventEmitter; /// TransportEmitter - EventEmitter implementation based on TransportAdapter /// /// This is the bridge connecting core layer and transport layer - use std::sync::Arc; -use async_trait::async_trait; -use bitfun_events::EventEmitter; -use crate::TransportAdapter; /// TransportEmitter - Implements EventEmitter using TransportAdapter #[derive(Clone)] diff --git a/src/crates/transport/src/event_bus.rs b/src/crates/transport/src/event_bus.rs index 83f6b98c..3399f7fb 100644 --- a/src/crates/transport/src/event_bus.rs +++ b/src/crates/transport/src/event_bus.rs @@ -1,22 +1,20 @@ -use log::{warn, error}; /// Unified event bus - Manages event distribution for all platforms - - use crate::traits::TransportAdapter; +use bitfun_events::AgenticEvent; use dashmap::DashMap; +use log::{error, warn}; use std::sync::Arc; use tokio::sync::mpsc; -use bitfun_events::AgenticEvent; /// Event bus - Core event dispatcher #[derive(Clone)] pub struct EventBus { /// Active transport adapters (indexed by session_id) adapters: Arc>>, - + /// Event queue (async buffer) event_tx: mpsc::UnboundedSender, - + /// Whether logging is enabled #[allow(dead_code)] enable_logging: bool, @@ -44,52 +42,63 @@ impl EventBus { pub fn new(enable_logging: bool) -> Self { let (event_tx, mut event_rx) = mpsc::unbounded_channel::(); let adapters: Arc>> = Arc::new(DashMap::new()); - + let adapters_clone = adapters.clone(); tokio::spawn(async move { while let Some(envelope) = event_rx.recv().await { if let Some(adapter) = adapters_clone.get(&envelope.session_id) { - if let Err(e) = adapter.emit_event(&envelope.session_id, envelope.event).await { - error!("Failed to emit event for session {}: {}", envelope.session_id, e); + if let Err(e) = adapter + .emit_event(&envelope.session_id, envelope.event) + .await + { + error!( + "Failed to emit event for session {}: {}", + envelope.session_id, e + ); } } else { warn!("No adapter registered for session: {}", envelope.session_id); } } }); - + Self { adapters, event_tx, enable_logging, } } - + /// Register transport adapter pub fn register_adapter(&self, session_id: String, adapter: Arc) { self.adapters.insert(session_id, adapter); } - + /// Unregister adapter pub fn unregister_adapter(&self, session_id: &str) { self.adapters.remove(session_id); } - + /// Emit event - pub async fn emit(&self, session_id: String, event: AgenticEvent, priority: EventPriority) -> anyhow::Result<()> { + pub async fn emit( + &self, + session_id: String, + event: AgenticEvent, + priority: EventPriority, + ) -> anyhow::Result<()> { let envelope = EventEnvelope { session_id, event, priority, }; - - self.event_tx.send(envelope).map_err(|e| { - anyhow::anyhow!("Failed to send event to queue: {}", e) - })?; - + + self.event_tx + .send(envelope) + .map_err(|e| anyhow::anyhow!("Failed to send event to queue: {}", e))?; + Ok(()) } - + /// Get active session count pub fn active_sessions(&self) -> usize { self.adapters.len() @@ -99,11 +108,10 @@ impl EventBus { #[cfg(test)] mod tests { use super::*; - + #[tokio::test] async fn test_event_bus_creation() { let bus = EventBus::new(true); assert_eq!(bus.active_sessions(), 0); } } - diff --git a/src/crates/transport/src/events.rs b/src/crates/transport/src/events.rs index de770e74..1ca4a4b4 100644 --- a/src/crates/transport/src/events.rs +++ b/src/crates/transport/src/events.rs @@ -1,8 +1,7 @@ /// Generic event definitions /// /// Supports multiple event types, uniformly distributed by transport layer - -use serde::{Serialize, Deserialize}; +use serde::{Deserialize, Serialize}; /// Unified event enum - All events to be sent to frontend #[derive(Debug, Clone, Serialize, Deserialize)] @@ -10,19 +9,19 @@ use serde::{Serialize, Deserialize}; pub enum UnifiedEvent { /// Agentic system event Agentic(AgenticEventPayload), - + /// LSP event Lsp(LspEventPayload), - + /// File watch event FileWatch(FileWatchEventPayload), - + /// Profile generation event Profile(ProfileEventPayload), - + /// Snapshot event Snapshot(SnapshotEventPayload), - + /// Generic backend event Backend(BackendEventPayload), } diff --git a/src/crates/transport/src/lib.rs b/src/crates/transport/src/lib.rs index c6f6a0a6..d71222df 100644 --- a/src/crates/transport/src/lib.rs +++ b/src/crates/transport/src/lib.rs @@ -1,24 +1,23 @@ +pub mod adapters; +pub mod emitter; +pub mod event_bus; +pub mod events; /// BitFun Transport Layer /// /// Cross-platform communication abstraction layer, supports: /// - CLI (tokio mpsc) /// - Tauri (app.emit) /// - WebSocket/SSE (web server) - pub mod traits; -pub mod event_bus; -pub mod adapters; -pub mod events; -pub mod emitter; +pub use adapters::{CliEvent, CliTransportAdapter, WebSocketTransportAdapter}; pub use emitter::TransportEmitter; -pub use traits::{TransportAdapter, TextChunk, ToolEventPayload, ToolEventType, StreamEvent}; pub use event_bus::{EventBus, EventPriority}; pub use events::{ - UnifiedEvent, AgenticEventPayload, LspEventPayload, FileWatchEventPayload, - ProfileEventPayload, SnapshotEventPayload, BackendEventPayload, + AgenticEventPayload, BackendEventPayload, FileWatchEventPayload, LspEventPayload, + ProfileEventPayload, SnapshotEventPayload, UnifiedEvent, }; -pub use adapters::{CliEvent, CliTransportAdapter, WebSocketTransportAdapter}; +pub use traits::{StreamEvent, TextChunk, ToolEventPayload, ToolEventType, TransportAdapter}; #[cfg(feature = "tauri-adapter")] pub use adapters::TauriTransportAdapter; diff --git a/src/crates/transport/src/traits.rs b/src/crates/transport/src/traits.rs index c3a4abd0..8dbf1bbd 100644 --- a/src/crates/transport/src/traits.rs +++ b/src/crates/transport/src/traits.rs @@ -4,33 +4,50 @@ /// - CLI (tokio::mpsc channels) /// - Tauri (app.emit events) /// - WebSocket/SSE (web server) - use async_trait::async_trait; +use bitfun_events::AgenticEvent; use serde::{Deserialize, Serialize}; use std::fmt::Debug; -use bitfun_events::AgenticEvent; /// Transport adapter trait - All platforms must implement this interface #[async_trait] pub trait TransportAdapter: Send + Sync + Debug { /// Emit agentic event to frontend async fn emit_event(&self, session_id: &str, event: AgenticEvent) -> anyhow::Result<()>; - + /// Emit text chunk (streaming output) async fn emit_text_chunk(&self, session_id: &str, chunk: TextChunk) -> anyhow::Result<()>; - + /// Emit tool event - async fn emit_tool_event(&self, session_id: &str, event: ToolEventPayload) -> anyhow::Result<()>; - + async fn emit_tool_event( + &self, + session_id: &str, + event: ToolEventPayload, + ) -> anyhow::Result<()>; + /// Emit stream start event - async fn emit_stream_start(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()>; - + async fn emit_stream_start( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()>; + /// Emit stream end event - async fn emit_stream_end(&self, session_id: &str, turn_id: &str, round_id: &str) -> anyhow::Result<()>; - + async fn emit_stream_end( + &self, + session_id: &str, + turn_id: &str, + round_id: &str, + ) -> anyhow::Result<()>; + /// Emit generic event (supports any event type) - async fn emit_generic(&self, event_name: &str, payload: serde_json::Value) -> anyhow::Result<()>; - + async fn emit_generic( + &self, + event_name: &str, + payload: serde_json::Value, + ) -> anyhow::Result<()>; + /// Get adapter type name fn adapter_type(&self) -> &str; } @@ -82,4 +99,3 @@ pub struct StreamEvent { pub event_type: String, pub payload: serde_json::Value, } - diff --git a/src/mobile-web/src/App.tsx b/src/mobile-web/src/App.tsx index d283a253..4b9c0803 100644 --- a/src/mobile-web/src/App.tsx +++ b/src/mobile-web/src/App.tsx @@ -3,6 +3,7 @@ import PairingPage from './pages/PairingPage'; import WorkspacePage from './pages/WorkspacePage'; import SessionListPage from './pages/SessionListPage'; import ChatPage from './pages/ChatPage'; +import { I18nProvider } from './i18n'; import { RelayHttpClient } from './services/RelayHttpClient'; import { RemoteSessionManager } from './services/RemoteSessionManager'; import { ThemeProvider } from './theme'; @@ -126,7 +127,9 @@ const AppContent: React.FC = () => { const App: React.FC = () => ( - + + + ); diff --git a/src/mobile-web/src/components/LanguageToggleButton.tsx b/src/mobile-web/src/components/LanguageToggleButton.tsx new file mode 100644 index 00000000..26117e14 --- /dev/null +++ b/src/mobile-web/src/components/LanguageToggleButton.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import { useI18n } from '../i18n'; + +interface LanguageToggleButtonProps { + className?: string; +} + +const LanguageToggleButton: React.FC = ({ className }) => { + const { language, toggleLanguage, t } = useI18n(); + + return ( + + ); +}; + +export default LanguageToggleButton; + diff --git a/src/mobile-web/src/i18n/I18nProvider.tsx b/src/mobile-web/src/i18n/I18nProvider.tsx new file mode 100644 index 00000000..59c82665 --- /dev/null +++ b/src/mobile-web/src/i18n/I18nProvider.tsx @@ -0,0 +1,137 @@ +import React, { createContext, useCallback, useLayoutEffect, useMemo, useState } from 'react'; +import { DEFAULT_LANGUAGE, messages, type MobileLanguage } from './messages'; + +interface TranslateParams { + [key: string]: string | number; +} + +interface I18nContextValue { + language: MobileLanguage; + setLanguage: (language: MobileLanguage) => void; + toggleLanguage: () => void; + t: (key: string, params?: TranslateParams) => string; +} + +const STORAGE_KEY = 'bitfun-mobile-language'; + +function isLanguage(value: string | null | undefined): value is MobileLanguage { + return value === 'zh-CN' || value === 'en-US'; +} + +function getByPath(source: unknown, path: string): string | null { + const segments = path.split('.'); + let current: unknown = source; + + for (const segment of segments) { + if (!current || typeof current !== 'object' || !(segment in current)) { + return null; + } + current = (current as Record)[segment]; + } + + return typeof current === 'string' ? current : null; +} + +function interpolate(template: string, params?: TranslateParams): string { + if (!params) return template; + return template.replace(/\{(\w+)\}/g, (_, key: string) => { + const value = params[key]; + return value == null ? '' : String(value); + }); +} + +export function translate(language: MobileLanguage, key: string, params?: TranslateParams): string { + const template = getByPath(messages[language], key) + ?? getByPath(messages[DEFAULT_LANGUAGE], key) + ?? key; + return interpolate(template, params); +} + +function detectInitialLanguage(): MobileLanguage { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (isLanguage(stored)) return stored; + } catch { + // ignore storage failures + } + + const urlLanguage = detectLanguageFromUrl(); + if (urlLanguage) return urlLanguage; + + const browserLanguage = navigator.language?.toLowerCase() || ''; + if (browserLanguage.startsWith('zh')) return 'zh-CN'; + return DEFAULT_LANGUAGE; +} + +function detectLanguageFromUrl(): MobileLanguage | null { + const candidates: Array = []; + + try { + candidates.push(new URLSearchParams(window.location.search).get('lang')); + } catch { + // ignore malformed search params + } + + const hash = window.location.hash || ''; + const hashQueryIndex = hash.indexOf('?'); + if (hashQueryIndex >= 0) { + try { + const hashQuery = hash.slice(hashQueryIndex + 1); + candidates.push(new URLSearchParams(hashQuery).get('lang')); + } catch { + // ignore malformed hash params + } + } + + for (const candidate of candidates) { + if (isLanguage(candidate)) { + return candidate; + } + } + + return null; +} + +export const I18nContext = createContext({ + language: DEFAULT_LANGUAGE, + setLanguage: () => {}, + toggleLanguage: () => {}, + t: (key) => key, +}); + +export const I18nProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const [language, setLanguageState] = useState(detectInitialLanguage); + + useLayoutEffect(() => { + document.documentElement.lang = language; + try { + localStorage.setItem(STORAGE_KEY, language); + } catch { + // ignore storage failures + } + }, [language]); + + const setLanguage = useCallback((nextLanguage: MobileLanguage) => { + setLanguageState(nextLanguage); + }, []); + + const toggleLanguage = useCallback(() => { + setLanguageState((prev) => (prev === 'zh-CN' ? 'en-US' : 'zh-CN')); + }, []); + + const value = useMemo(() => ({ + language, + setLanguage, + toggleLanguage, + t: (key, params) => translate(language, key, params), + }), [language, setLanguage, toggleLanguage]); + + return ( + + {children} + + ); +}; + +export type { MobileLanguage, TranslateParams }; + diff --git a/src/mobile-web/src/i18n/index.ts b/src/mobile-web/src/i18n/index.ts new file mode 100644 index 00000000..9ee132a6 --- /dev/null +++ b/src/mobile-web/src/i18n/index.ts @@ -0,0 +1,4 @@ +export { I18nProvider, translate } from './I18nProvider'; +export type { MobileLanguage, TranslateParams } from './I18nProvider'; +export { useI18n } from './useI18n'; + diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts new file mode 100644 index 00000000..6e486b9e --- /dev/null +++ b/src/mobile-web/src/i18n/messages.ts @@ -0,0 +1,264 @@ +export type MobileLanguage = 'zh-CN' | 'en-US'; + +type MessageLeaf = string; +type MessageTree = { [key: string]: MessageLeaf | MessageTree }; + +export const DEFAULT_LANGUAGE: MobileLanguage = 'en-US'; + +export const messages: Record = { + 'en-US': { + common: { + appName: 'BitFun Remote', + back: 'Back', + continue: 'Continue', + cancel: 'Cancel', + switch: 'Switch', + workspace: 'Workspace', + userId: 'User ID', + loading: 'Loading...', + language: 'Language', + switchLanguage: 'Switch language', + toggleTheme: 'Toggle theme', + attachImage: 'Attach image', + stop: 'Stop', + submit: 'Submit', + submitting: 'Submitting...', + submitted: 'Submitted', + other: 'Other', + customTextInput: 'Custom text input', + typeYourAnswer: 'Type your answer...', + itemCount: '{count} items', + justNow: 'just now', + minutesAgo: '{count}m ago', + hoursAgo: '{count}h ago', + daysAgo: '{count}d ago', + }, + pairing: { + enterUserIdToContinue: 'Enter your user ID to continue', + connectingAndPairing: 'Connecting and pairing...', + pairedLoadingSessions: 'Paired! Loading sessions...', + connectionError: 'Connection error', + invalidQrCode: 'Invalid QR code: missing room or public key', + userIdRequired: 'User ID is required', + tooManyAttempts: 'Too many failed attempts. Try again in {seconds}s.', + pairingFailed: 'Pairing failed', + fieldLabel: 'User ID', + placeholder: 'Enter a user ID', + note: 'The first successful connection binds this URL to your user ID for the current remote session.', + connecting: 'Connecting...', + retryIn: 'Retry in {seconds}s', + continue: 'Continue', + }, + sessions: { + remoteCockpit: 'Remote cockpit', + workspace: 'Workspace', + switchWorkspace: 'Switch workspace', + noWorkspaceSelected: 'No workspace selected', + launch: 'Launch', + startRemoteFlow: 'Start a new remote flow', + codeSession: 'Code Session', + codeSessionDesc: 'For coding anywhere, anytime.', + coworkSession: 'Cowork Session', + coworkSessionDesc: 'For assisting with everyday work.', + recent: 'Recent', + sessionHistory: 'Session history', + loadingSessions: 'Loading sessions...', + noSessions: 'No sessions yet. Create one to get started.', + untitledSession: 'Untitled Session', + loadingMore: 'Loading more...', + remoteCodeSession: 'Remote Code Session', + remoteCoworkSession: 'Remote Cowork Session', + agentCode: 'Code', + agentCowork: 'Cowork', + agentDefault: 'Default', + pullToRefresh: 'Pull to refresh', + }, + workspace: { + title: 'Workspace', + loadingInfo: 'Loading workspace info...', + currentWorkspace: 'Current Workspace', + unknownProject: 'Unknown Project', + noWorkspaceOpen: 'No workspace is currently open on the desktop.', + noWorkspaceHint: 'Select a recent workspace below, or open one on the desktop first.', + selectWorkspace: 'Select Workspace', + recentWorkspaces: 'Recent Workspaces', + noRecentWorkspaces: 'No recent workspaces found. Please open a workspace on the desktop first.', + failedToSetWorkspace: 'Failed to set workspace', + openingWorkspace: 'Opening workspace...', + }, + chat: { + session: 'Session', + loadingOlderMessages: 'Loading older messages...', + showResponse: 'Show response', + hideResponse: 'Hide response', + analyzingImage: 'Analyzing image with image understanding model...', + inputPlaceholder: 'How can I help you...', + workingPlaceholder: 'BitFun is working...', + imageAnalyzingPlaceholder: 'Analyzing image...', + imageAttachmentFallback: '(see attached images)', + askQuestionCount: '{count} question{suffix}', + waiting: 'Waiting', + modeAgentic: 'Agentic', + modePlan: 'Plan', + modeDebug: 'Debug', + thinking: 'Thinking...', + allTasksCompleted: 'All tasks completed', + task: 'Task', + toolCalls: '{count} tool call{suffix}', + done: '{count} done', + running: '{count} running', + thoughtCharacters: 'Thought {count} characters', + textCharacters: 'Text {count} characters', + readToolsDone: '{summary}', + readToolsRunning: '{summary} ({doneCount} done)', + fileLoading: 'Loading...', + fileUnavailable: 'File unavailable', + fileDownloading: 'Downloading...', + fileDownloaded: 'Downloaded', + clickToDownload: 'Click to download', + }, + tools: { + explore: 'Explore', + read: 'Read', + write: 'Write', + ls: 'LS', + shell: 'Shell', + glob: 'Glob', + grep: 'Grep', + delete: 'Delete', + task: 'Task', + search: 'Search', + edit: 'Edit', + web: 'Web', + todo: 'Todo', + }, + }, + 'zh-CN': { + common: { + appName: 'BitFun Remote', + back: '返回', + continue: '继续', + cancel: '取消', + switch: '切换', + workspace: '工作区', + userId: '用户 ID', + loading: '加载中...', + language: '语言', + switchLanguage: '切换语言', + toggleTheme: '切换主题', + attachImage: '添加图片', + stop: '停止', + submit: '提交', + submitting: '提交中...', + submitted: '已提交', + other: '其他', + customTextInput: '自定义输入', + typeYourAnswer: '请输入你的回答...', + itemCount: '{count} 项', + justNow: '刚刚', + minutesAgo: '{count} 分钟前', + hoursAgo: '{count} 小时前', + daysAgo: '{count} 天前', + }, + pairing: { + enterUserIdToContinue: '请输入你的用户 ID 继续', + connectingAndPairing: '正在连接并配对...', + pairedLoadingSessions: '配对成功,正在加载会话...', + connectionError: '连接异常', + invalidQrCode: '二维码无效:缺少 room 或 public key', + userIdRequired: '用户 ID 不能为空', + tooManyAttempts: '失败次数过多,请在 {seconds} 秒后重试。', + pairingFailed: '配对失败', + fieldLabel: '用户 ID', + placeholder: '请输入用户 ID', + note: '首次成功连接后,本次远程会话会把该 URL 绑定到你的用户 ID。', + connecting: '连接中...', + retryIn: '{seconds} 秒后重试', + continue: '继续', + }, + sessions: { + remoteCockpit: 'Remote cockpit', + workspace: '工作区', + switchWorkspace: '切换工作区', + noWorkspaceSelected: '未选择工作区', + launch: '启动', + startRemoteFlow: '开始一个新的远程流程', + codeSession: '代码会话', + codeSessionDesc: '随时随地进行编码。', + coworkSession: '协作会话', + coworkSessionDesc: '处理日常协作与办公任务。', + recent: '最近', + sessionHistory: '会话历史', + loadingSessions: '正在加载会话...', + noSessions: '还没有会话,先创建一个开始吧。', + untitledSession: '未命名会话', + loadingMore: '正在加载更多...', + remoteCodeSession: '远程代码会话', + remoteCoworkSession: '远程协作会话', + agentCode: '代码', + agentCowork: '协作', + agentDefault: '默认', + pullToRefresh: '下拉刷新', + }, + workspace: { + title: '工作区', + loadingInfo: '正在加载工作区信息...', + currentWorkspace: '当前工作区', + unknownProject: '未知项目', + noWorkspaceOpen: '桌面端当前没有打开工作区。', + noWorkspaceHint: '你可以在下方选择最近工作区,或先在桌面端打开一个工作区。', + selectWorkspace: '选择工作区', + recentWorkspaces: '最近工作区', + noRecentWorkspaces: '没有找到最近工作区,请先在桌面端打开一个工作区。', + failedToSetWorkspace: '设置工作区失败', + openingWorkspace: '正在打开工作区...', + }, + chat: { + session: '会话', + loadingOlderMessages: '正在加载更早的消息...', + showResponse: '展开回复', + hideResponse: '收起回复', + analyzingImage: '正在使用图像理解模型分析图片...', + inputPlaceholder: '我可以帮你做什么...', + workingPlaceholder: 'BitFun 正在处理中...', + imageAnalyzingPlaceholder: '正在分析图片...', + imageAttachmentFallback: '(见附带图片)', + askQuestionCount: '{count} 个问题', + waiting: '等待中', + modeAgentic: '智能代理', + modePlan: '规划', + modeDebug: '调试', + thinking: '思考中...', + allTasksCompleted: '所有任务已完成', + task: '任务', + toolCalls: '{count} 次工具调用', + done: '已完成 {count}', + running: '运行中 {count}', + thoughtCharacters: '思考 {count} 个字符', + textCharacters: '文本 {count} 个字符', + readToolsDone: '{summary}', + readToolsRunning: '{summary}(已完成 {doneCount})', + fileLoading: '加载中...', + fileUnavailable: '文件不可用', + fileDownloading: '下载中...', + fileDownloaded: '已下载', + clickToDownload: '点击下载', + }, + tools: { + explore: '探索', + read: '读取', + write: '写入', + ls: '列表', + shell: 'Shell', + glob: 'Glob', + grep: 'Grep', + delete: '删除', + task: '任务', + search: '搜索', + edit: '编辑', + web: '网络', + todo: '待办', + }, + }, +}; + diff --git a/src/mobile-web/src/i18n/useI18n.ts b/src/mobile-web/src/i18n/useI18n.ts new file mode 100644 index 00000000..f2d430df --- /dev/null +++ b/src/mobile-web/src/i18n/useI18n.ts @@ -0,0 +1,7 @@ +import { useContext } from 'react'; +import { I18nContext } from './I18nProvider'; + +export function useI18n() { + return useContext(I18nContext); +} + diff --git a/src/mobile-web/src/pages/ChatPage.tsx b/src/mobile-web/src/pages/ChatPage.tsx index 290d95a9..df27b381 100644 --- a/src/mobile-web/src/pages/ChatPage.tsx +++ b/src/mobile-web/src/pages/ChatPage.tsx @@ -3,6 +3,7 @@ import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { useI18n } from '../i18n'; import { RemoteSessionManager, SessionPoller, @@ -98,7 +99,7 @@ const CODE_FILE_EXTENSIONS = new Set([ 'c', 'cpp', 'cc', 'cxx', 'h', 'hpp', 'hxx', 'hh', 'cs', 'rb', 'php', 'swift', 'vue', 'svelte', - 'html', 'htm', 'css', 'scss', 'less', 'sass', + 'css', 'scss', 'less', 'sass', 'json', 'jsonc', 'yaml', 'yml', 'toml', 'xml', 'md', 'mdx', 'rst', 'txt', 'sh', 'bash', 'zsh', 'fish', 'ps1', 'bat', 'cmd', @@ -235,6 +236,7 @@ interface FileCardProps { const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) => { const { isDark } = useTheme(); + const { t } = useI18n(); const [state, setState] = useState({ status: 'loading' }); const onGetFileInfoRef = useRef(onGetFileInfo); onGetFileInfoRef.current = onGetFileInfo; @@ -289,7 +291,7 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) return ( - Loading… + {t('chat.fileLoading')} ); } @@ -297,7 +299,7 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) return ( - File unavailable + {t('chat.fileUnavailable')} ); } @@ -314,7 +316,7 @@ const FileCard: React.FC = ({ path, onGetFileInfo, onDownload }) role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') handleClick(); }} - title={isDownloading ? 'Downloading…' : isDone ? 'Downloaded' : 'Click to download'} + title={isDownloading ? t('chat.fileDownloading') : isDone ? t('chat.fileDownloaded') : t('chat.clickToDownload')} > @@ -514,6 +516,7 @@ const MarkdownContent: React.FC = ({ content, onFileDownlo // ─── Thinking (ModelThinkingDisplay-style) ─────────────────────────────────── const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ thinking, streaming }) => { + const { t } = useI18n(); const [open, setOpen] = useState(false); const wrapperRef = useRef(null); const [scrollState, setScrollState] = useState({ atTop: true, atBottom: true }); @@ -531,8 +534,8 @@ const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ th const charCount = thinking.length; const label = streaming && charCount === 0 - ? 'Thinking...' - : `Thought ${charCount} characters`; + ? t('chat.thinking') + : t('chat.thoughtCharacters', { count: charCount }); return (
@@ -567,25 +570,26 @@ const ThinkingBlock: React.FC<{ thinking: string; streaming?: boolean }> = ({ th // ─── Tool Card ────────────────────────────────────────────────────────────── const TOOL_TYPE_MAP: Record = { - explore: 'Explore', - read_file: 'Read', - write_file: 'Write', - list_directory: 'LS', - bash: 'Shell', - glob: 'Glob', - grep: 'Grep', - create_file: 'Write', - delete_file: 'Delete', - Task: 'Task', - search: 'Search', - edit_file: 'Edit', - web_search: 'Web', - TodoWrite: 'Todo', + explore: 'tools.explore', + read_file: 'tools.read', + write_file: 'tools.write', + list_directory: 'tools.ls', + bash: 'tools.shell', + glob: 'tools.glob', + grep: 'tools.grep', + create_file: 'tools.write', + delete_file: 'tools.delete', + Task: 'tools.task', + search: 'tools.search', + edit_file: 'tools.edit', + web_search: 'tools.web', + TodoWrite: 'tools.todo', }; // ─── TodoWrite card ───────────────────────────────────────────────────────── const TodoCard: React.FC<{ tool: RemoteToolStatus }> = ({ tool }) => { + const { t } = useI18n(); const [expanded, setExpanded] = useState(false); const todos: { id?: string; content: string; status: string }[] = useMemo(() => { @@ -623,7 +627,7 @@ const TodoCard: React.FC<{ tool: RemoteToolStatus }> = ({ tool }) => { {allDone && !expanded ? ( - All tasks completed + {t('chat.allTasksCompleted')} ) : inProgress && !expanded ? ( {inProgress.content} ) : null} @@ -671,10 +675,13 @@ function parseTaskInfo(tool: RemoteToolStatus): { description?: string; agentTyp /** * Summarize a subItem for display inside a Task card. */ -function subItemLabel(item: ChatMessageItem): string { +function subItemLabel( + item: ChatMessageItem, + t: (key: string, params?: Record) => string, +): string { if (item.type === 'thinking') { const len = (item.content || '').length; - return `Thought ${len} characters`; + return t('chat.thoughtCharacters', { count: len }); } if (item.type === 'tool' && item.tool) { const t = item.tool; @@ -683,7 +690,7 @@ function subItemLabel(item: ChatMessageItem): string { } if (item.type === 'text') { const len = (item.content || '').length; - return `Text ${len} characters`; + return t('chat.textCharacters', { count: len }); } return ''; } @@ -694,6 +701,7 @@ const TaskToolCard: React.FC<{ subItems?: ChatMessageItem[]; onCancelTool?: (toolId: string) => void; }> = ({ tool, now, subItems = [], onCancelTool }) => { + const { t } = useI18n(); const scrollRef = useRef(null); const prevCountRef = useRef(0); const [stepsExpanded, setStepsExpanded] = useState(false); @@ -741,7 +749,7 @@ const TaskToolCard: React.FC<{ )} - {taskInfo?.description || 'Task'} + {taskInfo?.description || t('chat.task')} {taskInfo?.agentType && ( {taskInfo.agentType} @@ -753,7 +761,7 @@ const TaskToolCard: React.FC<{ )} {isOtherSelected && ( setCustomTexts(prev => ({ ...prev, [qIdx]: e.target.value }))} disabled={submitted || submitting} @@ -1310,7 +1329,7 @@ const AskQuestionCard: React.FC = ({ tool, onAnswer }) => onClick={handleSubmit} > - {submitted ? 'Submitted' : submitting ? 'Submitting...' : 'Submit'} + {submitted ? t('common.submitted') : submitting ? t('common.submitting') : t('common.submit')}
); @@ -1552,15 +1571,10 @@ const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => ( type AgentMode = 'agentic' | 'Plan' | 'debug'; -const MODE_OPTIONS: { id: AgentMode; label: string }[] = [ - { id: 'agentic', label: 'Agentic' }, - { id: 'Plan', label: 'Plan' }, - { id: 'debug', label: 'Debug' }, -]; - // ─── ChatPage ─────────────────────────────────────────────────────────────── const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, onBack, autoFocus }) => { + const { t } = useI18n(); const { getMessages, setMessages, @@ -1574,6 +1588,11 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } = useMobileStore(); const { isDark, toggleTheme } = useTheme(); + const modeOptions: { id: AgentMode; label: string }[] = useMemo(() => ([ + { id: 'agentic', label: t('chat.modeAgentic') }, + { id: 'Plan', label: t('chat.modePlan') }, + { id: 'debug', label: t('chat.modeDebug') }, + ]), [t]); const messages = getMessages(sessionId); const [input, setInput] = useState(''); const [agentMode, setAgentMode] = useState('agentic'); @@ -1821,7 +1840,12 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, } try { - await sessionMgr.sendMessage(sessionId, text || '(see attached images)', agentMode, imageContexts); + await sessionMgr.sendMessage( + sessionId, + text || t('chat.imageAttachmentFallback'), + agentMode, + imageContexts, + ); pollerRef.current?.nudge(); } catch (e: any) { setError(e.message); @@ -1829,7 +1853,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, setImageAnalyzing(false); setOptimisticMsg(null); } - }, [input, pendingImages, isStreaming, sessionId, sessionMgr, setError, agentMode]); + }, [agentMode, imageAnalyzing, input, isStreaming, pendingImages, sessionId, sessionMgr, setError, t]); const handleImageSelect = useCallback(() => { fileInputRef.current?.click(); @@ -1927,14 +1951,14 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, const workspaceName = currentWorkspace?.project_name || currentWorkspace?.path?.split('/').pop() || ''; const gitBranch = currentWorkspace?.git_branch; - const displayName = liveTitle || sessionName || 'Session'; + const displayName = liveTitle || sessionName || t('chat.session'); return (
{/* Header */}
-
-
@@ -1964,7 +1988,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, {/* Messages */}
{isLoadingMore && ( -
Loading older messages…
+
{t('chat.loadingOlderMessages')}
)} {(() => { @@ -2030,7 +2054,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, - Show response + {t('chat.showResponse')}
); @@ -2052,7 +2076,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, - Hide response + {t('chat.hideResponse')} )} {hasItems ? ( @@ -2105,7 +2129,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName, ] : []; const onCancel = (toolId: string) => { - sessionMgr.cancelTool(toolId, 'User cancelled').catch(err => { setError(String(err)); }); + sessionMgr.cancelTool(toolId, t('common.cancel')).catch(err => { setError(String(err)); }); }; return ( @@ -2178,7 +2202,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName,
- Analyzing image with image understanding model... + {t('chat.analyzingImage')}
@@ -2211,7 +2235,7 @@ const ChatPage: React.FC = ({ sessionMgr, sessionId, sessionName,