1use std::sync::Arc;
14
15use async_trait::async_trait;
16use robit_agent::error::Result;
17use robit_agent::event::AgentEvent;
18use robit_agent::frontend::Frontend;
19use robit_agent::tool::ToolCallInfo;
20use tokio::sync::Mutex;
21
22use crate::adapter::{PlatformCaps, SendResult, UploadResult};
23use crate::confirmer::Confirmer;
24use crate::markdown::prepare_markdown_for_platform;
25#[async_trait]
31pub trait PlatformSender: Send + Sync {
32 async fn send(&self, chat_id: &str, text: &str) -> Result<SendResult>;
34 async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> Result<()>;
36 async fn upload_file(&self, chat_id: &str, file_path: &str, media_type: &str) -> Result<UploadResult>;
38 async fn send_media_message(&self, chat_id: &str, file_url: &str, file_name: &str, media_type: &str) -> Result<SendResult>;
40 fn capabilities(&self) -> PlatformCaps;
42}
43
44#[async_trait]
50pub trait PlatformExt: Send + Sync {
51 async fn upload_file(&self, file_path: &str, media_type: &str) -> Result<UploadResult>;
53 async fn send_media_message(&self, file_url: &str, file_name: &str, media_type: &str) -> Result<SendResult>;
55}
56
57pub struct ChatbotFrontend {
63 pub chat_id: String,
65 pub platform_sender: Arc<dyn PlatformSender>,
67 pub confirmer: Arc<Confirmer>,
69 pub buffer: Mutex<String>,
71 pub last_msg_id: Mutex<Option<String>>,
74 pub progress_hint_sent: Mutex<bool>,
76 pub auto_approve: bool,
78}
79
80impl ChatbotFrontend {
81 pub fn new(
83 chat_id: String,
84 platform_sender: Arc<dyn PlatformSender>,
85 confirmer: Arc<Confirmer>,
86 auto_approve: bool,
87 ) -> Self {
88 Self {
89 chat_id,
90 platform_sender,
91 confirmer,
92 buffer: Mutex::new(String::new()),
93 last_msg_id: Mutex::new(None),
94 progress_hint_sent: Mutex::new(false),
95 auto_approve,
96 }
97 }
98
99 async fn append_delta(&self, delta: &str) {
103 let mut buffer = self.buffer.lock().await;
104 buffer.push_str(delta);
105 }
106
107 async fn flush_buffer(&self) {
110 let mut buffer = self.buffer.lock().await;
111 if buffer.is_empty() {
112 return;
113 }
114 let text = std::mem::take(&mut *buffer);
115 drop(buffer);
116
117 let caps = self.platform_sender.capabilities();
118
119 let prepared = if caps.supports_markdown {
120 prepare_markdown_for_platform(&text, &caps.markdown_features)
121 } else {
122 text
123 };
124 let prepared = truncate_to_max(&prepared, caps.max_message_length);
125
126 let mut last_msg_id = self.last_msg_id.lock().await;
128 if caps.supports_edit && last_msg_id.is_some() {
129 let msg_id = last_msg_id.clone().unwrap();
131 if self.platform_sender.edit(&self.chat_id, &msg_id, &prepared).await.is_err() {
132 if let Ok(res) = self.platform_sender.send(&self.chat_id, &prepared).await {
134 *last_msg_id = Some(res.msg_id);
135 }
136 }
137 } else {
138 if let Ok(res) = self.platform_sender.send(&self.chat_id, &prepared).await {
140 *last_msg_id = Some(res.msg_id);
141 }
142 }
143 }
144
145 async fn send_progress_hint(&self, tool_name: &str) {
147 let mut sent = self.progress_hint_sent.lock().await;
148 if *sent {
149 return;
150 }
151 *sent = true;
152 drop(sent);
153
154 let hint = match tool_name {
155 "bash" => "🔧 正在执行命令...".to_string(),
156 "read" => "📖 正在读取文件...".to_string(),
157 "write" => "✏️ 正在写入文件...".to_string(),
158 "edit" => "✏️ 正在编辑文件...".to_string(),
159 "grep" => "🔍 正在搜索...".to_string(),
160 "find" => "🔍 正在查找...".to_string(),
161 _ => "🔧 正在处理...".to_string(),
162 };
163 if let Ok(res) = self.platform_sender.send(&self.chat_id, &hint).await {
164 *self.last_msg_id.lock().await = Some(res.msg_id);
165 }
166 }
167}
168
169#[async_trait]
170impl Frontend for ChatbotFrontend {
171 async fn on_event(&self, event: AgentEvent) -> Result<()> {
172 match event {
173 AgentEvent::TextDelta(delta) => {
174 self.append_delta(&delta).await;
175 }
176 AgentEvent::ToolCallRequested { name, .. } => {
177 self.flush_buffer().await;
179 if self.auto_approve {
183 self.send_progress_hint(&name).await;
184 }
185 }
186 AgentEvent::ToolCallResult { .. } => {
187 }
191 AgentEvent::TurnComplete => {
192 self.flush_buffer().await;
193 *self.progress_hint_sent.lock().await = false;
195 *self.last_msg_id.lock().await = None;
196 }
197 AgentEvent::Error(e) => {
198 self.flush_buffer().await;
199 let msg = format!("❌ Error: {}", e);
200 let _ = self.platform_sender.send(&self.chat_id, &msg).await;
201 }
202 AgentEvent::SkillTriggered { .. } => {
203 }
206 }
207 Ok(())
208 }
209
210 async fn request_tool_confirmation(&self, info: &ToolCallInfo) -> Result<bool> {
211 self.confirmer
212 .request(&self.chat_id, info, self.auto_approve)
213 .await
214 }
215}
216
217#[async_trait]
218impl PlatformExt for ChatbotFrontend {
219 async fn upload_file(&self, file_path: &str, media_type: &str) -> Result<UploadResult> {
220 self.platform_sender
221 .upload_file(&self.chat_id, file_path, media_type)
222 .await
223 }
224
225 async fn send_media_message(
226 &self,
227 file_url: &str,
228 file_name: &str,
229 media_type: &str,
230 ) -> Result<SendResult> {
231 self.platform_sender
232 .send_media_message(&self.chat_id, file_url, file_name, media_type)
233 .await
234 }
235}
236
237fn truncate_to_max(text: &str, max: usize) -> String {
239 if max == 0 {
240 return text.to_string();
241 }
242 if text.chars().count() <= max {
243 return text.to_string();
244 }
245 let mut out: String = text.chars().take(max.saturating_sub(1)).collect();
246 out.push('…');
247 out
248}
249
250#[cfg(test)]
251mod tests {
252 use super::*;
253 use crate::adapter::MarkdownFeatures;
254 use std::sync::Mutex as StdMutex;
255
256 struct MockSender {
257 sent: StdMutex<Vec<(String, String)>>,
258 edits: StdMutex<Vec<(String, String, String)>>,
259 caps: PlatformCaps,
260 }
261
262 impl MockSender {
263 fn new_with_edit(edit: bool) -> Arc<Self> {
264 Arc::new(Self {
265 sent: StdMutex::new(Vec::new()),
266 edits: StdMutex::new(Vec::new()),
267 caps: PlatformCaps {
268 supports_edit: edit,
269 returns_msg_id: true,
270 supports_markdown: true,
271 markdown_features: MarkdownFeatures::qq(),
272 max_message_length: 2000,
273 supports_images: true,
274 supports_files: true,
275 max_upload_size: 20 * 1024 * 1024,
276 },
277 })
278 }
279 fn sent_texts(&self) -> Vec<String> {
280 self.sent.lock().unwrap().iter().map(|(_, t)| t.clone()).collect()
281 }
282 }
283
284 #[async_trait]
285 impl PlatformSender for MockSender {
286 async fn send(&self, chat_id: &str, text: &str) -> Result<SendResult> {
287 self.sent
288 .lock()
289 .unwrap()
290 .push((chat_id.to_string(), text.to_string()));
291 Ok(SendResult {
292 msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
293 })
294 }
295 async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> Result<()> {
296 self.edits.lock().unwrap().push((
297 chat_id.to_string(),
298 msg_id.to_string(),
299 text.to_string(),
300 ));
301 Ok(())
302 }
303 async fn upload_file(&self, _chat_id: &str, _file_path: &str, _media_type: &str) -> Result<UploadResult> {
304 Ok(UploadResult {
305 file_id: "mock-file-id".into(),
306 url: "/mock/upload.png".into(),
307 })
308 }
309 async fn send_media_message(
310 &self,
311 chat_id: &str,
312 _file_url: &str,
313 file_name: &str,
314 media_type: &str,
315 ) -> Result<SendResult> {
316 self.sent
317 .lock()
318 .unwrap()
319 .push((chat_id.to_string(), format!("[media:{}] {}", media_type, file_name)));
320 Ok(SendResult {
321 msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
322 })
323 }
324 fn capabilities(&self) -> PlatformCaps {
325 self.caps.clone()
326 }
327 }
328
329 fn make_frontend(sender: Arc<dyn PlatformSender>, auto_approve: bool) -> ChatbotFrontend {
330 let confirmer = Arc::new(Confirmer::new(sender.clone(), std::time::Duration::from_secs(60)));
331 ChatbotFrontend::new("group:1".to_string(), sender, confirmer, auto_approve)
332 }
333
334 #[tokio::test]
335 async fn textdelta_accumulates_until_turn_complete() {
336 let sender = MockSender::new_with_edit(false);
337 let fe = make_frontend(sender.clone(), false);
338 fe.on_event(AgentEvent::TextDelta("你好".to_string())).await.unwrap();
340 fe.on_event(AgentEvent::TextDelta("世界".to_string())).await.unwrap();
341 assert!(sender.sent_texts().is_empty());
342 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
344 assert_eq!(sender.sent_texts().len(), 1);
345 assert!(sender.sent_texts()[0].contains("你好世界"));
346 }
347
348 #[tokio::test]
349 async fn turn_complete_flushes_accumulated_text() {
350 let sender = MockSender::new_with_edit(false);
351 let fe = make_frontend(sender.clone(), false);
352 fe.on_event(AgentEvent::TextDelta("一段未被刷新的文本".to_string())).await.unwrap();
353 assert!(sender.sent_texts().is_empty());
354 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
355 assert_eq!(sender.sent_texts().len(), 1);
356 assert!(sender.sent_texts()[0].contains("一段未被刷新的文本"));
357 }
358
359 #[tokio::test]
360 async fn progress_hint_rate_limited_per_turn() {
361 let sender = MockSender::new_with_edit(false);
362 let fe = make_frontend(sender.clone(), true); fe.on_event(AgentEvent::ToolCallRequested {
365 tool_call_id: "tc1".into(),
366 name: "bash".into(),
367 arguments: "{}".into(),
368 }).await.unwrap();
369 fe.on_event(AgentEvent::ToolCallRequested {
370 tool_call_id: "tc2".into(),
371 name: "read".into(),
372 arguments: "{}".into(),
373 }).await.unwrap();
374 let hints: Vec<_> = sender
375 .sent_texts()
376 .into_iter()
377 .filter(|t| t.contains("正在"))
378 .collect();
379 assert_eq!(hints.len(), 1);
380 }
381
382 #[tokio::test]
383 async fn progress_hint_resets_on_turn_complete() {
384 let sender = MockSender::new_with_edit(false);
385 let fe = make_frontend(sender.clone(), true);
386 fe.on_event(AgentEvent::ToolCallRequested {
387 tool_call_id: "tc1".into(),
388 name: "bash".into(),
389 arguments: "{}".into(),
390 }).await.unwrap();
391 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
392 fe.on_event(AgentEvent::ToolCallRequested {
394 tool_call_id: "tc2".into(),
395 name: "bash".into(),
396 arguments: "{}".into(),
397 }).await.unwrap();
398 let hints: Vec<_> = sender
399 .sent_texts()
400 .into_iter()
401 .filter(|t| t.contains("正在"))
402 .collect();
403 assert_eq!(hints.len(), 2);
404 }
405
406 #[tokio::test]
407 async fn no_hint_in_manual_mode() {
408 let sender = MockSender::new_with_edit(false);
409 let fe = make_frontend(sender.clone(), false); fe.on_event(AgentEvent::ToolCallRequested {
411 tool_call_id: "tc1".into(),
412 name: "bash".into(),
413 arguments: "{}".into(),
414 }).await.unwrap();
415 assert!(!sender.sent_texts().iter().any(|t| t.contains("正在")));
417 }
418
419 #[tokio::test]
420 async fn error_sends_error_message() {
421 let sender = MockSender::new_with_edit(false);
422 let fe = make_frontend(sender.clone(), false);
423 fe.on_event(AgentEvent::Error(robit_agent::AgentError::ToolError("boom".into())))
424 .await
425 .unwrap();
426 assert!(sender.sent_texts().iter().any(|t| t.contains("Error") && t.contains("boom")));
427 }
428
429}