Skip to main content

robit_chatbot/
frontend.rs

1//! Per-session Frontend implementation for Bot platforms.
2//!
3//! [`ChatbotFrontend`] implements [`robit_agent::frontend::Frontend`] for a
4//! single chat (group or private). It buffers streaming `TextDelta` events and
5//! flushes them in natural-boundary segments (so Markdown and code blocks
6//! aren't cut mid-construct), sends a rate-limited progress hint when tools
7//! run, and delegates tool confirmation to the shared [`Confirmer`].
8//!
9//! On platforms with edit support, the first message is sent then edited in
10//! place to create a "growing message" effect; otherwise segments are sent as
11//! separate messages.
12
13use 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/// Abstracted message sending capability (platform-agnostic).
26///
27/// `ChatbotFrontend` talks to the platform through this trait rather than
28/// `PlatformAdapter` directly, so the manager can supply a bridge that wraps
29/// the concrete adapter.
30#[async_trait]
31pub trait PlatformSender: Send + Sync {
32    /// Send a text message to a chat; returns the platform message ID.
33    async fn send(&self, chat_id: &str, text: &str) -> Result<SendResult>;
34    /// Edit a previously-sent message in place.
35    async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> Result<()>;
36    /// Upload a file to the platform. Returns the platform file URL/ID.
37    async fn upload_file(&self, chat_id: &str, file_path: &str, media_type: &str) -> Result<UploadResult>;
38    /// Send a media message (image/file) to a chat.
39    async fn send_media_message(&self, chat_id: &str, file_url: &str, file_name: &str, media_type: &str) -> Result<SendResult>;
40    /// Platform capabilities (drives streaming strategy).
41    fn capabilities(&self) -> PlatformCaps;
42}
43
44/// Platform extension for file/media operations.
45///
46/// Exposed to tools via `ToolContext.extensions` under key `"chatbot.platform_ext"`.
47/// `ChatbotFrontend` implements this by delegating to its `PlatformSender`,
48/// using the frontend's own `chat_id` internally.
49#[async_trait]
50pub trait PlatformExt: Send + Sync {
51    /// Upload a local file to the platform. Returns the platform identifier.
52    async fn upload_file(&self, file_path: &str, media_type: &str) -> Result<UploadResult>;
53    /// Send an already-uploaded media file to the chat.
54    async fn send_media_message(&self, file_url: &str, file_name: &str, media_type: &str) -> Result<SendResult>;
55}
56
57/// Per-session Frontend trait implementation for Bot platforms.
58///
59/// Each chat (group or private) gets its own `ChatbotFrontend` instance.
60/// `TextDelta` events are buffered and flushed once at TurnComplete with
61/// full Markdown sanitization to ensure clean output on platforms like QQ.
62pub struct ChatbotFrontend {
63    /// The chat this frontend belongs to (`group:{id}` or `private:{id}`).
64    pub chat_id: String,
65    /// The session ID for database storage.
66    pub session_id: String,
67    /// Platform message sender (shared across all frontends).
68    pub platform_sender: Arc<dyn PlatformSender>,
69    /// Tool confirmation coordinator (shared).
70    pub confirmer: Arc<Confirmer>,
71    /// Database connection for persisting messages.
72    pub db: Arc<Mutex<rusqlite::Connection>>,
73    /// Buffer to accumulate text until TurnComplete.
74    pub buffer: Mutex<String>,
75    /// ID of the last message sent (for edit-based updates, e.g., replacing a
76    /// progress hint with the actual response).
77    pub last_msg_id: Mutex<Option<String>>,
78    /// Whether a progress hint has already been sent this turn (rate limit).
79    pub progress_hint_sent: Mutex<bool>,
80    /// Auto-approve all tool calls.
81    pub auto_approve: bool,
82}
83
84impl ChatbotFrontend {
85    /// Create a new `ChatbotFrontend` for `chat_id`.
86    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    /// Save an assistant message to the database.
108    async fn save_assistant_message(&self, content: &str) {
109        let db = self.db.lock().await;
110        // Don't save empty messages - they cause problems with LLM API
111        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    /// Save a user message to the database (called from manager).
133    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    /// Append a delta to the buffer (no streaming send, just accumulate).
153    /// For QQ Bot, we send the full sanitized message at TurnComplete to
154    /// avoid duplicate messages and ensure proper Markdown handling.
155    async fn append_delta(&self, delta: &str) {
156        let mut buffer = self.buffer.lock().await;
157        buffer.push_str(delta);
158    }
159
160    /// Flush the buffer: take all accumulated text, sanitize it, send it, and save to DB.
161    /// This ensures Markdown is parsed as a whole and we only send once per turn.
162    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        // Just send once, no edit tricks - simple and reliable
186        let mut last_msg_id = self.last_msg_id.lock().await;
187        if caps.supports_edit && last_msg_id.is_some() {
188            // Edit if we already sent something this turn (e.g., progress hint)
189            let msg_id = last_msg_id.clone().unwrap();
190            if self.platform_sender.edit(&self.chat_id, &msg_id, &prepared).await.is_err() {
191                // Edit failed, fall back to send
192                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            // No previous message this turn, just send
198            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        // Save the original (un-truncated) message to database
214        if !text.is_empty() {
215            self.save_assistant_message(&text).await;
216        }
217    }
218
219    /// Send a brief progress hint. Only sends once per turn to avoid spam.
220    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                // Flush any buffered text before showing tool progress.
266                self.flush_buffer().await;
267                // In auto-approve mode, send a progress hint so the user knows
268                // the bot is working. In manual mode, the Confirmer already
269                // sends a confirm prompt — no extra hint needed.
270                if self.auto_approve {
271                    self.send_progress_hint(&name).await;
272                }
273            }
274            AgentEvent::ToolCallResult { tool_call_id, ref result } => {
275                // Async task started: in manual mode (no progress hint was
276                // sent for this call) tell the user the task is running in the
277                // background. In auto-approve mode the ToolCallRequested hint
278                // already covered "working on it".
279                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                // Tool outputs are internal; the user only sees the final text
293                // reply. Traced for diagnostics.
294                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                // Reset per-turn state.
306                *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                // Silent: the Agent wakes the LLM, whose reply is delivered
335                // via TextDelta and flushed to the chat as usual. Traced so
336                // completion is observable in logs.
337                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
373/// Truncate `text` to `max` characters, appending an ellipsis if cut.
374fn 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        // 创建一个内存中的 SQLite 连接用于测试
468        let db = Arc::new(Mutex::new(rusqlite::Connection::open_in_memory().unwrap()));
469        // 初始化数据库 schema
470        {
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        // Text deltas only accumulate, nothing is sent until TurnComplete.
489        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        // Nothing sent until TurnComplete.
493        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; // auto_approve
513        // Two tool calls in one turn — only one hint should be sent.
514        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        // After TurnComplete, a new tool call should send another hint.
543        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; // manual confirm
560        fe.on_event(AgentEvent::ToolCallRequested {
561            tool_call_id: "tc1".into(),
562            name: "bash".into(),
563            arguments: "{}".into(),
564        }).await.unwrap();
565        // No progress hint in manual mode (Confirmer sends the prompt instead).
566        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}