Skip to main content

ragrig_gpui/rag/
chat.rs

1//! The composite RAG chat surface: transcript, attachments, and composer.
2//!
3//! [`RagChat`] wires the M1 primitives to a [`RagController`]. Hosts that
4//! want a custom layout can still take the individual entities through
5//! [`chat`](Self::chat), [`composer`](Self::composer), and
6//! [`controller`](Self::controller).
7
8use std::path::PathBuf;
9
10use gpui_kit::{
11    AppContext as _, Context, Entity, EventEmitter, IntoElement, ParentElement as _, Render,
12    SharedString, Styled as _, Window, component::v_flex, div, prelude::FluentBuilder as _, px,
13};
14
15#[cfg(feature = "grobid")]
16use crate::references::CitationAttachment;
17use crate::{
18    attachments::{AttachmentEvent, AttachmentRows},
19    chat_view::{ChatIdentity, ChatView, ChatViewEvent, EmptyState},
20    composer::{Composer, ComposerEvent},
21    model::{Attachment, ChatRole, MessageId, MessageMeta, NewMessage, SourceRef},
22};
23
24use super::{
25    PendingAttachment, RagController, RagEvent, RagReply, RequestId, SessionTurn, count_label,
26};
27
28/// What the composite chat surface reports to its host.
29#[derive(Clone, Debug, PartialEq)]
30pub enum RagChatEvent {
31    /// A one-line status for the host's status bar.
32    Status(SharedString),
33    /// The user clicked a source chip.
34    SourceClicked(SourceRef),
35    /// A message body was copied.
36    Copied(MessageId),
37}
38
39struct PendingTurn {
40    request: RequestId,
41    message: MessageId,
42    tokens: usize,
43}
44
45/// A complete chat surface backed by a Ragrig controller.
46///
47/// Create it with [`RagChat::new`], embed it directly (it implements
48/// [`Render`]), and drive sessions through [`controller`](Self::controller).
49/// Attachments are one-shot: they are cleared after each question.
50pub struct RagChat {
51    controller: Entity<RagController>,
52    chat: Entity<ChatView>,
53    composer: Entity<Composer>,
54    pending: Option<PendingTurn>,
55    attachments: Vec<PendingAttachment>,
56    attachment_counter: u64,
57    streaming: bool,
58}
59
60impl RagChat {
61    /// Create the transcript, composer, and event wiring.
62    pub fn new(
63        window: &mut Window,
64        cx: &mut Context<Self>,
65        controller: Entity<RagController>,
66    ) -> Self {
67        let chat = cx.new(ChatView::new);
68        let composer = cx.new(|cx| Composer::new(window, cx));
69
70        cx.subscribe(&composer, |this, _, event: &ComposerEvent, cx| {
71            this.on_composer(event.clone(), cx);
72        })
73        .detach();
74        cx.subscribe(&chat, |this, _, event: &ChatViewEvent, cx| {
75            this.on_chat_event(event.clone(), cx);
76        })
77        .detach();
78        cx.subscribe(&controller, |this, _, event: &RagEvent, cx| {
79            this.on_rag_event(event.clone(), cx);
80        })
81        .detach();
82
83        composer.update(cx, |composer, cx| composer.focus(window, cx));
84
85        Self {
86            controller,
87            chat,
88            composer,
89            pending: None,
90            attachments: Vec::new(),
91            attachment_counter: 0,
92            streaming: false,
93        }
94    }
95
96    /// Replace the empty-state card shown before the first question.
97    pub fn set_empty_state(&mut self, empty: EmptyState, cx: &mut Context<Self>) {
98        self.chat
99            .update(cx, |chat, cx| chat.set_empty_state(empty, cx));
100    }
101
102    /// Set how the assistant signs its messages.
103    pub fn set_assistant(&mut self, identity: ChatIdentity, cx: &mut Context<Self>) {
104        self.chat
105            .update(cx, |chat, cx| chat.set_assistant(identity, cx));
106    }
107
108    /// The transcript.
109    pub fn chat(&self) -> &Entity<ChatView> {
110        &self.chat
111    }
112
113    /// The composer.
114    pub fn composer(&self) -> &Entity<Composer> {
115        &self.composer
116    }
117
118    /// The controller, for session and library actions.
119    pub fn controller(&self) -> &Entity<RagController> {
120        &self.controller
121    }
122
123    /// Whether an answer is currently streaming.
124    pub fn is_streaming(&self) -> bool {
125        self.streaming
126    }
127
128    /// Context waiting for the next question.
129    pub fn attachments(&self) -> &[PendingAttachment] {
130        &self.attachments
131    }
132
133    /// Attach a file. The worker parses it before generating.
134    pub fn attach_file(
135        &mut self,
136        name: impl Into<String>,
137        path: impl Into<PathBuf>,
138        cx: &mut Context<Self>,
139    ) {
140        self.attachment_counter = self.attachment_counter.wrapping_add(1);
141        let id = format!("file-{}", self.attachment_counter);
142        self.attachments
143            .push(PendingAttachment::file(id, name, path));
144        cx.notify();
145    }
146
147    /// Attach in-memory text, e.g. an editor selection.
148    pub fn attach_text(
149        &mut self,
150        label: impl Into<String>,
151        text: impl Into<String>,
152        cx: &mut Context<Self>,
153    ) {
154        self.attachment_counter = self.attachment_counter.wrapping_add(1);
155        let id = format!("text-{}", self.attachment_counter);
156        self.attachments
157            .push(PendingAttachment::text(id, label, text));
158        cx.notify();
159    }
160
161    /// Drop one attachment by id.
162    pub fn detach(&mut self, id: &str, cx: &mut Context<Self>) {
163        self.attachments.retain(|attachment| attachment.id != id);
164        cx.notify();
165    }
166
167    /// Drop every pending attachment.
168    pub fn clear_attachments(&mut self, cx: &mut Context<Self>) {
169        self.attachments.clear();
170        cx.notify();
171    }
172
173    /// Ask a question programmatically, e.g. from a command palette or an
174    /// onboarding action.
175    pub fn ask(&mut self, prompt: impl Into<String>, cx: &mut Context<Self>) {
176        self.send_prompt(prompt.into(), true, cx);
177    }
178
179    /// Stop the running answer, keeping the partial text.
180    pub fn stop(&mut self, cx: &mut Context<Self>) {
181        if let Some(pending) = &self.pending {
182            self.controller
183                .update(cx, |controller, cx| controller.stop(pending.request, cx));
184            cx.emit(RagChatEvent::Status("Stopping…".into()));
185        }
186    }
187
188    fn send_prompt(&mut self, prompt: String, echo_user: bool, cx: &mut Context<Self>) {
189        if self.streaming {
190            return;
191        }
192        if echo_user {
193            self.chat.update(cx, |chat, cx| {
194                chat.push_user(prompt.clone(), cx);
195            });
196        }
197        let message = self.chat.update(cx, |chat, cx| chat.begin_assistant(cx));
198        let attachments = std::mem::take(&mut self.attachments);
199
200        match self
201            .controller
202            .update(cx, |controller, cx| controller.ask(prompt, attachments, cx))
203        {
204            Ok(request) => {
205                self.pending = Some(PendingTurn {
206                    request,
207                    message,
208                    tokens: 0,
209                });
210                self.streaming = true;
211                // The composer is switched to Stop when `Started` arrives.
212                self.chat.update(cx, |chat, cx| chat.scroll_to_end(cx));
213                cx.emit(RagChatEvent::Status("Generating…".into()));
214            }
215            Err(error) => {
216                self.chat
217                    .update(cx, |chat, cx| chat.fail(message, error.clone(), cx));
218                cx.emit(RagChatEvent::Status(error.into()));
219            }
220        }
221        cx.notify();
222    }
223
224    fn retry(&mut self, id: MessageId, cx: &mut Context<Self>) {
225        if self.streaming {
226            return;
227        }
228        let prompt = {
229            let state = self.chat.read(cx).state();
230            let state = state.read(cx);
231            let messages = state.messages();
232            match messages.iter().position(|message| message.id() == id) {
233                Some(index) if index + 1 == messages.len() => messages[..index]
234                    .iter()
235                    .rev()
236                    .find(|message| message.role() == ChatRole::User)
237                    .map(|message| message.text().to_owned()),
238                _ => None,
239            }
240        };
241        let Some(prompt) = prompt else {
242            cx.emit(RagChatEvent::Status(
243                "Only the last failed answer can be retried.".into(),
244            ));
245            return;
246        };
247        self.chat.update(cx, |chat, cx| {
248            chat.remove(id, cx);
249        });
250        self.send_prompt(prompt, false, cx);
251    }
252
253    fn on_composer(&mut self, event: ComposerEvent, cx: &mut Context<Self>) {
254        match event {
255            ComposerEvent::Submit(text) => self.send_prompt(text, true, cx),
256            ComposerEvent::Stop => self.stop(cx),
257        }
258    }
259
260    fn on_chat_event(&mut self, event: ChatViewEvent, cx: &mut Context<Self>) {
261        match event {
262            ChatViewEvent::Retry(id) => {
263                // ChatView emits this while it is still being updated; defer
264                // the mutation until the effect cycle has unwound.
265                let this = cx.entity();
266                cx.defer(move |cx| {
267                    this.update(cx, |chat, cx| chat.retry(id, cx));
268                });
269            }
270            ChatViewEvent::SourceClicked(source) => {
271                cx.emit(RagChatEvent::SourceClicked(source));
272            }
273            ChatViewEvent::Copied(id) => {
274                cx.emit(RagChatEvent::Copied(id));
275            }
276        }
277    }
278
279    fn on_rag_event(&mut self, event: RagEvent, cx: &mut Context<Self>) {
280        match event {
281            RagEvent::Started { id } => {
282                if self.matches(id) {
283                    self.streaming = true;
284                    self.composer
285                        .update(cx, |composer, cx| composer.set_streaming(true, cx));
286                    cx.emit(RagChatEvent::Status("Generating…".into()));
287                }
288            }
289            RagEvent::Chunk { id, delta } => {
290                if let Some(pending) = &mut self.pending
291                    && pending.request == id
292                {
293                    pending.tokens = pending
294                        .tokens
295                        .saturating_add(delta.split_whitespace().count());
296                    self.chat
297                        .update(cx, |chat, cx| chat.append(pending.message, &delta, cx));
298                }
299            }
300            RagEvent::Finished { id, reply } => {
301                if let Some(pending) = self.pending.take().filter(|pending| pending.request == id) {
302                    if pending.tokens == 0 && !reply.answer.is_empty() {
303                        self.chat.update(cx, |chat, cx| {
304                            chat.append(pending.message, &reply.answer, cx);
305                        });
306                    }
307                    let meta = reply_meta(&reply, pending.tokens);
308                    self.chat.update(cx, |chat, cx| {
309                        chat.set_meta(pending.message, meta, cx);
310                        chat.finish(pending.message, cx);
311                    });
312                    self.finish_streaming(cx);
313                    cx.emit(RagChatEvent::Status("Answer complete".into()));
314                }
315            }
316            RagEvent::Stopped { id, .. } => {
317                if let Some(pending) = self.pending.take().filter(|pending| pending.request == id) {
318                    self.chat
319                        .update(cx, |chat, cx| chat.stop(pending.message, cx));
320                    self.finish_streaming(cx);
321                    cx.emit(RagChatEvent::Status("Stopped".into()));
322                }
323            }
324            RagEvent::Failed { id, message } => {
325                if let Some(pending) = self.pending.take().filter(|pending| pending.request == id) {
326                    self.chat.update(cx, |chat, cx| {
327                        chat.fail(pending.message, message.clone(), cx);
328                    });
329                    self.finish_streaming(cx);
330                    cx.emit(RagChatEvent::Status(message.into()));
331                }
332            }
333            RagEvent::SessionStarted { .. } => {
334                self.pending = None;
335                self.streaming = false;
336                self.attachments.clear();
337                self.chat.update(cx, |chat, cx| chat.clear(cx));
338                self.composer
339                    .update(cx, |composer, cx| composer.set_streaming(false, cx));
340            }
341            RagEvent::SessionLoaded { turns, .. } => {
342                self.pending = None;
343                self.streaming = false;
344                self.attachments.clear();
345                self.chat
346                    .update(cx, |chat, cx| chat.replace(loaded_messages(turns), cx));
347                self.composer
348                    .update(cx, |composer, cx| composer.set_streaming(false, cx));
349            }
350            RagEvent::AgentReady(info) => {
351                cx.emit(RagChatEvent::Status(
352                    format!("{} ready", info.chat_model).into(),
353                ));
354            }
355            RagEvent::AgentFailed { message } => {
356                cx.emit(RagChatEvent::Status(message.into()));
357            }
358            RagEvent::SyncFinished { summary, .. } => {
359                cx.emit(RagChatEvent::Status(
360                    format!(
361                        "Indexed {} ({}) from {}",
362                        count_label(summary.documents, "document", "documents"),
363                        count_label(summary.chunks, "chunk", "chunks"),
364                        summary.corpus
365                    )
366                    .into(),
367                ));
368            }
369            RagEvent::SyncStopped { .. } => {
370                cx.emit(RagChatEvent::Status("Indexing stopped".into()));
371            }
372            RagEvent::SyncFailed { message, .. } => {
373                cx.emit(RagChatEvent::Status(message.into()));
374            }
375            RagEvent::Notice(message) => {
376                cx.emit(RagChatEvent::Status(message.into()));
377            }
378            #[cfg(feature = "grobid")]
379            RagEvent::Citations { resolved, missed } => {
380                if let Some(note) = citation_note(&resolved, &missed) {
381                    let before = self.pending.as_ref().map(|pending| pending.message);
382                    self.chat.update(cx, |chat, cx| {
383                        let inserted = before.is_some_and(|before| {
384                            chat.push_system_before(before, note.clone(), cx)
385                        });
386                        if !inserted {
387                            chat.push_system(note, cx);
388                        }
389                    });
390                }
391            }
392            #[cfg(feature = "grobid")]
393            RagEvent::ReferencesStarted { .. } | RagEvent::ReferencesLoaded { .. } => {}
394            #[cfg(feature = "grobid")]
395            RagEvent::ReferencesProgress { progress, .. } => {
396                cx.emit(RagChatEvent::Status(progress.summary().into()));
397            }
398            #[cfg(feature = "grobid")]
399            RagEvent::ReferencesFinished { report, .. } => {
400                cx.emit(RagChatEvent::Status(report.summary().into()));
401            }
402            #[cfg(feature = "grobid")]
403            RagEvent::ReferencesStopped { .. } => {
404                cx.emit(RagChatEvent::Status("Reference extraction stopped".into()));
405            }
406            #[cfg(feature = "grobid")]
407            RagEvent::ReferencesFailed { message, .. } => {
408                cx.emit(RagChatEvent::Status(message.into()));
409            }
410            RagEvent::Sessions(_)
411            | RagEvent::Library(_)
412            | RagEvent::SyncStarted { .. }
413            | RagEvent::SyncProgress { .. } => {}
414        }
415        cx.notify();
416    }
417
418    fn matches(&self, request: RequestId) -> bool {
419        self.pending
420            .as_ref()
421            .is_some_and(|pending| pending.request == request)
422    }
423
424    fn finish_streaming(&mut self, cx: &mut Context<Self>) {
425        self.streaming = false;
426        self.composer
427            .update(cx, |composer, cx| composer.set_streaming(false, cx));
428    }
429}
430
431impl Render for RagChat {
432    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
433        let rows = if self.attachments.is_empty() {
434            None
435        } else {
436            let chips = self
437                .attachments
438                .iter()
439                .map(|attachment| {
440                    Attachment::pending(attachment.id.as_str(), attachment.label.as_str())
441                })
442                .collect::<Vec<_>>();
443            let this = cx.entity();
444            Some(AttachmentRows::new(chips).on_event(move |event, _, cx| {
445                if let AttachmentEvent::Dropped(id) = event {
446                    this.update(cx, |chat, cx| chat.detach(&id, cx));
447                }
448            }))
449        };
450
451        v_flex()
452            .size_full()
453            .child(div().flex_1().min_h(px(0.)).child(self.chat.clone()))
454            .when_some(rows, |this, rows| {
455                this.child(div().px_3().py_2().child(rows))
456            })
457            .child(self.composer.clone())
458    }
459}
460
461impl EventEmitter<RagChatEvent> for RagChat {}
462
463#[cfg(feature = "grobid")]
464fn citation_note(resolved: &[CitationAttachment], missed: &[String]) -> Option<String> {
465    if resolved.is_empty() && missed.is_empty() {
466        return None;
467    }
468    let mut lines = Vec::with_capacity(missed.len() + 1);
469    if !resolved.is_empty() {
470        let keys = resolved
471            .iter()
472            .map(|attachment| format!("@{}", attachment.key))
473            .collect::<Vec<_>>()
474            .join(", ");
475        lines.push(format!("Attached: {keys}"));
476    }
477    lines.extend(missed.iter().cloned());
478    Some(lines.join("\n"))
479}
480
481fn reply_meta(reply: &RagReply, streamed_tokens: usize) -> MessageMeta {
482    let tokens = streamed_tokens.max(reply.answer.split_whitespace().count());
483    MessageMeta {
484        model: Some(reply.model.clone()),
485        chunks: reply.chunks,
486        sources: reply.sources.clone(),
487        tokens: (tokens > 0).then_some(tokens),
488        elapsed: reply.elapsed,
489    }
490}
491
492fn loaded_messages(turns: Vec<SessionTurn>) -> Vec<NewMessage> {
493    turns
494        .into_iter()
495        .map(|turn| match turn.role {
496            ChatRole::User => NewMessage::user(turn.text),
497            ChatRole::Assistant => NewMessage::assistant(turn.text),
498            ChatRole::System => NewMessage::system(turn.text),
499        })
500        .collect()
501}