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 if content.is_empty() {
112 tracing::debug!("Not saving empty assistant message to DB");
113 return;
114 }
115 match robit_agent::storage::insert_message(
116 &db,
117 &self.session_id,
118 "assistant",
119 content,
120 None,
121 None,
122 None,
123 ) {
124 Ok(_) => {
125 tracing::debug!("Saved assistant message to DB: session_id={}", self.session_id);
126 let _ = robit_agent::storage::touch_session(&db, &self.session_id);
127 }
128 Err(e) => tracing::warn!("Failed to save assistant message: {}", e),
129 }
130 }
131
132 pub async fn save_user_message(&self, content: &str) {
134 let db = self.db.lock().await;
135 match robit_agent::storage::insert_message(
136 &db,
137 &self.session_id,
138 "user",
139 content,
140 None,
141 None,
142 None,
143 ) {
144 Ok(_) => {
145 tracing::debug!("Saved user message to DB: session_id={}", self.session_id);
146 let _ = robit_agent::storage::touch_session(&db, &self.session_id);
147 }
148 Err(e) => tracing::warn!("Failed to save user message: {}", e),
149 }
150 }
151
152 async fn append_delta(&self, delta: &str) {
156 let mut buffer = self.buffer.lock().await;
157 buffer.push_str(delta);
158 }
159
160 async fn flush_buffer(&self) {
163 let mut buffer = self.buffer.lock().await;
164 if buffer.is_empty() {
165 return;
166 }
167 let text = std::mem::take(&mut *buffer);
168 drop(buffer);
169
170 tracing::trace!(
171 "[chatbot] flush_buffer: chat_id='{}', text_len={}",
172 self.chat_id,
173 text.len()
174 );
175
176 let caps = self.platform_sender.capabilities();
177
178 let prepared = if caps.supports_markdown {
179 prepare_markdown_for_platform(&text, &caps.markdown_features)
180 } else {
181 text.clone()
182 };
183 let prepared = truncate_to_max(&prepared, caps.max_message_length);
184
185 let mut last_msg_id = self.last_msg_id.lock().await;
187 if caps.supports_edit && last_msg_id.is_some() {
188 let msg_id = last_msg_id.clone().unwrap();
190 if self.platform_sender.edit(&self.chat_id, &msg_id, &prepared).await.is_err() {
191 if let Ok(res) = self.platform_sender.send(&self.chat_id, &prepared).await {
193 *last_msg_id = Some(res.msg_id);
194 }
195 }
196 } else {
197 match self.platform_sender.send(&self.chat_id, &prepared).await {
199 Ok(res) => {
200 *last_msg_id = Some(res.msg_id);
201 tracing::trace!(
202 "[chatbot] reply sent: chat_id='{}', msg_len={}",
203 self.chat_id, prepared.len()
204 );
205 }
206 Err(e) => tracing::warn!(
207 "[chatbot] failed to send reply to platform: chat_id='{}', error={}",
208 self.chat_id, e
209 ),
210 }
211 }
212
213 if !text.is_empty() {
215 self.save_assistant_message(&text).await;
216 }
217 }
218
219 async fn send_progress_hint(&self, tool_name: &str) {
221 let mut sent = self.progress_hint_sent.lock().await;
222 if *sent {
223 return;
224 }
225 *sent = true;
226 drop(sent);
227
228 let hint = match tool_name {
229 "bash" => "🔧 正在执行命令...".to_string(),
230 "read" => "📖 正在读取文件...".to_string(),
231 "write" => "✏️ 正在写入文件...".to_string(),
232 "edit" => "✏️ 正在编辑文件...".to_string(),
233 "grep" => "🔍 正在搜索...".to_string(),
234 "find" => "🔍 正在查找...".to_string(),
235 _ => "🔧 正在处理...".to_string(),
236 };
237 match self.platform_sender.send(&self.chat_id, &hint).await {
238 Ok(res) => {
239 *self.last_msg_id.lock().await = Some(res.msg_id);
240 tracing::trace!(
241 "[chatbot] progress hint sent: chat_id='{}', tool='{}'",
242 self.chat_id, tool_name
243 );
244 }
245 Err(e) => tracing::warn!(
246 "[chatbot] failed to send progress hint: chat_id='{}', tool='{}', error={}",
247 self.chat_id, tool_name, e
248 ),
249 }
250 }
251}
252
253#[async_trait]
254impl Frontend for ChatbotFrontend {
255 async fn on_event(&self, event: AgentEvent) -> Result<()> {
256 match event {
257 AgentEvent::TextDelta(delta) => {
258 self.append_delta(&delta).await;
259 }
260 AgentEvent::ToolCallRequested { name, .. } => {
261 tracing::trace!(
262 "[chatbot] ToolCallRequested received: chat_id='{}', tool='{}' (auto_approve={})",
263 self.chat_id, name, self.auto_approve
264 );
265 self.flush_buffer().await;
267 if self.auto_approve {
271 self.send_progress_hint(&name).await;
272 }
273 }
274 AgentEvent::ToolCallResult { tool_call_id, ref result } => {
275 if result.is_pending && !self.auto_approve {
280 let task_id = result.pending_task_id.as_deref().unwrap_or("?");
281 let msg = format!(
282 "🎨 后台任务已启动(task_id={}),完成后自动通知",
283 task_id
284 );
285 if let Err(e) = self.platform_sender.send(&self.chat_id, &msg).await {
286 tracing::warn!(
287 "[chatbot] failed to send async-task hint: chat_id='{}', error={}",
288 self.chat_id, e
289 );
290 }
291 }
292 tracing::trace!(
295 "[chatbot] ToolCallResult received: chat_id='{}', tool_call_id='{}', is_pending={}, is_error={}, content_len={}",
296 self.chat_id, tool_call_id, result.is_pending, result.is_error, result.content.len()
297 );
298 }
299 AgentEvent::TurnComplete => {
300 tracing::trace!(
301 "[chatbot] TurnComplete received: chat_id='{}', flushing buffered reply",
302 self.chat_id
303 );
304 self.flush_buffer().await;
305 *self.progress_hint_sent.lock().await = false;
307 *self.last_msg_id.lock().await = None;
308 }
309 AgentEvent::Error(e) => {
310 tracing::trace!(
311 "[chatbot] Error received: chat_id='{}', error={}",
312 self.chat_id, e
313 );
314 self.flush_buffer().await;
315 let msg = format!("❌ Error: {}", e);
316 if let Err(send_err) = self.platform_sender.send(&self.chat_id, &msg).await {
317 tracing::warn!(
318 "[chatbot] failed to send error message to platform: chat_id='{}', error={}",
319 self.chat_id, send_err
320 );
321 }
322 }
323 AgentEvent::SkillTriggered { ref name, .. } => {
324 tracing::trace!(
325 "[chatbot] SkillTriggered received (silent by design): chat_id='{}', skill='{}'",
326 self.chat_id, name
327 );
328 }
329 AgentEvent::AsyncToolCompleted {
330 task_id,
331 tool_call_id,
332 result,
333 } => {
334 tracing::trace!(
338 "[chatbot] AsyncToolCompleted: chat_id='{}', task_id='{}', tool_call_id='{}', is_error={}",
339 self.chat_id, task_id, tool_call_id, result.is_error
340 );
341 }
342 }
343 Ok(())
344 }
345
346 async fn request_tool_confirmation(&self, info: &ToolCallInfo) -> Result<bool> {
347 self.confirmer
348 .request(&self.chat_id, info, self.auto_approve)
349 .await
350 }
351}
352
353#[async_trait]
354impl PlatformExt for ChatbotFrontend {
355 async fn upload_file(&self, file_path: &str, media_type: &str) -> Result<UploadResult> {
356 self.platform_sender
357 .upload_file(&self.chat_id, file_path, media_type)
358 .await
359 }
360
361 async fn send_media_message(
362 &self,
363 file_url: &str,
364 file_name: &str,
365 media_type: &str,
366 ) -> Result<SendResult> {
367 self.platform_sender
368 .send_media_message(&self.chat_id, file_url, file_name, media_type)
369 .await
370 }
371}
372
373fn truncate_to_max(text: &str, max: usize) -> String {
375 if max == 0 {
376 return text.to_string();
377 }
378 if text.chars().count() <= max {
379 return text.to_string();
380 }
381 let mut out: String = text.chars().take(max.saturating_sub(1)).collect();
382 out.push('…');
383 out
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use crate::adapter::MarkdownFeatures;
390 use std::sync::Mutex as StdMutex;
391
392 struct MockSender {
393 sent: StdMutex<Vec<(String, String)>>,
394 edits: StdMutex<Vec<(String, String, String)>>,
395 caps: PlatformCaps,
396 }
397
398 impl MockSender {
399 fn new_with_edit(edit: bool) -> Arc<Self> {
400 Arc::new(Self {
401 sent: StdMutex::new(Vec::new()),
402 edits: StdMutex::new(Vec::new()),
403 caps: PlatformCaps {
404 supports_edit: edit,
405 returns_msg_id: true,
406 supports_markdown: true,
407 markdown_features: MarkdownFeatures::qq(),
408 max_message_length: 2000,
409 supports_images: true,
410 supports_files: true,
411 max_upload_size: 20 * 1024 * 1024,
412 },
413 })
414 }
415 fn sent_texts(&self) -> Vec<String> {
416 self.sent.lock().unwrap().iter().map(|(_, t)| t.clone()).collect()
417 }
418 }
419
420 #[async_trait]
421 impl PlatformSender for MockSender {
422 async fn send(&self, chat_id: &str, text: &str) -> Result<SendResult> {
423 self.sent
424 .lock()
425 .unwrap()
426 .push((chat_id.to_string(), text.to_string()));
427 Ok(SendResult {
428 msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
429 })
430 }
431 async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> Result<()> {
432 self.edits.lock().unwrap().push((
433 chat_id.to_string(),
434 msg_id.to_string(),
435 text.to_string(),
436 ));
437 Ok(())
438 }
439 async fn upload_file(&self, _chat_id: &str, _file_path: &str, _media_type: &str) -> Result<UploadResult> {
440 Ok(UploadResult {
441 file_id: "mock-file-id".into(),
442 url: "/mock/upload.png".into(),
443 })
444 }
445 async fn send_media_message(
446 &self,
447 chat_id: &str,
448 _file_url: &str,
449 file_name: &str,
450 media_type: &str,
451 ) -> Result<SendResult> {
452 self.sent
453 .lock()
454 .unwrap()
455 .push((chat_id.to_string(), format!("[media:{}] {}", media_type, file_name)));
456 Ok(SendResult {
457 msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
458 })
459 }
460 fn capabilities(&self) -> PlatformCaps {
461 self.caps.clone()
462 }
463 }
464
465 async fn make_frontend(sender: Arc<dyn PlatformSender>, auto_approve: bool) -> ChatbotFrontend {
466 let confirmer = Arc::new(Confirmer::new(sender.clone(), std::time::Duration::from_secs(60)));
467 let db = Arc::new(Mutex::new(rusqlite::Connection::open_in_memory().unwrap()));
469 {
471 let db = db.lock().await;
472 robit_agent::storage::init_db(&db).unwrap();
473 }
474 ChatbotFrontend::new(
475 "group:1".to_string(),
476 "test-session-1".to_string(),
477 sender,
478 confirmer,
479 db,
480 auto_approve
481 )
482 }
483
484 #[tokio::test]
485 async fn textdelta_accumulates_until_turn_complete() {
486 let sender = MockSender::new_with_edit(false);
487 let fe = make_frontend(sender.clone(), false).await;
488 fe.on_event(AgentEvent::TextDelta("你好".to_string())).await.unwrap();
490 fe.on_event(AgentEvent::TextDelta("世界".to_string())).await.unwrap();
491 assert!(sender.sent_texts().is_empty());
492 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
494 assert_eq!(sender.sent_texts().len(), 1);
495 assert!(sender.sent_texts()[0].contains("你好世界"));
496 }
497
498 #[tokio::test]
499 async fn turn_complete_flushes_accumulated_text() {
500 let sender = MockSender::new_with_edit(false);
501 let fe = make_frontend(sender.clone(), false).await;
502 fe.on_event(AgentEvent::TextDelta("一段未被刷新的文本".to_string())).await.unwrap();
503 assert!(sender.sent_texts().is_empty());
504 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
505 assert_eq!(sender.sent_texts().len(), 1);
506 assert!(sender.sent_texts()[0].contains("一段未被刷新的文本"));
507 }
508
509 #[tokio::test]
510 async fn progress_hint_rate_limited_per_turn() {
511 let sender = MockSender::new_with_edit(false);
512 let fe = make_frontend(sender.clone(), true).await; fe.on_event(AgentEvent::ToolCallRequested {
515 tool_call_id: "tc1".into(),
516 name: "bash".into(),
517 arguments: "{}".into(),
518 }).await.unwrap();
519 fe.on_event(AgentEvent::ToolCallRequested {
520 tool_call_id: "tc2".into(),
521 name: "read".into(),
522 arguments: "{}".into(),
523 }).await.unwrap();
524 let hints: Vec<_> = sender
525 .sent_texts()
526 .into_iter()
527 .filter(|t| t.contains("正在"))
528 .collect();
529 assert_eq!(hints.len(), 1);
530 }
531
532 #[tokio::test]
533 async fn progress_hint_resets_on_turn_complete() {
534 let sender = MockSender::new_with_edit(false);
535 let fe = make_frontend(sender.clone(), true).await;
536 fe.on_event(AgentEvent::ToolCallRequested {
537 tool_call_id: "tc1".into(),
538 name: "bash".into(),
539 arguments: "{}".into(),
540 }).await.unwrap();
541 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
542 fe.on_event(AgentEvent::ToolCallRequested {
544 tool_call_id: "tc2".into(),
545 name: "bash".into(),
546 arguments: "{}".into(),
547 }).await.unwrap();
548 let hints: Vec<_> = sender
549 .sent_texts()
550 .into_iter()
551 .filter(|t| t.contains("正在"))
552 .collect();
553 assert_eq!(hints.len(), 2);
554 }
555
556 #[tokio::test]
557 async fn no_hint_in_manual_mode() {
558 let sender = MockSender::new_with_edit(false);
559 let fe = make_frontend(sender.clone(), false).await; fe.on_event(AgentEvent::ToolCallRequested {
561 tool_call_id: "tc1".into(),
562 name: "bash".into(),
563 arguments: "{}".into(),
564 }).await.unwrap();
565 assert!(!sender.sent_texts().iter().any(|t| t.contains("正在")));
567 }
568
569 #[tokio::test]
570 async fn error_sends_error_message() {
571 let sender = MockSender::new_with_edit(false);
572 let fe = make_frontend(sender.clone(), false).await;
573 fe.on_event(AgentEvent::Error(robit_agent::AgentError::ToolError("boom".into())))
574 .await
575 .unwrap();
576 assert!(sender.sent_texts().iter().any(|t| t.contains("Error") && t.contains("boom")));
577 }
578
579}