From 58611a6bc194ecab82bcc02bb818cd4ec5b00077 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 1 Apr 2026 10:08:20 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B6=88=E6=81=AF=E6=B8=A0=E9=81=93=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=BC=9A=E8=AF=9D=E7=9B=B8=E5=85=B3=E6=8C=87=E4=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/bin/codeg_server.rs | 7 +- .../src/chat_channel/backends/telegram.rs | 14 +- .../src/chat_channel/command_dispatcher.rs | 99 +- src-tauri/src/chat_channel/i18n.rs | 30 +- src-tauri/src/chat_channel/manager.rs | 27 +- src-tauri/src/chat_channel/mod.rs | 3 + src-tauri/src/chat_channel/session_bridge.rs | 75 ++ .../src/chat_channel/session_commands.rs | 848 ++++++++++++++++++ .../chat_channel/session_event_subscriber.rs | 458 ++++++++++ src-tauri/src/chat_channel/types.rs | 9 + .../entities/chat_channel_sender_context.rs | 35 + src-tauri/src/db/entities/mod.rs | 1 + src-tauri/src/db/entities/prelude.rs | 1 + ...0401_000001_chat_channel_sender_context.rs | 124 +++ src-tauri/src/db/migration/mod.rs | 2 + src-tauri/src/db/service/mod.rs | 1 + .../src/db/service/sender_context_service.rs | 101 +++ src-tauri/src/lib.rs | 4 +- .../settings/channel-commands-tab.tsx | 8 + src/i18n/messages/ar.json | 8 + src/i18n/messages/de.json | 10 +- src/i18n/messages/en.json | 8 + src/i18n/messages/es.json | 8 + src/i18n/messages/fr.json | 8 + src/i18n/messages/ja.json | 8 + src/i18n/messages/ko.json | 8 + src/i18n/messages/pt.json | 8 + src/i18n/messages/zh-CN.json | 8 + src/i18n/messages/zh-TW.json | 8 + 29 files changed, 1915 insertions(+), 14 deletions(-) create mode 100644 src-tauri/src/chat_channel/session_bridge.rs create mode 100644 src-tauri/src/chat_channel/session_commands.rs create mode 100644 src-tauri/src/chat_channel/session_event_subscriber.rs create mode 100644 src-tauri/src/db/entities/chat_channel_sender_context.rs create mode 100644 src-tauri/src/db/migration/m20260401_000001_chat_channel_sender_context.rs create mode 100644 src-tauri/src/db/service/sender_context_service.rs diff --git a/src-tauri/src/bin/codeg_server.rs b/src-tauri/src/bin/codeg_server.rs index 9ed8e5a..b013876 100644 --- a/src-tauri/src/bin/codeg_server.rs +++ b/src-tauri/src/bin/codeg_server.rs @@ -53,7 +53,12 @@ async fn main() { // Start chat channel background tasks (event subscriber, command dispatcher, scheduler, auto-connect) state .chat_channel_manager - .start_background(state.event_broadcaster.clone(), state.db.conn.clone()) + .start_background( + state.event_broadcaster.clone(), + state.db.conn.clone(), + state.connection_manager.clone_ref(), + state.emitter.clone(), + ) .await; // Build router diff --git a/src-tauri/src/chat_channel/backends/telegram.rs b/src-tauri/src/chat_channel/backends/telegram.rs index 8362e1f..59ce5f0 100644 --- a/src-tauri/src/chat_channel/backends/telegram.rs +++ b/src-tauri/src/chat_channel/backends/telegram.rs @@ -162,6 +162,9 @@ impl ChatChannelBackend for TelegramBackend { if let Ok(body) = resp.json::().await { if let Some(updates) = body.get("result").and_then(|r| r.as_array()) { + if !updates.is_empty() { + eprintln!("[Telegram] got {} update(s)", updates.len()); + } for update in updates { if let Some(uid) = update.get("update_id").and_then(|u| u.as_i64()) @@ -184,6 +187,7 @@ impl ChatChannelBackend for TelegramBackend { let at_bot = format!("@{}", bot_username); if !text.to_lowercase().contains(&at_bot) { + eprintln!("[Telegram] skipped group msg without @bot: {text}"); continue; } } @@ -196,7 +200,8 @@ impl ChatChannelBackend for TelegramBackend { .and_then(|i| i.as_i64()) .map(|i| i.to_string()) .unwrap_or_default(); - let _ = command_tx + eprintln!("[Telegram] dispatching: {clean_text}"); + let send_result = command_tx .send(IncomingCommand { channel_id, sender_id, @@ -204,9 +209,16 @@ impl ChatChannelBackend for TelegramBackend { metadata: update.clone(), }) .await; + if let Err(e) = send_result { + eprintln!("[Telegram] command_tx.send failed: {e}"); + } + } else { + eprintln!("[Telegram] update without /message/text"); } } } + } else { + eprintln!("[Telegram] failed to parse response body"); } } Err(e) => { diff --git a/src-tauri/src/chat_channel/command_dispatcher.rs b/src-tauri/src/chat_channel/command_dispatcher.rs index e7b3559..dd1f841 100644 --- a/src-tauri/src/chat_channel/command_dispatcher.rs +++ b/src-tauri/src/chat_channel/command_dispatcher.rs @@ -1,14 +1,19 @@ +use std::sync::Arc; use std::time::{Duration, Instant}; use sea_orm::DatabaseConnection; -use tokio::sync::mpsc; +use tokio::sync::{mpsc, Mutex}; use tokio::task::JoinHandle; use super::command_handlers; use super::i18n::{self, Lang}; use super::manager::ChatChannelManager; +use super::session_bridge::SessionBridge; +use super::session_commands; use super::types::IncomingCommand; +use crate::acp::manager::ConnectionManager; use crate::db::service::{app_metadata_service, chat_channel_message_log_service}; +use crate::web::event_bridge::EventEmitter; const COMMAND_PREFIX_KEY: &str = "chat_command_prefix"; const DEFAULT_COMMAND_PREFIX: &str = "/"; @@ -52,12 +57,19 @@ pub fn spawn_command_dispatcher( mut command_rx: mpsc::Receiver, manager: ChatChannelManager, db_conn: DatabaseConnection, + conn_mgr: ConnectionManager, + emitter: EventEmitter, + bridge: Arc>, ) -> JoinHandle<()> { tokio::spawn(async move { let mut config = CommandConfigCache::new(); while let Some(cmd) = command_rx.recv().await { let text = cmd.command_text.trim(); + eprintln!( + "[ChatChannel] received command from channel={} sender={}: {:?}", + cmd.channel_id, cmd.sender_id, text + ); // Log inbound command let _ = chat_channel_message_log_service::create_log( @@ -73,7 +85,25 @@ pub fn spawn_command_dispatcher( config.refresh_if_needed(&db_conn).await; - let response = dispatch_command(text, &config.prefix, &db_conn, &manager, config.lang).await; + let response = dispatch_command( + text, + &config.prefix, + &db_conn, + &manager, + &conn_mgr, + &emitter, + &bridge, + cmd.channel_id, + &cmd.sender_id, + config.lang, + ) + .await; + + eprintln!( + "[ChatChannel] dispatch result: title={:?}, body_len={}", + response.title, + response.body.len() + ); // Send response back via the same channel let send_result = manager.send_to_channel(cmd.channel_id, &response).await; @@ -102,17 +132,36 @@ pub fn spawn_command_dispatcher( }) } +#[allow(clippy::too_many_arguments)] async fn dispatch_command( text: &str, prefix: &str, db: &DatabaseConnection, manager: &ChatChannelManager, + conn_mgr: &ConnectionManager, + emitter: &EventEmitter, + bridge: &Arc>, + channel_id: i32, + sender_id: &str, lang: Lang, ) -> super::types::RichMessage { - // Strip prefix; if text doesn't start with it, show help + // Strip prefix; if text doesn't start with it, try as follow-up let without_prefix = match text.strip_prefix(prefix) { Some(rest) => rest, - None => return command_handlers::handle_help(prefix, lang), + None => { + // Check if sender has an active session for follow-up + let has_session = { + let guard = bridge.lock().await; + guard.find_by_sender(channel_id, sender_id).is_some() + }; + if has_session { + return session_commands::handle_followup( + db, text, channel_id, sender_id, conn_mgr, bridge, lang, + ) + .await; + } + return command_handlers::handle_help(prefix, lang); + } }; let parts: Vec<&str> = without_prefix.splitn(2, ' ').collect(); @@ -120,6 +169,7 @@ async fn dispatch_command( let args = parts.get(1).map(|s| s.trim()).unwrap_or(""); match command.as_str() { + // Existing commands "recent" => command_handlers::handle_recent(db, lang).await, "search" => { if args.is_empty() { @@ -140,6 +190,47 @@ async fn dispatch_command( "today" => command_handlers::handle_today(db, lang).await, "status" => command_handlers::handle_status(manager, lang).await, "help" | "start" => command_handlers::handle_help(prefix, lang), + + // Session commands + "folder" => { + session_commands::handle_folder(db, args, channel_id, sender_id, lang).await + } + "agent" => { + session_commands::handle_agent(db, args, channel_id, sender_id, lang).await + } + "task" | "do" => { + session_commands::handle_task( + db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, + ) + .await + } + "sessions" => { + session_commands::handle_sessions(db, channel_id, sender_id, lang).await + } + "resume" => { + session_commands::handle_resume( + db, args, channel_id, sender_id, conn_mgr, emitter, bridge, lang, + ) + .await + } + "cancel" => { + session_commands::handle_cancel(db, channel_id, sender_id, conn_mgr, bridge, lang) + .await + } + "approve" => { + let always = args.eq_ignore_ascii_case("always"); + session_commands::handle_permission_response( + true, always, db, channel_id, sender_id, conn_mgr, bridge, lang, + ) + .await + } + "deny" => { + session_commands::handle_permission_response( + false, false, db, channel_id, sender_id, conn_mgr, bridge, lang, + ) + .await + } + _ => super::types::RichMessage::info(i18n::unknown_command(lang, prefix, &command)) .with_title(i18n::unknown_command_title(lang)), } diff --git a/src-tauri/src/chat_channel/i18n.rs b/src-tauri/src/chat_channel/i18n.rs index b87b806..f944e20 100644 --- a/src-tauri/src/chat_channel/i18n.rs +++ b/src-tauri/src/chat_channel/i18n.rs @@ -610,12 +610,23 @@ pub fn help_title(lang: Lang) -> &'static str { pub fn help_body(lang: Lang, prefix: &str) -> String { match lang { Lang::ZhCn => format!( - "{prefix}recent - 最近 5 条会话\n\ + "📂 {prefix}folder - 选择工作目录\n\ + 🤖 {prefix}agent - 选择 Agent\n\ + 🚀 {prefix}task <描述> - 创建会话并执行任务\n\ + 📋 {prefix}sessions - 当前目录的活跃会话\n\ + ▶️ {prefix}resume - 恢复已有会话\n\ + ⏹️ {prefix}cancel - 取消当前任务\n\ + ✅ {prefix}approve [always] - 批准权限请求\n\ + ❌ {prefix}deny - 拒绝权限请求\n\ + \n\ + {prefix}recent - 最近 5 条会话\n\ {prefix}search <关键词> - 搜索会话\n\ {prefix}detail - 会话详情\n\ {prefix}today - 今日活动汇总\n\ {prefix}status - 渠道连接状态\n\ - {prefix}help - 显示帮助" + {prefix}help - 显示帮助\n\ + \n\ + 💡 有活跃会话时,直接发文本即可继续对话" ), Lang::ZhTw => format!( "{prefix}recent - 最近 5 條對話\n\ @@ -682,12 +693,23 @@ pub fn help_body(lang: Lang, prefix: &str) -> String { {prefix}help - عرض المساعدة" ), Lang::En => format!( - "{prefix}recent - 5 most recent conversations\n\ + "📂 {prefix}folder - Select working folder\n\ + 🤖 {prefix}agent - Select agent\n\ + 🚀 {prefix}task - Create session & run task\n\ + 📋 {prefix}sessions - Active sessions in folder\n\ + ▶️ {prefix}resume - Resume a session\n\ + ⏹️ {prefix}cancel - Cancel current task\n\ + ✅ {prefix}approve [always] - Approve permission\n\ + ❌ {prefix}deny - Deny permission\n\ + \n\ + {prefix}recent - 5 most recent conversations\n\ {prefix}search - Search conversations\n\ {prefix}detail - Conversation details\n\ {prefix}today - Today's activity summary\n\ {prefix}status - Channel connection status\n\ - {prefix}help - Show help" + {prefix}help - Show help\n\ + \n\ + 💡 When a session is active, just type text to continue the conversation" ), } } diff --git a/src-tauri/src/chat_channel/manager.rs b/src-tauri/src/chat_channel/manager.rs index 974f836..0f1fd2e 100644 --- a/src-tauri/src/chat_channel/manager.rs +++ b/src-tauri/src/chat_channel/manager.rs @@ -5,9 +5,11 @@ use sea_orm::DatabaseConnection; use tokio::sync::{mpsc, Mutex}; use super::error::ChatChannelError; +use super::session_bridge::SessionBridge; use super::traits::ChatChannelBackend; use super::types::*; -use crate::web::event_bridge::WebEventBroadcaster; +use crate::acp::manager::ConnectionManager; +use crate::web::event_bridge::{EventEmitter, WebEventBroadcaster}; struct ActiveChannel { id: i32, @@ -212,28 +214,49 @@ impl ChatChannelManager { &self, broadcaster: Arc, db_conn: DatabaseConnection, + conn_mgr: ConnectionManager, + emitter: EventEmitter, ) { // Store broadcaster for status event emission *self.inner.broadcaster.lock().await = Some(broadcaster.clone()); let db_conn2 = db_conn.clone(); + // Create shared session bridge + let bridge = Arc::new(Mutex::new(SessionBridge::new())); + // Spawn event subscriber let manager_for_events = self.clone_ref(); super::event_subscriber::spawn_event_subscriber( - broadcaster, + broadcaster.clone(), manager_for_events, db_conn.clone(), ); + // Spawn session event subscriber (ACP event routing to channels) + let manager_for_session_events = self.clone_ref(); + super::session_event_subscriber::spawn_session_event_subscriber( + broadcaster, + bridge.clone(), + manager_for_session_events, + conn_mgr.clone_ref(), + db_conn.clone(), + ); + // Spawn command dispatcher if let Some(command_rx) = self.take_command_receiver().await { + eprintln!("[ChatChannel] command dispatcher started"); let manager_for_cmds = self.clone_ref(); super::command_dispatcher::spawn_command_dispatcher( command_rx, manager_for_cmds, db_conn.clone(), + conn_mgr, + emitter, + bridge, ); + } else { + eprintln!("[ChatChannel] WARNING: command_rx already taken, dispatcher NOT started"); } // Spawn daily report scheduler diff --git a/src-tauri/src/chat_channel/mod.rs b/src-tauri/src/chat_channel/mod.rs index a52edf5..87aa7d4 100644 --- a/src-tauri/src/chat_channel/mod.rs +++ b/src-tauri/src/chat_channel/mod.rs @@ -7,5 +7,8 @@ pub mod i18n; pub mod manager; pub mod message_formatter; pub mod scheduler; +pub mod session_bridge; +pub mod session_commands; +pub mod session_event_subscriber; pub mod traits; pub mod types; diff --git a/src-tauri/src/chat_channel/session_bridge.rs b/src-tauri/src/chat_channel/session_bridge.rs new file mode 100644 index 0000000..d0edaf3 --- /dev/null +++ b/src-tauri/src/chat_channel/session_bridge.rs @@ -0,0 +1,75 @@ +use std::collections::HashMap; +use std::time::Instant; + +use crate::acp::types::PermissionOptionInfo; +use crate::chat_channel::types::SentMessageId; + +pub struct PendingPermission { + pub request_id: String, + pub tool_description: String, + pub options: Vec, + pub sent_message_id: Option, +} + +pub struct ActiveSession { + pub channel_id: i32, + pub sender_id: String, + pub conversation_id: i32, + pub connection_id: String, + pub content_buffer: String, + pub tool_calls: Vec, + pub last_flushed: Instant, + pub pending_prompt: Option, + pub permission_pending: Option, +} + +#[derive(Default)] +pub struct SessionBridge { + sessions: HashMap, +} + +impl SessionBridge { + pub fn new() -> Self { + Self::default() + } + + pub fn register(&mut self, connection_id: String, session: ActiveSession) { + self.sessions.insert(connection_id, session); + } + + pub fn remove(&mut self, connection_id: &str) -> Option { + self.sessions.remove(connection_id) + } + + pub fn get(&self, connection_id: &str) -> Option<&ActiveSession> { + self.sessions.get(connection_id) + } + + pub fn get_mut(&mut self, connection_id: &str) -> Option<&mut ActiveSession> { + self.sessions.get_mut(connection_id) + } + + pub fn find_by_sender(&self, channel_id: i32, sender_id: &str) -> Option<&ActiveSession> { + self.sessions.values().find(|s| { + s.channel_id == channel_id && s.sender_id == sender_id + }) + } + + pub fn find_by_sender_mut( + &mut self, + channel_id: i32, + sender_id: &str, + ) -> Option<&mut ActiveSession> { + self.sessions.values_mut().find(|s| { + s.channel_id == channel_id && s.sender_id == sender_id + }) + } + + pub fn all_sessions(&self) -> impl Iterator { + self.sessions.values() + } + + pub fn all_sessions_mut(&mut self) -> impl Iterator { + self.sessions.values_mut() + } +} diff --git a/src-tauri/src/chat_channel/session_commands.rs b/src-tauri/src/chat_channel/session_commands.rs new file mode 100644 index 0000000..b73ba02 --- /dev/null +++ b/src-tauri/src/chat_channel/session_commands.rs @@ -0,0 +1,848 @@ +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::Instant; + +use sea_orm::DatabaseConnection; +use tokio::sync::Mutex; + +use super::i18n::Lang; +use super::session_bridge::{ActiveSession, SessionBridge}; +use super::types::RichMessage; +use crate::acp::manager::ConnectionManager; +use crate::acp::registry::all_acp_agents; +use crate::acp::types::PromptInputBlock; +use crate::db::entities::conversation; +use crate::db::service::{conversation_service, folder_service, sender_context_service}; +use crate::models::agent::AgentType; +use crate::web::event_bridge::EventEmitter; + +// ── /folder ── + +pub async fn handle_folder( + db: &DatabaseConnection, + args: &str, + channel_id: i32, + sender_id: &str, + lang: Lang, +) -> RichMessage { + if args.is_empty() { + return list_folders(db, channel_id, sender_id, lang).await; + } + + // Try parse as index (1-based) + if let Ok(idx) = args.parse::() { + return select_folder_by_index(db, idx, channel_id, sender_id, lang).await; + } + + // Treat as path + select_folder_by_path(db, args, channel_id, sender_id, lang).await +} + +async fn list_folders( + db: &DatabaseConnection, + channel_id: i32, + sender_id: &str, + lang: Lang, +) -> RichMessage { + let folders = match folder_service::list_folders(db).await { + Ok(f) => f, + Err(e) => return RichMessage::error(format!("Failed to list folders: {e}")), + }; + + if folders.is_empty() { + return RichMessage::info(t(lang, "No folders found.", "没有找到项目目录。")) + .with_title(t(lang, "Working Folder", "工作目录")); + } + + let ctx = sender_context_service::get_or_create(db, channel_id, sender_id) + .await + .ok(); + + let mut body = String::new(); + for (i, f) in folders.iter().take(10).enumerate() { + let current = ctx + .as_ref() + .and_then(|c| c.current_folder_id) + .map(|id| id == f.id) + .unwrap_or(false); + let marker = if current { " [*]" } else { "" }; + body.push_str(&format!( + "{}. {}{} ({})\n", + i + 1, + f.name, + marker, + f.path + )); + } + + body.push_str(&format!( + "\n{}", + t( + lang, + "Reply /folder to select.", + "回复 /folder <数字> 选择目录。" + ) + )); + + RichMessage::info(body.trim_end()) + .with_title(t(lang, "Working Folder", "工作目录")) +} + +async fn select_folder_by_index( + db: &DatabaseConnection, + idx: usize, + channel_id: i32, + sender_id: &str, + lang: Lang, +) -> RichMessage { + if idx == 0 { + return RichMessage::info(t(lang, "Index starts from 1.", "序号从 1 开始。")); + } + + let folders = match folder_service::list_folders(db).await { + Ok(f) => f, + Err(e) => return RichMessage::error(format!("Failed to list folders: {e}")), + }; + + let Some(folder) = folders.get(idx - 1) else { + return RichMessage::info(t( + lang, + "Index out of range. Use /folder to list.", + "序号超出范围,请使用 /folder 查看列表。", + )); + }; + + let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(folder.id)) + .await; + + RichMessage::info(format!("{} ({})", folder.name, folder.path)) + .with_title(t(lang, "Folder Selected", "已选择目录")) +} + +async fn select_folder_by_path( + db: &DatabaseConnection, + path: &str, + channel_id: i32, + sender_id: &str, + lang: Lang, +) -> RichMessage { + let entry = match folder_service::add_folder(db, path).await { + Ok(e) => e, + Err(e) => return RichMessage::error(format!("Failed to add folder: {e}")), + }; + + let _ = + sender_context_service::update_folder(db, channel_id, sender_id, Some(entry.id)).await; + + RichMessage::info(format!("{} ({})", entry.name, entry.path)) + .with_title(t(lang, "Folder Selected", "已选择目录")) +} + +// ── /agent ── + +pub async fn handle_agent( + db: &DatabaseConnection, + args: &str, + channel_id: i32, + sender_id: &str, + lang: Lang, +) -> RichMessage { + if args.is_empty() { + return list_agents(db, channel_id, sender_id, lang).await; + } + + // Try parse as index + if let Ok(idx) = args.parse::() { + return select_agent_by_index(db, idx, channel_id, sender_id, lang).await; + } + + // Try parse as agent type name + select_agent_by_name(db, args, channel_id, sender_id, lang).await +} + +async fn list_agents( + db: &DatabaseConnection, + channel_id: i32, + sender_id: &str, + lang: Lang, +) -> RichMessage { + let agents = all_acp_agents(); + let ctx = sender_context_service::get_or_create(db, channel_id, sender_id) + .await + .ok(); + + let mut body = String::new(); + for (i, at) in agents.iter().enumerate() { + let at_str = agent_type_to_string(*at); + let current = ctx + .as_ref() + .and_then(|c| c.current_agent_type.as_deref()) + .map(|s| s == at_str) + .unwrap_or(false); + let marker = if current { " [*]" } else { "" }; + body.push_str(&format!("{}. {}{}\n", i + 1, at, marker)); + } + + body.push_str(&format!( + "\n{}", + t( + lang, + "Reply /agent or /agent to select.", + "回复 /agent <数字> 或 /agent <名称> 选择。" + ) + )); + + RichMessage::info(body.trim_end()) + .with_title(t(lang, "Agent Selection", "选择 Agent")) +} + +async fn select_agent_by_index( + db: &DatabaseConnection, + idx: usize, + channel_id: i32, + sender_id: &str, + lang: Lang, +) -> RichMessage { + let agents = all_acp_agents(); + if idx == 0 || idx > agents.len() { + return RichMessage::info(t( + lang, + "Index out of range. Use /agent to list.", + "序号超出范围,请使用 /agent 查看列表。", + )); + } + + let at = agents[idx - 1]; + let at_str = agent_type_to_string(at); + let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await; + + RichMessage::info(at.to_string()) + .with_title(t(lang, "Agent Selected", "已选择 Agent")) +} + +async fn select_agent_by_name( + db: &DatabaseConnection, + name: &str, + channel_id: i32, + sender_id: &str, + lang: Lang, +) -> RichMessage { + let at = match parse_agent_type(name) { + Some(a) => a, + None => { + return RichMessage::info(format!( + "{}{}", + t(lang, "Unknown agent: ", "未知 Agent: "), + name + )); + } + }; + + let at_str = agent_type_to_string(at); + let _ = sender_context_service::update_agent(db, channel_id, sender_id, Some(at_str)).await; + + RichMessage::info(at.to_string()) + .with_title(t(lang, "Agent Selected", "已选择 Agent")) +} + +// ── /task ── + +#[allow(clippy::too_many_arguments)] +pub async fn handle_task( + db: &DatabaseConnection, + task_description: &str, + channel_id: i32, + sender_id: &str, + conn_mgr: &ConnectionManager, + emitter: &EventEmitter, + bridge: &Arc>, + lang: Lang, +) -> RichMessage { + if task_description.is_empty() { + return RichMessage::info(t( + lang, + "Usage: /task ", + "用法: /task <任务描述>", + )); + } + + // 1. Load sender context + let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { + Ok(c) => c, + Err(e) => return RichMessage::error(format!("Failed to load context: {e}")), + }; + + let folder_id = match ctx.current_folder_id { + Some(id) => id, + None => { + return RichMessage::info(t( + lang, + "No folder selected. Use /folder first.", + "未选择工作目录,请先使用 /folder 选择。", + )); + } + }; + + // 2. Get folder info + let folder = match folder_service::get_folder_by_id(db, folder_id).await { + Ok(Some(f)) => f, + _ => { + return RichMessage::info(t( + lang, + "Folder not found. Use /folder to select.", + "目录不存在,请使用 /folder 重新选择。", + )); + } + }; + + // 3. Resolve agent type + let agent_type = resolve_agent_type(&ctx.current_agent_type, &folder.default_agent_type); + + // 4. Create conversation record + let conv = match conversation_service::create( + db, + folder_id, + agent_type, + Some(truncate_title(task_description)), + folder.git_branch.clone(), + ) + .await + { + Ok(c) => c, + Err(e) => return RichMessage::error(format!("Failed to create conversation: {e}")), + }; + + // 5. Spawn ACP agent + let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id); + let connection_id = match conn_mgr + .spawn_agent( + agent_type, + Some(folder.path.clone()), + None, + BTreeMap::new(), + owner_label, + emitter.clone(), + ) + .await + { + Ok(id) => id, + Err(e) => { + // Clean up the conversation record + let _ = conversation_service::update_status( + db, + conv.id, + conversation::ConversationStatus::Cancelled, + ) + .await; + return RichMessage::error(format!( + "{}{e}", + t(lang, "Failed to start agent: ", "启动 Agent 失败: ") + )); + } + }; + + // 6. Register in bridge (prompt will be sent after SessionStarted event) + { + let session = ActiveSession { + channel_id, + sender_id: sender_id.to_string(), + conversation_id: conv.id, + connection_id: connection_id.clone(), + content_buffer: String::new(), + tool_calls: Vec::new(), + last_flushed: Instant::now(), + pending_prompt: Some(task_description.to_string()), + permission_pending: None, + }; + bridge.lock().await.register(connection_id.clone(), session); + } + + // 7. Update sender context + let _ = sender_context_service::update_session( + db, + channel_id, + sender_id, + Some(conv.id), + Some(connection_id), + ) + .await; + + RichMessage::info(format!( + "[{}] #{} @ {}", + agent_type, conv.id, folder.name, + )) + .with_title(t(lang, "Task Started", "任务已启动")) +} + +// ── /sessions ── + +pub async fn handle_sessions( + db: &DatabaseConnection, + channel_id: i32, + sender_id: &str, + lang: Lang, +) -> RichMessage { + let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { + Ok(c) => c, + Err(e) => return RichMessage::error(format!("Failed to load context: {e}")), + }; + + let folder_id = match ctx.current_folder_id { + Some(id) => id, + None => { + return RichMessage::info(t( + lang, + "No folder selected. Use /folder first.", + "未选择工作目录,请先使用 /folder 选择。", + )); + } + }; + + let folder = match folder_service::get_folder_by_id(db, folder_id).await { + Ok(Some(f)) => f, + _ => { + return RichMessage::info(t( + lang, + "Folder not found.", + "目录不存在。", + )); + } + }; + + let convs = match conversation_service::list_by_folder( + db, + folder_id, + None, + None, + None, + Some("in_progress".to_string()), + ) + .await + { + Ok(c) => c, + Err(e) => return RichMessage::error(format!("Failed to list sessions: {e}")), + }; + + if convs.is_empty() { + return RichMessage::info(t( + lang, + "No active sessions in this folder.", + "当前目录没有进行中的会话。", + )) + .with_title(format!( + "{} - {}", + t(lang, "Sessions", "会话列表"), + folder.name + )); + } + + let mut body = String::new(); + for (i, c) in convs.iter().take(10).enumerate() { + let title = c.title.as_deref().unwrap_or("(untitled)"); + let current = ctx + .current_conversation_id + .map(|id| id == c.id) + .unwrap_or(false); + let marker = if current { " [*]" } else { "" }; + body.push_str(&format!( + "{}. [{}] {} (#{}){} \n", + i + 1, + c.agent_type, + title, + c.id, + marker, + )); + } + + body.push_str(&format!( + "\n{}", + t( + lang, + "Reply /resume to continue.", + "回复 /resume 继续会话。" + ) + )); + + RichMessage::info(body.trim_end()).with_title(format!( + "{} - {}", + t(lang, "Sessions", "会话列表"), + folder.name + )) +} + +// ── /resume ── + +#[allow(clippy::too_many_arguments)] +pub async fn handle_resume( + db: &DatabaseConnection, + args: &str, + channel_id: i32, + sender_id: &str, + conn_mgr: &ConnectionManager, + emitter: &EventEmitter, + bridge: &Arc>, + lang: Lang, +) -> RichMessage { + let conversation_id: i32 = match args.parse() { + Ok(id) => id, + Err(_) => { + return RichMessage::info(t( + lang, + "Usage: /resume ", + "用法: /resume <会话ID>", + )); + } + }; + + let conv = match conversation_service::get_by_id(db, conversation_id).await { + Ok(c) => c, + Err(_) => { + return RichMessage::info(t( + lang, + "Conversation not found.", + "会话不存在。", + )); + } + }; + + let folder = match folder_service::get_folder_by_id(db, conv.folder_id).await { + Ok(Some(f)) => f, + _ => { + return RichMessage::info(t(lang, "Folder not found.", "目录不存在。")); + } + }; + + // Spawn agent with session_id for resume + let owner_label = format!("chat_channel:{}:{}", channel_id, sender_id); + let connection_id = match conn_mgr + .spawn_agent( + conv.agent_type, + Some(folder.path.clone()), + conv.external_id.clone(), + BTreeMap::new(), + owner_label, + emitter.clone(), + ) + .await + { + Ok(id) => id, + Err(e) => { + return RichMessage::error(format!( + "{}{e}", + t(lang, "Failed to start agent: ", "启动 Agent 失败: ") + )); + } + }; + + // Register in bridge (no pending prompt for resume) + { + let session = ActiveSession { + channel_id, + sender_id: sender_id.to_string(), + conversation_id: conv.id, + connection_id: connection_id.clone(), + content_buffer: String::new(), + tool_calls: Vec::new(), + last_flushed: Instant::now(), + pending_prompt: None, + permission_pending: None, + }; + bridge.lock().await.register(connection_id.clone(), session); + } + + // Update sender context + let _ = sender_context_service::update_session( + db, + channel_id, + sender_id, + Some(conv.id), + Some(connection_id), + ) + .await; + let _ = sender_context_service::update_folder(db, channel_id, sender_id, Some(conv.folder_id)) + .await; + + let title = conv.title.as_deref().unwrap_or("(untitled)"); + RichMessage::info(format!( + "[{}] #{} {} @ {}", + conv.agent_type, conv.id, title, folder.name, + )) + .with_title(t(lang, "Session Resumed", "会话已恢复")) +} + +// ── /cancel ── + +pub async fn handle_cancel( + db: &DatabaseConnection, + channel_id: i32, + sender_id: &str, + conn_mgr: &ConnectionManager, + bridge: &Arc>, + lang: Lang, +) -> RichMessage { + let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { + Ok(c) => c, + Err(e) => return RichMessage::error(format!("Failed to load context: {e}")), + }; + + let connection_id = match &ctx.current_connection_id { + Some(id) => id.clone(), + None => { + return RichMessage::info(t( + lang, + "No active session to cancel.", + "没有进行中的任务可取消。", + )); + } + }; + + // Cancel the ACP connection + let _ = conn_mgr.cancel(&connection_id).await; + + // Remove from bridge + bridge.lock().await.remove(&connection_id); + + // Update conversation status + if let Some(conv_id) = ctx.current_conversation_id { + let _ = conversation_service::update_status( + db, + conv_id, + conversation::ConversationStatus::Cancelled, + ) + .await; + } + + // Clear session from context + let _ = sender_context_service::clear_session(db, channel_id, sender_id).await; + + RichMessage::info(t( + lang, + "Current task has been cancelled.", + "当前任务已取消。", + )) + .with_title(t(lang, "Task Cancelled", "任务已取消")) +} + +// ── /approve, /deny ── + +#[allow(clippy::too_many_arguments)] +pub async fn handle_permission_response( + approve: bool, + always: bool, + db: &DatabaseConnection, + channel_id: i32, + sender_id: &str, + conn_mgr: &ConnectionManager, + bridge: &Arc>, + lang: Lang, +) -> RichMessage { + let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { + Ok(c) => c, + Err(e) => return RichMessage::error(format!("Failed to load context: {e}")), + }; + + let connection_id = match &ctx.current_connection_id { + Some(id) => id.clone(), + None => { + return RichMessage::info(t( + lang, + "No active session.", + "没有活跃的会话。", + )); + } + }; + + let pending = { + let mut bridge_guard = bridge.lock().await; + let session = match bridge_guard.get_mut(&connection_id) { + Some(s) => s, + None => { + return RichMessage::info(t( + lang, + "No active session found.", + "未找到活跃的会话。", + )); + } + }; + session.permission_pending.take() + }; + + let pending = match pending { + Some(p) => p, + None => { + return RichMessage::info(t( + lang, + "No pending permission request.", + "没有待处理的权限请求。", + )); + } + }; + + // Find the appropriate option_id + let option_id = if approve { + pending + .options + .iter() + .find(|o| o.kind == "allow" || o.kind == "allowForSession") + .or_else(|| pending.options.first()) + .map(|o| o.option_id.clone()) + } else { + pending + .options + .iter() + .find(|o| o.kind == "deny") + .or_else(|| pending.options.last()) + .map(|o| o.option_id.clone()) + }; + + let Some(option_id) = option_id else { + return RichMessage::info(t( + lang, + "No valid permission option found.", + "未找到有效的权限选项。", + )); + }; + + if let Err(e) = conn_mgr + .respond_permission(&connection_id, &pending.request_id, &option_id) + .await + { + return RichMessage::error(format!( + "{}{e}", + t( + lang, + "Failed to respond to permission: ", + "权限响应失败: " + ) + )); + } + + // Update auto_approve if requested + if always && approve { + let _ = + sender_context_service::update_auto_approve(db, channel_id, sender_id, true).await; + } + + let action = if approve { + t(lang, "Approved", "已批准") + } else { + t(lang, "Denied", "已拒绝") + }; + + let mut msg = RichMessage::info(format!("{}: {}", action, pending.tool_description)); + if always && approve { + msg = msg.with_field( + "", + t( + lang, + "Auto-approve enabled for this session.", + "已启用自动批准。", + ), + ); + } + msg.with_title(t(lang, "Permission Response", "权限响应")) +} + +// ── follow-up (non-command text) ── + +pub async fn handle_followup( + db: &DatabaseConnection, + text: &str, + channel_id: i32, + sender_id: &str, + conn_mgr: &ConnectionManager, + bridge: &Arc>, + lang: Lang, +) -> RichMessage { + let ctx = match sender_context_service::get_or_create(db, channel_id, sender_id).await { + Ok(c) => c, + Err(e) => return RichMessage::error(format!("Failed to load context: {e}")), + }; + + let connection_id = match &ctx.current_connection_id { + Some(id) => id.clone(), + None => { + return RichMessage::info(t( + lang, + "No active session. Use /task to start one.", + "没有活跃的会话,请使用 /task 开始新任务。", + )); + } + }; + + // Check connection exists in bridge + { + let bridge_guard = bridge.lock().await; + if bridge_guard.get(&connection_id).is_none() { + // Connection lost, clear context + drop(bridge_guard); + let _ = sender_context_service::clear_session(db, channel_id, sender_id).await; + return RichMessage::info(t( + lang, + "Session connection lost. Use /task to start a new one.", + "会话连接已断开,请使用 /task 开始新任务。", + )); + } + } + + // Send prompt to agent + let blocks = vec![PromptInputBlock::Text { + text: text.to_string(), + }]; + + if let Err(e) = conn_mgr.send_prompt(&connection_id, blocks).await { + // Connection may have died + bridge.lock().await.remove(&connection_id); + let _ = sender_context_service::clear_session(db, channel_id, sender_id).await; + return RichMessage::error(format!( + "{}{e}", + t(lang, "Failed to send message: ", "发送消息失败: ") + )); + } + + RichMessage::info(t(lang, "Message sent.", "消息已发送。")) +} + +// ── Helpers ── + +fn t(lang: Lang, en: &str, zh: &str) -> String { + match lang { + Lang::ZhCn | Lang::ZhTw => zh.to_string(), + _ => en.to_string(), + } +} + +fn agent_type_to_string(at: AgentType) -> String { + serde_json::to_value(at) + .ok() + .and_then(|v| v.as_str().map(String::from)) + .unwrap_or_default() +} + +fn parse_agent_type(name: &str) -> Option { + let normalized = name.to_lowercase().replace([' ', '-'], "_"); + serde_json::from_value(serde_json::Value::String(normalized)).ok() +} + +fn resolve_agent_type( + sender_agent: &Option, + folder_default: &Option, +) -> AgentType { + if let Some(ref at_str) = sender_agent { + if let Some(at) = parse_agent_type(at_str) { + return at; + } + } + if let Some(at) = folder_default { + return *at; + } + AgentType::ClaudeCode +} + +fn truncate_title(s: &str) -> String { + if s.len() <= 80 { + s.to_string() + } else { + format!("{}...", &s[..77]) + } +} diff --git a/src-tauri/src/chat_channel/session_event_subscriber.rs b/src-tauri/src/chat_channel/session_event_subscriber.rs new file mode 100644 index 0000000..3343629 --- /dev/null +++ b/src-tauri/src/chat_channel/session_event_subscriber.rs @@ -0,0 +1,458 @@ +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use sea_orm::DatabaseConnection; +use tokio::sync::Mutex; +use tokio::task::JoinHandle; + +use super::i18n::Lang; +use super::session_bridge::{PendingPermission, SessionBridge}; +use super::types::{MessageLevel, RichMessage}; +use crate::acp::manager::ConnectionManager; +use crate::acp::types::PromptInputBlock; +use crate::db::service::{ + app_metadata_service, conversation_service, sender_context_service, +}; +use crate::web::event_bridge::WebEventBroadcaster; + +use super::manager::ChatChannelManager; + +const FLUSH_INTERVAL_SECS: u64 = 5; +const BUFFER_FLUSH_THRESHOLD: usize = 500; +const MAX_MESSAGE_LEN: usize = 2000; +const MESSAGE_LANGUAGE_KEY: &str = "chat_message_language"; + +pub fn spawn_session_event_subscriber( + broadcaster: Arc, + bridge: Arc>, + manager: ChatChannelManager, + conn_mgr: ConnectionManager, + db_conn: DatabaseConnection, +) -> JoinHandle<()> { + let mut rx = broadcaster.subscribe(); + + tokio::spawn(async move { + let mut last_heartbeat = Instant::now(); + + loop { + tokio::select! { + result = rx.recv() => { + let event = match result { + Ok(e) => e, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + eprintln!("[SessionEventSub] lagged {n} events"); + continue; + } + Err(_) => break, + }; + + if event.channel == "acp://event" { + handle_acp_event_payload( + &event.payload, + &bridge, + &manager, + &conn_mgr, + &db_conn, + ) + .await; + } + } + _ = tokio::time::sleep(Duration::from_secs(FLUSH_INTERVAL_SECS)) => { + if last_heartbeat.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS) { + flush_progress(&bridge, &manager).await; + last_heartbeat = Instant::now(); + } + } + } + } + }) +} + +async fn get_lang(db: &DatabaseConnection) -> Lang { + app_metadata_service::get_value(db, MESSAGE_LANGUAGE_KEY) + .await + .ok() + .flatten() + .map(|v| Lang::from_str_lossy(&v)) + .unwrap_or_default() +} + +async fn handle_acp_event_payload( + payload: &serde_json::Value, + bridge: &Arc>, + manager: &ChatChannelManager, + conn_mgr: &ConnectionManager, + db: &DatabaseConnection, +) { + let event_type = match payload.get("type").and_then(|v| v.as_str()) { + Some(t) => t, + None => return, + }; + let connection_id = match payload.get("connection_id").and_then(|v| v.as_str()) { + Some(id) => id, + None => return, + }; + + match event_type { + "session_started" => { + let session_id = payload + .get("session_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let mut guard = bridge.lock().await; + if let Some(session) = guard.get_mut(connection_id) { + let _ = conversation_service::update_external_id( + db, + session.conversation_id, + session_id.to_string(), + ) + .await; + + if let Some(prompt_text) = session.pending_prompt.take() { + let blocks = vec![PromptInputBlock::Text { text: prompt_text }]; + if let Err(e) = conn_mgr.send_prompt(connection_id, blocks).await { + eprintln!("[SessionEventSub] failed to send pending prompt: {e}"); + let channel_id = session.channel_id; + let msg = RichMessage::error(format!("Failed to send task: {e}")); + let _ = manager.send_to_channel(channel_id, &msg).await; + } + } + } + } + + "content_delta" => { + let text = payload + .get("text") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let mut guard = bridge.lock().await; + if let Some(session) = guard.get_mut(connection_id) { + session.content_buffer.push_str(text); + + if session.content_buffer.len() >= BUFFER_FLUSH_THRESHOLD + && session.last_flushed.elapsed() >= Duration::from_secs(2) + { + let channel_id = session.channel_id; + let buf_len = session.content_buffer.len(); + let last_tool = session.tool_calls.last().cloned(); + session.last_flushed = Instant::now(); + + let mut status = format!("... ({buf_len} chars)"); + if let Some(tool) = last_tool { + status.push_str(&format!(" | {tool}")); + } + drop(guard); + + let msg = RichMessage::info(status); + let _ = manager.send_to_channel(channel_id, &msg).await; + } + } + } + + "tool_call" => { + let title = payload + .get("title") + .and_then(|v| v.as_str()) + .unwrap_or("tool"); + let status = payload + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + let mut guard = bridge.lock().await; + if let Some(session) = guard.get_mut(connection_id) { + session.tool_calls.push(title.to_string()); + let channel_id = session.channel_id; + drop(guard); + + if status != "completed" { + let msg = RichMessage::info(format!(">> {title}")); + let _ = manager.send_to_channel(channel_id, &msg).await; + } + } + } + + "tool_call_update" => { + let title = payload.get("title").and_then(|v| v.as_str()); + let status = payload.get("status").and_then(|v| v.as_str()); + + if let (Some(title), Some("completed")) = (title, status) { + let guard = bridge.lock().await; + if let Some(session) = guard.get(connection_id) { + let channel_id = session.channel_id; + drop(guard); + + let msg = RichMessage::info(format!(">> {title} [done]")); + let _ = manager.send_to_channel(channel_id, &msg).await; + } + } + } + + "permission_request" => { + let request_id = payload + .get("request_id") + .and_then(|v| v.as_str()) + .unwrap_or(""); + let tool_call = payload + .get("tool_call") + .cloned() + .unwrap_or(serde_json::Value::Null); + let options: Vec = payload + .get("options") + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + let mut guard = bridge.lock().await; + if let Some(session) = guard.get_mut(connection_id) { + let channel_id = session.channel_id; + let sender_id = session.sender_id.clone(); + + let auto_approve = sender_context_service::get_or_create( + db, + channel_id, + &sender_id, + ) + .await + .map(|ctx| ctx.auto_approve) + .unwrap_or(false); + + if auto_approve { + let option_id = options + .iter() + .find(|o| o.kind == "allow" || o.kind == "allowForSession") + .or_else(|| options.first()) + .map(|o| o.option_id.clone()); + + drop(guard); + + if let Some(oid) = option_id { + let _ = conn_mgr + .respond_permission(connection_id, request_id, &oid) + .await; + } + return; + } + + let tool_desc = tool_call + .get("title") + .and_then(|v| v.as_str()) + .or_else(|| tool_call.get("tool_name").and_then(|v| v.as_str())) + .unwrap_or("Unknown tool") + .to_string(); + + session.permission_pending = Some(PendingPermission { + request_id: request_id.to_string(), + tool_description: tool_desc.clone(), + options, + sent_message_id: None, + }); + + drop(guard); + + let lang = get_lang(db).await; + let body = match lang { + Lang::ZhCn | Lang::ZhTw => { + format!("Agent 请求权限: {tool_desc}\n\n/approve 批准 | /deny 拒绝 | /approve always 自动批准") + } + _ => { + format!("Agent requests permission: {tool_desc}\n\n/approve | /deny | /approve always") + } + }; + + let msg = RichMessage { + title: Some(match lang { + Lang::ZhCn | Lang::ZhTw => "权限请求".to_string(), + _ => "Permission Request".to_string(), + }), + body, + fields: Vec::new(), + level: MessageLevel::Warning, + }; + let _ = manager.send_to_channel(channel_id, &msg).await; + } + } + + "turn_complete" => { + let stop_reason = payload + .get("stop_reason") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let agent_type = payload + .get("agent_type") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + let mut guard = bridge.lock().await; + if let Some(session) = guard.get_mut(connection_id) { + let channel_id = session.channel_id; + let conv_id = session.conversation_id; + let content = std::mem::take(&mut session.content_buffer); + let tool_count = session.tool_calls.len(); + session.tool_calls.clear(); + session.last_flushed = Instant::now(); + drop(guard); + + let lang = get_lang(db).await; + let body = format_completion(&content, tool_count, lang); + + let msg = RichMessage::info(body) + .with_title(match lang { + Lang::ZhCn | Lang::ZhTw => "任务完成", + _ => "Turn Complete", + }) + .with_field("Agent", agent_type) + .with_field( + match lang { + Lang::ZhCn | Lang::ZhTw => "结束原因", + _ => "Stop Reason", + }, + stop_reason, + ); + + let _ = manager.send_to_channel(channel_id, &msg).await; + + if stop_reason == "end_turn" { + let _ = conversation_service::update_status( + db, + conv_id, + crate::db::entities::conversation::ConversationStatus::Completed, + ) + .await; + } + } + } + + "error" => { + let message = payload + .get("message") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown error"); + let agent_type = payload + .get("agent_type") + .and_then(|v| v.as_str()) + .unwrap_or("Unknown"); + + let mut guard = bridge.lock().await; + if let Some(session) = guard.remove(connection_id) { + let channel_id = session.channel_id; + let sender_id = session.sender_id.clone(); + let conv_id = session.conversation_id; + drop(guard); + + let lang = get_lang(db).await; + let msg = RichMessage { + title: Some(match lang { + Lang::ZhCn | Lang::ZhTw => "Agent 错误".to_string(), + _ => "Agent Error".to_string(), + }), + body: format!("[{agent_type}] {message}"), + fields: Vec::new(), + level: MessageLevel::Error, + }; + let _ = manager.send_to_channel(channel_id, &msg).await; + + let _ = conversation_service::update_status( + db, + conv_id, + crate::db::entities::conversation::ConversationStatus::Cancelled, + ) + .await; + let _ = + sender_context_service::clear_session(db, channel_id, &sender_id).await; + } + } + + "status_changed" => { + let status = payload + .get("status") + .and_then(|v| v.as_str()) + .unwrap_or(""); + + if status == "disconnected" || status == "error" { + let mut guard = bridge.lock().await; + if let Some(session) = guard.remove(connection_id) { + let channel_id = session.channel_id; + let sender_id = session.sender_id.clone(); + drop(guard); + + let _ = + sender_context_service::clear_session(db, channel_id, &sender_id).await; + } + } + } + + _ => {} + } +} + +async fn flush_progress(bridge: &Arc>, manager: &ChatChannelManager) { + let updates: Vec<(i32, String)> = { + let mut guard = bridge.lock().await; + let mut out = Vec::new(); + for session in guard.all_sessions_mut() { + if !session.content_buffer.is_empty() + && session.last_flushed.elapsed() >= Duration::from_secs(FLUSH_INTERVAL_SECS) + { + let buf_len = session.content_buffer.len(); + let tool_count = session.tool_calls.len(); + session.last_flushed = Instant::now(); + out.push(( + session.channel_id, + format!("... ({buf_len} chars, {tool_count} tools)"), + )); + } + } + out + }; + + for (channel_id, text) in updates { + let msg = RichMessage::info(text); + let _ = manager.send_to_channel(channel_id, &msg).await; + } +} + +fn format_completion(content: &str, tool_count: usize, lang: Lang) -> String { + if content.is_empty() { + return match lang { + Lang::ZhCn | Lang::ZhTw => format!("(无文本输出, {tool_count} 次工具调用)"), + _ => format!("(No text output, {tool_count} tool calls)"), + }; + } + + if content.len() <= MAX_MESSAGE_LEN { + let mut body = content.to_string(); + if tool_count > 0 { + body.push_str(&format!( + "\n\n[{} {}]", + tool_count, + match lang { + Lang::ZhCn | Lang::ZhTw => "次工具调用", + _ => "tool calls", + } + )); + } + return body; + } + + // Truncate long content + let head = &content[..500.min(content.len())]; + let tail_start = content.len().saturating_sub(500); + let tail = &content[tail_start..]; + + match lang { + Lang::ZhCn | Lang::ZhTw => { + format!( + "{head}\n\n...\n\n{tail}\n\n[完整回复: {} 字符, {tool_count} 次工具调用]", + content.len() + ) + } + _ => { + format!( + "{head}\n\n...\n\n{tail}\n\n[Full response: {} chars, {tool_count} tool calls]", + content.len() + ) + } + } +} diff --git a/src-tauri/src/chat_channel/types.rs b/src-tauri/src/chat_channel/types.rs index fe8533c..edc4fcf 100644 --- a/src-tauri/src/chat_channel/types.rs +++ b/src-tauri/src/chat_channel/types.rs @@ -74,6 +74,15 @@ impl RichMessage { } } + pub fn error(body: impl Into) -> Self { + Self { + title: None, + body: body.into(), + fields: Vec::new(), + level: MessageLevel::Error, + } + } + pub fn with_title(mut self, title: impl Into) -> Self { self.title = Some(title.into()); self diff --git a/src-tauri/src/db/entities/chat_channel_sender_context.rs b/src-tauri/src/db/entities/chat_channel_sender_context.rs new file mode 100644 index 0000000..fdd99bf --- /dev/null +++ b/src-tauri/src/db/entities/chat_channel_sender_context.rs @@ -0,0 +1,35 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, DeriveEntityModel)] +#[sea_orm(table_name = "chat_channel_sender_context")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub channel_id: i32, + pub sender_id: String, + pub current_folder_id: Option, + pub current_agent_type: Option, + pub current_conversation_id: Option, + pub current_connection_id: Option, + pub auto_approve: bool, + pub created_at: DateTimeUtc, + pub updated_at: DateTimeUtc, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::chat_channel::Entity", + from = "Column::ChannelId", + to = "super::chat_channel::Column::Id" + )] + ChatChannel, +} + +impl Related for Entity { + fn to() -> RelationDef { + Relation::ChatChannel.def() + } +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/src-tauri/src/db/entities/mod.rs b/src-tauri/src/db/entities/mod.rs index b826120..66b4c61 100644 --- a/src-tauri/src/db/entities/mod.rs +++ b/src-tauri/src/db/entities/mod.rs @@ -2,6 +2,7 @@ pub mod agent_setting; pub mod app_metadata; pub mod chat_channel; pub mod chat_channel_message_log; +pub mod chat_channel_sender_context; pub mod conversation; pub mod folder; pub mod folder_command; diff --git a/src-tauri/src/db/entities/prelude.rs b/src-tauri/src/db/entities/prelude.rs index a8a775d..ae0d71f 100644 --- a/src-tauri/src/db/entities/prelude.rs +++ b/src-tauri/src/db/entities/prelude.rs @@ -4,6 +4,7 @@ pub use super::agent_setting::Entity as AgentSetting; pub use super::app_metadata::Entity as AppMetadata; pub use super::chat_channel::Entity as ChatChannel; pub use super::chat_channel_message_log::Entity as ChatChannelMessageLog; +pub use super::chat_channel_sender_context::Entity as ChatChannelSenderContext; pub use super::conversation::Entity as Conversation; pub use super::folder::Entity as Folder; pub use super::folder_command::Entity as FolderCommand; diff --git a/src-tauri/src/db/migration/m20260401_000001_chat_channel_sender_context.rs b/src-tauri/src/db/migration/m20260401_000001_chat_channel_sender_context.rs new file mode 100644 index 0000000..7956741 --- /dev/null +++ b/src-tauri/src/db/migration/m20260401_000001_chat_channel_sender_context.rs @@ -0,0 +1,124 @@ +use sea_orm_migration::prelude::*; + +#[derive(DeriveMigrationName)] +pub struct Migration; + +#[async_trait::async_trait] +impl MigrationTrait for Migration { + async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .create_table( + Table::create() + .table(ChatChannelSenderContext::Table) + .if_not_exists() + .col( + ColumnDef::new(ChatChannelSenderContext::Id) + .integer() + .not_null() + .auto_increment() + .primary_key(), + ) + .col( + ColumnDef::new(ChatChannelSenderContext::ChannelId) + .integer() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannelSenderContext::SenderId) + .string() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannelSenderContext::CurrentFolderId) + .integer() + .null(), + ) + .col( + ColumnDef::new(ChatChannelSenderContext::CurrentAgentType) + .string() + .null(), + ) + .col( + ColumnDef::new(ChatChannelSenderContext::CurrentConversationId) + .integer() + .null(), + ) + .col( + ColumnDef::new(ChatChannelSenderContext::CurrentConnectionId) + .string() + .null(), + ) + .col( + ColumnDef::new(ChatChannelSenderContext::AutoApprove) + .boolean() + .not_null() + .default(false), + ) + .col( + ColumnDef::new(ChatChannelSenderContext::CreatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .col( + ColumnDef::new(ChatChannelSenderContext::UpdatedAt) + .timestamp_with_time_zone() + .not_null(), + ) + .foreign_key( + ForeignKey::create() + .name("fk_ccsc_channel_id") + .from( + ChatChannelSenderContext::Table, + ChatChannelSenderContext::ChannelId, + ) + .to(ChatChannel::Table, ChatChannel::Id) + .on_delete(ForeignKeyAction::Cascade), + ) + .to_owned(), + ) + .await?; + + manager + .create_index( + Index::create() + .name("idx_ccsc_channel_sender") + .table(ChatChannelSenderContext::Table) + .col(ChatChannelSenderContext::ChannelId) + .col(ChatChannelSenderContext::SenderId) + .unique() + .to_owned(), + ) + .await + } + + async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { + manager + .drop_table( + Table::drop() + .table(ChatChannelSenderContext::Table) + .to_owned(), + ) + .await + } +} + +#[derive(DeriveIden)] +enum ChatChannelSenderContext { + Table, + Id, + ChannelId, + SenderId, + CurrentFolderId, + CurrentAgentType, + CurrentConversationId, + CurrentConnectionId, + AutoApprove, + CreatedAt, + UpdatedAt, +} + +#[derive(DeriveIden)] +enum ChatChannel { + Table, + Id, +} diff --git a/src-tauri/src/db/migration/mod.rs b/src-tauri/src/db/migration/mod.rs index 6e2fa02..12b10e0 100644 --- a/src-tauri/src/db/migration/mod.rs +++ b/src-tauri/src/db/migration/mod.rs @@ -6,6 +6,7 @@ mod m20260221_000001_folder_is_open; mod m20260226_000001_agent_setting; mod m20260227_000001_folder_parent_branch; mod m20260330_000001_chat_channel; +mod m20260401_000001_chat_channel_sender_context; pub struct Migrator; #[async_trait::async_trait] @@ -18,6 +19,7 @@ impl MigratorTrait for Migrator { Box::new(m20260226_000001_agent_setting::Migration), Box::new(m20260227_000001_folder_parent_branch::Migration), Box::new(m20260330_000001_chat_channel::Migration), + Box::new(m20260401_000001_chat_channel_sender_context::Migration), ] } } diff --git a/src-tauri/src/db/service/mod.rs b/src-tauri/src/db/service/mod.rs index 97949ec..a23c6d1 100644 --- a/src-tauri/src/db/service/mod.rs +++ b/src-tauri/src/db/service/mod.rs @@ -6,3 +6,4 @@ pub mod conversation_service; pub mod folder_command_service; pub mod folder_service; pub mod import_service; +pub mod sender_context_service; diff --git a/src-tauri/src/db/service/sender_context_service.rs b/src-tauri/src/db/service/sender_context_service.rs new file mode 100644 index 0000000..97aae58 --- /dev/null +++ b/src-tauri/src/db/service/sender_context_service.rs @@ -0,0 +1,101 @@ +use chrono::Utc; +use sea_orm::{ + ActiveModelTrait, ActiveValue::NotSet, ColumnTrait, DatabaseConnection, EntityTrait, + IntoActiveModel, QueryFilter, Set, +}; + +use crate::db::entities::chat_channel_sender_context; +use crate::db::error::DbError; + +pub async fn get_or_create( + conn: &DatabaseConnection, + channel_id: i32, + sender_id: &str, +) -> Result { + let existing = chat_channel_sender_context::Entity::find() + .filter(chat_channel_sender_context::Column::ChannelId.eq(channel_id)) + .filter(chat_channel_sender_context::Column::SenderId.eq(sender_id)) + .one(conn) + .await?; + + if let Some(model) = existing { + return Ok(model); + } + + let now = Utc::now(); + let active = chat_channel_sender_context::ActiveModel { + id: NotSet, + channel_id: Set(channel_id), + sender_id: Set(sender_id.to_string()), + current_folder_id: Set(None), + current_agent_type: Set(None), + current_conversation_id: Set(None), + current_connection_id: Set(None), + auto_approve: Set(false), + created_at: Set(now), + updated_at: Set(now), + }; + Ok(active.insert(conn).await?) +} + +pub async fn update_folder( + conn: &DatabaseConnection, + channel_id: i32, + sender_id: &str, + folder_id: Option, +) -> Result { + let model = get_or_create(conn, channel_id, sender_id).await?; + let mut active = model.into_active_model(); + active.current_folder_id = Set(folder_id); + active.updated_at = Set(Utc::now()); + Ok(active.update(conn).await?) +} + +pub async fn update_agent( + conn: &DatabaseConnection, + channel_id: i32, + sender_id: &str, + agent_type: Option, +) -> Result { + let model = get_or_create(conn, channel_id, sender_id).await?; + let mut active = model.into_active_model(); + active.current_agent_type = Set(agent_type); + active.updated_at = Set(Utc::now()); + Ok(active.update(conn).await?) +} + +pub async fn update_session( + conn: &DatabaseConnection, + channel_id: i32, + sender_id: &str, + conversation_id: Option, + connection_id: Option, +) -> Result { + let model = get_or_create(conn, channel_id, sender_id).await?; + let mut active = model.into_active_model(); + active.current_conversation_id = Set(conversation_id); + active.current_connection_id = Set(connection_id); + active.updated_at = Set(Utc::now()); + Ok(active.update(conn).await?) +} + +pub async fn clear_session( + conn: &DatabaseConnection, + channel_id: i32, + sender_id: &str, +) -> Result { + update_session(conn, channel_id, sender_id, None, None).await +} + +pub async fn update_auto_approve( + conn: &DatabaseConnection, + channel_id: i32, + sender_id: &str, + auto_approve: bool, +) -> Result { + let model = get_or_create(conn, channel_id, sender_id).await?; + let mut active = model.into_active_model(); + active.auto_approve = Set(auto_approve); + active.updated_at = Set(Utc::now()); + Ok(active.update(conn).await?) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 59e7b17..ab22db5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -90,8 +90,10 @@ mod tauri_app { let db_conn = app.state::().conn.clone(); let ccm_ref = ccm.clone_ref(); let br = broadcaster.inner().clone(); + let cm = app.state::().clone_ref(); + let emitter = web::event_bridge::EventEmitter::Tauri(app.handle().clone()); tauri::async_runtime::spawn(async move { - ccm_ref.start_background(br, db_conn).await; + ccm_ref.start_background(br, db_conn, cm, emitter).await; }); } diff --git a/src/components/settings/channel-commands-tab.tsx b/src/components/settings/channel-commands-tab.tsx index 412f71d..bf8cc6f 100644 --- a/src/components/settings/channel-commands-tab.tsx +++ b/src/components/settings/channel-commands-tab.tsx @@ -10,6 +10,14 @@ import { Input } from "@/components/ui/input" import { getChatCommandPrefix, setChatCommandPrefix } from "@/lib/api" const BUILT_IN_COMMANDS = [ + { name: "folder [n|path]", descKey: "folderDesc" }, + { name: "agent [n|name]", descKey: "agentDesc" }, + { name: "task ", descKey: "taskDesc" }, + { name: "sessions", descKey: "sessionsDesc" }, + { name: "resume ", descKey: "resumeDesc" }, + { name: "cancel", descKey: "cancelDesc" }, + { name: "approve [always]", descKey: "approveDesc" }, + { name: "deny", descKey: "denyDesc" }, { name: "recent", descKey: "recentDesc" }, { name: "search ", descKey: "searchDesc" }, { name: "detail ", descKey: "detailDesc" }, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 152eb51..a0497e1 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "فشل حفظ بادئة الأمر.", "prefixInvalid": "يجب أن تكون البادئة 1-3 أحرف غير أبجدية رقمية.", "save": "حفظ", + "folderDesc": "اختيار مجلد العمل", + "agentDesc": "اختيار وكيل الذكاء الاصطناعي", + "taskDesc": "إنشاء جلسة وتنفيذ المهمة", + "sessionsDesc": "عرض الجلسات النشطة في المجلد", + "resumeDesc": "استئناف جلسة موجودة", + "cancelDesc": "إلغاء المهمة الحالية", + "approveDesc": "الموافقة على طلب إذن الوكيل", + "denyDesc": "رفض طلب إذن الوكيل", "recentDesc": "عرض آخر 5 محادثات", "searchDesc": "البحث في المحادثات حسب الكلمة المفتاحية", "detailDesc": "عرض تفاصيل المحادثة", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index d44c854..f916deb 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -1055,7 +1055,7 @@ "pushWindow": { "title": "Code pushen", "noUnpushedCommits": "Keine ungepushten Commits", - "noRemoteConfigured": "Kein Git-Remote konfiguriert\nFüge einen unter \u201ERemotes verwalten\u201C hinzu", + "noRemoteConfigured": "Kein Git-Remote konfiguriert\nFüge einen unter Remotes verwalten hinzu", "newBranchNoPushedCommits": "Neuer Branch — pushen, um Remote-Tracking-Branch zu erstellen", "unpushed": "Nicht gepusht", "selectFileToViewDiff": "Datei auswählen, um Unterschiede anzuzeigen", @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "Fehler beim Speichern des Präfixes.", "prefixInvalid": "Das Präfix muss 1-3 nicht-alphanumerische Zeichen sein.", "save": "Speichern", + "folderDesc": "Arbeitsordner auswählen", + "agentDesc": "KI-Agent auswählen", + "taskDesc": "Sitzung erstellen und Aufgabe ausführen", + "sessionsDesc": "Aktive Sitzungen im Ordner anzeigen", + "resumeDesc": "Bestehende Sitzung fortsetzen", + "cancelDesc": "Aktuelle Aufgabe abbrechen", + "approveDesc": "Berechtigungsanfrage des Agenten genehmigen", + "denyDesc": "Berechtigungsanfrage des Agenten ablehnen", "recentDesc": "Die 5 neuesten Konversationen anzeigen", "searchDesc": "Konversationen nach Stichwort suchen", "detailDesc": "Konversationsdetails anzeigen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 1dbd71f..528b24c 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "Failed to save command prefix.", "prefixInvalid": "Prefix must be 1-3 non-alphanumeric characters.", "save": "Save", + "folderDesc": "Select working folder", + "agentDesc": "Select AI agent", + "taskDesc": "Create session and run task", + "sessionsDesc": "List active sessions in folder", + "resumeDesc": "Resume an existing session", + "cancelDesc": "Cancel current task", + "approveDesc": "Approve agent permission request", + "denyDesc": "Deny agent permission request", "recentDesc": "Show 5 most recent conversations", "searchDesc": "Search conversations by keyword", "detailDesc": "Show conversation details", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 349b1f6..9ddee42 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "Error al guardar el prefijo.", "prefixInvalid": "El prefijo debe ser de 1-3 caracteres no alfanuméricos.", "save": "Guardar", + "folderDesc": "Seleccionar carpeta de trabajo", + "agentDesc": "Seleccionar agente de IA", + "taskDesc": "Crear sesión y ejecutar tarea", + "sessionsDesc": "Listar sesiones activas en la carpeta", + "resumeDesc": "Reanudar una sesión existente", + "cancelDesc": "Cancelar tarea actual", + "approveDesc": "Aprobar solicitud de permiso del agente", + "denyDesc": "Denegar solicitud de permiso del agente", "recentDesc": "Mostrar las 5 conversaciones más recientes", "searchDesc": "Buscar conversaciones por palabra clave", "detailDesc": "Mostrar detalles de la conversación", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 7f78fe6..cd9d272 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "Échec de l'enregistrement du préfixe.", "prefixInvalid": "Le préfixe doit être de 1-3 caractères non alphanumériques.", "save": "Enregistrer", + "folderDesc": "Sélectionner le dossier de travail", + "agentDesc": "Sélectionner l'agent IA", + "taskDesc": "Créer une session et exécuter la tâche", + "sessionsDesc": "Lister les sessions actives du dossier", + "resumeDesc": "Reprendre une session existante", + "cancelDesc": "Annuler la tâche en cours", + "approveDesc": "Approuver la demande de permission de l'agent", + "denyDesc": "Refuser la demande de permission de l'agent", "recentDesc": "Afficher les 5 conversations les plus récentes", "searchDesc": "Rechercher des conversations par mot-clé", "detailDesc": "Afficher les détails de la conversation", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 39533eb..e4b75ea 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "コマンドプレフィックスの保存に失敗しました。", "prefixInvalid": "プレフィックスは1-3文字の英数字以外の文字である必要があります。", "save": "保存", + "folderDesc": "作業フォルダを選択", + "agentDesc": "AIエージェントを選択", + "taskDesc": "セッションを作成してタスクを実行", + "sessionsDesc": "フォルダ内のアクティブなセッション一覧", + "resumeDesc": "既存のセッションを再開", + "cancelDesc": "現在のタスクをキャンセル", + "approveDesc": "エージェントの権限リクエストを承認", + "denyDesc": "エージェントの権限リクエストを拒否", "recentDesc": "最近の会話5件を表示", "searchDesc": "キーワードで会話を検索", "detailDesc": "会話の詳細を表示", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 14406bd..e162892 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "명령어 접두사 저장에 실패했습니다.", "prefixInvalid": "접두사는 1-3개의 영숫자가 아닌 문자여야 합니다.", "save": "저장", + "folderDesc": "작업 폴더 선택", + "agentDesc": "AI 에이전트 선택", + "taskDesc": "세션 생성 및 작업 실행", + "sessionsDesc": "폴더 내 활성 세션 목록", + "resumeDesc": "기존 세션 재개", + "cancelDesc": "현재 작업 취소", + "approveDesc": "에이전트 권한 요청 승인", + "denyDesc": "에이전트 권한 요청 거부", "recentDesc": "최근 대화 5개 표시", "searchDesc": "키워드로 대화 검색", "detailDesc": "대화 상세 정보 표시", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 27413d8..135664b 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "Falha ao salvar o prefixo.", "prefixInvalid": "O prefixo deve ser de 1-3 caracteres não alfanuméricos.", "save": "Salvar", + "folderDesc": "Selecionar pasta de trabalho", + "agentDesc": "Selecionar agente de IA", + "taskDesc": "Criar sessão e executar tarefa", + "sessionsDesc": "Listar sessões ativas na pasta", + "resumeDesc": "Retomar uma sessão existente", + "cancelDesc": "Cancelar tarefa atual", + "approveDesc": "Aprovar solicitação de permissão do agente", + "denyDesc": "Negar solicitação de permissão do agente", "recentDesc": "Mostrar as 5 conversas mais recentes", "searchDesc": "Pesquisar conversas por palavra-chave", "detailDesc": "Mostrar detalhes da conversa", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index d62b451..16de7e1 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "保存指令前缀失败。", "prefixInvalid": "前缀必须是 1-3 个非字母数字字符。", "save": "保存", + "folderDesc": "选择工作目录", + "agentDesc": "选择 AI Agent", + "taskDesc": "创建会话并执行任务", + "sessionsDesc": "列出当前目录的活跃会话", + "resumeDesc": "恢复已有会话", + "cancelDesc": "取消当前任务", + "approveDesc": "批准 Agent 权限请求", + "denyDesc": "拒绝 Agent 权限请求", "recentDesc": "最近 5 条会话", "searchDesc": "按关键词搜索会话", "detailDesc": "会话详情", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d008b63..6648265 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -1728,6 +1728,14 @@ "prefixSaveFailed": "儲存指令前綴失敗。", "prefixInvalid": "前綴必須是 1-3 個非字母數字字元。", "save": "儲存", + "folderDesc": "選擇工作目錄", + "agentDesc": "選擇 AI Agent", + "taskDesc": "建立會話並執行任務", + "sessionsDesc": "列出當前目錄的活躍會話", + "resumeDesc": "恢復已有會話", + "cancelDesc": "取消當前任務", + "approveDesc": "批准 Agent 權限請求", + "denyDesc": "拒絕 Agent 權限請求", "recentDesc": "最近 5 筆對話", "searchDesc": "依關鍵字搜尋對話", "detailDesc": "對話詳情",