消息渠道支持会话相关指令
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -162,6 +162,9 @@ impl ChatChannelBackend for TelegramBackend {
|
||||
|
||||
if let Ok(body) = resp.json::<serde_json::Value>().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) => {
|
||||
|
||||
@@ -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<IncomingCommand>,
|
||||
manager: ChatChannelManager,
|
||||
db_conn: DatabaseConnection,
|
||||
conn_mgr: ConnectionManager,
|
||||
emitter: EventEmitter,
|
||||
bridge: Arc<Mutex<SessionBridge>>,
|
||||
) -> 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<Mutex<SessionBridge>>,
|
||||
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)),
|
||||
}
|
||||
|
||||
@@ -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 <ID> - 恢复已有会话\n\
|
||||
⏹️ {prefix}cancel - 取消当前任务\n\
|
||||
✅ {prefix}approve [always] - 批准权限请求\n\
|
||||
❌ {prefix}deny - 拒绝权限请求\n\
|
||||
\n\
|
||||
{prefix}recent - 最近 5 条会话\n\
|
||||
{prefix}search <关键词> - 搜索会话\n\
|
||||
{prefix}detail <ID> - 会话详情\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 <desc> - Create session & run task\n\
|
||||
📋 {prefix}sessions - Active sessions in folder\n\
|
||||
▶️ {prefix}resume <ID> - 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 <keyword> - Search conversations\n\
|
||||
{prefix}detail <ID> - 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"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<WebEventBroadcaster>,
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
75
src-tauri/src/chat_channel/session_bridge.rs
Normal file
75
src-tauri/src/chat_channel/session_bridge.rs
Normal file
@@ -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<PermissionOptionInfo>,
|
||||
pub sent_message_id: Option<SentMessageId>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub last_flushed: Instant,
|
||||
pub pending_prompt: Option<String>,
|
||||
pub permission_pending: Option<PendingPermission>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SessionBridge {
|
||||
sessions: HashMap<String, ActiveSession>,
|
||||
}
|
||||
|
||||
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<ActiveSession> {
|
||||
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<Item = &ActiveSession> {
|
||||
self.sessions.values()
|
||||
}
|
||||
|
||||
pub fn all_sessions_mut(&mut self) -> impl Iterator<Item = &mut ActiveSession> {
|
||||
self.sessions.values_mut()
|
||||
}
|
||||
}
|
||||
848
src-tauri/src/chat_channel/session_commands.rs
Normal file
848
src-tauri/src/chat_channel/session_commands.rs
Normal file
@@ -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::<usize>() {
|
||||
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 <number> 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::<usize>() {
|
||||
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 <number> or /agent <name> 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<Mutex<SessionBridge>>,
|
||||
lang: Lang,
|
||||
) -> RichMessage {
|
||||
if task_description.is_empty() {
|
||||
return RichMessage::info(t(
|
||||
lang,
|
||||
"Usage: /task <description>",
|
||||
"用法: /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 <id> to continue.",
|
||||
"回复 /resume <ID> 继续会话。"
|
||||
)
|
||||
));
|
||||
|
||||
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<Mutex<SessionBridge>>,
|
||||
lang: Lang,
|
||||
) -> RichMessage {
|
||||
let conversation_id: i32 = match args.parse() {
|
||||
Ok(id) => id,
|
||||
Err(_) => {
|
||||
return RichMessage::info(t(
|
||||
lang,
|
||||
"Usage: /resume <conversation_id>",
|
||||
"用法: /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<Mutex<SessionBridge>>,
|
||||
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<Mutex<SessionBridge>>,
|
||||
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<Mutex<SessionBridge>>,
|
||||
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<AgentType> {
|
||||
let normalized = name.to_lowercase().replace([' ', '-'], "_");
|
||||
serde_json::from_value(serde_json::Value::String(normalized)).ok()
|
||||
}
|
||||
|
||||
fn resolve_agent_type(
|
||||
sender_agent: &Option<String>,
|
||||
folder_default: &Option<AgentType>,
|
||||
) -> 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])
|
||||
}
|
||||
}
|
||||
458
src-tauri/src/chat_channel/session_event_subscriber.rs
Normal file
458
src-tauri/src/chat_channel/session_event_subscriber.rs
Normal file
@@ -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<WebEventBroadcaster>,
|
||||
bridge: Arc<Mutex<SessionBridge>>,
|
||||
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<Mutex<SessionBridge>>,
|
||||
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<crate::acp::types::PermissionOptionInfo> = 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<Mutex<SessionBridge>>, 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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,15 @@ impl RichMessage {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn error(body: impl Into<String>) -> Self {
|
||||
Self {
|
||||
title: None,
|
||||
body: body.into(),
|
||||
fields: Vec::new(),
|
||||
level: MessageLevel::Error,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_title(mut self, title: impl Into<String>) -> Self {
|
||||
self.title = Some(title.into());
|
||||
self
|
||||
|
||||
35
src-tauri/src/db/entities/chat_channel_sender_context.rs
Normal file
35
src-tauri/src/db/entities/chat_channel_sender_context.rs
Normal file
@@ -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<i32>,
|
||||
pub current_agent_type: Option<String>,
|
||||
pub current_conversation_id: Option<i32>,
|
||||
pub current_connection_id: Option<String>,
|
||||
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<super::chat_channel::Entity> for Entity {
|
||||
fn to() -> RelationDef {
|
||||
Relation::ChatChannel.def()
|
||||
}
|
||||
}
|
||||
|
||||
impl ActiveModelBehavior for ActiveModel {}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
101
src-tauri/src/db/service/sender_context_service.rs
Normal file
101
src-tauri/src/db/service/sender_context_service.rs
Normal file
@@ -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<chat_channel_sender_context::Model, DbError> {
|
||||
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<i32>,
|
||||
) -> Result<chat_channel_sender_context::Model, DbError> {
|
||||
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<String>,
|
||||
) -> Result<chat_channel_sender_context::Model, DbError> {
|
||||
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<i32>,
|
||||
connection_id: Option<String>,
|
||||
) -> Result<chat_channel_sender_context::Model, DbError> {
|
||||
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<chat_channel_sender_context::Model, DbError> {
|
||||
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<chat_channel_sender_context::Model, DbError> {
|
||||
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?)
|
||||
}
|
||||
@@ -90,8 +90,10 @@ mod tauri_app {
|
||||
let db_conn = app.state::<db::AppDatabase>().conn.clone();
|
||||
let ccm_ref = ccm.clone_ref();
|
||||
let br = broadcaster.inner().clone();
|
||||
let cm = app.state::<ConnectionManager>().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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 <description>", descKey: "taskDesc" },
|
||||
{ name: "sessions", descKey: "sessionsDesc" },
|
||||
{ name: "resume <id>", descKey: "resumeDesc" },
|
||||
{ name: "cancel", descKey: "cancelDesc" },
|
||||
{ name: "approve [always]", descKey: "approveDesc" },
|
||||
{ name: "deny", descKey: "denyDesc" },
|
||||
{ name: "recent", descKey: "recentDesc" },
|
||||
{ name: "search <keyword>", descKey: "searchDesc" },
|
||||
{ name: "detail <id>", descKey: "detailDesc" },
|
||||
|
||||
@@ -1728,6 +1728,14 @@
|
||||
"prefixSaveFailed": "فشل حفظ بادئة الأمر.",
|
||||
"prefixInvalid": "يجب أن تكون البادئة 1-3 أحرف غير أبجدية رقمية.",
|
||||
"save": "حفظ",
|
||||
"folderDesc": "اختيار مجلد العمل",
|
||||
"agentDesc": "اختيار وكيل الذكاء الاصطناعي",
|
||||
"taskDesc": "إنشاء جلسة وتنفيذ المهمة",
|
||||
"sessionsDesc": "عرض الجلسات النشطة في المجلد",
|
||||
"resumeDesc": "استئناف جلسة موجودة",
|
||||
"cancelDesc": "إلغاء المهمة الحالية",
|
||||
"approveDesc": "الموافقة على طلب إذن الوكيل",
|
||||
"denyDesc": "رفض طلب إذن الوكيل",
|
||||
"recentDesc": "عرض آخر 5 محادثات",
|
||||
"searchDesc": "البحث في المحادثات حسب الكلمة المفتاحية",
|
||||
"detailDesc": "عرض تفاصيل المحادثة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1728,6 +1728,14 @@
|
||||
"prefixSaveFailed": "コマンドプレフィックスの保存に失敗しました。",
|
||||
"prefixInvalid": "プレフィックスは1-3文字の英数字以外の文字である必要があります。",
|
||||
"save": "保存",
|
||||
"folderDesc": "作業フォルダを選択",
|
||||
"agentDesc": "AIエージェントを選択",
|
||||
"taskDesc": "セッションを作成してタスクを実行",
|
||||
"sessionsDesc": "フォルダ内のアクティブなセッション一覧",
|
||||
"resumeDesc": "既存のセッションを再開",
|
||||
"cancelDesc": "現在のタスクをキャンセル",
|
||||
"approveDesc": "エージェントの権限リクエストを承認",
|
||||
"denyDesc": "エージェントの権限リクエストを拒否",
|
||||
"recentDesc": "最近の会話5件を表示",
|
||||
"searchDesc": "キーワードで会話を検索",
|
||||
"detailDesc": "会話の詳細を表示",
|
||||
|
||||
@@ -1728,6 +1728,14 @@
|
||||
"prefixSaveFailed": "명령어 접두사 저장에 실패했습니다.",
|
||||
"prefixInvalid": "접두사는 1-3개의 영숫자가 아닌 문자여야 합니다.",
|
||||
"save": "저장",
|
||||
"folderDesc": "작업 폴더 선택",
|
||||
"agentDesc": "AI 에이전트 선택",
|
||||
"taskDesc": "세션 생성 및 작업 실행",
|
||||
"sessionsDesc": "폴더 내 활성 세션 목록",
|
||||
"resumeDesc": "기존 세션 재개",
|
||||
"cancelDesc": "현재 작업 취소",
|
||||
"approveDesc": "에이전트 권한 요청 승인",
|
||||
"denyDesc": "에이전트 권한 요청 거부",
|
||||
"recentDesc": "최근 대화 5개 표시",
|
||||
"searchDesc": "키워드로 대화 검색",
|
||||
"detailDesc": "대화 상세 정보 표시",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "会话详情",
|
||||
|
||||
@@ -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": "對話詳情",
|
||||
|
||||
Reference in New Issue
Block a user