use std::sync::Arc;
use async_trait::async_trait;
use robit_agent::error::Result;
use robit_agent::event::AgentEvent;
use robit_agent::frontend::Frontend;
use robit_agent::tool::ToolCallInfo;
use tokio::sync::Mutex;
use crate::adapter::{PlatformCaps, SendResult, UploadResult};
use crate::confirmer::Confirmer;
use crate::markdown::prepare_markdown_for_platform;
#[async_trait]
pub trait PlatformSender: Send + Sync {
async fn send(&self, chat_id: &str, text: &str) -> Result<SendResult>;
async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> Result<()>;
async fn upload_file(&self, chat_id: &str, file_path: &str, media_type: &str) -> Result<UploadResult>;
async fn send_media_message(&self, chat_id: &str, file_url: &str, file_name: &str, media_type: &str) -> Result<SendResult>;
fn capabilities(&self) -> PlatformCaps;
}
#[async_trait]
pub trait PlatformExt: Send + Sync {
async fn upload_file(&self, file_path: &str, media_type: &str) -> Result<UploadResult>;
async fn send_media_message(&self, file_url: &str, file_name: &str, media_type: &str) -> Result<SendResult>;
}
pub struct ChatbotFrontend {
pub chat_id: String,
pub platform_sender: Arc<dyn PlatformSender>,
pub confirmer: Arc<Confirmer>,
pub buffer: Mutex<String>,
pub last_msg_id: Mutex<Option<String>>,
pub progress_hint_sent: Mutex<bool>,
pub auto_approve: bool,
}
impl ChatbotFrontend {
pub fn new(
chat_id: String,
platform_sender: Arc<dyn PlatformSender>,
confirmer: Arc<Confirmer>,
auto_approve: bool,
) -> Self {
Self {
chat_id,
platform_sender,
confirmer,
buffer: Mutex::new(String::new()),
last_msg_id: Mutex::new(None),
progress_hint_sent: Mutex::new(false),
auto_approve,
}
}
async fn append_delta(&self, delta: &str) {
let mut buffer = self.buffer.lock().await;
buffer.push_str(delta);
}
async fn flush_buffer(&self) {
let mut buffer = self.buffer.lock().await;
if buffer.is_empty() {
return;
}
let text = std::mem::take(&mut *buffer);
drop(buffer);
let caps = self.platform_sender.capabilities();
let prepared = if caps.supports_markdown {
prepare_markdown_for_platform(&text, &caps.markdown_features)
} else {
text
};
let prepared = truncate_to_max(&prepared, caps.max_message_length);
let mut last_msg_id = self.last_msg_id.lock().await;
if caps.supports_edit && last_msg_id.is_some() {
let msg_id = last_msg_id.clone().unwrap();
if self.platform_sender.edit(&self.chat_id, &msg_id, &prepared).await.is_err() {
if let Ok(res) = self.platform_sender.send(&self.chat_id, &prepared).await {
*last_msg_id = Some(res.msg_id);
}
}
} else {
if let Ok(res) = self.platform_sender.send(&self.chat_id, &prepared).await {
*last_msg_id = Some(res.msg_id);
}
}
}
async fn send_progress_hint(&self, tool_name: &str) {
let mut sent = self.progress_hint_sent.lock().await;
if *sent {
return;
}
*sent = true;
drop(sent);
let hint = match tool_name {
"bash" => "🔧 正在执行命令...".to_string(),
"read" => "📖 正在读取文件...".to_string(),
"write" => "✏️ 正在写入文件...".to_string(),
"edit" => "✏️ 正在编辑文件...".to_string(),
"grep" => "🔍 正在搜索...".to_string(),
"find" => "🔍 正在查找...".to_string(),
_ => "🔧 正在处理...".to_string(),
};
if let Ok(res) = self.platform_sender.send(&self.chat_id, &hint).await {
*self.last_msg_id.lock().await = Some(res.msg_id);
}
}
}
#[async_trait]
impl Frontend for ChatbotFrontend {
async fn on_event(&self, event: AgentEvent) -> Result<()> {
match event {
AgentEvent::TextDelta(delta) => {
self.append_delta(&delta).await;
}
AgentEvent::ToolCallRequested { name, .. } => {
self.flush_buffer().await;
if self.auto_approve {
self.send_progress_hint(&name).await;
}
}
AgentEvent::ToolCallResult { .. } => {
}
AgentEvent::TurnComplete => {
self.flush_buffer().await;
*self.progress_hint_sent.lock().await = false;
*self.last_msg_id.lock().await = None;
}
AgentEvent::Error(e) => {
self.flush_buffer().await;
let msg = format!("❌ Error: {}", e);
let _ = self.platform_sender.send(&self.chat_id, &msg).await;
}
AgentEvent::SkillTriggered { .. } => {
}
}
Ok(())
}
async fn request_tool_confirmation(&self, info: &ToolCallInfo) -> Result<bool> {
self.confirmer
.request(&self.chat_id, info, self.auto_approve)
.await
}
}
#[async_trait]
impl PlatformExt for ChatbotFrontend {
async fn upload_file(&self, file_path: &str, media_type: &str) -> Result<UploadResult> {
self.platform_sender
.upload_file(&self.chat_id, file_path, media_type)
.await
}
async fn send_media_message(
&self,
file_url: &str,
file_name: &str,
media_type: &str,
) -> Result<SendResult> {
self.platform_sender
.send_media_message(&self.chat_id, file_url, file_name, media_type)
.await
}
}
fn truncate_to_max(text: &str, max: usize) -> String {
if max == 0 {
return text.to_string();
}
if text.chars().count() <= max {
return text.to_string();
}
let mut out: String = text.chars().take(max.saturating_sub(1)).collect();
out.push('…');
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adapter::MarkdownFeatures;
use std::sync::Mutex as StdMutex;
struct MockSender {
sent: StdMutex<Vec<(String, String)>>,
edits: StdMutex<Vec<(String, String, String)>>,
caps: PlatformCaps,
}
impl MockSender {
fn new_with_edit(edit: bool) -> Arc<Self> {
Arc::new(Self {
sent: StdMutex::new(Vec::new()),
edits: StdMutex::new(Vec::new()),
caps: PlatformCaps {
supports_edit: edit,
returns_msg_id: true,
supports_markdown: true,
markdown_features: MarkdownFeatures::qq(),
max_message_length: 2000,
supports_images: true,
supports_files: true,
max_upload_size: 20 * 1024 * 1024,
},
})
}
fn sent_texts(&self) -> Vec<String> {
self.sent.lock().unwrap().iter().map(|(_, t)| t.clone()).collect()
}
}
#[async_trait]
impl PlatformSender for MockSender {
async fn send(&self, chat_id: &str, text: &str) -> Result<SendResult> {
self.sent
.lock()
.unwrap()
.push((chat_id.to_string(), text.to_string()));
Ok(SendResult {
msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
})
}
async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> Result<()> {
self.edits.lock().unwrap().push((
chat_id.to_string(),
msg_id.to_string(),
text.to_string(),
));
Ok(())
}
async fn upload_file(&self, _chat_id: &str, _file_path: &str, _media_type: &str) -> Result<UploadResult> {
Ok(UploadResult {
file_id: "mock-file-id".into(),
url: "/mock/upload.png".into(),
})
}
async fn send_media_message(
&self,
chat_id: &str,
_file_url: &str,
file_name: &str,
media_type: &str,
) -> Result<SendResult> {
self.sent
.lock()
.unwrap()
.push((chat_id.to_string(), format!("[media:{}] {}", media_type, file_name)));
Ok(SendResult {
msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
})
}
fn capabilities(&self) -> PlatformCaps {
self.caps.clone()
}
}
fn make_frontend(sender: Arc<dyn PlatformSender>, auto_approve: bool) -> ChatbotFrontend {
let confirmer = Arc::new(Confirmer::new(sender.clone(), std::time::Duration::from_secs(60)));
ChatbotFrontend::new("group:1".to_string(), sender, confirmer, auto_approve)
}
#[tokio::test]
async fn textdelta_accumulates_until_turn_complete() {
let sender = MockSender::new_with_edit(false);
let fe = make_frontend(sender.clone(), false);
fe.on_event(AgentEvent::TextDelta("你好".to_string())).await.unwrap();
fe.on_event(AgentEvent::TextDelta("世界".to_string())).await.unwrap();
assert!(sender.sent_texts().is_empty());
fe.on_event(AgentEvent::TurnComplete).await.unwrap();
assert_eq!(sender.sent_texts().len(), 1);
assert!(sender.sent_texts()[0].contains("你好世界"));
}
#[tokio::test]
async fn turn_complete_flushes_accumulated_text() {
let sender = MockSender::new_with_edit(false);
let fe = make_frontend(sender.clone(), false);
fe.on_event(AgentEvent::TextDelta("一段未被刷新的文本".to_string())).await.unwrap();
assert!(sender.sent_texts().is_empty());
fe.on_event(AgentEvent::TurnComplete).await.unwrap();
assert_eq!(sender.sent_texts().len(), 1);
assert!(sender.sent_texts()[0].contains("一段未被刷新的文本"));
}
#[tokio::test]
async fn progress_hint_rate_limited_per_turn() {
let sender = MockSender::new_with_edit(false);
let fe = make_frontend(sender.clone(), true); fe.on_event(AgentEvent::ToolCallRequested {
tool_call_id: "tc1".into(),
name: "bash".into(),
arguments: "{}".into(),
}).await.unwrap();
fe.on_event(AgentEvent::ToolCallRequested {
tool_call_id: "tc2".into(),
name: "read".into(),
arguments: "{}".into(),
}).await.unwrap();
let hints: Vec<_> = sender
.sent_texts()
.into_iter()
.filter(|t| t.contains("正在"))
.collect();
assert_eq!(hints.len(), 1);
}
#[tokio::test]
async fn progress_hint_resets_on_turn_complete() {
let sender = MockSender::new_with_edit(false);
let fe = make_frontend(sender.clone(), true);
fe.on_event(AgentEvent::ToolCallRequested {
tool_call_id: "tc1".into(),
name: "bash".into(),
arguments: "{}".into(),
}).await.unwrap();
fe.on_event(AgentEvent::TurnComplete).await.unwrap();
fe.on_event(AgentEvent::ToolCallRequested {
tool_call_id: "tc2".into(),
name: "bash".into(),
arguments: "{}".into(),
}).await.unwrap();
let hints: Vec<_> = sender
.sent_texts()
.into_iter()
.filter(|t| t.contains("正在"))
.collect();
assert_eq!(hints.len(), 2);
}
#[tokio::test]
async fn no_hint_in_manual_mode() {
let sender = MockSender::new_with_edit(false);
let fe = make_frontend(sender.clone(), false); fe.on_event(AgentEvent::ToolCallRequested {
tool_call_id: "tc1".into(),
name: "bash".into(),
arguments: "{}".into(),
}).await.unwrap();
assert!(!sender.sent_texts().iter().any(|t| t.contains("正在")));
}
#[tokio::test]
async fn error_sends_error_message() {
let sender = MockSender::new_with_edit(false);
let fe = make_frontend(sender.clone(), false);
fe.on_event(AgentEvent::Error(robit_agent::AgentError::ToolError("boom".into())))
.await
.unwrap();
assert!(sender.sent_texts().iter().any(|t| t.contains("Error") && t.contains("boom")));
}
}