1use std::{
8 path::PathBuf,
9 thread,
10 time::{Duration, SystemTime},
11};
12
13use async_channel::{Sender, TrySendError};
14use gpui_kit::{Context, EventEmitter};
15
16use crate::model::{AgentComponent, ComponentKind, ComponentStatus, ComponentToggle, SessionEntry};
17#[cfg(feature = "grobid")]
18use crate::references::{ReferenceEntry, ReferenceOptions, ReferenceProgress, ReferenceReport};
19
20use super::{
21 AgentInfo, IngestProgress, LibrarySnapshot, PendingAttachment, RagCommand, RagEvent, RagSetup,
22 RequestId, SessionInfo, actor, count_label,
23};
24
25pub struct RagController {
27 commands: Sender<RagCommand>,
28 worker: Option<thread::JoinHandle<()>>,
29 sessions: Vec<SessionEntry>,
30 current_session: Option<String>,
31 library: LibrarySnapshot,
32 agent: Option<AgentInfo>,
33 memory_enabled: bool,
36 hybrid: bool,
38 ingest: Option<IngestProgress>,
39 sync_request: Option<RequestId>,
40 busy: bool,
41 last_error: Option<String>,
42 last_notice: Option<String>,
43 next_request: u64,
44 #[cfg(feature = "grobid")]
45 references: Vec<ReferenceEntry>,
46 #[cfg(feature = "grobid")]
47 reference_bibliography: Option<PathBuf>,
48 #[cfg(feature = "grobid")]
49 reference_report: Option<ReferenceReport>,
50 #[cfg(feature = "grobid")]
51 reference_progress: Option<ReferenceProgress>,
52 #[cfg(feature = "grobid")]
53 reference_request: Option<RequestId>,
54 #[cfg(feature = "grobid")]
55 reference_folder: Option<PathBuf>,
56}
57
58impl RagController {
59 pub fn new(setup: RagSetup, cx: &mut Context<Self>) -> Result<Self, String> {
65 let memory_enabled = setup.memory_enabled();
66 let hybrid = setup.hybrid;
67 let (commands, command_rx) = async_channel::bounded(32);
68 let (events_tx, events) = async_channel::bounded(256);
69 let worker = actor::start(setup, None, command_rx, events_tx)
70 .map_err(|error| format!("cannot start the RAG worker: {error}"))?;
71
72 cx.spawn(async move |this, cx| {
73 while let Ok(event) = events.recv().await {
74 if this
75 .update(cx, |controller, cx| controller.on_event(event, cx))
76 .is_err()
77 {
78 break;
79 }
80 }
81 })
82 .detach();
83
84 let controller = Self {
85 commands,
86 worker: Some(worker),
87 sessions: Vec::new(),
88 current_session: None,
89 library: LibrarySnapshot::default(),
90 agent: None,
91 memory_enabled,
92 hybrid,
93 ingest: None,
94 sync_request: None,
95 busy: false,
96 last_error: None,
97 last_notice: None,
98 next_request: 0,
99 #[cfg(feature = "grobid")]
100 references: Vec::new(),
101 #[cfg(feature = "grobid")]
102 reference_bibliography: None,
103 #[cfg(feature = "grobid")]
104 reference_report: None,
105 #[cfg(feature = "grobid")]
106 reference_progress: None,
107 #[cfg(feature = "grobid")]
108 reference_request: None,
109 #[cfg(feature = "grobid")]
110 reference_folder: None,
111 };
112 Ok(controller)
115 }
116
117 pub fn ask(
119 &mut self,
120 prompt: impl Into<String>,
121 attachments: Vec<PendingAttachment>,
122 cx: &mut Context<Self>,
123 ) -> Result<RequestId, String> {
124 self.ask_with_retrieval(prompt, None, attachments, cx)
125 }
126
127 pub fn ask_with_retrieval(
134 &mut self,
135 prompt: impl Into<String>,
136 retrieval: Option<String>,
137 attachments: Vec<PendingAttachment>,
138 cx: &mut Context<Self>,
139 ) -> Result<RequestId, String> {
140 let prompt = prompt.into();
141 let prompt = prompt.trim().to_owned();
142 if prompt.is_empty() {
143 return Err("enter a question first".to_owned());
144 }
145 let retrieval = retrieval
146 .map(|text| text.trim().to_owned())
147 .filter(|text| !text.is_empty());
148 let id = self.request_id();
149 self.send(
150 RagCommand::Ask {
151 id,
152 prompt,
153 retrieval,
154 attachments,
155 },
156 cx,
157 )?;
158 Ok(id)
159 }
160
161 pub fn stop(&mut self, id: RequestId, cx: &mut Context<Self>) {
163 self.send_quiet(RagCommand::Stop { id }, cx);
164 }
165
166 pub fn new_session(&mut self, cx: &mut Context<Self>) {
168 self.send_quiet(RagCommand::NewSession, cx);
169 }
170
171 pub fn load_session(&mut self, id: impl Into<String>, cx: &mut Context<Self>) {
173 self.send_quiet(RagCommand::LoadSession { id: id.into() }, cx);
174 }
175
176 pub fn refresh_sessions(&mut self, cx: &mut Context<Self>) {
178 self.send_quiet(RagCommand::RefreshSessions, cx);
179 }
180
181 pub fn refresh_library(&mut self, cx: &mut Context<Self>) {
183 self.send_quiet(RagCommand::RefreshLibrary, cx);
184 }
185
186 pub fn sync_corpus(
188 &mut self,
189 name: impl Into<String>,
190 folder: impl Into<PathBuf>,
191 cx: &mut Context<Self>,
192 ) -> Result<RequestId, String> {
193 let id = self.request_id();
194 self.send(
195 RagCommand::SyncCorpus {
196 id,
197 name: name.into(),
198 folder: folder.into(),
199 },
200 cx,
201 )?;
202 Ok(id)
203 }
204
205 pub fn stop_sync(&mut self, cx: &mut Context<Self>) {
207 if let Some(id) = self.sync_request {
208 self.send_quiet(RagCommand::StopSync { id }, cx);
209 }
210 }
211
212 #[cfg(feature = "grobid")]
215 pub fn extract_references(
216 &mut self,
217 folder: impl Into<PathBuf>,
218 options: ReferenceOptions,
219 cx: &mut Context<Self>,
220 ) -> Result<RequestId, String> {
221 let folder = folder.into();
222 let id = self.request_id();
223 self.reference_folder = Some(folder.clone());
224 self.send(
225 RagCommand::ExtractReferences {
226 id,
227 folder,
228 options,
229 },
230 cx,
231 )?;
232 Ok(id)
233 }
234
235 #[cfg(feature = "grobid")]
237 pub fn stop_references(&mut self, cx: &mut Context<Self>) {
238 if let Some(id) = self.reference_request {
239 self.send_quiet(RagCommand::StopReferences { id }, cx);
240 }
241 }
242
243 #[cfg(feature = "grobid")]
245 pub fn refresh_references(&mut self, folder: impl Into<PathBuf>, cx: &mut Context<Self>) {
246 let folder = folder.into();
247 self.reference_folder = Some(folder.clone());
248 self.send_quiet(RagCommand::RefreshReferences { folder }, cx);
249 }
250
251 pub fn apply(&mut self, setup: RagSetup, cx: &mut Context<Self>) {
253 self.memory_enabled = setup.memory_enabled();
254 self.hybrid = setup.hybrid;
255 self.agent = None;
256 self.busy = false;
257 self.ingest = None;
258 self.sync_request = None;
259 self.current_session = None;
260 self.library = LibrarySnapshot::default();
261 self.last_error = None;
262 self.last_notice = None;
263 #[cfg(feature = "grobid")]
264 let reference_folder = self.reference_folder.clone();
265 #[cfg(feature = "grobid")]
266 {
267 self.references.clear();
268 self.reference_bibliography = None;
269 self.reference_report = None;
270 self.reference_progress = None;
271 self.reference_request = None;
272 }
273 self.send_quiet(
274 RagCommand::Apply {
275 setup: Box::new(setup),
276 },
277 cx,
278 );
279 #[cfg(feature = "grobid")]
280 if let Some(folder) = reference_folder {
281 self.send_quiet(RagCommand::RefreshReferences { folder }, cx);
282 }
283 }
284
285 pub fn sessions(&self) -> &[SessionEntry] {
287 &self.sessions
288 }
289
290 pub fn current_session(&self) -> Option<&str> {
292 self.current_session.as_deref()
293 }
294
295 pub fn library(&self) -> &LibrarySnapshot {
297 &self.library
298 }
299
300 pub fn agent(&self) -> Option<&AgentInfo> {
302 self.agent.as_ref()
303 }
304
305 pub fn ingest_progress(&self) -> Option<&IngestProgress> {
307 self.ingest.as_ref()
308 }
309
310 pub fn is_busy(&self) -> bool {
312 self.busy
313 }
314
315 pub fn is_ingesting(&self) -> bool {
317 self.ingest.is_some()
318 }
319
320 #[cfg(feature = "grobid")]
322 pub fn references(&self) -> &[ReferenceEntry] {
323 &self.references
324 }
325
326 #[cfg(feature = "grobid")]
328 pub fn reference_bibliography(&self) -> Option<&std::path::Path> {
329 self.reference_bibliography.as_deref()
330 }
331
332 #[cfg(feature = "grobid")]
334 pub fn reference_report(&self) -> Option<&ReferenceReport> {
335 self.reference_report.as_ref()
336 }
337
338 #[cfg(feature = "grobid")]
340 pub fn reference_progress(&self) -> Option<&ReferenceProgress> {
341 self.reference_progress.as_ref()
342 }
343
344 #[cfg(feature = "grobid")]
346 pub fn is_extracting_references(&self) -> bool {
347 self.reference_progress.is_some()
348 }
349
350 pub fn error(&self) -> Option<&str> {
352 self.last_error.as_deref()
353 }
354
355 pub fn status_line(&self) -> String {
357 if let Some(error) = &self.last_error {
358 return error.clone();
359 }
360 if let Some(progress) = &self.ingest {
361 return progress.summary();
362 }
363 #[cfg(feature = "grobid")]
364 if let Some(progress) = &self.reference_progress {
365 return progress.summary();
366 }
367 if self.busy {
368 return "Generating…".to_owned();
369 }
370 match &self.agent {
371 Some(agent) => format!(
372 "{} · {} · {}",
373 agent.chat_model,
374 count_label(agent.documents, "document", "documents"),
375 count_label(agent.chunks, "chunk", "chunks")
376 ),
377 None => self
378 .last_notice
379 .clone()
380 .unwrap_or_else(|| "Starting the RAG worker…".to_owned()),
381 }
382 }
383
384 pub fn memory_enabled(&self) -> bool {
386 self.memory_enabled
387 }
388
389 pub fn hybrid(&self) -> bool {
391 self.hybrid
392 }
393
394 pub fn agent_components(&self) -> Vec<AgentComponent> {
396 let memory_toggle = ComponentToggle::new("memory", "Enabled", self.memory_enabled)
397 .detail("Conversation transcript and query rewriting.");
398 let hybrid_toggle = ComponentToggle::new("hybrid", "Enabled", self.hybrid)
399 .detail("BM25 plus embeddings. Off needs no embedding model.");
400
401 let Some(agent) = &self.agent else {
402 let status = if self.last_error.is_some() {
403 ComponentStatus::Error
404 } else {
405 ComponentStatus::Inactive
406 };
407 let memory_status = if self.memory_enabled {
408 status
409 } else {
410 ComponentStatus::Inactive
411 };
412 let retrieval_status = if self.hybrid {
413 status
414 } else {
415 ComponentStatus::Inactive
416 };
417 let components = vec![
418 AgentComponent::new("chat", ComponentKind::Chat, "Chat model").status(status),
419 AgentComponent::new("memory", ComponentKind::Memory, "Chat memory")
421 .status(memory_status)
422 .detail(if self.memory_enabled {
423 "conversation memory"
424 } else {
425 "off · no transcript or rewriting"
426 })
427 .toggle(memory_toggle),
428 AgentComponent::new("embed", ComponentKind::Embedder, "Embedder")
429 .status(retrieval_status)
430 .detail(if self.hybrid {
431 "hybrid retrieval"
432 } else {
433 "off · no embedding model"
434 }),
435 AgentComponent::new("store", ComponentKind::Store, "Vector store").status(status),
436 AgentComponent::new("parser", ComponentKind::Parser, "PDF parser").status(status),
437 AgentComponent::new("chunker", ComponentKind::Chunker, "Chunker").status(status),
438 AgentComponent::new("retriever", ComponentKind::Retriever, "Hybrid retrieval")
439 .status(retrieval_status)
440 .detail(if self.hybrid {
441 "BM25 + embeddings"
442 } else {
443 "off · no embeddings"
444 })
445 .toggle(hybrid_toggle),
446 ];
447 #[cfg(feature = "grobid")]
448 let components = {
449 let mut components = components;
450 components.push(
451 AgentComponent::new("references", ComponentKind::References, "GROBID")
452 .status(status),
453 );
454 components
455 };
456 return components;
457 };
458
459 let pipeline = if self.ingest.is_some() {
460 ComponentStatus::Busy
461 } else {
462 ComponentStatus::Ready
463 };
464 let retrieval_status = if self.hybrid {
465 pipeline
466 } else {
467 ComponentStatus::Inactive
468 };
469 let memory_status = if self.memory_enabled {
470 ComponentStatus::Ready
471 } else {
472 ComponentStatus::Inactive
473 };
474 let chat = if self.busy {
475 ComponentStatus::Busy
476 } else {
477 ComponentStatus::Ready
478 };
479
480 let components = vec![
481 AgentComponent::new("chat", ComponentKind::Chat, agent.chat_model.clone())
482 .status(chat)
483 .detail(format!("{} · {} memory", agent.chat_backend, agent.memory)),
484 AgentComponent::new("memory", ComponentKind::Memory, "Chat memory")
486 .status(memory_status)
487 .detail(if self.memory_enabled {
488 format!("{} strategy", agent.memory)
489 } else {
490 "off · no transcript or rewriting".to_owned()
491 })
492 .toggle(memory_toggle),
493 AgentComponent::new(
494 "embed",
495 ComponentKind::Embedder,
496 if self.hybrid {
497 agent.embed_model.clone()
498 } else {
499 "No embeddings".to_owned()
500 },
501 )
502 .status(retrieval_status)
503 .detail(if self.hybrid {
504 format!("{} · top {}", agent.embed_backend, agent.top_k)
505 } else {
506 "off · no embedding model".to_owned()
507 }),
508 AgentComponent::new("store", ComponentKind::Store, "Vector store")
509 .status(pipeline)
510 .detail(format!(
511 "{} · {}",
512 count_label(agent.documents, "document", "documents"),
513 count_label(agent.chunks, "chunk", "chunks")
514 )),
515 AgentComponent::new("parser", ComponentKind::Parser, agent.pdf_backend.clone())
516 .status(pipeline)
517 .detail("PDF and Markdown"),
518 AgentComponent::new("chunker", ComponentKind::Chunker, "Markdown")
519 .status(pipeline)
520 .detail(format!(
521 "{} tokens · {} overlap",
522 agent.chunk_size, agent.chunk_overlap
523 )),
524 AgentComponent::new("retriever", ComponentKind::Retriever, "Hybrid retrieval")
525 .status(retrieval_status)
526 .detail(if self.hybrid {
527 format!("BM25 + embeddings · top {}", agent.top_k)
528 } else {
529 "off · no embeddings".to_owned()
530 })
531 .toggle(hybrid_toggle),
532 ];
533 #[cfg(feature = "grobid")]
534 let components = {
535 let mut components = components;
536 let status = if self.reference_progress.is_some() {
537 ComponentStatus::Busy
538 } else if self.references.is_empty() {
539 ComponentStatus::Inactive
540 } else {
541 ComponentStatus::Ready
542 };
543 let detail = match &self.reference_bibliography {
544 Some(path) => format!(
545 "{} · {}",
546 count_label(self.references.len(), "reference", "references"),
547 path.file_name()
548 .and_then(|name| name.to_str())
549 .unwrap_or("references.bib")
550 ),
551 None => count_label(self.references.len(), "reference", "references"),
552 };
553 components.push(
554 AgentComponent::new("references", ComponentKind::References, "GROBID")
555 .status(status)
556 .detail(detail),
557 );
558 components
559 };
560 components
561 }
562
563 fn request_id(&mut self) -> RequestId {
564 self.next_request = self.next_request.wrapping_add(1);
565 RequestId::new(self.next_request)
566 }
567
568 fn send(&self, command: RagCommand, cx: &mut Context<Self>) -> Result<(), String> {
569 self.commands
570 .try_send(command)
571 .map_err(|error| match error {
572 TrySendError::Full(_) => "the RAG worker is busy; try again",
573 TrySendError::Closed(_) => "the RAG worker stopped",
574 })?;
575 cx.notify();
576 Ok(())
577 }
578
579 fn send_quiet(&mut self, command: RagCommand, cx: &mut Context<Self>) {
580 if let Err(message) = self.send(command, cx) {
581 self.last_error = Some(message);
582 cx.notify();
583 }
584 }
585
586 fn on_event(&mut self, event: RagEvent, cx: &mut Context<Self>) {
587 match &event {
588 RagEvent::AgentReady(info) => {
589 self.memory_enabled = info.memory != "off";
590 self.hybrid = info.hybrid;
591 self.agent = Some(info.clone());
592 self.last_error = None;
593 }
594 RagEvent::AgentFailed { message } => {
595 self.agent = None;
596 self.busy = false;
597 self.last_error = Some(message.clone());
598 }
599 RagEvent::SessionStarted { id } | RagEvent::SessionLoaded { id, .. } => {
600 self.current_session = Some(id.clone());
601 }
602 RagEvent::Sessions(sessions) => {
603 self.sessions = session_entries(sessions, SystemTime::now());
604 }
605 RagEvent::Started { .. } => {
606 self.busy = true;
607 self.last_notice = None;
608 self.last_error = None;
609 }
610 RagEvent::Chunk { .. } => {}
611 RagEvent::Finished { .. } | RagEvent::Stopped { .. } => {
612 self.busy = false;
613 }
614 RagEvent::Failed { message, .. } => {
615 self.busy = false;
616 self.last_error = Some(message.clone());
617 }
618 RagEvent::Library(snapshot) => {
619 self.library = snapshot.clone();
620 if let Some(agent) = &mut self.agent {
621 agent.chunks = snapshot.chunks;
622 agent.documents = snapshot.documents.len();
623 }
624 }
625 #[cfg(feature = "grobid")]
626 RagEvent::Citations { .. } => {}
627 #[cfg(feature = "grobid")]
628 RagEvent::ReferencesStarted { id, folder } => {
629 self.reference_request = Some(*id);
630 self.reference_folder = Some(folder.clone());
631 self.reference_progress = Some(ReferenceProgress::default());
632 self.last_error = None;
633 }
634 #[cfg(feature = "grobid")]
635 RagEvent::ReferencesProgress { progress, .. } => {
636 self.reference_progress = Some(progress.clone());
637 }
638 #[cfg(feature = "grobid")]
639 RagEvent::ReferencesLoaded {
640 entries,
641 bibliography,
642 } => {
643 self.references = entries.clone();
644 self.reference_bibliography = Some(bibliography.clone());
645 }
646 #[cfg(feature = "grobid")]
647 RagEvent::ReferencesFinished { report, .. } => {
648 self.reference_report = Some(report.clone());
649 self.reference_progress = None;
650 self.reference_request = None;
651 }
652 #[cfg(feature = "grobid")]
653 RagEvent::ReferencesStopped { .. } => {
654 self.reference_progress = None;
655 self.reference_request = None;
656 }
657 #[cfg(feature = "grobid")]
658 RagEvent::ReferencesFailed { message, .. } => {
659 self.reference_progress = None;
660 self.reference_request = None;
661 self.last_error = Some(message.clone());
662 }
663 RagEvent::SyncStarted { id, corpus } => {
664 self.sync_request = Some(*id);
665 self.last_error = None;
666 self.ingest = Some(IngestProgress {
667 corpus: corpus.clone(),
668 ..IngestProgress::default()
669 });
670 }
671 RagEvent::SyncProgress { progress, .. } => {
672 self.ingest = Some(progress.clone());
673 }
674 RagEvent::SyncFinished { .. } | RagEvent::SyncStopped { .. } => {
675 self.ingest = None;
676 self.sync_request = None;
677 }
678 RagEvent::SyncFailed { message, .. } => {
679 self.ingest = None;
680 self.sync_request = None;
681 self.last_error = Some(message.clone());
682 }
683 RagEvent::Notice(message) => {
684 self.last_notice = Some(message.clone());
685 }
686 }
687
688 cx.notify();
689 cx.emit(event);
690 }
691}
692
693impl EventEmitter<RagEvent> for RagController {}
694
695impl Drop for RagController {
696 fn drop(&mut self) {
697 self.commands.close();
698 if let Some(worker) = self.worker.take() {
699 let _ = worker.join();
700 }
701 }
702}
703
704fn session_entries(sessions: &[SessionInfo], now: SystemTime) -> Vec<SessionEntry> {
705 let mut sessions = sessions.to_vec();
706 sessions.sort_by_key(|session| std::cmp::Reverse(session.updated));
707 sessions
708 .into_iter()
709 .map(|session| {
710 let label = session
711 .summary
712 .as_deref()
713 .map(str::trim)
714 .filter(|summary| !summary.is_empty())
715 .map(str::to_owned)
716 .unwrap_or_else(|| match session.turn_count {
717 0 => "Empty conversation".to_owned(),
718 1 => "1 turn".to_owned(),
719 count => format!("{count} turns"),
720 });
721 SessionEntry::new(session.id, label).detail(relative_time(now, session.updated))
722 })
723 .collect()
724}
725
726fn relative_time(now: SystemTime, then: SystemTime) -> String {
727 let elapsed = now.duration_since(then).unwrap_or_default();
728 if elapsed < Duration::from_secs(60) {
729 "just now".to_owned()
730 } else if elapsed < Duration::from_secs(3_600) {
731 format!("{} min ago", elapsed.as_secs() / 60)
732 } else if elapsed < Duration::from_secs(86_400) {
733 format!("{} h ago", elapsed.as_secs() / 3_600)
734 } else {
735 format!("{} d ago", elapsed.as_secs() / 86_400)
736 }
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742
743 fn info(id: &str, turns: usize, summary: Option<&str>, updated: u64) -> SessionInfo {
744 SessionInfo {
745 id: id.to_owned(),
746 turn_count: turns,
747 summary: summary.map(str::to_owned),
748 updated: SystemTime::UNIX_EPOCH + Duration::from_secs(updated),
749 }
750 }
751
752 #[test]
753 fn sessions_are_sorted_and_labelled() {
754 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
755 let entries = session_entries(
756 &[
757 info("old", 1, None, 1_000),
758 info("new", 3, None, 9_000),
759 info("summary", 0, Some("Methods review"), 5_000),
760 ],
761 now,
762 );
763 assert_eq!(entries[0].id, "new");
764 assert_eq!(entries[0].label, "3 turns");
765 assert_eq!(entries[1].id, "summary");
766 assert_eq!(entries[1].label, "Methods review");
767 assert_eq!(entries[2].id, "old");
768 assert_eq!(entries[2].label, "1 turn");
769 }
770
771 #[test]
772 fn relative_times_are_human_readable() {
773 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000);
774 assert_eq!(relative_time(now, now - Duration::from_secs(5)), "just now");
775 assert_eq!(
776 relative_time(now, now - Duration::from_secs(120)),
777 "2 min ago"
778 );
779 assert_eq!(
780 relative_time(now, now - Duration::from_secs(7_200)),
781 "2 h ago"
782 );
783 assert_eq!(
784 relative_time(now, now - Duration::from_secs(172_800)),
785 "2 d ago"
786 );
787 }
788}