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    /// Platform message sender (shared across all frontends).
66    pub platform_sender: Arc<dyn PlatformSender>,
67    /// Tool confirmation coordinator (shared).
68    pub confirmer: Arc<Confirmer>,
69    /// Buffer to accumulate text until TurnComplete.
70    pub buffer: Mutex<String>,
71    /// ID of the last message sent (for edit-based updates, e.g., replacing a
72    /// progress hint with the actual response).
73    pub last_msg_id: Mutex<Option<String>>,
74    /// Whether a progress hint has already been sent this turn (rate limit).
75    pub progress_hint_sent: Mutex<bool>,
76    /// Auto-approve all tool calls.
77    pub auto_approve: bool,
78}
79
80impl ChatbotFrontend {
81    /// Create a new `ChatbotFrontend` for `chat_id`.
82    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    /// Append a delta to the buffer (no streaming send, just accumulate).
100    /// For QQ Bot, we send the full sanitized message at TurnComplete to
101    /// avoid duplicate messages and ensure proper Markdown handling.
102    async fn append_delta(&self, delta: &str) {
103        let mut buffer = self.buffer.lock().await;
104        buffer.push_str(delta);
105    }
106
107    /// Flush the buffer: take all accumulated text, sanitize it, and send it.
108    /// This ensures Markdown is parsed as a whole and we only send once per turn.
109    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        // Just send once, no edit tricks - simple and reliable
127        let mut last_msg_id = self.last_msg_id.lock().await;
128        if caps.supports_edit && last_msg_id.is_some() {
129            // Edit if we already sent something this turn (e.g., progress hint)
130            let msg_id = last_msg_id.clone().unwrap();
131            if self.platform_sender.edit(&self.chat_id, &msg_id, &prepared).await.is_err() {
132                // Edit failed, fall back to send
133                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            // No previous message this turn, just send
139            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    /// Send a brief progress hint. Only sends once per turn to avoid spam.
146    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                // Flush any buffered text before showing tool progress.
178                self.flush_buffer().await;
179                // In auto-approve mode, send a progress hint so the user knows
180                // the bot is working. In manual mode, the Confirmer already
181                // sends a confirm prompt — no extra hint needed.
182                if self.auto_approve {
183                    self.send_progress_hint(&name).await;
184                }
185            }
186            AgentEvent::ToolCallResult { .. } => {
187                // Silent: tool outputs are internal; the user only sees the
188                // final text reply. Any progress hint is replaced by the reply
189                // on TurnComplete.
190            }
191            AgentEvent::TurnComplete => {
192                self.flush_buffer().await;
193                // Reset per-turn state.
194                *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                // Silent: skill trigger is internal; the skill's own output
204                // arrives as TextDelta events.
205            }
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
237/// Truncate `text` to `max` characters, appending an ellipsis if cut.
238fn 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        // Text deltas only accumulate, nothing is sent until TurnComplete.
339        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        // Nothing sent until TurnComplete.
343        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); // auto_approve
363        // Two tool calls in one turn — only one hint should be sent.
364        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        // After TurnComplete, a new tool call should send another hint.
393        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); // manual confirm
410        fe.on_event(AgentEvent::ToolCallRequested {
411            tool_call_id: "tc1".into(),
412            name: "bash".into(),
413            arguments: "{}".into(),
414        }).await.unwrap();
415        // No progress hint in manual mode (Confirmer sends the prompt instead).
416        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}