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, 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 ingest: Option<IngestProgress>,
34 sync_request: Option<RequestId>,
35 busy: bool,
36 last_error: Option<String>,
37 last_notice: Option<String>,
38 next_request: u64,
39 #[cfg(feature = "grobid")]
40 references: Vec<ReferenceEntry>,
41 #[cfg(feature = "grobid")]
42 reference_bibliography: Option<PathBuf>,
43 #[cfg(feature = "grobid")]
44 reference_report: Option<ReferenceReport>,
45 #[cfg(feature = "grobid")]
46 reference_progress: Option<ReferenceProgress>,
47 #[cfg(feature = "grobid")]
48 reference_request: Option<RequestId>,
49 #[cfg(feature = "grobid")]
50 reference_folder: Option<PathBuf>,
51}
52
53impl RagController {
54 pub fn new(setup: RagSetup, cx: &mut Context<Self>) -> Result<Self, String> {
60 let (commands, command_rx) = async_channel::bounded(32);
61 let (events_tx, events) = async_channel::bounded(256);
62 let worker = actor::start(setup, None, command_rx, events_tx)
63 .map_err(|error| format!("cannot start the RAG worker: {error}"))?;
64
65 cx.spawn(async move |this, cx| {
66 while let Ok(event) = events.recv().await {
67 if this
68 .update(cx, |controller, cx| controller.on_event(event, cx))
69 .is_err()
70 {
71 break;
72 }
73 }
74 })
75 .detach();
76
77 let controller = Self {
78 commands,
79 worker: Some(worker),
80 sessions: Vec::new(),
81 current_session: None,
82 library: LibrarySnapshot::default(),
83 agent: None,
84 ingest: None,
85 sync_request: None,
86 busy: false,
87 last_error: None,
88 last_notice: None,
89 next_request: 0,
90 #[cfg(feature = "grobid")]
91 references: Vec::new(),
92 #[cfg(feature = "grobid")]
93 reference_bibliography: None,
94 #[cfg(feature = "grobid")]
95 reference_report: None,
96 #[cfg(feature = "grobid")]
97 reference_progress: None,
98 #[cfg(feature = "grobid")]
99 reference_request: None,
100 #[cfg(feature = "grobid")]
101 reference_folder: None,
102 };
103 Ok(controller)
106 }
107
108 pub fn ask(
110 &mut self,
111 prompt: impl Into<String>,
112 attachments: Vec<PendingAttachment>,
113 cx: &mut Context<Self>,
114 ) -> Result<RequestId, String> {
115 let prompt = prompt.into();
116 let prompt = prompt.trim().to_owned();
117 if prompt.is_empty() {
118 return Err("enter a question first".to_owned());
119 }
120 let id = self.request_id();
121 self.send(
122 RagCommand::Ask {
123 id,
124 prompt,
125 attachments,
126 },
127 cx,
128 )?;
129 Ok(id)
130 }
131
132 pub fn stop(&mut self, id: RequestId, cx: &mut Context<Self>) {
134 self.send_quiet(RagCommand::Stop { id }, cx);
135 }
136
137 pub fn new_session(&mut self, cx: &mut Context<Self>) {
139 self.send_quiet(RagCommand::NewSession, cx);
140 }
141
142 pub fn load_session(&mut self, id: impl Into<String>, cx: &mut Context<Self>) {
144 self.send_quiet(RagCommand::LoadSession { id: id.into() }, cx);
145 }
146
147 pub fn refresh_sessions(&mut self, cx: &mut Context<Self>) {
149 self.send_quiet(RagCommand::RefreshSessions, cx);
150 }
151
152 pub fn refresh_library(&mut self, cx: &mut Context<Self>) {
154 self.send_quiet(RagCommand::RefreshLibrary, cx);
155 }
156
157 pub fn sync_corpus(
159 &mut self,
160 name: impl Into<String>,
161 folder: impl Into<PathBuf>,
162 cx: &mut Context<Self>,
163 ) -> Result<RequestId, String> {
164 let id = self.request_id();
165 self.send(
166 RagCommand::SyncCorpus {
167 id,
168 name: name.into(),
169 folder: folder.into(),
170 },
171 cx,
172 )?;
173 Ok(id)
174 }
175
176 pub fn stop_sync(&mut self, cx: &mut Context<Self>) {
178 if let Some(id) = self.sync_request {
179 self.send_quiet(RagCommand::StopSync { id }, cx);
180 }
181 }
182
183 #[cfg(feature = "grobid")]
186 pub fn extract_references(
187 &mut self,
188 folder: impl Into<PathBuf>,
189 options: ReferenceOptions,
190 cx: &mut Context<Self>,
191 ) -> Result<RequestId, String> {
192 let folder = folder.into();
193 let id = self.request_id();
194 self.reference_folder = Some(folder.clone());
195 self.send(
196 RagCommand::ExtractReferences {
197 id,
198 folder,
199 options,
200 },
201 cx,
202 )?;
203 Ok(id)
204 }
205
206 #[cfg(feature = "grobid")]
208 pub fn stop_references(&mut self, cx: &mut Context<Self>) {
209 if let Some(id) = self.reference_request {
210 self.send_quiet(RagCommand::StopReferences { id }, cx);
211 }
212 }
213
214 #[cfg(feature = "grobid")]
216 pub fn refresh_references(&mut self, folder: impl Into<PathBuf>, cx: &mut Context<Self>) {
217 let folder = folder.into();
218 self.reference_folder = Some(folder.clone());
219 self.send_quiet(RagCommand::RefreshReferences { folder }, cx);
220 }
221
222 pub fn apply(&mut self, setup: RagSetup, cx: &mut Context<Self>) {
224 self.agent = None;
225 self.busy = false;
226 self.ingest = None;
227 self.sync_request = None;
228 self.current_session = None;
229 self.library = LibrarySnapshot::default();
230 self.last_error = None;
231 self.last_notice = None;
232 #[cfg(feature = "grobid")]
233 let reference_folder = self.reference_folder.clone();
234 #[cfg(feature = "grobid")]
235 {
236 self.references.clear();
237 self.reference_bibliography = None;
238 self.reference_report = None;
239 self.reference_progress = None;
240 self.reference_request = None;
241 }
242 self.send_quiet(
243 RagCommand::Apply {
244 setup: Box::new(setup),
245 },
246 cx,
247 );
248 #[cfg(feature = "grobid")]
249 if let Some(folder) = reference_folder {
250 self.send_quiet(RagCommand::RefreshReferences { folder }, cx);
251 }
252 }
253
254 pub fn sessions(&self) -> &[SessionEntry] {
256 &self.sessions
257 }
258
259 pub fn current_session(&self) -> Option<&str> {
261 self.current_session.as_deref()
262 }
263
264 pub fn library(&self) -> &LibrarySnapshot {
266 &self.library
267 }
268
269 pub fn agent(&self) -> Option<&AgentInfo> {
271 self.agent.as_ref()
272 }
273
274 pub fn ingest_progress(&self) -> Option<&IngestProgress> {
276 self.ingest.as_ref()
277 }
278
279 pub fn is_busy(&self) -> bool {
281 self.busy
282 }
283
284 pub fn is_ingesting(&self) -> bool {
286 self.ingest.is_some()
287 }
288
289 #[cfg(feature = "grobid")]
291 pub fn references(&self) -> &[ReferenceEntry] {
292 &self.references
293 }
294
295 #[cfg(feature = "grobid")]
297 pub fn reference_bibliography(&self) -> Option<&std::path::Path> {
298 self.reference_bibliography.as_deref()
299 }
300
301 #[cfg(feature = "grobid")]
303 pub fn reference_report(&self) -> Option<&ReferenceReport> {
304 self.reference_report.as_ref()
305 }
306
307 #[cfg(feature = "grobid")]
309 pub fn reference_progress(&self) -> Option<&ReferenceProgress> {
310 self.reference_progress.as_ref()
311 }
312
313 #[cfg(feature = "grobid")]
315 pub fn is_extracting_references(&self) -> bool {
316 self.reference_progress.is_some()
317 }
318
319 pub fn error(&self) -> Option<&str> {
321 self.last_error.as_deref()
322 }
323
324 pub fn status_line(&self) -> String {
326 if let Some(error) = &self.last_error {
327 return error.clone();
328 }
329 if let Some(progress) = &self.ingest {
330 return progress.summary();
331 }
332 #[cfg(feature = "grobid")]
333 if let Some(progress) = &self.reference_progress {
334 return progress.summary();
335 }
336 if self.busy {
337 return "Generating…".to_owned();
338 }
339 match &self.agent {
340 Some(agent) => format!(
341 "{} · {} · {}",
342 agent.chat_model,
343 count_label(agent.documents, "document", "documents"),
344 count_label(agent.chunks, "chunk", "chunks")
345 ),
346 None => self
347 .last_notice
348 .clone()
349 .unwrap_or_else(|| "Starting the RAG worker…".to_owned()),
350 }
351 }
352
353 pub fn agent_components(&self) -> Vec<AgentComponent> {
355 let Some(agent) = &self.agent else {
356 let status = if self.last_error.is_some() {
357 ComponentStatus::Error
358 } else {
359 ComponentStatus::Inactive
360 };
361 let components: Vec<AgentComponent> = [
362 (ComponentKind::Chat, "Chat model"),
363 (ComponentKind::Embedder, "Embedder"),
364 (ComponentKind::Store, "Vector store"),
365 (ComponentKind::Parser, "PDF parser"),
366 (ComponentKind::Chunker, "Chunker"),
367 (ComponentKind::Retriever, "Retriever"),
368 ]
369 .into_iter()
370 .map(|(kind, label)| AgentComponent::new(kind_label(kind), kind, label).status(status))
371 .collect();
372 #[cfg(feature = "grobid")]
373 let components = {
374 let mut components = components;
375 components.push(
376 AgentComponent::new("references", ComponentKind::References, "GROBID")
377 .status(status),
378 );
379 components
380 };
381 return components;
382 };
383
384 let pipeline = if self.ingest.is_some() {
385 ComponentStatus::Busy
386 } else {
387 ComponentStatus::Ready
388 };
389 let chat = if self.busy {
390 ComponentStatus::Busy
391 } else {
392 ComponentStatus::Ready
393 };
394
395 let components = vec![
396 AgentComponent::new("chat", ComponentKind::Chat, agent.chat_model.clone())
397 .status(chat)
398 .detail(format!("{} · {} memory", agent.chat_backend, agent.memory)),
399 AgentComponent::new("embed", ComponentKind::Embedder, agent.embed_model.clone())
400 .status(pipeline)
401 .detail(format!("{} · top {}", agent.embed_backend, agent.top_k)),
402 AgentComponent::new("store", ComponentKind::Store, "Vector store")
403 .status(pipeline)
404 .detail(format!(
405 "{} · {}",
406 count_label(agent.documents, "document", "documents"),
407 count_label(agent.chunks, "chunk", "chunks")
408 )),
409 AgentComponent::new("parser", ComponentKind::Parser, agent.pdf_backend.clone())
410 .status(pipeline)
411 .detail("PDF and Markdown"),
412 AgentComponent::new("chunker", ComponentKind::Chunker, "Markdown")
413 .status(pipeline)
414 .detail(format!(
415 "{} tokens · {} overlap",
416 agent.chunk_size, agent.chunk_overlap
417 )),
418 AgentComponent::new("retriever", ComponentKind::Retriever, "Hybrid retrieval")
419 .status(pipeline)
420 .detail(format!("top {}", agent.top_k)),
421 ];
422 #[cfg(feature = "grobid")]
423 let components = {
424 let mut components = components;
425 let status = if self.reference_progress.is_some() {
426 ComponentStatus::Busy
427 } else if self.references.is_empty() {
428 ComponentStatus::Inactive
429 } else {
430 ComponentStatus::Ready
431 };
432 let detail = match &self.reference_bibliography {
433 Some(path) => format!(
434 "{} · {}",
435 count_label(self.references.len(), "reference", "references"),
436 path.file_name()
437 .and_then(|name| name.to_str())
438 .unwrap_or("references.bib")
439 ),
440 None => count_label(self.references.len(), "reference", "references"),
441 };
442 components.push(
443 AgentComponent::new("references", ComponentKind::References, "GROBID")
444 .status(status)
445 .detail(detail),
446 );
447 components
448 };
449 components
450 }
451
452 fn request_id(&mut self) -> RequestId {
453 self.next_request = self.next_request.wrapping_add(1);
454 RequestId::new(self.next_request)
455 }
456
457 fn send(&self, command: RagCommand, cx: &mut Context<Self>) -> Result<(), String> {
458 self.commands
459 .try_send(command)
460 .map_err(|error| match error {
461 TrySendError::Full(_) => "the RAG worker is busy; try again",
462 TrySendError::Closed(_) => "the RAG worker stopped",
463 })?;
464 cx.notify();
465 Ok(())
466 }
467
468 fn send_quiet(&mut self, command: RagCommand, cx: &mut Context<Self>) {
469 if let Err(message) = self.send(command, cx) {
470 self.last_error = Some(message);
471 cx.notify();
472 }
473 }
474
475 fn on_event(&mut self, event: RagEvent, cx: &mut Context<Self>) {
476 match &event {
477 RagEvent::AgentReady(info) => {
478 self.agent = Some(info.clone());
479 self.last_error = None;
480 }
481 RagEvent::AgentFailed { message } => {
482 self.agent = None;
483 self.busy = false;
484 self.last_error = Some(message.clone());
485 }
486 RagEvent::SessionStarted { id } | RagEvent::SessionLoaded { id, .. } => {
487 self.current_session = Some(id.clone());
488 }
489 RagEvent::Sessions(sessions) => {
490 self.sessions = session_entries(sessions, SystemTime::now());
491 }
492 RagEvent::Started { .. } => {
493 self.busy = true;
494 self.last_notice = None;
495 self.last_error = None;
496 }
497 RagEvent::Chunk { .. } => {}
498 RagEvent::Finished { .. } | RagEvent::Stopped { .. } => {
499 self.busy = false;
500 }
501 RagEvent::Failed { message, .. } => {
502 self.busy = false;
503 self.last_error = Some(message.clone());
504 }
505 RagEvent::Library(snapshot) => {
506 self.library = snapshot.clone();
507 if let Some(agent) = &mut self.agent {
508 agent.chunks = snapshot.chunks;
509 agent.documents = snapshot.documents.len();
510 }
511 }
512 #[cfg(feature = "grobid")]
513 RagEvent::Citations { .. } => {}
514 #[cfg(feature = "grobid")]
515 RagEvent::ReferencesStarted { id, folder } => {
516 self.reference_request = Some(*id);
517 self.reference_folder = Some(folder.clone());
518 self.reference_progress = Some(ReferenceProgress::default());
519 self.last_error = None;
520 }
521 #[cfg(feature = "grobid")]
522 RagEvent::ReferencesProgress { progress, .. } => {
523 self.reference_progress = Some(progress.clone());
524 }
525 #[cfg(feature = "grobid")]
526 RagEvent::ReferencesLoaded {
527 entries,
528 bibliography,
529 } => {
530 self.references = entries.clone();
531 self.reference_bibliography = Some(bibliography.clone());
532 }
533 #[cfg(feature = "grobid")]
534 RagEvent::ReferencesFinished { report, .. } => {
535 self.reference_report = Some(report.clone());
536 self.reference_progress = None;
537 self.reference_request = None;
538 }
539 #[cfg(feature = "grobid")]
540 RagEvent::ReferencesStopped { .. } => {
541 self.reference_progress = None;
542 self.reference_request = None;
543 }
544 #[cfg(feature = "grobid")]
545 RagEvent::ReferencesFailed { message, .. } => {
546 self.reference_progress = None;
547 self.reference_request = None;
548 self.last_error = Some(message.clone());
549 }
550 RagEvent::SyncStarted { id, corpus } => {
551 self.sync_request = Some(*id);
552 self.last_error = None;
553 self.ingest = Some(IngestProgress {
554 corpus: corpus.clone(),
555 ..IngestProgress::default()
556 });
557 }
558 RagEvent::SyncProgress { progress, .. } => {
559 self.ingest = Some(progress.clone());
560 }
561 RagEvent::SyncFinished { .. } | RagEvent::SyncStopped { .. } => {
562 self.ingest = None;
563 self.sync_request = None;
564 }
565 RagEvent::SyncFailed { message, .. } => {
566 self.ingest = None;
567 self.sync_request = None;
568 self.last_error = Some(message.clone());
569 }
570 RagEvent::Notice(message) => {
571 self.last_notice = Some(message.clone());
572 }
573 }
574
575 cx.notify();
576 cx.emit(event);
577 }
578}
579
580impl EventEmitter<RagEvent> for RagController {}
581
582impl Drop for RagController {
583 fn drop(&mut self) {
584 self.commands.close();
585 if let Some(worker) = self.worker.take() {
586 let _ = worker.join();
587 }
588 }
589}
590
591fn kind_label(kind: ComponentKind) -> &'static str {
592 match kind {
593 ComponentKind::Chat => "chat",
594 ComponentKind::Embedder => "embed",
595 ComponentKind::Store => "store",
596 ComponentKind::Parser => "parser",
597 ComponentKind::Chunker => "chunker",
598 ComponentKind::Retriever => "retriever",
599 ComponentKind::Vision => "vision",
600 ComponentKind::References => "references",
601 ComponentKind::Other => "component",
602 }
603}
604
605fn session_entries(sessions: &[SessionInfo], now: SystemTime) -> Vec<SessionEntry> {
606 let mut sessions = sessions.to_vec();
607 sessions.sort_by_key(|session| std::cmp::Reverse(session.updated));
608 sessions
609 .into_iter()
610 .map(|session| {
611 let label = session
612 .summary
613 .as_deref()
614 .map(str::trim)
615 .filter(|summary| !summary.is_empty())
616 .map(str::to_owned)
617 .unwrap_or_else(|| match session.turn_count {
618 0 => "Empty conversation".to_owned(),
619 1 => "1 turn".to_owned(),
620 count => format!("{count} turns"),
621 });
622 SessionEntry::new(session.id, label).detail(relative_time(now, session.updated))
623 })
624 .collect()
625}
626
627fn relative_time(now: SystemTime, then: SystemTime) -> String {
628 let elapsed = now.duration_since(then).unwrap_or_default();
629 if elapsed < Duration::from_secs(60) {
630 "just now".to_owned()
631 } else if elapsed < Duration::from_secs(3_600) {
632 format!("{} min ago", elapsed.as_secs() / 60)
633 } else if elapsed < Duration::from_secs(86_400) {
634 format!("{} h ago", elapsed.as_secs() / 3_600)
635 } else {
636 format!("{} d ago", elapsed.as_secs() / 86_400)
637 }
638}
639
640#[cfg(test)]
641mod tests {
642 use super::*;
643
644 fn info(id: &str, turns: usize, summary: Option<&str>, updated: u64) -> SessionInfo {
645 SessionInfo {
646 id: id.to_owned(),
647 turn_count: turns,
648 summary: summary.map(str::to_owned),
649 updated: SystemTime::UNIX_EPOCH + Duration::from_secs(updated),
650 }
651 }
652
653 #[test]
654 fn sessions_are_sorted_and_labelled() {
655 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(10_000);
656 let entries = session_entries(
657 &[
658 info("old", 1, None, 1_000),
659 info("new", 3, None, 9_000),
660 info("summary", 0, Some("Methods review"), 5_000),
661 ],
662 now,
663 );
664 assert_eq!(entries[0].id, "new");
665 assert_eq!(entries[0].label, "3 turns");
666 assert_eq!(entries[1].id, "summary");
667 assert_eq!(entries[1].label, "Methods review");
668 assert_eq!(entries[2].id, "old");
669 assert_eq!(entries[2].label, "1 turn");
670 }
671
672 #[test]
673 fn relative_times_are_human_readable() {
674 let now = SystemTime::UNIX_EPOCH + Duration::from_secs(100_000);
675 assert_eq!(relative_time(now, now - Duration::from_secs(5)), "just now");
676 assert_eq!(
677 relative_time(now, now - Duration::from_secs(120)),
678 "2 min ago"
679 );
680 assert_eq!(
681 relative_time(now, now - Duration::from_secs(7_200)),
682 "2 h ago"
683 );
684 assert_eq!(
685 relative_time(now, now - Duration::from_secs(172_800)),
686 "2 d ago"
687 );
688 }
689}