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 let caps = self.platform_sender.capabilities();
171
172 let prepared = if caps.supports_markdown {
173 prepare_markdown_for_platform(&text, &caps.markdown_features)
174 } else {
175 text.clone()
176 };
177 let prepared = truncate_to_max(&prepared, caps.max_message_length);
178
179 let mut last_msg_id = self.last_msg_id.lock().await;
181 if caps.supports_edit && last_msg_id.is_some() {
182 let msg_id = last_msg_id.clone().unwrap();
184 if self.platform_sender.edit(&self.chat_id, &msg_id, &prepared).await.is_err() {
185 if let Ok(res) = self.platform_sender.send(&self.chat_id, &prepared).await {
187 *last_msg_id = Some(res.msg_id);
188 }
189 }
190 } else {
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
197 if !text.is_empty() {
199 self.save_assistant_message(&text).await;
200 }
201 }
202
203 async fn send_progress_hint(&self, tool_name: &str) {
205 let mut sent = self.progress_hint_sent.lock().await;
206 if *sent {
207 return;
208 }
209 *sent = true;
210 drop(sent);
211
212 let hint = match tool_name {
213 "bash" => "🔧 正在执行命令...".to_string(),
214 "read" => "📖 正在读取文件...".to_string(),
215 "write" => "✏️ 正在写入文件...".to_string(),
216 "edit" => "✏️ 正在编辑文件...".to_string(),
217 "grep" => "🔍 正在搜索...".to_string(),
218 "find" => "🔍 正在查找...".to_string(),
219 _ => "🔧 正在处理...".to_string(),
220 };
221 if let Ok(res) = self.platform_sender.send(&self.chat_id, &hint).await {
222 *self.last_msg_id.lock().await = Some(res.msg_id);
223 }
224 }
225}
226
227#[async_trait]
228impl Frontend for ChatbotFrontend {
229 async fn on_event(&self, event: AgentEvent) -> Result<()> {
230 match event {
231 AgentEvent::TextDelta(delta) => {
232 self.append_delta(&delta).await;
233 }
234 AgentEvent::ToolCallRequested { name, .. } => {
235 self.flush_buffer().await;
237 if self.auto_approve {
241 self.send_progress_hint(&name).await;
242 }
243 }
244 AgentEvent::ToolCallResult { .. } => {
245 }
249 AgentEvent::TurnComplete => {
250 self.flush_buffer().await;
251 *self.progress_hint_sent.lock().await = false;
253 *self.last_msg_id.lock().await = None;
254 }
255 AgentEvent::Error(e) => {
256 self.flush_buffer().await;
257 let msg = format!("❌ Error: {}", e);
258 let _ = self.platform_sender.send(&self.chat_id, &msg).await;
259 }
260 AgentEvent::SkillTriggered { .. } => {
261 }
264 }
265 Ok(())
266 }
267
268 async fn request_tool_confirmation(&self, info: &ToolCallInfo) -> Result<bool> {
269 self.confirmer
270 .request(&self.chat_id, info, self.auto_approve)
271 .await
272 }
273}
274
275#[async_trait]
276impl PlatformExt for ChatbotFrontend {
277 async fn upload_file(&self, file_path: &str, media_type: &str) -> Result<UploadResult> {
278 self.platform_sender
279 .upload_file(&self.chat_id, file_path, media_type)
280 .await
281 }
282
283 async fn send_media_message(
284 &self,
285 file_url: &str,
286 file_name: &str,
287 media_type: &str,
288 ) -> Result<SendResult> {
289 self.platform_sender
290 .send_media_message(&self.chat_id, file_url, file_name, media_type)
291 .await
292 }
293}
294
295fn truncate_to_max(text: &str, max: usize) -> String {
297 if max == 0 {
298 return text.to_string();
299 }
300 if text.chars().count() <= max {
301 return text.to_string();
302 }
303 let mut out: String = text.chars().take(max.saturating_sub(1)).collect();
304 out.push('…');
305 out
306}
307
308#[cfg(test)]
309mod tests {
310 use super::*;
311 use crate::adapter::MarkdownFeatures;
312 use std::sync::Mutex as StdMutex;
313
314 struct MockSender {
315 sent: StdMutex<Vec<(String, String)>>,
316 edits: StdMutex<Vec<(String, String, String)>>,
317 caps: PlatformCaps,
318 }
319
320 impl MockSender {
321 fn new_with_edit(edit: bool) -> Arc<Self> {
322 Arc::new(Self {
323 sent: StdMutex::new(Vec::new()),
324 edits: StdMutex::new(Vec::new()),
325 caps: PlatformCaps {
326 supports_edit: edit,
327 returns_msg_id: true,
328 supports_markdown: true,
329 markdown_features: MarkdownFeatures::qq(),
330 max_message_length: 2000,
331 supports_images: true,
332 supports_files: true,
333 max_upload_size: 20 * 1024 * 1024,
334 },
335 })
336 }
337 fn sent_texts(&self) -> Vec<String> {
338 self.sent.lock().unwrap().iter().map(|(_, t)| t.clone()).collect()
339 }
340 }
341
342 #[async_trait]
343 impl PlatformSender for MockSender {
344 async fn send(&self, chat_id: &str, text: &str) -> Result<SendResult> {
345 self.sent
346 .lock()
347 .unwrap()
348 .push((chat_id.to_string(), text.to_string()));
349 Ok(SendResult {
350 msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
351 })
352 }
353 async fn edit(&self, chat_id: &str, msg_id: &str, text: &str) -> Result<()> {
354 self.edits.lock().unwrap().push((
355 chat_id.to_string(),
356 msg_id.to_string(),
357 text.to_string(),
358 ));
359 Ok(())
360 }
361 async fn upload_file(&self, _chat_id: &str, _file_path: &str, _media_type: &str) -> Result<UploadResult> {
362 Ok(UploadResult {
363 file_id: "mock-file-id".into(),
364 url: "/mock/upload.png".into(),
365 })
366 }
367 async fn send_media_message(
368 &self,
369 chat_id: &str,
370 _file_url: &str,
371 file_name: &str,
372 media_type: &str,
373 ) -> Result<SendResult> {
374 self.sent
375 .lock()
376 .unwrap()
377 .push((chat_id.to_string(), format!("[media:{}] {}", media_type, file_name)));
378 Ok(SendResult {
379 msg_id: format!("msg-{}", self.sent.lock().unwrap().len()),
380 })
381 }
382 fn capabilities(&self) -> PlatformCaps {
383 self.caps.clone()
384 }
385 }
386
387 async fn make_frontend(sender: Arc<dyn PlatformSender>, auto_approve: bool) -> ChatbotFrontend {
388 let confirmer = Arc::new(Confirmer::new(sender.clone(), std::time::Duration::from_secs(60)));
389 let db = Arc::new(Mutex::new(rusqlite::Connection::open_in_memory().unwrap()));
391 {
393 let db = db.lock().await;
394 robit_agent::storage::init_db(&db).unwrap();
395 }
396 ChatbotFrontend::new(
397 "group:1".to_string(),
398 "test-session-1".to_string(),
399 sender,
400 confirmer,
401 db,
402 auto_approve
403 )
404 }
405
406 #[tokio::test]
407 async fn textdelta_accumulates_until_turn_complete() {
408 let sender = MockSender::new_with_edit(false);
409 let fe = make_frontend(sender.clone(), false).await;
410 fe.on_event(AgentEvent::TextDelta("你好".to_string())).await.unwrap();
412 fe.on_event(AgentEvent::TextDelta("世界".to_string())).await.unwrap();
413 assert!(sender.sent_texts().is_empty());
414 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
416 assert_eq!(sender.sent_texts().len(), 1);
417 assert!(sender.sent_texts()[0].contains("你好世界"));
418 }
419
420 #[tokio::test]
421 async fn turn_complete_flushes_accumulated_text() {
422 let sender = MockSender::new_with_edit(false);
423 let fe = make_frontend(sender.clone(), false).await;
424 fe.on_event(AgentEvent::TextDelta("一段未被刷新的文本".to_string())).await.unwrap();
425 assert!(sender.sent_texts().is_empty());
426 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
427 assert_eq!(sender.sent_texts().len(), 1);
428 assert!(sender.sent_texts()[0].contains("一段未被刷新的文本"));
429 }
430
431 #[tokio::test]
432 async fn progress_hint_rate_limited_per_turn() {
433 let sender = MockSender::new_with_edit(false);
434 let fe = make_frontend(sender.clone(), true).await; fe.on_event(AgentEvent::ToolCallRequested {
437 tool_call_id: "tc1".into(),
438 name: "bash".into(),
439 arguments: "{}".into(),
440 }).await.unwrap();
441 fe.on_event(AgentEvent::ToolCallRequested {
442 tool_call_id: "tc2".into(),
443 name: "read".into(),
444 arguments: "{}".into(),
445 }).await.unwrap();
446 let hints: Vec<_> = sender
447 .sent_texts()
448 .into_iter()
449 .filter(|t| t.contains("正在"))
450 .collect();
451 assert_eq!(hints.len(), 1);
452 }
453
454 #[tokio::test]
455 async fn progress_hint_resets_on_turn_complete() {
456 let sender = MockSender::new_with_edit(false);
457 let fe = make_frontend(sender.clone(), true).await;
458 fe.on_event(AgentEvent::ToolCallRequested {
459 tool_call_id: "tc1".into(),
460 name: "bash".into(),
461 arguments: "{}".into(),
462 }).await.unwrap();
463 fe.on_event(AgentEvent::TurnComplete).await.unwrap();
464 fe.on_event(AgentEvent::ToolCallRequested {
466 tool_call_id: "tc2".into(),
467 name: "bash".into(),
468 arguments: "{}".into(),
469 }).await.unwrap();
470 let hints: Vec<_> = sender
471 .sent_texts()
472 .into_iter()
473 .filter(|t| t.contains("正在"))
474 .collect();
475 assert_eq!(hints.len(), 2);
476 }
477
478 #[tokio::test]
479 async fn no_hint_in_manual_mode() {
480 let sender = MockSender::new_with_edit(false);
481 let fe = make_frontend(sender.clone(), false).await; fe.on_event(AgentEvent::ToolCallRequested {
483 tool_call_id: "tc1".into(),
484 name: "bash".into(),
485 arguments: "{}".into(),
486 }).await.unwrap();
487 assert!(!sender.sent_texts().iter().any(|t| t.contains("正在")));
489 }
490
491 #[tokio::test]
492 async fn error_sends_error_message() {
493 let sender = MockSender::new_with_edit(false);
494 let fe = make_frontend(sender.clone(), false).await;
495 fe.on_event(AgentEvent::Error(robit_agent::AgentError::ToolError("boom".into())))
496 .await
497 .unwrap();
498 assert!(sender.sent_texts().iter().any(|t| t.contains("Error") && t.contains("boom")));
499 }
500
501}