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 session_id: String,
67 pub platform_sender: Arc<dyn PlatformSender>,
69 pub confirmer: Arc<Confirmer>,
71 pub db: Arc<Mutex<rusqlite::Connection>>,
73 pub buffer: Mutex<String>,
75 pub last_msg_id: Mutex<Option<String>>,
78 pub progress_hint_sent: Mutex<bool>,
80 pub auto_approve: bool,
82}
83
84impl ChatbotFrontend {
85 pub fn new(
87 chat_id: String,
88 session_id: String,
89 platform_sender: Arc<dyn PlatformSender>,
90 confirmer: Arc<Confirmer>,
91 db: Arc<Mutex<rusqlite::Connection>>,
92 auto_approve: bool,
93 ) -> Self {
94 Self {
95 chat_id,
96 session_id,
97 platform_sender,
98 confirmer,
99 db,
100 buffer: Mutex::new(String::new()),
101 last_msg_id: Mutex::new(None),
102 progress_hint_sent: Mutex::new(false),
103 auto_approve,
104 }
105 }
106
107 async fn save_assistant_message(&self, content: &str) {
109 let db = self.db.lock().await;
110 match robit_agent::storage::insert_message(
111 &db,
112 &self.session_id,
113 "assistant",
114 content,
115 None,
116 None,
117 None,
118 ) {
119 Ok(_) => {
120 tracing::debug!("Saved assistant message to DB: session_id={}", self.session_id);
121 let _ = robit_agent::storage::touch_session(&db, &self.session_id);
122 }
123 Err(e) => tracing::warn!("Failed to save assistant message: {}", e),
124 }
125 }
126
127 pub async fn save_user_message(&self, content: &str) {
129 let db = self.db.lock().await;
130 match robit_agent::storage::insert_message(
131 &db,
132 &self.session_id,
133 "user",
134 content,
135 None,
136 None,
137 None,
138 ) {
139 Ok(_) => {
140 tracing::debug!("Saved user message to DB: session_id={}", self.session_id);
141 let _ = robit_agent::storage::touch_session(&db, &self.session_id);
142 }
143 Err(e) => tracing::warn!("Failed to save user message: {}", e),
144 }
145 }
146
147 async fn append_delta(&self, delta: &str) {
151 let mut buffer = self.buffer.lock().await;
152 buffer.push_str(delta);
153 }
154
155 async fn flush_buffer(&self) {
158 let mut buffer = self.buffer.lock().await;
159 if buffer.is_empty() {
160 return;
161 }
162 let text = std::mem::take(&mut *buffer);
163 drop(buffer);
164
165 let caps = self.platform_sender.capabilities();
166
167 let prepared = if caps.supports_markdown {
168 prepare_markdown_for_platform(&text, &caps.markdown_features)
169 } else {
170 text.clone()
171 };
172 let prepared = truncate_to_max(&prepared, caps.max_message_length);
173
174 let mut last_msg_id = self.last_msg_id.lock().await;
176 if caps.supports_edit && last_msg_id.is_some() {
177 let msg_id = last_msg_id.clone().unwrap();
179 if self.platform_sender.edit(&self.chat_id, &msg_id, &prepared).await.is_err() {
180 if let Ok(res) = self.platform_sender.send(&self.chat_id, &prepared).await {
182 *last_msg_id = Some(res.msg_id);
183 }
184 }
185 } else {
186 if let Ok(res) = self.platform_sender.send(&self.chat_id, &prepared).await {
188 *last_msg_id = Some(res.msg_id);
189 }
190 }
191
192 if !text.is_empty() {
194 self.save_assistant_message(&text).await;
195 }
196 }
197
198 async fn send_progress_hint(&self, tool_name: &str) {
200 let mut sent = self.progress_hint_sent.lock().await;
201 if *sent {
202 return;
203 }
204 *sent = true;
205 drop(sent);
206
207 let hint = match tool_name {
208 "bash" => "🔧 正在执行命令...".to_string(),
209 "read" => "📖 正在读取文件...".to_string(),
210 "write" => "✏️ 正在写入文件...".to_string(),
211 "edit" => "✏️ 正在编辑文件...".to_string(),
212 "grep" => "🔍 正在搜索...".to_string(),
213 "find" => "🔍 正在查找...".to_string(),
214 _ => "🔧 正在处理...".to_string(),
215 };
216 if let Ok(res) = self.platform_sender.send(&self.chat_id, &hint).await {
217 *self.last_msg_id.lock().await = Some(res.msg_id);
218 }
219 }
220}
221
222#[async_trait]
223impl Frontend for ChatbotFrontend {
224 async fn on_event(&self, event: AgentEvent) -> Result<()> {
225 match event {
226 AgentEvent::TextDelta(delta) => {
227 self.append_delta(&delta).await;
228 }
229 AgentEvent::ToolCallRequested { name, .. } => {
230 self.flush_buffer().await;
232 if self.auto_approve {
236 self.send_progress_hint(&name).await;
237 }
238 }
239 AgentEvent::ToolCallResult { .. } => {
240 }
244 AgentEvent::TurnComplete => {
245 self.flush_buffer().await;
246 *self.progress_hint_sent.lock().await = false;
248 *self.last_msg_id.lock().await = None;
249 }
250 AgentEvent::Error(e) => {
251 self.flush_buffer().await;
252 let msg = format!("❌ Error: {}", e);
253 let _ = self.platform_sender.send(&self.chat_id, &msg).await;
254 }
255 AgentEvent::SkillTriggered { .. } => {
256 }
259 }
260 Ok(())
261 }
262
263 async fn request_tool_confirmation(&self, info: &ToolCallInfo) -> Result<bool> {
264 self.confirmer
265 .request(&self.chat_id, info, self.auto_approve)
266 .await
267 }
268}
269
270#[async_trait]
271impl PlatformExt for ChatbotFrontend {
272 async fn upload_file(&self, file_path: &str, media_type: &str) -> Result<UploadResult> {
273 self.platform_sender
274 .upload_file(&self.chat_id, file_path, media_type)
275 .await
276 }
277
278 async fn send_media_message(
279 &self,
280 file_url: &str,
281 file_name: &str,
282 media_type: &str,
283 ) -> Result<SendResult> {
284 self.platform_sender
285 .send_media_message(&self.chat_id, file_url, file_name, media_type)
286 .await
287 }
288}
289
290fn truncate_to_max(text: &str, max: usize) -> String {
292 if max == 0 {
293 return text.to_string();
294 }
295 if text.chars().count() <= max {
296 return text.to_string();
297 }
298 let mut out: String = text.chars().take(max.saturating_sub(1)).collect();
299 out.push('…');
300 out
301}
302
303#[cfg(test)]
304mod tests {
305 use super::*;
306 use crate::adapter::MarkdownFeatures;
307 use std::sync::Mutex as StdMutex;
308
309 struct MockSender {
310 sent: StdMutex<Vec<(String, String)>>,
311 edits: StdMutex<Vec<(String, String, String)>>,
312 caps: PlatformCaps,
313 }
314
315 impl MockSender {
316 fn new_with_edit(edit: bool) -> Arc<Self> {
317 Arc::new(Self {
318 sent: StdMutex::new(Vec::new()),
319 edits: StdMutex::new(Vec::new()),
320 caps: PlatformCaps {
321 supports_edit: edit,
322 returns_msg_id: true,
323 supports_markdown: true,
324 markdown_features: MarkdownFeatures::qq(),
325 max_message_length: 2000,
326 supports_images: true,
327 supports_files: true,
328 max_upload_size: 20 * 1024 * 1024,
329 },
330 })
331 }
332 fn sent_texts(&self) -> Vec<String> {
333 self.sent.lock().unwrap().iter().map(|(_, t)| t.clone()).collect()
334 }
335 }
336
337 #[async_trait]
338 impl PlatformSender for MockSender {
339 async fn send(&self, chat_id: &str, text: &str) -> Result<SendResult> {
340 self.sent
341 .lock()
342 .unwrap()
343 .push((chat_id.to_string(), text.to_string()));
344 Ok(SendResult {
345 msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
346 })
347 }
348 async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> Result<()> {
349 self.edits.lock().unwrap().push((
350 chat_id.to_string(),
351 msg_id.to_string(),
352 text.to_string(),
353 ));
354 Ok(())
355 }
356 async fn upload_file(&self, _chat_id: &str, _file_path: &str, _media_type: &str) -> Result<UploadResult> {
357 Ok(UploadResult {
358 file_id: "mock-file-id".into(),
359 url: "/mock/upload.png".into(),
360 })
361 }
362 async fn send_media_message(
363 &self,
364 chat_id: &str,
365 _file_url: &str,
366 file_name: &str,
367 media_type: &str,
368 ) -> Result<SendResult> {
369 self.sent
370 .lock()
371 .unwrap()
372 .push((chat_id.to_string(), format!("[media:{}] {}", media_type, file_name)));
373 Ok(SendResult {
374 msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
375 })
376 }
377 fn capabilities(&self) -> PlatformCaps {
378 self.caps.clone()
379 }
380 }
381
382 fn make_frontend(sender: Arc<dyn PlatformSender>, auto_approve: bool) -> ChatbotFrontend {
383 let confirmer = Arc::new(Confirmer::new(sender.clone(), std::time::Duration::from_secs(60)));
384 let db = Arc::new(Mutex::new(rusqlite::Connection::open_in_memory().unwrap()));
386 {
388 let db = db.blocking_lock();
389 robit_agent::storage::init_db(&db).unwrap();
390 }
391 ChatbotFrontend::new(
392 "group:1".to_string(),
393 "test-session-1".to_string(),
394 sender,
395 confirmer,
396 db,
397 auto_approve
398 )
399 }
400
401 #[tokio::test]
402 async fn textdelta_accumulates_until_turn_complete() {
403 let sender = MockSender::new_with_edit(false);
404 let fe = make_frontend(sender.clone(), false);
405 fe.on_event(AgentEvent::TextDelta("你好".to_string())).await.unwrap();
407 fe.on_event(AgentEvent::TextDelta("世界".to_string())).await.unwrap();
408 assert!(sender.sent_texts().is_empty());
409 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
411 assert_eq!(sender.sent_texts().len(), 1);
412 assert!(sender.sent_texts()[0].contains("你好世界"));
413 }
414
415 #[tokio::test]
416 async fn turn_complete_flushes_accumulated_text() {
417 let sender = MockSender::new_with_edit(false);
418 let fe = make_frontend(sender.clone(), false);
419 fe.on_event(AgentEvent::TextDelta("一段未被刷新的文本".to_string())).await.unwrap();
420 assert!(sender.sent_texts().is_empty());
421 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
422 assert_eq!(sender.sent_texts().len(), 1);
423 assert!(sender.sent_texts()[0].contains("一段未被刷新的文本"));
424 }
425
426 #[tokio::test]
427 async fn progress_hint_rate_limited_per_turn() {
428 let sender = MockSender::new_with_edit(false);
429 let fe = make_frontend(sender.clone(), true); fe.on_event(AgentEvent::ToolCallRequested {
432 tool_call_id: "tc1".into(),
433 name: "bash".into(),
434 arguments: "{}".into(),
435 }).await.unwrap();
436 fe.on_event(AgentEvent::ToolCallRequested {
437 tool_call_id: "tc2".into(),
438 name: "read".into(),
439 arguments: "{}".into(),
440 }).await.unwrap();
441 let hints: Vec<_> = sender
442 .sent_texts()
443 .into_iter()
444 .filter(|t| t.contains("正在"))
445 .collect();
446 assert_eq!(hints.len(), 1);
447 }
448
449 #[tokio::test]
450 async fn progress_hint_resets_on_turn_complete() {
451 let sender = MockSender::new_with_edit(false);
452 let fe = make_frontend(sender.clone(), true);
453 fe.on_event(AgentEvent::ToolCallRequested {
454 tool_call_id: "tc1".into(),
455 name: "bash".into(),
456 arguments: "{}".into(),
457 }).await.unwrap();
458 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
459 fe.on_event(AgentEvent::ToolCallRequested {
461 tool_call_id: "tc2".into(),
462 name: "bash".into(),
463 arguments: "{}".into(),
464 }).await.unwrap();
465 let hints: Vec<_> = sender
466 .sent_texts()
467 .into_iter()
468 .filter(|t| t.contains("正在"))
469 .collect();
470 assert_eq!(hints.len(), 2);
471 }
472
473 #[tokio::test]
474 async fn no_hint_in_manual_mode() {
475 let sender = MockSender::new_with_edit(false);
476 let fe = make_frontend(sender.clone(), false); fe.on_event(AgentEvent::ToolCallRequested {
478 tool_call_id: "tc1".into(),
479 name: "bash".into(),
480 arguments: "{}".into(),
481 }).await.unwrap();
482 assert!(!sender.sent_texts().iter().any(|t| t.contains("正在")));
484 }
485
486 #[tokio::test]
487 async fn error_sends_error_message() {
488 let sender = MockSender::new_with_edit(false);
489 let fe = make_frontend(sender.clone(), false);
490 fe.on_event(AgentEvent::Error(robit_agent::AgentError::ToolError("boom".into())))
491 .await
492 .unwrap();
493 assert!(sender.sent_texts().iter().any(|t| t.contains("Error") && t.contains("boom")));
494 }
495
496}