消息渠道支持会话相关指令

This commit is contained in:
xintaofei
2026-04-01 10:08:20 +08:00
parent 8d8eadb994
commit 58611a6bc1
29 changed files with 1915 additions and 14 deletions

View File

@@ -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

View File

@@ -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) => {

View File

@@ -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)),
}

View File

@@ -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"
),
}
}

View File

@@ -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

View File

@@ -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;

View 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()
}
}

View 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])
}
}

View 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()
)
}
}
}

View File

@@ -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

View 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 {}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
}

View File

@@ -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),
]
}
}

View File

@@ -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;

View 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?)
}

View File

@@ -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;
});
}

View File

@@ -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" },

View File

@@ -1728,6 +1728,14 @@
"prefixSaveFailed": "فشل حفظ بادئة الأمر.",
"prefixInvalid": "يجب أن تكون البادئة 1-3 أحرف غير أبجدية رقمية.",
"save": "حفظ",
"folderDesc": "اختيار مجلد العمل",
"agentDesc": "اختيار وكيل الذكاء الاصطناعي",
"taskDesc": "إنشاء جلسة وتنفيذ المهمة",
"sessionsDesc": "عرض الجلسات النشطة في المجلد",
"resumeDesc": "استئناف جلسة موجودة",
"cancelDesc": "إلغاء المهمة الحالية",
"approveDesc": "الموافقة على طلب إذن الوكيل",
"denyDesc": "رفض طلب إذن الوكيل",
"recentDesc": "عرض آخر 5 محادثات",
"searchDesc": "البحث في المحادثات حسب الكلمة المفتاحية",
"detailDesc": "عرض تفاصيل المحادثة",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",

View File

@@ -1728,6 +1728,14 @@
"prefixSaveFailed": "コマンドプレフィックスの保存に失敗しました。",
"prefixInvalid": "プレフィックスは1-3文字の英数字以外の文字である必要があります。",
"save": "保存",
"folderDesc": "作業フォルダを選択",
"agentDesc": "AIエージェントを選択",
"taskDesc": "セッションを作成してタスクを実行",
"sessionsDesc": "フォルダ内のアクティブなセッション一覧",
"resumeDesc": "既存のセッションを再開",
"cancelDesc": "現在のタスクをキャンセル",
"approveDesc": "エージェントの権限リクエストを承認",
"denyDesc": "エージェントの権限リクエストを拒否",
"recentDesc": "最近の会話5件を表示",
"searchDesc": "キーワードで会話を検索",
"detailDesc": "会話の詳細を表示",

View File

@@ -1728,6 +1728,14 @@
"prefixSaveFailed": "명령어 접두사 저장에 실패했습니다.",
"prefixInvalid": "접두사는 1-3개의 영숫자가 아닌 문자여야 합니다.",
"save": "저장",
"folderDesc": "작업 폴더 선택",
"agentDesc": "AI 에이전트 선택",
"taskDesc": "세션 생성 및 작업 실행",
"sessionsDesc": "폴더 내 활성 세션 목록",
"resumeDesc": "기존 세션 재개",
"cancelDesc": "현재 작업 취소",
"approveDesc": "에이전트 권한 요청 승인",
"denyDesc": "에이전트 권한 요청 거부",
"recentDesc": "최근 대화 5개 표시",
"searchDesc": "키워드로 대화 검색",
"detailDesc": "대화 상세 정보 표시",

View File

@@ -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",

View File

@@ -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": "会话详情",

View File

@@ -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": "對話詳情",