diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 995edc3c..edb6d77b 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -208,6 +208,8 @@ pub struct RemoteConnectStatusResponse { /// Independent bot connection info — e.g. "Telegram(7096812005)". /// Present when a bot is active, regardless of relay pairing state. pub bot_connected: Option, + /// Bot verbose mode setting — when true, intermediate progress is sent to users. + pub bot_verbose_mode: bool, } #[derive(Debug, Serialize)] @@ -436,6 +438,7 @@ pub async fn remote_connect_status() -> Result Result Resul Ok(()) } + +#[tauri::command] +pub async fn remote_connect_get_bot_verbose_mode() -> Result { + let data = bot::load_bot_persistence(); + Ok(data.verbose_mode) +} + +#[tauri::command] +pub async fn remote_connect_set_bot_verbose_mode(verbose: bool) -> Result<(), String> { + log::info!("remote_connect_set_bot_verbose_mode called with verbose={}", verbose); + let mut data = bot::load_bot_persistence(); + data.verbose_mode = verbose; + bot::save_bot_persistence(&data); + log::info!("Saved bot verbose_mode={} to persistence", verbose); + Ok(()) +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index a5367c5b..5bc5588e 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -611,6 +611,8 @@ pub async fn run() { api::remote_connect_api::remote_connect_set_form_state, api::remote_connect_api::remote_connect_configure_custom_server, api::remote_connect_api::remote_connect_configure_bot, + api::remote_connect_api::remote_connect_get_bot_verbose_mode, + api::remote_connect_api::remote_connect_set_bot_verbose_mode, // MiniApp API api::miniapp_api::list_miniapps, api::miniapp_api::get_miniapp, 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 a5f2621b..3e81b208 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 @@ -27,12 +27,28 @@ impl BotLanguage { } } +/// Display mode for bot sessions - Professional or Assistant +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +pub enum BotDisplayMode { + /// Professional mode: can create Code/Cowork sessions + #[serde(rename = "pro")] + Pro, + /// Assistant mode: can create Claw sessions + #[serde(rename = "assistant")] + #[default] + Assistant, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BotChatState { pub chat_id: String, pub paired: bool, pub current_workspace: Option, + pub current_assistant: Option, pub current_session_id: Option, + /// Display mode: Professional (Pro) or Assistant + #[serde(default)] + pub display_mode: BotDisplayMode, #[serde(skip)] pub pending_action: Option, /// Pending file downloads awaiting user confirmation. @@ -49,7 +65,9 @@ impl BotChatState { chat_id, paired: false, current_workspace: None, + current_assistant: None, current_session_id: None, + display_mode: BotDisplayMode::Assistant, pending_action: None, pending_files: std::collections::HashMap::new(), } @@ -72,6 +90,9 @@ pub enum PendingAction { SelectWorkspace { options: Vec<(String, String)>, }, + SelectAssistant { + options: Vec<(String, String)>, + }, SelectSession { options: Vec<(String, String)>, page: usize, @@ -93,9 +114,12 @@ pub enum PendingAction { pub enum BotCommand { Start, SwitchWorkspace, + SwitchAssistant, + SwitchMode(BotDisplayMode), ResumeSession, NewCodeSession, NewCoworkSession, + NewClawSession, CancelTask(Option), Help, PairingCode(String), @@ -207,9 +231,13 @@ pub fn parse_command(text: &str) -> BotCommand { match trimmed { "/start" => BotCommand::Start, "/switch_workspace" => BotCommand::SwitchWorkspace, + "/switch_assistant" => BotCommand::SwitchAssistant, + "/pro" => BotCommand::SwitchMode(BotDisplayMode::Pro), + "/assistant" => BotCommand::SwitchMode(BotDisplayMode::Assistant), "/resume_session" => BotCommand::ResumeSession, "/new_code_session" => BotCommand::NewCodeSession, "/new_cowork_session" => BotCommand::NewCoworkSession, + "/new_claw_session" => BotCommand::NewClawSession, "/help" => BotCommand::Help, "0" => BotCommand::NextPage, _ => { @@ -252,19 +280,29 @@ pub fn help_message(language: BotLanguage) -> &'static str { if language.is_chinese() { "\ 可用命令: -/switch_workspace - 列出并切换工作区 -/resume_session - 恢复已有会话 -/new_code_session - 创建新的编码会话 -/new_cowork_session - 创建新的协作会话 +/switch_workspace - 列出并切换工作区(专业模式) +/switch_assistant - 列出并切换助理(助理模式) +/pro - 切换到专业模式(可创建 Code/Cowork 会话) +/assistant - 切换到助理模式(可创建助理会话) +/verbose - 开启详细模式(显示任务执行过程) +/concise - 开启简洁模式(仅显示最终结果) +/new_code_session - 创建新的编码会话(专业模式) +/new_cowork_session - 创建新的协作会话(专业模式) +/new_claw_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 +/switch_workspace - List and switch workspaces (Expert mode) +/switch_assistant - List and switch assistants (Assistant mode) +/pro - Switch to Expert mode (can create Code/Cowork sessions) +/assistant - Switch to Assistant mode (can create Claw sessions) +/verbose - Enable verbose mode (show task execution progress) +/concise - Enable concise mode (only show final results) +/new_code_session - Create a new coding session (Expert mode) +/new_cowork_session - Create a new cowork session (Expert mode) +/new_claw_session - Create a new claw session (Assistant mode) /cancel_task - Cancel the current task /help - Show this help message" } @@ -313,6 +351,30 @@ fn label_new_cowork_session(language: BotLanguage) -> &'static str { } } +fn label_new_claw_session(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "新建助理会话" + } else { + "New Claw Session" + } +} + +fn label_switch_assistant(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "切换助理" + } else { + "Switch Assistant" + } +} + +fn label_help(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "帮助" + } else { + "Help" + } +} + fn label_cancel_task(language: BotLanguage) -> &'static str { if language.is_chinese() { "取消任务" @@ -329,6 +391,22 @@ fn label_next_page(language: BotLanguage) -> &'static str { } } +fn label_switch_pro_mode(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "专业模式" + } else { + "Expert Mode" + } +} + +fn label_switch_assistant_mode(language: BotLanguage) -> &'static str { + if language.is_chinese() { + "助理模式" + } else { + "Assistant Mode" + } +} + fn other_label(language: BotLanguage) -> &'static str { if language.is_chinese() { "其他" @@ -337,20 +415,47 @@ fn other_label(language: BotLanguage) -> &'static str { } } -pub fn main_menu_actions(language: BotLanguage) -> Vec { +pub fn main_menu_actions(language: BotLanguage, display_mode: BotDisplayMode) -> Vec { + let is_pro = display_mode == BotDisplayMode::Pro; + + if is_pro { + // Pro mode: show workspace switch + vec![ + BotAction::primary(label_switch_workspace(language), "/switch_workspace"), + BotAction::secondary(label_resume_session(language), "/resume_session"), + BotAction::secondary(label_switch_assistant_mode(language), "/assistant"), + BotAction::secondary(label_new_code_session(language), "/new_code_session"), + BotAction::secondary(label_new_cowork_session(language), "/new_cowork_session"), + BotAction::secondary(label_help(language), "/help"), + ] + } else { + // Assistant mode: show assistant switch (not workspace) + vec![ + BotAction::primary(label_switch_assistant(language), "/switch_assistant"), + BotAction::secondary(label_resume_session(language), "/resume_session"), + BotAction::secondary(label_switch_pro_mode(language), "/pro"), + BotAction::secondary(label_new_claw_session(language), "/new_claw_session"), + BotAction::secondary(label_help(language), "/help"), + ] + } +} + +fn pro_mode_actions(language: BotLanguage) -> Vec { vec![ - 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::primary(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", - ), + BotAction::secondary(label_switch_workspace(language), "/switch_workspace"), + BotAction::secondary(label_switch_assistant_mode(language), "/assistant"), + BotAction::secondary(label_help(language), "/help"), + ] +} + +fn assistant_mode_actions(language: BotLanguage) -> Vec { + vec![ + BotAction::primary(label_new_claw_session(language), "/new_claw_session"), + BotAction::secondary(label_switch_assistant(language), "/switch_assistant"), + BotAction::secondary(label_switch_pro_mode(language), "/pro"), + BotAction::secondary(label_help(language), "/help"), ] } @@ -361,19 +466,53 @@ fn workspace_required_actions(language: BotLanguage) -> Vec { )] } -fn session_entry_actions(language: BotLanguage) -> Vec { - vec![ - 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 assistant_required_actions(language: BotLanguage) -> Vec { + vec![BotAction::primary( + label_switch_assistant(language), + "/switch_assistant", + )] } -fn new_session_actions(language: BotLanguage) -> Vec { - vec![ - BotAction::primary(label_new_code_session(language), "/new_code_session"), - BotAction::secondary(label_new_cowork_session(language), "/new_cowork_session"), - ] +fn session_entry_actions(language: BotLanguage, display_mode: BotDisplayMode) -> Vec { + let is_pro = display_mode == BotDisplayMode::Pro; + if is_pro { + vec![ + 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"), + BotAction::secondary(label_switch_workspace(language), "/switch_workspace"), + BotAction::secondary(label_switch_assistant_mode(language), "/assistant"), + BotAction::secondary(label_help(language), "/help"), + ] + } else { + vec![ + BotAction::primary(label_resume_session(language), "/resume_session"), + BotAction::secondary(label_new_claw_session(language), "/new_claw_session"), + BotAction::secondary(label_switch_assistant(language), "/switch_assistant"), + BotAction::secondary(label_switch_pro_mode(language), "/pro"), + BotAction::secondary(label_help(language), "/help"), + ] + } +} + +fn new_session_actions(language: BotLanguage, display_mode: BotDisplayMode) -> Vec { + let is_pro = display_mode == BotDisplayMode::Pro; + if is_pro { + vec![ + BotAction::primary(label_new_code_session(language), "/new_code_session"), + BotAction::secondary(label_new_cowork_session(language), "/new_cowork_session"), + BotAction::secondary(label_switch_workspace(language), "/switch_workspace"), + BotAction::secondary(label_switch_assistant_mode(language), "/assistant"), + BotAction::secondary(label_help(language), "/help"), + ] + } else { + vec![ + BotAction::primary(label_new_claw_session(language), "/new_claw_session"), + BotAction::secondary(label_switch_assistant(language), "/switch_assistant"), + BotAction::secondary(label_switch_pro_mode(language), "/pro"), + BotAction::secondary(label_help(language), "/help"), + ] + } } fn cancel_task_actions(language: BotLanguage, command: impl Into) -> Vec { @@ -403,7 +542,7 @@ pub async fn handle_command( if state.paired { HandleResult { reply: help_message(language).to_string(), - actions: main_menu_actions(language), + actions: main_menu_actions(language, state.display_mode), forward_to_session: None, } } else { @@ -414,6 +553,43 @@ pub async fn handle_command( } } } + BotCommand::SwitchMode(new_mode) => { + if !state.paired { + return not_paired(language); + } + state.display_mode = new_mode; + let mode_name = if new_mode == BotDisplayMode::Pro { + if language.is_chinese() { "专业模式" } else { "Expert Mode" } + } else { + if language.is_chinese() { "助理模式" } else { "Assistant Mode" } + }; + let desc = if new_mode == BotDisplayMode::Pro { + if language.is_chinese() { + "适合目标明确、一次完成的即时任务。" + } else { + "Best for focused, one-shot tasks with a clear goal." + } + } else { + if language.is_chinese() { + "适合持续推进、需要延续上下文和个人偏好的任务。" + } else { + "Best for ongoing work with context and personal preferences." + } + }; + HandleResult { + reply: if language.is_chinese() { + format!("已切换到 {}\n\n{}\n\n你现在可以:", mode_name, desc) + } else { + format!("Switched to {}\n\n{}\n\nYou can now:", mode_name, desc) + }, + actions: if new_mode == BotDisplayMode::Pro { + pro_mode_actions(language) + } else { + assistant_mode_actions(language) + }, + forward_to_session: None, + } + } BotCommand::PairingCode(_) => HandleResult { reply: if language.is_chinese() { "配对码会自动处理。如果你需要重新配对,请在 BitFun Desktop 中重新启动连接。" @@ -431,12 +607,24 @@ pub async fn handle_command( } handle_switch_workspace(state).await } + BotCommand::SwitchAssistant => { + if !state.paired { + return not_paired(language); + } + handle_switch_assistant(state).await + } BotCommand::ResumeSession => { if !state.paired { return not_paired(language); } - if state.current_workspace.is_none() { - return need_workspace(language); + if state.display_mode == BotDisplayMode::Pro { + if state.current_workspace.is_none() { + return need_workspace(language); + } + } else { + if state.current_assistant.is_none() { + return need_assistant(language); + } } handle_resume_session(state, 0).await } @@ -444,6 +632,10 @@ pub async fn handle_command( if !state.paired { return not_paired(language); } + // Code session only available in Pro mode + if state.display_mode != BotDisplayMode::Pro { + return wrong_mode_for_pro(language); + } if state.current_workspace.is_none() { return need_workspace(language); } @@ -453,11 +645,26 @@ pub async fn handle_command( if !state.paired { return not_paired(language); } + // Cowork session only available in Pro mode + if state.display_mode != BotDisplayMode::Pro { + return wrong_mode_for_pro(language); + } if state.current_workspace.is_none() { return need_workspace(language); } handle_new_session(state, "Cowork").await } + BotCommand::NewClawSession => { + if !state.paired { + return not_paired(language); + } + // Claw session only available in Assistant mode + if state.display_mode != BotDisplayMode::Assistant { + return wrong_mode_for_assistant(language); + } + // Claw sessions don't need workspace + handle_new_session(state, "Claw").await + } BotCommand::CancelTask(turn_id) => { if !state.paired { return not_paired(language); @@ -512,6 +719,42 @@ fn need_workspace(language: BotLanguage) -> HandleResult { } } +fn need_assistant(language: BotLanguage) -> HandleResult { + HandleResult { + reply: if language.is_chinese() { + "尚未选择助理。请先使用 /switch_assistant。".to_string() + } else { + "No assistant selected. Use /switch_assistant first.".to_string() + }, + actions: assistant_required_actions(language), + forward_to_session: None, + } +} + +fn wrong_mode_for_pro(language: BotLanguage) -> HandleResult { + HandleResult { + reply: if language.is_chinese() { + "该会话只能在专业模式下创建。请先发送 /pro 切换到专业模式。".to_string() + } else { + "This session can only be created in Expert mode. Please send /pro to switch to Expert mode.".to_string() + }, + actions: pro_mode_actions(language), + forward_to_session: None, + } +} + +fn wrong_mode_for_assistant(language: BotLanguage) -> HandleResult { + HandleResult { + reply: if language.is_chinese() { + "该会话只能在助理模式下创建。请先发送 /assistant 切换到助理模式。".to_string() + } else { + "This session can only be created in Assistant mode. Please send /assistant to switch to Assistant mode.".to_string() + }, + actions: assistant_mode_actions(language), + forward_to_session: None, + } +} + fn question_option_line(index: usize, option: &BotQuestionOption) -> String { if option.description.is_empty() { format!("{}. {}", index + 1, option.label) @@ -701,14 +944,91 @@ async fn handle_switch_workspace(state: &mut BotChatState) -> HandleResult { } } +async fn handle_switch_assistant(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: if language.is_chinese() { + "工作区服务不可用。".to_string() + } else { + "Workspace service not available.".to_string() + }, + actions: vec![], + forward_to_session: None, + }; + } + }; + + let assistants = ws_service.get_assistant_workspaces().await; + if assistants.is_empty() { + return HandleResult { + reply: if language.is_chinese() { + "未找到助理。请先在 BitFun Desktop 中创建助理。".to_string() + } else { + "No assistants found. Please create an assistant in BitFun Desktop first.".to_string() + }, + actions: assistant_mode_actions(language), + forward_to_session: None, + }; + } + + let effective_current: Option<&str> = state.current_assistant.as_deref(); + + let mut text = if language.is_chinese() { + String::from("请选择助理:\n\n") + } else { + String::from("Select an assistant:\n\n") + }; + let mut options: Vec<(String, String)> = Vec::new(); + for (i, ws) in assistants.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 { + if language.is_chinese() { + " [当前]" + } else { + " [current]" + } + } else { + "" + }; + text.push_str(&format!("{}. {}{}\n", i + 1, ws.name, marker)); + options.push((path, ws.name.clone())); + } + text.push_str(if language.is_chinese() { + "\n请回复助理编号。" + } else { + "\nReply with the assistant number." + }); + + let action_labels: Vec = options.iter().map(|(_, name)| name.clone()).collect(); + state.pending_action = Some(PendingAction::SelectAssistant { options }); + HandleResult { + reply: text, + actions: numbered_actions(&action_labels), + forward_to_session: None, + } +} + 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(language), + let ws_path = if state.display_mode == BotDisplayMode::Pro { + match &state.current_workspace { + Some(p) => std::path::PathBuf::from(p), + None => return need_workspace(language), + } + } else { + match &state.current_assistant { + Some(p) => std::path::PathBuf::from(p), + None => return need_assistant(language), + } }; let page_size = 10usize; @@ -760,15 +1080,22 @@ async fn handle_resume_session(state: &mut BotChatState, page: usize) -> HandleR }; if all_meta.is_empty() { - return HandleResult { - reply: if language.is_chinese() { - "当前工作区没有会话。请使用 /new_code_session 或 /new_cowork_session 创建一个。" - .to_string() + let reply = if language.is_chinese() { + if state.display_mode == BotDisplayMode::Pro { + "当前工作区没有会话。请使用 /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), + "当前工作区没有会话。请使用 /new_claw_session 创建一个。".to_string() + } + } else { + if state.display_mode == BotDisplayMode::Pro { + "No sessions found in this workspace. Use /new_code_session or /new_cowork_session to create one.".to_string() + } else { + "No sessions found in this workspace. Use /new_claw_session to create one.".to_string() + } + }; + return HandleResult { + reply, + actions: new_session_actions(language, state.display_mode), forward_to_session: None, }; } @@ -876,7 +1203,10 @@ 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; + use crate::service::workspace::get_global_workspace_service; + let language = current_bot_language().await; + let is_claw = agent_type == "Claw"; let coordinator = match get_global_coordinator() { Some(c) => c, @@ -893,7 +1223,58 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl } }; - let ws_path = state.current_workspace.clone(); + let ws_path = if is_claw { + // For Claw sessions, prefer current_assistant, or get/create default + if let Some(ref assistant_path) = state.current_assistant { + Some(assistant_path.clone()) + } else { + let ws_service = match get_global_workspace_service() { + Some(s) => s, + None => { + return HandleResult { + reply: if language.is_chinese() { + "工作区服务不可用。".to_string() + } else { + "Workspace service not available.".to_string() + }, + actions: vec![], + forward_to_session: None, + }; + } + }; + + // Get or create default assistant workspace + let workspaces = ws_service.get_assistant_workspaces().await; + let resolved = if let Some(default_ws) = + workspaces.into_iter().find(|w| w.assistant_id.is_none()) + { + Some(default_ws.root_path.to_string_lossy().to_string()) + } else { + match ws_service.create_assistant_workspace(None).await { + Ok(ws_info) => Some(ws_info.root_path.to_string_lossy().to_string()), + Err(e) => { + return HandleResult { + reply: if language.is_chinese() { + format!("创建助理工作区失败:{}", e) + } else { + format!("Failed to create assistant workspace: {}", e) + }, + actions: vec![], + forward_to_session: None, + }; + } + } + }; + if let Some(ref path) = resolved { + state.current_assistant = Some(path.clone()); + } + resolved + } + } else { + // For Code/Cowork sessions, use current workspace + state.current_workspace.clone() + }; + let session_name = match agent_type { "Cowork" => { if language.is_chinese() { @@ -902,6 +1283,13 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl "Remote Cowork Session" } } + "Claw" => { + if language.is_chinese() { + "远程助理会话" + } else { + "Remote Claw Session" + } + } _ => { if language.is_chinese() { "远程编码会话" @@ -911,15 +1299,11 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl } }; - let Some(workspace_path) = ws_path.clone() else { - return HandleResult { - reply: if language.is_chinese() { - "请先选择工作区。".to_string() - } else { - "Please select a workspace first.".to_string() - }, - actions: vec![], - forward_to_session: None, + let Some(workspace_path) = ws_path else { + return if is_claw { + need_assistant(language) + } else { + need_workspace(language) }; }; @@ -939,30 +1323,40 @@ async fn handle_new_session(state: &mut BotChatState, agent_type: &str) -> Handl Ok(session) => { let session_id = session.session_id.clone(); state.current_session_id = Some(session_id.clone()); - let label = if agent_type == "Cowork" { - if language.is_chinese() { - "协作" - } else { - "cowork" + let label = match agent_type { + "Cowork" => { + if language.is_chinese() { + "协作" + } else { + "cowork" + } } - } else { - if language.is_chinese() { - "编码" - } else { - "coding" + "Claw" => { + if language.is_chinese() { + "助理" + } else { + "claw" + } + } + _ => { + if language.is_chinese() { + "编码" + } else { + "coding" + } } }; - let workspace = workspace_path.as_str(); + let workspace_display = workspace_path.clone(); HandleResult { reply: if language.is_chinese() { format!( "已创建新的{}会话:{}\n工作区:{}\n\n你现在可以发送消息与 AI 助手交互。", - label, session_name, workspace + label, session_name, workspace_display ) } else { format!( "Created new {} session: {}\nWorkspace: {}\n\nYou can now send messages to interact with the AI agent.", - label, session_name, workspace + label, session_name, workspace_display ) }, actions: vec![], @@ -1021,6 +1415,42 @@ async fn handle_number_selection(state: &mut BotChatState, n: usize) -> HandleRe let (path, name) = options[n - 1].clone(); select_workspace(state, &path, &name).await } + Some(PendingAction::SelectAssistant { options }) => { + if n < 1 || n > options.len() { + state.pending_action = Some(PendingAction::SelectAssistant { options }); + return HandleResult { + reply: if language.is_chinese() { + format!( + "无效选择。请输入 1-{}。", + state + .pending_action + .as_ref() + .map(|a| match a { + PendingAction::SelectAssistant { options } => options.len(), + _ => 0, + }) + .unwrap_or(0) + ) + } else { + format!( + "Invalid selection. Please enter 1-{}.", + state + .pending_action + .as_ref() + .map(|a| match a { + PendingAction::SelectAssistant { options } => options.len(), + _ => 0, + }) + .unwrap_or(0) + ) + }, + actions: vec![], + forward_to_session: None, + }; + } + let (path, name) = options[n - 1].clone(); + select_assistant(state, &path, &name).await + } Some(PendingAction::SelectSession { options, page, @@ -1105,11 +1535,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(language, name, session_count); + let reply = build_workspace_switched_reply(language, name, session_count, state.display_mode); let actions = if session_count > 0 { - session_entry_actions(language) + session_entry_actions(language, state.display_mode) } else { - new_session_actions(language) + new_session_actions(language, state.display_mode) }; HandleResult { reply, @@ -1129,6 +1559,69 @@ async fn select_workspace(state: &mut BotChatState, path: &str, name: &str) -> H } } +async fn select_assistant(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: if language.is_chinese() { + "工作区服务不可用。".to_string() + } else { + "Workspace service not available.".to_string() + }, + actions: vec![], + forward_to_session: None, + }; + } + }; + + let path_buf = std::path::PathBuf::from(path); + match ws_service.open_workspace(path_buf).await { + Ok(info) => { + if let Err(e) = crate::service::snapshot::initialize_snapshot_manager_for_workspace( + info.root_path.clone(), + None, + ) + .await + { + error!("Failed to init snapshot after bot assistant switch: {e}"); + } + state.current_assistant = Some(path.to_string()); + state.current_session_id = None; + info!("Bot switched assistant to: {path}"); + + let session_count = count_workspace_sessions(path).await; + let reply = if language.is_chinese() { + format!("已切换到助理:{}\n\n会话数:{}", name, session_count) + } else { + format!("Switched to assistant: {}\n\nSessions: {}", name, session_count) + }; + let actions = if session_count > 0 { + session_entry_actions(language, state.display_mode) + } else { + new_session_actions(language, state.display_mode) + }; + HandleResult { + reply, + actions, + forward_to_session: None, + } + } + Err(e) => HandleResult { + reply: if language.is_chinese() { + format!("切换助理失败:{e}") + } else { + format!("Failed to switch assistant: {e}") + }, + actions: vec![], + forward_to_session: None, + }, + } +} + async fn count_workspace_sessions(workspace_path: &str) -> usize { use crate::agentic::persistence::PersistenceManager; use crate::infrastructure::PathManager; @@ -1153,41 +1646,68 @@ fn build_workspace_switched_reply( language: BotLanguage, name: &str, session_count: usize, + display_mode: BotDisplayMode, ) -> String { + let is_pro = display_mode == BotDisplayMode::Pro; + let mode_label = if is_pro { + if language.is_chinese() { "专业模式" } else { "Expert Mode" } + } else { + if language.is_chinese() { "助理模式" } else { "Assistant Mode" } + }; + let mut reply = if language.is_chinese() { - format!("已切换到工作区:{name}\n\n") + format!("已切换到工作区:{}\n当前模式:{}\n\n", name, mode_label) } else { - format!("Switched to workspace: {name}\n\n") + format!("Switched to workspace: {}\nCurrent mode: {}\n\n", name, mode_label) }; + if session_count > 0 { if language.is_chinese() { reply.push_str(&format!( - "这个工作区已有 {session_count} 个会话。你想做什么?\n\n\ - /resume_session - 恢复已有会话\n\ - /new_code_session - 开始新的编码会话\n\ - /new_cowork_session - 开始新的协作会话" + "这个工作区已有 {session_count} 个会话。你想做什么?\n\n" )); } 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" + "This workspace has {session_count} existing session{s}. What would you like to do?\n\n" )); } } else { + if language.is_chinese() { + reply.push_str("这个工作区还没有会话。你想做什么?\n\n"); + } else { + reply.push_str("No sessions found in this workspace. What would you like to do?\n\n"); + } + } + + if is_pro { if language.is_chinese() { reply.push_str( - "这个工作区还没有会话。你想做什么?\n\n\ + "/resume_session - 恢复已有会话\n\ /new_code_session - 开始新的编码会话\n\ - /new_cowork_session - 开始新的协作会话", + /new_cowork_session - 开始新的协作会话\n\ + /assistant - 切换到助理模式" ); } else { reply.push_str( - "No sessions found in this workspace. 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", + /new_cowork_session - Start a new cowork session\n\ + /assistant - Switch to Assistant mode" + ); + } + } else { + if language.is_chinese() { + reply.push_str( + "/resume_session - 恢复已有会话\n\ + /new_claw_session - 开始新的助理会话\n\ + /pro - 切换到专业模式" + ); + } else { + reply.push_str( + "/resume_session - Resume an existing session\n\ + /new_claw_session - Start a new claw session\n\ + /pro - Switch to Expert mode" ); } } @@ -1324,7 +1844,7 @@ async fn handle_cancel_task( } else { "No active session to cancel.".to_string() }, - actions: session_entry_actions(language), + actions: session_entry_actions(language, state.display_mode), forward_to_session: None, }; } @@ -1689,6 +2209,15 @@ async fn handle_chat_message( actions: vec![], forward_to_session: None, }, + PendingAction::SelectAssistant { .. } => HandleResult { + reply: if language.is_chinese() { + "请回复助理编号。".to_string() + } else { + "Please reply with the assistant number.".to_string() + }, + actions: vec![], + forward_to_session: None, + }, PendingAction::SelectSession { has_more, .. } => HandleResult { reply: if has_more { if language.is_chinese() { @@ -1711,7 +2240,7 @@ async fn handle_chat_message( }; } - if state.current_workspace.is_none() { + if state.display_mode == BotDisplayMode::Pro && state.current_workspace.is_none() { return HandleResult { reply: if language.is_chinese() { "尚未选择工作区。请先使用 /switch_workspace 选择工作区。".to_string() @@ -1723,15 +2252,26 @@ async fn handle_chat_message( }; } if state.current_session_id.is_none() { - return HandleResult { - reply: if language.is_chinese() { - "当前没有活动会话。请使用 /resume_session 恢复已有会话,或使用 /new_code_session /new_cowork_session 创建新会话。" + let reply = if language.is_chinese() { + if state.display_mode == BotDisplayMode::Pro { + "当前没有活动会话。请使用 /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." + "当前没有活动会话。请使用 /resume_session 恢复已有会话,或使用 /new_claw_session 创建新会话。" .to_string() - }, - actions: session_entry_actions(language), + } + } else { + if state.display_mode == BotDisplayMode::Pro { + "No active session. Use /resume_session to resume one or /new_code_session, /new_cowork_session to create a new one." + .to_string() + } else { + "No active session. Use /resume_session to resume one or /new_claw_session to create a new one." + .to_string() + } + }; + return HandleResult { + reply, + actions: session_entry_actions(language, state.display_mode), forward_to_session: None, }; } @@ -1776,7 +2316,8 @@ async fn handle_chat_message( pub async fn execute_forwarded_turn( forward: ForwardRequest, interaction_handler: Option, - _message_sender: Option, + message_sender: Option, + verbose_mode: bool, ) -> ForwardedTurnResult { use crate::agentic::coordination::{DialogSubmissionPolicy, DialogTriggerSource}; use crate::service::remote_connect::remote_server::{ @@ -1813,41 +2354,102 @@ pub async fn execute_forwarded_turn( let result = tokio::time::timeout(std::time::Duration::from_secs(3600), async { let mut response = String::new(); + let mut thinking_buf = String::new(); + // Cache tool params from ToolStarted so we can display them on ToolCompleted. + let mut tool_params_cache: std::collections::HashMap> = + std::collections::HashMap::new(); + loop { match event_rx.recv().await { Ok(event) => match event { - TrackerEvent::ThinkingChunk(_) | TrackerEvent::ThinkingEnd => {} - TrackerEvent::TextChunk(t) => response.push_str(&t), + TrackerEvent::ThinkingChunk(chunk) => { + thinking_buf.push_str(&chunk); + } + TrackerEvent::ThinkingEnd => { + if verbose_mode && !thinking_buf.trim().is_empty() { + if let Some(sender) = message_sender.as_ref() { + let content = truncate_at_char_boundary(&thinking_buf, 500); + let msg = if language.is_chinese() { + format!("[思考过程]\n{content}") + } else { + format!("[Thinking]\n{content}") + }; + sender(msg).await; + } + } + thinking_buf.clear(); + } + TrackerEvent::TextChunk(t) => { + response.push_str(&t); + } TrackerEvent::ToolStarted { tool_id, tool_name, params, - } if tool_name == "AskUserQuestion" => { - if let Some(questions_value) = - params.and_then(|p| p.get("questions").cloned()) - { - if let Ok(questions) = - serde_json::from_value::>(questions_value) + } => { + if tool_name == "AskUserQuestion" { + if let Some(questions_value) = + params.and_then(|p| p.get("questions").cloned()) { - let request = build_question_prompt( - language, - tool_id, - questions, - 0, - Vec::new(), - false, - None, - ); - if let Some(handler) = interaction_handler.as_ref() { - handler(request).await; + if let Ok(questions) = + serde_json::from_value::>(questions_value) + { + let request = build_question_prompt( + language, + tool_id, + questions, + 0, + Vec::new(), + false, + None, + ); + if let Some(handler) = interaction_handler.as_ref() { + handler(request).await; + } } } + } else { + tool_params_cache.insert(tool_id, params); + } + } + TrackerEvent::ToolCompleted { + tool_id, + tool_name, + duration_ms, + success, + } => { + if verbose_mode { + if let Some(sender) = message_sender.as_ref() { + let params_str = tool_params_cache + .remove(&tool_id) + .flatten() + .and_then(|p| format_tool_params_slim(&p)) + .unwrap_or_default(); + let duration_str = duration_ms + .map(|ms| { + if ms >= 1000 { + format!("{:.1}s", ms as f64 / 1000.0) + } else { + format!("{}ms", ms) + } + }) + .unwrap_or_default(); + let status = if success { "OK" } else { "FAILED" }; + let msg = if params_str.is_empty() { + format!("[{tool_name}] {status} {duration_str}") + } else { + format!( + "[{tool_name}] {params_str}\n=> {status} {duration_str}" + ) + }; + sender(msg).await; + } } } TrackerEvent::TurnCompleted => break, TrackerEvent::TurnFailed(e) => { let msg = if language.is_chinese() { - format!("错误:{e}") + format!("错误: {e}") } else { format!("Error: {e}") }; @@ -1867,7 +2469,6 @@ pub async fn execute_forwarded_turn( full_text: msg, }; } - _ => {} }, Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { log::warn!("Bot event receiver lagged by {n} events"); @@ -1878,9 +2479,6 @@ pub async fn execute_forwarded_turn( } } - // Use the tracker's authoritative accumulated_text as the full - // 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 @@ -1923,3 +2521,55 @@ pub async fn execute_forwarded_turn( full_text: String::new(), }) } + +fn truncate_at_char_boundary(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + return s.to_string(); + } + let mut end = max_len; + while !s.is_char_boundary(end) { + end -= 1; + } + format!("{}...", &s[..end]) +} + +/// Format tool params into a compact display string for bot messages. +/// Filters out large string values and truncates remaining ones. +fn format_tool_params_slim(params: &serde_json::Value) -> Option { + const MAX_VAL_LEN: usize = 120; + match params { + serde_json::Value::Object(obj) => { + let parts: Vec = obj + .iter() + .filter_map(|(k, v)| { + let val_str = match v { + serde_json::Value::String(s) => { + if s.len() > MAX_VAL_LEN { + return None; + } + s.clone() + } + serde_json::Value::Bool(b) => b.to_string(), + serde_json::Value::Number(n) => n.to_string(), + serde_json::Value::Null => "null".to_string(), + _ => { + let json = serde_json::to_string(v).unwrap_or_default(); + if json.len() > MAX_VAL_LEN { + return None; + } + json + } + }; + Some(format!("{k}: {val_str}")) + }) + .collect(); + if parts.is_empty() { + None + } else { + Some(parts.join(", ")) + } + } + serde_json::Value::String(s) => Some(truncate_at_char_boundary(s, MAX_VAL_LEN)), + _ => None, + } +} 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 49cb2416..50032fc0 100644 --- a/src/crates/core/src/service/remote_connect/bot/feishu.rs +++ b/src/crates/core/src/service/remote_connect/bot/feishu.rs @@ -16,7 +16,7 @@ use tokio_tungstenite::tungstenite::Message as WsMessage; use super::command_router::{ 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, + BotChatState, BotDisplayMode, BotInteractionHandler, BotInteractiveRequest, BotLanguage, BotMessageSender, HandleResult, }; use super::{load_bot_persistence, save_bot_persistence, BotConfig, SavedBotConnection}; @@ -826,6 +826,22 @@ impl FeishuBot { "Switch Workspace" }, ), + ( + "/pro", + if language.is_chinese() { + "专业模式" + } else { + "Expert Mode" + }, + ), + ( + "/assistant", + if language.is_chinese() { + "助理模式" + } else { + "Assistant Mode" + }, + ), ( "/resume_session", if language.is_chinese() { @@ -850,6 +866,14 @@ impl FeishuBot { "New Cowork Session" }, ), + ( + "/new_claw_session", + if language.is_chinese() { + "新建助理会话" + } else { + "New Claw Session" + }, + ), ( "/cancel_task", if language.is_chinese() { @@ -1152,7 +1176,7 @@ impl FeishuBot { info!("Feishu pairing successful, chat_id={chat_id}"); let result = HandleResult { reply: paired_success_message(language), - actions: main_menu_actions(language), + actions: main_menu_actions(language, BotDisplayMode::Assistant), forward_to_session: None, }; self.send_handle_result(&chat_id, &result).await.ok(); @@ -1486,7 +1510,7 @@ impl FeishuBot { state.paired = true; let result = HandleResult { reply: paired_success_message(language), - actions: main_menu_actions(language), + actions: main_menu_actions(language, BotDisplayMode::Assistant), forward_to_session: None, }; self.send_handle_result(chat_id, &result).await.ok(); @@ -1548,7 +1572,8 @@ impl FeishuBot { } }) }); - let result = execute_forwarded_turn(forward, Some(handler), Some(sender)).await; + let verbose_mode = load_bot_persistence().verbose_mode; + let result = execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode).await; if let Err(err) = bot.send_message(&cid, &result.display_text).await { warn!("Failed to send Feishu final message to {cid}: {err}"); } 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 9f6784ba..6f5f6b75 100644 --- a/src/crates/core/src/service/remote_connect/bot/mod.rs +++ b/src/crates/core/src/service/remote_connect/bot/mod.rs @@ -55,6 +55,10 @@ pub struct BotPersistenceData { pub connections: Vec, #[serde(default)] pub form_state: RemoteConnectFormState, + /// Global verbose mode setting for all bot connections. + /// When true, intermediate tool execution progress is sent to the user. + #[serde(default)] + pub verbose_mode: bool, } impl BotPersistenceData { 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 92d152c5..d9c9632e 100644 --- a/src/crates/core/src/service/remote_connect/bot/telegram.rs +++ b/src/crates/core/src/service/remote_connect/bot/telegram.rs @@ -334,9 +334,12 @@ impl TelegramBot { let commands = serde_json::json!({ "commands": [ { "command": "switch_workspace", "description": "List and switch workspaces" }, + { "command": "pro", "description": "Switch to Expert mode (Code/Cowork)" }, + { "command": "assistant", "description": "Switch to Assistant mode (Claw)" }, { "command": "resume_session", "description": "Resume an existing session" }, - { "command": "new_code_session", "description": "Create a new coding session" }, - { "command": "new_cowork_session", "description": "Create a new cowork session" }, + { "command": "new_code_session", "description": "Create coding session (Expert)" }, + { "command": "new_cowork_session", "description": "Create cowork session (Expert)" }, + { "command": "new_claw_session", "description": "Create claw session (Assistant)" }, { "command": "cancel_task", "description": "Cancel the current task" }, { "command": "help", "description": "Show available commands" }, ] @@ -703,7 +706,8 @@ impl TelegramBot { msg_bot.send_message(chat_id, &text).await.ok(); }) }); - let result = execute_forwarded_turn(forward, Some(handler), Some(sender)).await; + let verbose_mode = load_bot_persistence().verbose_mode; + let result = execute_forwarded_turn(forward, Some(handler), Some(sender), verbose_mode).await; bot.send_message(chat_id, &result.display_text).await.ok(); bot.notify_files_ready(chat_id, &result.full_text).await; }); 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 e893fcc7..9294a003 100644 --- a/src/crates/core/src/service/remote_connect/remote_server.rs +++ b/src/crates/core/src/service/remote_connect/remote_server.rs @@ -245,6 +245,10 @@ pub enum RemoteCommand { SetWorkspace { path: String, }, + ListAssistants, + SetAssistant { + path: String, + }, ListSessions { workspace_path: Option, limit: Option, @@ -353,6 +357,15 @@ pub enum RemoteResponse { project_name: Option, error: Option, }, + AssistantList { + assistants: Vec, + }, + AssistantUpdated { + success: bool, + path: Option, + name: Option, + error: Option, + }, SessionList { sessions: Vec, has_more: bool, @@ -503,6 +516,13 @@ pub struct RecentWorkspaceEntry { pub last_opened: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AssistantEntry { + pub path: String, + pub name: String, + pub assistant_id: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActiveTurnSnapshot { pub turn_id: String, @@ -918,12 +938,21 @@ struct TrackerState { pub enum TrackerEvent { TextChunk(String), ThinkingChunk(String), + /// All thinking content for the current round has been emitted. + /// Carries the full accumulated thinking text so consumers can send + /// a single summary instead of per-chunk messages. ThinkingEnd, ToolStarted { tool_id: String, tool_name: String, params: Option, }, + ToolCompleted { + tool_id: String, + tool_name: String, + duration_ms: Option, + success: bool, + }, TurnCompleted, TurnFailed(String), TurnCancelled, @@ -1023,6 +1052,11 @@ impl RemoteSessionStateTracker { self.state.read().unwrap().accumulated_text.clone() } + /// Return the full accumulated thinking text for the current turn. + pub fn accumulated_thinking(&self) -> String { + self.state.read().unwrap().accumulated_thinking.clone() + } + /// Returns true if the turn has ended (completed/failed/cancelled) but /// the tracker state hasn't been cleaned up yet (waiting for persistence). pub fn is_turn_finished(&self) -> bool { @@ -1269,6 +1303,7 @@ impl RemoteSessionStateTracker { let mut s = self.state.write().unwrap(); let allow_name_fallback = tool_id.is_empty() && !tool_name.is_empty(); + let mut pending_tool_event: Option = None; match event_type { "EarlyDetected" => { Self::upsert_active_tool( @@ -1364,6 +1399,12 @@ impl RemoteSessionStateTracker { t.duration_ms = duration; } } + pending_tool_event = Some(TrackerEvent::ToolCompleted { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + duration_ms: duration, + success: true, + }); } "Failed" => { if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { @@ -1384,6 +1425,12 @@ impl RemoteSessionStateTracker { t.status = "failed".to_string(); } } + pending_tool_event = Some(TrackerEvent::ToolCompleted { + tool_id: tool_id.clone(), + tool_name: tool_name.clone(), + duration_ms: None, + success: false, + }); } "Cancelled" => { if let Some(t) = s.active_tools.iter_mut().rev().find(|t| { @@ -1415,6 +1462,9 @@ impl RemoteSessionStateTracker { } drop(s); self.bump_version(); + if let Some(evt) = pending_tool_event { + let _ = self.event_tx.send(evt); + } } } AE::DialogTurnStarted { turn_id, .. } if is_direct => { @@ -1797,7 +1847,9 @@ impl RemoteServer { RemoteCommand::GetWorkspaceInfo | RemoteCommand::ListRecentWorkspaces - | RemoteCommand::SetWorkspace { .. } => self.handle_workspace_command(cmd).await, + | RemoteCommand::SetWorkspace { .. } + | RemoteCommand::ListAssistants + | RemoteCommand::SetAssistant { .. } => self.handle_workspace_command(cmd).await, RemoteCommand::ListSessions { .. } | RemoteCommand::CreateSession { .. } @@ -2271,6 +2323,63 @@ impl RemoteServer { }, } } + RemoteCommand::ListAssistants => { + let ws_service = match get_global_workspace_service() { + Some(s) => s, + None => { + return RemoteResponse::AssistantList { assistants: vec![] }; + } + }; + let assistants = ws_service.get_assistant_workspaces().await; + let entries = assistants + .into_iter() + .map(|w| AssistantEntry { + path: w.root_path.to_string_lossy().to_string(), + name: w.name.clone(), + assistant_id: w.assistant_id.clone(), + }) + .collect(); + RemoteResponse::AssistantList { assistants: entries } + } + RemoteCommand::SetAssistant { path } => { + let ws_service = match get_global_workspace_service() { + Some(s) => s, + None => { + return RemoteResponse::AssistantUpdated { + success: false, + path: None, + name: None, + error: Some("Workspace service not available".into()), + }; + } + }; + let path_buf = std::path::PathBuf::from(path); + match ws_service.open_workspace(path_buf).await { + Ok(info) => { + if let Err(e) = + crate::service::snapshot::initialize_snapshot_manager_for_workspace( + info.root_path.clone(), + None, + ) + .await + { + error!("Failed to initialize snapshot after remote assistant set: {e}"); + } + RemoteResponse::AssistantUpdated { + success: true, + path: Some(info.root_path.to_string_lossy().to_string()), + name: Some(info.name.clone()), + error: None, + } + } + Err(e) => RemoteResponse::AssistantUpdated { + success: false, + path: None, + name: None, + error: Some(e.to_string()), + }, + } + } _ => RemoteResponse::Error { message: "Unknown workspace command".into(), }, @@ -2372,26 +2481,63 @@ impl RemoteServer { workspace_path: requested_ws_path, } => { let agent = resolve_agent_type(agent_type.as_deref()); - let session_name = - custom_name - .as_deref() - .filter(|n| !n.is_empty()) - .unwrap_or(match agent { - "Cowork" => "Remote Cowork Session", - _ => "Remote Code Session", - }); - let binding_ws_str = requested_ws_path + let is_claw = agent == "Claw"; + + let session_name = custom_name .as_deref() - .filter(|path| !path.is_empty()) - .map(ToOwned::to_owned); + .filter(|n| !n.is_empty()) + .unwrap_or(match agent { + "Cowork" => "Remote Cowork Session", + "Claw" => "Remote Claw Session", + _ => "Remote Code Session", + }); + + let binding_ws_str = if is_claw { + // For Claw sessions, get or create default assistant workspace + use crate::service::workspace::get_global_workspace_service; + + let ws_service = match get_global_workspace_service() { + Some(s) => s, + None => { + return RemoteResponse::Error { + message: "Workspace service not available".to_string(), + }; + } + }; + + let workspaces = ws_service.get_assistant_workspaces().await; + if let Some(default_ws) = workspaces.into_iter().find(|w| w.assistant_id.is_none()) { + Some(default_ws.root_path.to_string_lossy().to_string()) + } else { + match ws_service.create_assistant_workspace(None).await { + Ok(ws_info) => Some(ws_info.root_path.to_string_lossy().to_string()), + Err(e) => { + return RemoteResponse::Error { + message: format!("Failed to create assistant workspace: {}", e), + }; + } + } + } + } else { + // For Code/Cowork sessions, use provided workspace + requested_ws_path + .as_deref() + .filter(|path| !path.is_empty()) + .map(ToOwned::to_owned) + }; debug!( - "Remote CreateSession: requested_ws={:?}, binding_ws={:?}", - requested_ws_path, binding_ws_str + "Remote CreateSession: agent={}, requested_ws={:?}, binding_ws={:?}", + agent, requested_ws_path, binding_ws_str ); + let Some(binding_ws_str) = binding_ws_str else { return RemoteResponse::Error { - message: "workspace_path is required for CreateSession".to_string(), + message: if is_claw { + "Failed to get or create assistant workspace".to_string() + } else { + "workspace_path is required for CreateSession".to_string() + }, }; }; diff --git a/src/mobile-web/src/i18n/messages.ts b/src/mobile-web/src/i18n/messages.ts index 1f25b085..1cd88889 100644 --- a/src/mobile-web/src/i18n/messages.ts +++ b/src/mobile-web/src/i18n/messages.ts @@ -53,13 +53,28 @@ export const messages: Record = { remoteCockpit: 'Remote cockpit', workspace: 'Workspace', switchWorkspace: 'Switch workspace', + selectWorkspace: 'Select Workspace', + noWorkspaces: 'No workspaces found', noWorkspaceSelected: 'No workspace selected', - launch: 'Launch', + selectMode: 'Mode', + chooseMode: 'Choose Your Mode', + changeMode: 'Change Mode', + proMode: 'Expert Mode', + proModeDesc: 'Best for focused, one-shot tasks with a clear goal.', + proModeNeedsWorkspace: 'Please select a workspace first to use Expert Mode.', + assistantMode: 'Assistant Mode', + assistantModeDesc: 'Best for ongoing work with context and personal preferences.', + assistant: 'Assistant', + defaultAssistant: 'Default Assistant', + switchAssistant: 'Switch assistant', + selectAssistant: 'Select Assistant', startRemoteFlow: 'Start a new remote flow', codeSession: 'Code Session', codeSessionDesc: 'For coding anywhere, anytime.', coworkSession: 'Cowork Session', coworkSessionDesc: 'For assisting with everyday work.', + clawSession: 'Claw Session', + clawSessionDesc: 'Personal assistant for daily tasks.', recent: 'Recent', sessionHistory: 'Session history', loadingSessions: 'Loading sessions...', @@ -68,8 +83,10 @@ export const messages: Record = { loadingMore: 'Loading more...', remoteCodeSession: 'Remote Code Session', remoteCoworkSession: 'Remote Cowork Session', + remoteClawSession: 'Remote Claw Session', agentCode: 'Code', agentCowork: 'Cowork', + agentClaw: 'Claw', agentDefault: 'Default', pullToRefresh: 'Pull to refresh', }, @@ -186,13 +203,29 @@ export const messages: Record = { remoteCockpit: 'Remote cockpit', workspace: '工作区', switchWorkspace: '切换工作区', + selectWorkspace: '选择工作区', + noWorkspaces: '暂无工作区', noWorkspaceSelected: '未选择工作区', + selectMode: '模式', + chooseMode: '选择你的模式', + changeMode: '切换模式', + proMode: '专业模式', + proModeDesc: '适合目标明确、一次完成的即时任务。', + proModeNeedsWorkspace: '使用专业模式请先选择一个工作区。', + assistantMode: '助理模式', + assistantModeDesc: '适合持续推进、需要延续上下文和个人偏好的任务。', + assistant: '助理', + defaultAssistant: '默认助理', + switchAssistant: '切换助理', + selectAssistant: '选择助理', launch: '启动', startRemoteFlow: '开始一个新的远程流程', codeSession: '代码会话', codeSessionDesc: '随时随地进行编码。', coworkSession: '协作会话', coworkSessionDesc: '处理日常协作与办公任务。', + clawSession: '助理会话', + clawSessionDesc: '处理日常任务的个人助手。', recent: '最近', sessionHistory: '会话历史', loadingSessions: '正在加载会话...', @@ -201,8 +234,10 @@ export const messages: Record = { loadingMore: '正在加载更多...', remoteCodeSession: '远程代码会话', remoteCoworkSession: '远程协作会话', + remoteClawSession: '远程助理会话', agentCode: 'Code', agentCowork: 'Cowork', + agentClaw: 'Claw', agentDefault: '默认', pullToRefresh: '下拉刷新', }, diff --git a/src/mobile-web/src/pages/SessionListPage.tsx b/src/mobile-web/src/pages/SessionListPage.tsx index 8f559443..eb82cc2f 100644 --- a/src/mobile-web/src/pages/SessionListPage.tsx +++ b/src/mobile-web/src/pages/SessionListPage.tsx @@ -8,6 +8,8 @@ import logoIcon from '../assets/Logo-ICON.png'; const PAGE_SIZE = 30; +type DisplayMode = 'pro' | 'assistant'; + interface SessionListPageProps { sessionMgr: RemoteSessionManager; onSelectSession: (sessionId: string, sessionName?: string, isNew?: boolean) => void; @@ -38,6 +40,9 @@ function agentLabel(agentType: string, t: (key: string) => string): string { case 'cowork': case 'Cowork': return t('sessions.agentCowork'); + case 'claw': + case 'Claw': + return t('sessions.agentClaw'); default: return agentType || t('sessions.agentDefault'); } @@ -47,6 +52,10 @@ function isCoworkAgent(agentType: string): boolean { return agentType === 'cowork' || agentType === 'Cowork'; } +function isClawAgent(agentType: string): boolean { + return agentType === 'claw' || agentType === 'Claw'; +} + function truncateMiddle(str: string, maxLen: number): string { if (!str || str.length <= maxLen) return str; const keep = maxLen - 3; @@ -67,6 +76,15 @@ function SessionTypeIcon({ agentType }: { agentType: string }) { ); } + if (isClawAgent(agentType)) { + return ( + + + + + ); + } + return ( @@ -74,6 +92,32 @@ function SessionTypeIcon({ agentType }: { agentType: string }) { ); } +/* Mode Selection Icons */ +const ProModeIcon = () => ( + + + + + +); + +const AssistantModeIcon = () => ( + + + + + + + + +); + +const WorkspaceIcon = () => ( + + + +); + const ThemeToggleIcon: React.FC<{ isDark: boolean }> = ({ isDark }) => ( {isDark ? ( @@ -93,6 +137,8 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS setError, currentWorkspace, setCurrentWorkspace, + currentAssistant, + setCurrentAssistant, authenticatedUserId, } = useMobileStore(); const { isDark, toggleTheme } = useTheme(); @@ -100,7 +146,12 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS const [loading, setLoading] = useState(false); const [loadingMore, setLoadingMore] = useState(false); const [hasMore, setHasMore] = useState(false); - + const [displayMode, setDisplayMode] = useState('pro'); + const [assistantList, setAssistantList] = useState>([]); + const [showAssistantPicker, setShowAssistantPicker] = useState(false); + const [workspaceList, setWorkspaceList] = useState>([]); + const [showWorkspacePicker, setShowWorkspacePicker] = useState(false); + const [pullDistance, setPullDistance] = useState(0); const [refreshing, setRefreshing] = useState(false); const offsetRef = useRef(0); @@ -108,6 +159,24 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS const touchStartY = useRef(0); const isPulling = useRef(false); + // Load assistant list when entering assistant mode + const loadAssistantList = useCallback(async () => { + try { + const assistants = await sessionMgr.listAssistants(); + setAssistantList(assistants); + // Set default assistant if none selected + if (!currentAssistant && assistants.length > 0) { + const defaultAssistant = assistants.find(a => !a.assistant_id) || assistants[0]; + setCurrentAssistant(defaultAssistant); + return defaultAssistant.path; + } + return currentAssistant?.path; + } catch (e: any) { + setError(e.message); + return undefined; + } + }, [sessionMgr, currentAssistant, setCurrentAssistant, setError]); + const loadFirstPage = useCallback(async (workspacePath: string | undefined) => { setLoading(true); offsetRef.current = 0; @@ -123,6 +192,35 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS } }, [sessionMgr, setSessions, setError]); + // Load workspace list for Pro mode picker + const loadWorkspaceList = useCallback(async () => { + try { + const workspaces = await sessionMgr.listRecentWorkspaces(); + setWorkspaceList(workspaces); + } catch (e: any) { + setError(e.message); + } + }, [sessionMgr, setError]); + + const handleSelectWorkspace = useCallback(async (workspace: { path: string; name: string }) => { + try { + const result = await sessionMgr.setWorkspace(workspace.path); + if (result.success) { + setCurrentWorkspace({ + has_workspace: true, + path: result.path || workspace.path, + project_name: result.project_name || workspace.name, + }); + setShowWorkspacePicker(false); + loadFirstPage(workspace.path); + } else { + setError(result.error || 'Failed to set workspace'); + } + } catch (e: any) { + setError(e.message); + } + }, [sessionMgr, setCurrentWorkspace, setError, loadFirstPage]); + const loadNextPage = useCallback(async (workspacePath: string | undefined) => { if (loadingMore || !hasMore) return; setLoadingMore(true); @@ -158,15 +256,23 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS const refreshData = useCallback(async () => { try { - const info = await sessionMgr.getWorkspaceInfo(); - const ws = info.has_workspace ? info : null; - setCurrentWorkspace(ws); - const resp = await sessionMgr.listSessions(ws?.path, PAGE_SIZE, 0); - setSessions(resp.sessions); - setHasMore(resp.has_more); - offsetRef.current = resp.sessions.length; + if (displayMode === 'pro') { + const info = await sessionMgr.getWorkspaceInfo(); + const ws = info.has_workspace ? info : null; + setCurrentWorkspace(ws); + const resp = await sessionMgr.listSessions(ws?.path, PAGE_SIZE, 0); + setSessions(resp.sessions); + setHasMore(resp.has_more); + offsetRef.current = resp.sessions.length; + } else { + // Assistant mode: use currentAssistant path + const resp = await sessionMgr.listSessions(currentAssistant?.path, PAGE_SIZE, 0); + setSessions(resp.sessions); + setHasMore(resp.has_more); + offsetRef.current = resp.sessions.length; + } } catch { /* ignore */ } - }, [sessionMgr, setSessions, setCurrentWorkspace]); + }, [sessionMgr, setSessions, setCurrentWorkspace, currentAssistant?.path, displayMode]); useEffect(() => { const poll = setInterval(refreshData, 10000); @@ -208,36 +314,71 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS const handleScroll = useCallback((e: React.UIEvent) => { const el = e.currentTarget; if (el.scrollHeight - el.scrollTop - el.clientHeight < 150) { - loadNextPage(currentWorkspace?.path); + const workspacePath = displayMode === 'assistant' ? currentAssistant?.path : currentWorkspace?.path; + loadNextPage(workspacePath); } - }, [currentWorkspace?.path, loadNextPage]); + }, [displayMode, currentAssistant?.path, currentWorkspace?.path, loadNextPage]); const handleCreate = useCallback(async (agentType: string) => { if (creating) return; setCreating(true); try { - const id = await sessionMgr.createSession(agentType, undefined, currentWorkspace?.path); - await loadFirstPage(currentWorkspace?.path); - const label = agentType === 'cowork' || agentType === 'Cowork' - ? t('sessions.remoteCoworkSession') - : t('sessions.remoteCodeSession'); + // For assistant mode (Claw), use currentAssistant.path + // For pro mode (Code/Cowork), use currentWorkspace.path + const workspacePath = displayMode === 'assistant' ? currentAssistant?.path : currentWorkspace?.path; + const id = await sessionMgr.createSession(agentType, undefined, workspacePath); + await loadFirstPage(workspacePath); + const label = isClawAgent(agentType) + ? t('sessions.remoteClawSession') + : isCoworkAgent(agentType) + ? t('sessions.remoteCoworkSession') + : t('sessions.remoteCodeSession'); onSelectSession(id, label, true); } catch (e: any) { setError(e.message); } finally { setCreating(false); } - }, [creating, currentWorkspace?.path, loadFirstPage, onSelectSession, sessionMgr, setError, t]); + }, [creating, currentWorkspace?.path, currentAssistant?.path, displayMode, loadFirstPage, onSelectSession, sessionMgr, setError, t]); + + const handleSelectMode = useCallback(async (mode: DisplayMode) => { + setDisplayMode(mode); + setShowAssistantPicker(false); + if (mode === 'assistant') { + // Load assistant list and set default + const assistantPath = await loadAssistantList(); + loadFirstPage(assistantPath); + } else { + // Pro mode needs workspace + loadFirstPage(currentWorkspace?.path); + } + }, [currentWorkspace?.path, loadFirstPage, loadAssistantList]); + + const handleSelectAssistant = useCallback(async (assistant: { path: string; name: string; assistant_id?: string }) => { + try { + await sessionMgr.setAssistant(assistant.path); + setCurrentAssistant(assistant); + setShowAssistantPicker(false); + loadFirstPage(assistant.path); + } catch (e: any) { + setError(e.message); + } + }, [sessionMgr, setCurrentAssistant, setError, loadFirstPage]); const workspaceDisplayName = currentWorkspace?.project_name || t('sessions.noWorkspaceSelected'); + const assistantDisplayName = currentAssistant?.name || t('sessions.defaultAssistant'); + const isProMode = displayMode === 'pro'; + return (
BitFun
- {t('sessions.remoteCockpit')}

{t('common.appName')}

+ {authenticatedUserId && ( + {authenticatedUserId} + )}
@@ -248,34 +389,6 @@ const SessionListPage: React.FC = ({ sessionMgr, onSelectS
-
- - - - - -
- {t('sessions.workspace')} - {truncateMiddle(workspaceDisplayName, 24)} -
- {currentWorkspace?.git_branch && ( - - - {truncateMiddle(currentWorkspace.git_branch, 20)} - - )} - - - -
- - {authenticatedUserId && ( -
- {t('common.userId')} - {authenticatedUserId} -
- )} -
= ({ sessionMgr, onSelectS
)} -
-
-
-
{t('sessions.launch')}
-
{t('sessions.startRemoteFlow')}
-
-
-
- + +
+ + {/* Pro Mode: Workspace Selection Required */} + {isProMode && ( + <> +
{ + loadWorkspaceList(); + setShowWorkspacePicker(true); + }} > -
- -
-
- {t('sessions.codeSession')} - {t('sessions.codeSessionDesc')} + + + +
+ {t('sessions.workspace')} + {truncateMiddle(workspaceDisplayName, 24)}
- - + {currentWorkspace?.git_branch && ( + + + {truncateMiddle(currentWorkspace.git_branch, 20)} + + )} + + - - +
+
+ {workspaceList.length === 0 ? ( +
{t('sessions.noWorkspaces')}
+ ) : ( + workspaceList.map((workspace, index) => ( + + )) + )} +
+
-
- {t('sessions.coworkSession')} - {t('sessions.coworkSessionDesc')} + )} + + {!currentWorkspace && ( +
+ + + + + + {t('sessions.proModeNeedsWorkspace')}
- - - - -
-
+ )} + + )} -
-
-
-
{t('sessions.recent')}
-
{t('sessions.sessionHistory')}
+ {/* Assistant Mode: Assistant Selection */} + {!isProMode && ( + <> +
{ + loadAssistantList(); + setShowAssistantPicker(true); + }} + > + + + +
+ {t('sessions.assistant')} + {assistantDisplayName} +
+ + +
-
{t('common.itemCount', { count: sessions.length })}
-
- {loading && sessions.length === 0 && ( -
{t('sessions.loadingSessions')}
- )} - {!loading && sessions.length === 0 && ( -
{t('sessions.noSessions')}
- )} - -
- {sessions.map((s) => ( -
onSelectSession(s.session_id, s.name)} - > -
- + {/* Assistant Picker Modal */} + {showAssistantPicker && ( +
setShowAssistantPicker(false)}> +
e.stopPropagation()}> +
+

{t('sessions.selectAssistant')}

+ +
+
+ {assistantList.map((assistant, index) => ( + + ))} +
-
-
-
{s.name || t('sessions.untitledSession')}
- - {agentLabel(s.agent_type, t)} - +
+ )} + + )} + + {/* Session Creation Options */} +
+
+
+
{t('sessions.launch')}
+
{t('sessions.startRemoteFlow')}
+
+
+ + {isProMode ? ( + /* Pro Mode: Code / Cowork - only show if workspace selected */ + currentWorkspace ? ( +
+ +
-
{formatTime(s.updated_at, language, t)}
+ ) : null + ) : ( + /* Assistant Mode: Claw */ +
+ +
+ )} +
+ + {/* Session History */} +
+
+
+
{t('sessions.recent')}
+
{t('sessions.sessionHistory')}
+
{t('common.itemCount', { count: sessions.length })}
- ))} -
- {loadingMore && ( -
{t('sessions.loadingMore')}
- )} -
- + {loading && sessions.length === 0 && ( +
{t('sessions.loadingSessions')}
+ )} + {!loading && sessions.length === 0 && ( +
{t('sessions.noSessions')}
+ )} + +
+ {sessions.map((s) => ( +
onSelectSession(s.session_id, s.name)} + > +
+ +
+
+
+
{s.name || t('sessions.untitledSession')}
+ + {agentLabel(s.agent_type, t)} + +
+
{formatTime(s.updated_at, language, t)}
+
+
+ ))} +
+ {loadingMore && ( +
{t('sessions.loadingMore')}
+ )} + + ); }; diff --git a/src/mobile-web/src/services/RemoteSessionManager.ts b/src/mobile-web/src/services/RemoteSessionManager.ts index 7c1780a1..2183a0fd 100644 --- a/src/mobile-web/src/services/RemoteSessionManager.ts +++ b/src/mobile-web/src/services/RemoteSessionManager.ts @@ -23,6 +23,12 @@ export interface RecentWorkspaceEntry { last_opened: string; } +export interface AssistantEntry { + path: string; + name: string; + assistant_id?: string; +} + export interface SessionInfo { session_id: string; name: string; @@ -175,6 +181,25 @@ export class RemoteSessionManager { return this.request({ cmd: 'set_workspace', path }); } + async listAssistants(): Promise { + const resp = await this.request<{ + resp: string; + assistants: AssistantEntry[]; + }>({ cmd: 'list_assistants' }); + return resp.assistants || []; + } + + async setAssistant( + path: string, + ): Promise<{ + success: boolean; + path?: string; + name?: string; + error?: string; + }> { + return this.request({ cmd: 'set_assistant', path }); + } + async listSessions( workspacePath?: string, limit = 30, diff --git a/src/mobile-web/src/services/store.ts b/src/mobile-web/src/services/store.ts index e78b332b..7ab578d9 100644 --- a/src/mobile-web/src/services/store.ts +++ b/src/mobile-web/src/services/store.ts @@ -4,6 +4,7 @@ import type { ChatMessage, WorkspaceInfo, ActiveTurnSnapshot, + AssistantEntry, } from './RemoteSessionManager'; export type ConnectionStatus = 'idle' | 'pairing' | 'paired' | 'error'; @@ -15,6 +16,9 @@ interface MobileStore { currentWorkspace: WorkspaceInfo | null; setCurrentWorkspace: (w: WorkspaceInfo | null) => void; + currentAssistant: AssistantEntry | null; + setCurrentAssistant: (a: AssistantEntry | null) => void; + authenticatedUserId: string | null; setAuthenticatedUserId: (userId: string | null) => void; @@ -45,6 +49,9 @@ export const useMobileStore = create((set, get) => ({ currentWorkspace: null, setCurrentWorkspace: (currentWorkspace) => set({ currentWorkspace }), + currentAssistant: null, + setCurrentAssistant: (currentAssistant) => set({ currentAssistant }), + authenticatedUserId: null, setAuthenticatedUserId: (authenticatedUserId) => set({ authenticatedUserId }), diff --git a/src/mobile-web/src/styles/components/sessions.scss b/src/mobile-web/src/styles/components/sessions.scss index 89146cf5..07061977 100644 --- a/src/mobile-web/src/styles/components/sessions.scss +++ b/src/mobile-web/src/styles/components/sessions.scss @@ -37,18 +37,26 @@ .session-list__header-copy { display: flex; flex-direction: column; + gap: 0; min-width: 0; h1 { margin: 0; font-size: var(--font-size-lg); - font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-bold); color: var(--color-text-primary); letter-spacing: 0.01em; width: fit-content; } } +.session-list__header-user-id { + font-size: 11px; + font-weight: var(--font-weight-medium); + color: var(--color-text-muted); + letter-spacing: 0.02em; +} + .session-list__header-kicker { font-size: 10px; font-weight: var(--font-weight-semibold); @@ -98,49 +106,69 @@ display: flex; align-items: center; gap: 12px; - margin: var(--size-gap-3) var(--size-gap-4) 0; padding: 12px; - border: 1px solid var(--border-subtle); + border: 1px solid var(--color-accent-200); @include squircle(20px); - background: var(--color-bg-elevated); - box-shadow: var(--shadow-sm); + background: var(--color-accent-50); cursor: pointer; transition: transform var(--motion-fast) var(--easing-standard), background var(--motion-fast) var(--easing-standard); &:active { transform: translateY(1px); - background: var(--element-bg-subtle); + background: var(--color-accent-100); } } -.session-list__identity-bar { +/* Mode Toggle - Inline pill style */ +.session-list__mode-toggle { display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin: var(--size-gap-3) var(--size-gap-4) 0; - padding: 10px 14px; + gap: 6px; + padding: 4px; border: 1px solid var(--border-subtle); @include squircle(16px); - background: var(--color-bg-elevated); - box-shadow: var(--shadow-sm); + background: var(--color-bg-secondary); } -.session-list__identity-label { - font-size: 10px; - letter-spacing: 0.14em; - text-transform: uppercase; - color: var(--color-text-muted); -} +.session-list__mode-toggle-btn { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 12px; + border: none; + @include squircle(12px); + background: transparent; + color: var(--color-text-secondary); + font-size: 13px; + font-weight: var(--font-weight-medium); + cursor: pointer; + transition: all var(--motion-fast) var(--easing-standard); -.session-list__identity-value { - font-size: var(--font-size-sm); - font-weight: var(--font-weight-semibold); - color: var(--color-text-primary); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + svg { + width: 18px; + height: 18px; + flex-shrink: 0; + } + + &.is-active { + background: var(--color-bg-elevated); + color: var(--color-text-primary); + box-shadow: var(--shadow-sm); + + &.is-active:first-child { + color: var(--color-accent-500); + } + + &.is-active:last-child { + color: var(--color-pink-500); + } + } + + &:active:not(.is-active) { + background: var(--element-bg-subtle); + } } .session-list__workspace-icon { @@ -167,7 +195,7 @@ font-size: 10px; letter-spacing: 0.14em; text-transform: uppercase; - color: var(--color-text-muted); + color: var(--color-accent-500); } .session-list__workspace-name { @@ -253,6 +281,83 @@ flex-shrink: 0; } +.session-list__hint { + background: var(--color-pink-100); + color: var(--color-pink-600); + border: 1px solid var(--color-pink-200); +} + +/* Assistant Bar - Same layout as workspace bar but for assistant mode */ +.session-list__assistant-bar { + position: relative; + z-index: 1; + display: flex; + align-items: center; + gap: 12px; + margin: 0; + padding: 12px; + border: 1px solid var(--color-pink-200); + @include squircle(20px); + background: var(--color-pink-50); + cursor: pointer; + transition: transform var(--motion-fast) var(--easing-standard), + background var(--motion-fast) var(--easing-standard); + + &:active { + transform: translateY(1px); + background: var(--color-pink-100); + } +} + +.session-list__assistant-icon { + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + @include squircle(10px); + color: var(--color-pink-500); + background: var(--color-pink-100); + + svg { + width: 20px; + height: 20px; + } +} + +.session-list__assistant-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} + +.session-list__assistant-label { + font-size: 10px; + letter-spacing: 0.14em; + text-transform: uppercase; + color: var(--color-pink-500); +} + +.session-list__assistant-name { + font-size: var(--font-size-sm); + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.session-list__assistant-switch { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--color-text-muted); +} + .session-list__create-row { display: flex; flex-direction: column; @@ -294,6 +399,10 @@ &--cowork { border-color: var(--color-purple-200); } + + &--claw { + border-color: var(--color-pink-200); + } } .session-list__create-icon { @@ -313,6 +422,11 @@ background: var(--color-purple-100); } +.session-list__create-btn--claw .session-list__create-icon { + color: var(--color-pink-500); + background: var(--color-pink-100); +} + .session-list__create-copy { display: flex; flex-direction: column; @@ -394,6 +508,12 @@ color: var(--color-purple-500); background: var(--color-purple-100); } + + &--claw, + &--Claw { + color: var(--color-pink-500); + background: var(--color-pink-100); + } } .session-list__item-body { @@ -456,6 +576,13 @@ color: var(--color-purple-500); border-color: var(--color-purple-200); } + + &--claw, + &--Claw { + background: var(--color-pink-100); + color: var(--color-pink-500); + border-color: var(--color-pink-200); + } } .session-list__empty { @@ -494,6 +621,174 @@ color: var(--color-text-muted); } +/* Picker Overlay */ +.session-list__picker-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: flex-end; + justify-content: center; + z-index: 1000; + animation: fadeIn var(--motion-fast) var(--easing-standard); +} + +.session-list__picker-modal { + width: 100%; + max-height: 60vh; + background: var(--color-bg-elevated); + border-radius: 20px 20px 0 0; + overflow: hidden; + animation: slideUp var(--motion-base) var(--easing-decelerate); +} + +.session-list__picker-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--size-gap-4); + border-bottom: 1px solid var(--border-subtle); + + h3 { + margin: 0; + font-size: 16px; + font-weight: var(--font-weight-semibold); + color: var(--color-text-primary); + } +} + +.session-list__picker-close { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + border-radius: 50%; + + &:active { + background: var(--element-bg-subtle); + } +} + +.session-list__picker-list { + padding: var(--size-gap-3); + max-height: calc(60vh - 60px); + overflow-y: auto; +} + +.session-list__picker-empty { + padding: var(--size-gap-6); + text-align: center; + color: var(--color-text-muted); + font-size: var(--font-size-sm); +} + +.session-list__picker-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 12px; + border: 1px solid var(--border-subtle); + @include squircle(16px); + background: var(--color-bg-secondary); + color: var(--color-text-primary); + cursor: pointer; + text-align: left; + transition: all var(--motion-fast) var(--easing-standard); + margin-bottom: 8px; + + &:last-child { + margin-bottom: 0; + } + + &:active { + transform: scale(0.99); + background: var(--element-bg-subtle); + } + + // Assistant mode (pink) + &.is-selected { + border-color: var(--color-pink-500); + background: var(--color-pink-50); + + svg { + color: var(--color-pink-500); + } + } + + // Workspace mode (blue) + &--workspace.is-selected { + border-color: var(--color-accent-500); + background: var(--color-accent-50); + + svg { + color: var(--color-accent-500); + } + } + + svg { + color: var(--color-text-muted); + flex-shrink: 0; + } +} + +.session-list__picker-item-icon { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + @include squircle(10px); + color: var(--color-pink-500); + background: var(--color-pink-100); + flex-shrink: 0; + + svg { + width: 18px; + height: 18px; + color: inherit; + } + + // Workspace mode (blue) + .session-list__picker-item--workspace & { + color: var(--color-accent-500); + background: var(--color-accent-100); + } +} + +.session-list__picker-item-name { + flex: 1; + font-size: var(--font-size-sm); + font-weight: var(--font-weight-medium); + color: var(--color-text-primary); +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideUp { + from { + transform: translateY(100%); + } + to { + transform: translateY(0); + } +} + @keyframes sessionItemIn { from { opacity: 0; diff --git a/src/mobile-web/src/theme/presets/dark.ts b/src/mobile-web/src/theme/presets/dark.ts index 7c82f1f7..d10e8f64 100644 --- a/src/mobile-web/src/theme/presets/dark.ts +++ b/src/mobile-web/src/theme/presets/dark.ts @@ -41,6 +41,26 @@ export const darkTheme: Record = { '--color-purple-700': 'rgba(124, 58, 237, 0.8)', '--color-purple-800': 'rgba(124, 58, 237, 0.9)', + '--color-pink-50': 'rgba(236, 72, 153, 0.04)', + '--color-pink-100': 'rgba(236, 72, 153, 0.08)', + '--color-pink-200': 'rgba(236, 72, 153, 0.15)', + '--color-pink-300': 'rgba(236, 72, 153, 0.25)', + '--color-pink-400': 'rgba(236, 72, 153, 0.4)', + '--color-pink-500': '#ec4899', + '--color-pink-600': '#db2777', + '--color-pink-700': 'rgba(219, 39, 119, 0.8)', + '--color-pink-800': 'rgba(219, 39, 119, 0.9)', + + '--color-orange-50': 'rgba(249, 115, 22, 0.04)', + '--color-orange-100': 'rgba(249, 115, 22, 0.08)', + '--color-orange-200': 'rgba(249, 115, 22, 0.15)', + '--color-orange-300': 'rgba(249, 115, 22, 0.25)', + '--color-orange-400': 'rgba(249, 115, 22, 0.4)', + '--color-orange-500': '#f97316', + '--color-orange-600': '#ea580c', + '--color-orange-700': 'rgba(234, 88, 12, 0.8)', + '--color-orange-800': 'rgba(234, 88, 12, 0.9)', + '--color-success': '#34d399', '--color-success-bg': 'rgba(52, 211, 153, 0.1)', '--color-success-border': 'rgba(52, 211, 153, 0.3)', @@ -66,6 +86,7 @@ export const darkTheme: Record = { '--glow-blue': '0 12px 32px rgba(225, 171, 128, 0.25), 0 6px 16px rgba(225, 171, 128, 0.18), 0 3px 8px rgba(0, 0, 0, 0.1)', '--glow-purple': '0 12px 32px rgba(139, 92, 246, 0.25), 0 6px 16px rgba(124, 58, 237, 0.18), 0 3px 8px rgba(0, 0, 0, 0.1)', + '--glow-orange': '0 12px 32px rgba(249, 115, 22, 0.25), 0 6px 16px rgba(234, 88, 12, 0.18), 0 3px 8px rgba(0, 0, 0, 0.1)', '--glow-mixed': '0 12px 32px rgba(225, 171, 128, 0.2), 0 6px 16px rgba(139, 92, 246, 0.15), 0 3px 8px rgba(0, 0, 0, 0.1)', '--shadow-xs': '0 1px 2px rgba(0, 0, 0, 0.9)', diff --git a/src/mobile-web/src/theme/presets/light.ts b/src/mobile-web/src/theme/presets/light.ts index 3cee8758..b0ae6fd3 100644 --- a/src/mobile-web/src/theme/presets/light.ts +++ b/src/mobile-web/src/theme/presets/light.ts @@ -41,6 +41,26 @@ export const lightTheme: Record = { '--color-purple-700': 'rgba(149, 54, 204, 0.8)', '--color-purple-800': 'rgba(149, 54, 204, 0.9)', + '--color-pink-50': 'rgba(236, 72, 153, 0.04)', + '--color-pink-100': 'rgba(236, 72, 153, 0.08)', + '--color-pink-200': 'rgba(236, 72, 153, 0.14)', + '--color-pink-300': 'rgba(236, 72, 153, 0.22)', + '--color-pink-400': 'rgba(236, 72, 153, 0.36)', + '--color-pink-500': '#EC4899', + '--color-pink-600': '#DB2777', + '--color-pink-700': 'rgba(219, 39, 119, 0.8)', + '--color-pink-800': 'rgba(219, 39, 119, 0.9)', + + '--color-orange-50': 'rgba(255, 149, 0, 0.04)', + '--color-orange-100': 'rgba(255, 149, 0, 0.08)', + '--color-orange-200': 'rgba(255, 149, 0, 0.14)', + '--color-orange-300': 'rgba(255, 149, 0, 0.22)', + '--color-orange-400': 'rgba(255, 149, 0, 0.36)', + '--color-orange-500': '#FF9500', + '--color-orange-600': '#CC7A00', + '--color-orange-700': 'rgba(204, 122, 0, 0.8)', + '--color-orange-800': 'rgba(204, 122, 0, 0.9)', + '--color-success': '#34C759', '--color-success-bg': 'rgba(52, 199, 89, 0.08)', '--color-success-border': 'rgba(52, 199, 89, 0.25)', @@ -66,6 +86,7 @@ export const lightTheme: Record = { '--glow-blue': '0 2px 8px rgba(0, 122, 255, 0.08)', '--glow-purple': '0 2px 8px rgba(175, 82, 222, 0.08)', + '--glow-orange': '0 2px 8px rgba(255, 149, 0, 0.08)', '--glow-mixed': '0 2px 8px rgba(0, 122, 255, 0.06), 0 2px 8px rgba(175, 82, 222, 0.04)', '--shadow-xs': '0 1px 2px rgba(0, 0, 0, 0.04)', diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss index 25ae4088..93abc55b 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.scss @@ -455,3 +455,47 @@ width: 100%; } +// ==================== Bot mode selector ==================== + +.bitfun-remote-connect__mode-selector { + display: inline-flex; + align-items: center; + gap: 0; + padding: 3px; + background: var(--element-bg-subtle); + border: 1px solid var(--border-subtle); + border-radius: 8px; +} + +.bitfun-remote-connect__mode-btn { + padding: 6px 14px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-muted); + background: transparent; + border: none; + border-radius: 6px; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + + &:hover:not(:disabled) { + color: var(--color-text-secondary); + } + + &:disabled { + cursor: default; + } + + &.is-active { + background: var(--color-accent-600, #2563eb); + color: #fff; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + } + + &:focus-visible { + outline: 2px solid color-mix(in srgb, var(--color-accent-500, #3b82f6) 55%, transparent); + outline-offset: 1px; + } +} + diff --git a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx index 3b2f6a31..8dc572fe 100644 --- a/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx +++ b/src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx @@ -87,6 +87,7 @@ export const RemoteConnectDialog: React.FC = ({ const [lanNetworkInfo, setLanNetworkInfo] = useState<{ localIp: string; gatewayIp: string | null } | null>(null); const [showDisclaimer, setShowDisclaimer] = useState(false); const [hasAgreedDisclaimer, setHasAgreedDisclaimer] = useState(() => getRemoteConnectDisclaimerAgreed()); + const [botVerboseMode, setBotVerboseMode] = useState(false); const [qrCopied, setQrCopied] = useState(false); const [customUrl, setCustomUrl] = useState(''); @@ -141,6 +142,7 @@ export const RemoteConnectDialog: React.FC = ({ const s = await remoteConnectAPI.getStatus(); if (cancelled) return; setStatus(s); + setBotVerboseMode(s.bot_verbose_mode); if (s.bot_connected) { const tab = botInfoToBotTab(s.bot_connected); @@ -271,6 +273,12 @@ export const RemoteConnectDialog: React.FC = ({ } catch { /* best effort */ } }, []); + const handleToggleBotVerboseMode = async () => { + const newMode = !botVerboseMode; + setBotVerboseMode(newMode); + await remoteConnectAPI.setBotVerboseMode(newMode); + }; + const handleCancelConnect = useCallback(async () => { if (pollRef.current) clearInterval(pollRef.current); pollRef.current = null; @@ -507,7 +515,38 @@ export const RemoteConnectDialog: React.FC = ({ const renderBotContent = () => { if (isBotConnected && connectedBotTab === botTab) { - return renderConnectedView(handleDisconnectBot); + return ( +
+
+ {t('remoteConnect.stateConnected')} +
+
+ + +
+ +
+ ); } if (connectionResult && activeGroup === 'bot') { return renderPairingInProgress(); diff --git a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts index c424c2d5..d20f0de6 100644 --- a/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/RemoteConnectAPI.ts @@ -37,6 +37,7 @@ export interface RemoteConnectStatus { peer_device_name: string | null; peer_user_id: string | null; bot_connected: string | null; + bot_verbose_mode: boolean; } export interface LanNetworkInfo { @@ -175,6 +176,24 @@ class RemoteConnectAPIService { throw e; } } + + async getBotVerboseMode(): Promise { + try { + return await this.adapter.request('remote_connect_get_bot_verbose_mode'); + } catch (e) { + log.error('getBotVerboseMode failed', e); + return false; + } + } + + async setBotVerboseMode(verbose: boolean): Promise { + try { + await this.adapter.request('remote_connect_set_bot_verbose_mode', { verbose }); + } catch (e) { + log.error('setBotVerboseMode failed', e); + throw e; + } + } } export const remoteConnectAPI = new RemoteConnectAPIService(); diff --git a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts index 8eebaf0d..73cbb3dc 100644 --- a/src/web-ui/src/infrastructure/config/services/modelConfigs.ts +++ b/src/web-ui/src/infrastructure/config/services/modelConfigs.ts @@ -93,7 +93,7 @@ export const PROVIDER_TEMPLATES: Record = { name: t('settings/ai-model:providers.minimax.name'), baseUrl: 'https://api.minimaxi.com/anthropic', format: 'anthropic', - models: ['MiniMax-M2.5', 'MiniMax-M2.1'], + models: ['MiniMax-M2.7-highspeed', 'MiniMax-M2.5-highspeed'], requiresApiKey: true, description: t('settings/ai-model:providers.minimax.description'), helpUrl: 'https://platform.minimax.io/', diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 9c610f1a..3b980f34 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -395,6 +395,8 @@ "botFeishuStep1Suffix": " and create a Custom App", "botFeishuStep2": "Enable Bot capability and configure Permissions & Scopes and Events & Callbacks", "botFeishuStep3": "Copy App ID and App Secret below", + "botVerboseMode": "Verbose Mode", + "botConciseMode": "Concise Mode", "openNgrokSetup": "Open ngrok setup page", "disclaimerTitle": "Remote Connect Disclaimer", "disclaimerIntro": "Before enabling Remote Connect, please read and accept the following:", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 04d25da2..e38d664c 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -395,6 +395,8 @@ "botFeishuStep1Suffix": ",创建企业自建应用", "botFeishuStep2": "启用机器人能力,配置权限管理和事件与回调", "botFeishuStep3": "将App ID和App Secret填入下方", + "botVerboseMode": "详细模式", + "botConciseMode": "简洁模式", "openNgrokSetup": "打开 ngrok 安装与配置页面", "disclaimerTitle": "远程连接免责声明", "disclaimerIntro": "启用远程连接前,请确认你已理解并接受以下事项:",