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::{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    /// The transcript.
103    pub fn chat(&self) -> &Entity<ChatView> {
104        &self.chat
105    }
106
107    /// The composer.
108    pub fn composer(&self) -> &Entity<Composer> {
109        &self.composer
110    }
111
112    /// The controller, for session and library actions.
113    pub fn controller(&self) -> &Entity<RagController> {
114        &self.controller
115    }
116
117    /// Whether an answer is currently streaming.
118    pub fn is_streaming(&self) -> bool {
119        self.streaming
120    }
121
122    /// Context waiting for the next question.
123    pub fn attachments(&self) -> &[PendingAttachment] {
124        &self.attachments
125    }
126
127    /// Attach a file. The worker parses it before generating.
128    pub fn attach_file(
129        &mut self,
130        name: impl Into<String>,
131        path: impl Into<PathBuf>,
132        cx: &mut Context<Self>,
133    ) {
134        self.attachment_counter = self.attachment_counter.wrapping_add(1);
135        let id = format!("file-{}", self.attachment_counter);
136        self.attachments
137            .push(PendingAttachment::file(id, name, path));
138        cx.notify();
139    }
140
141    /// Attach in-memory text, e.g. an editor selection.
142    pub fn attach_text(
143        &mut self,
144        label: impl Into<String>,
145        text: impl Into<String>,
146        cx: &mut Context<Self>,
147    ) {
148        self.attachment_counter = self.attachment_counter.wrapping_add(1);
149        let id = format!("text-{}", self.attachment_counter);
150        self.attachments
151            .push(PendingAttachment::text(id, label, text));
152        cx.notify();
153    }
154
155    /// Drop one attachment by id.
156    pub fn detach(&mut self, id: &str, cx: &mut Context<Self>) {
157        self.attachments.retain(|attachment| attachment.id != id);
158        cx.notify();
159    }
160
161    /// Drop every pending attachment.
162    pub fn clear_attachments(&mut self, cx: &mut Context<Self>) {
163        self.attachments.clear();
164        cx.notify();
165    }
166
167    /// Ask a question programmatically, e.g. from a command palette or an
168    /// onboarding action.
169    pub fn ask(&mut self, prompt: impl Into<String>, cx: &mut Context<Self>) {
170        self.send_prompt(prompt.into(), true, cx);
171    }
172
173    /// Stop the running answer, keeping the partial text.
174    pub fn stop(&mut self, cx: &mut Context<Self>) {
175        if let Some(pending) = &self.pending {
176            self.controller
177                .update(cx, |controller, cx| controller.stop(pending.request, cx));
178            cx.emit(RagChatEvent::Status("Stopping…".into()));
179        }
180    }
181
182    fn send_prompt(&mut self, prompt: String, echo_user: bool, cx: &mut Context<Self>) {
183        if self.streaming {
184            return;
185        }
186        if echo_user {
187            self.chat.update(cx, |chat, cx| {
188                chat.push_user(prompt.clone(), cx);
189            });
190        }
191        let message = self.chat.update(cx, |chat, cx| chat.begin_assistant(cx));
192        let attachments = std::mem::take(&mut self.attachments);
193
194        match self
195            .controller
196            .update(cx, |controller, cx| controller.ask(prompt, attachments, cx))
197        {
198            Ok(request) => {
199                self.pending = Some(PendingTurn {
200                    request,
201                    message,
202                    tokens: 0,
203                });
204                self.streaming = true;
205                // The composer is switched to Stop when `Started` arrives.
206                self.chat.update(cx, |chat, cx| chat.scroll_to_end(cx));
207                cx.emit(RagChatEvent::Status("Generating…".into()));
208            }
209            Err(error) => {
210                self.chat
211                    .update(cx, |chat, cx| chat.fail(message, error.clone(), cx));
212                cx.emit(RagChatEvent::Status(error.into()));
213            }
214        }
215        cx.notify();
216    }
217
218    fn retry(&mut self, id: MessageId, cx: &mut Context<Self>) {
219        if self.streaming {
220            return;
221        }
222        let prompt = {
223            let state = self.chat.read(cx).state();
224            let state = state.read(cx);
225            let messages = state.messages();
226            match messages.iter().position(|message| message.id() == id) {
227                Some(index) if index + 1 == messages.len() => messages[..index]
228                    .iter()
229                    .rev()
230                    .find(|message| message.role() == ChatRole::User)
231                    .map(|message| message.text().to_owned()),
232                _ => None,
233            }
234        };
235        let Some(prompt) = prompt else {
236            cx.emit(RagChatEvent::Status(
237                "Only the last failed answer can be retried.".into(),
238            ));
239            return;
240        };
241        self.chat.update(cx, |chat, cx| {
242            chat.remove(id, cx);
243        });
244        self.send_prompt(prompt, false, cx);
245    }
246
247    fn on_composer(&mut self, event: ComposerEvent, cx: &mut Context<Self>) {
248        match event {
249            ComposerEvent::Submit(text) => self.send_prompt(text, true, cx),
250            ComposerEvent::Stop => self.stop(cx),
251        }
252    }
253
254    fn on_chat_event(&mut self, event: ChatViewEvent, cx: &mut Context<Self>) {
255        match event {
256            ChatViewEvent::Retry(id) => {
257                // ChatView emits this while it is still being updated; defer
258                // the mutation until the effect cycle has unwound.
259                let this = cx.entity();
260                cx.defer(move |cx| {
261                    this.update(cx, |chat, cx| chat.retry(id, cx));
262                });
263            }
264            ChatViewEvent::SourceClicked(source) => {
265                cx.emit(RagChatEvent::SourceClicked(source));
266            }
267            ChatViewEvent::Copied(id) => {
268                cx.emit(RagChatEvent::Copied(id));
269            }
270        }
271    }
272
273    fn on_rag_event(&mut self, event: RagEvent, cx: &mut Context<Self>) {
274        match event {
275            RagEvent::Started { id } => {
276                if self.matches(id) {
277                    self.streaming = true;
278                    self.composer
279                        .update(cx, |composer, cx| composer.set_streaming(true, cx));
280                    cx.emit(RagChatEvent::Status("Generating…".into()));
281                }
282            }
283            RagEvent::Chunk { id, delta } => {
284                if let Some(pending) = &mut self.pending
285                    && pending.request == id
286                {
287                    pending.tokens = pending
288                        .tokens
289                        .saturating_add(delta.split_whitespace().count());
290                    self.chat
291                        .update(cx, |chat, cx| chat.append(pending.message, &delta, cx));
292                }
293            }
294            RagEvent::Finished { id, reply } => {
295                if let Some(pending) = self.pending.take().filter(|pending| pending.request == id) {
296                    if pending.tokens == 0 && !reply.answer.is_empty() {
297                        self.chat.update(cx, |chat, cx| {
298                            chat.append(pending.message, &reply.answer, cx);
299                        });
300                    }
301                    let meta = reply_meta(&reply, pending.tokens);
302                    self.chat.update(cx, |chat, cx| {
303                        chat.set_meta(pending.message, meta, cx);
304                        chat.finish(pending.message, cx);
305                    });
306                    self.finish_streaming(cx);
307                    cx.emit(RagChatEvent::Status("Answer complete".into()));
308                }
309            }
310            RagEvent::Stopped { id, .. } => {
311                if let Some(pending) = self.pending.take().filter(|pending| pending.request == id) {
312                    self.chat
313                        .update(cx, |chat, cx| chat.stop(pending.message, cx));
314                    self.finish_streaming(cx);
315                    cx.emit(RagChatEvent::Status("Stopped".into()));
316                }
317            }
318            RagEvent::Failed { id, message } => {
319                if let Some(pending) = self.pending.take().filter(|pending| pending.request == id) {
320                    self.chat.update(cx, |chat, cx| {
321                        chat.fail(pending.message, message.clone(), cx);
322                    });
323                    self.finish_streaming(cx);
324                    cx.emit(RagChatEvent::Status(message.into()));
325                }
326            }
327            RagEvent::SessionStarted { .. } => {
328                self.pending = None;
329                self.streaming = false;
330                self.attachments.clear();
331                self.chat.update(cx, |chat, cx| chat.clear(cx));
332                self.composer
333                    .update(cx, |composer, cx| composer.set_streaming(false, cx));
334            }
335            RagEvent::SessionLoaded { turns, .. } => {
336                self.pending = None;
337                self.streaming = false;
338                self.attachments.clear();
339                self.chat
340                    .update(cx, |chat, cx| chat.replace(loaded_messages(turns), cx));
341                self.composer
342                    .update(cx, |composer, cx| composer.set_streaming(false, cx));
343            }
344            RagEvent::AgentReady(info) => {
345                cx.emit(RagChatEvent::Status(
346                    format!("{} ready", info.chat_model).into(),
347                ));
348            }
349            RagEvent::AgentFailed { message } => {
350                cx.emit(RagChatEvent::Status(message.into()));
351            }
352            RagEvent::SyncFinished { summary, .. } => {
353                cx.emit(RagChatEvent::Status(
354                    format!(
355                        "Indexed {} ({}) from {}",
356                        count_label(summary.documents, "document", "documents"),
357                        count_label(summary.chunks, "chunk", "chunks"),
358                        summary.corpus
359                    )
360                    .into(),
361                ));
362            }
363            RagEvent::SyncStopped { .. } => {
364                cx.emit(RagChatEvent::Status("Indexing stopped".into()));
365            }
366            RagEvent::SyncFailed { message, .. } => {
367                cx.emit(RagChatEvent::Status(message.into()));
368            }
369            RagEvent::Notice(message) => {
370                cx.emit(RagChatEvent::Status(message.into()));
371            }
372            #[cfg(feature = "grobid")]
373            RagEvent::Citations { resolved, missed } => {
374                if let Some(note) = citation_note(&resolved, &missed) {
375                    let before = self.pending.as_ref().map(|pending| pending.message);
376                    self.chat.update(cx, |chat, cx| {
377                        let inserted = before.is_some_and(|before| {
378                            chat.push_system_before(before, note.clone(), cx)
379                        });
380                        if !inserted {
381                            chat.push_system(note, cx);
382                        }
383                    });
384                }
385            }
386            #[cfg(feature = "grobid")]
387            RagEvent::ReferencesStarted { .. } | RagEvent::ReferencesLoaded { .. } => {}
388            #[cfg(feature = "grobid")]
389            RagEvent::ReferencesProgress { progress, .. } => {
390                cx.emit(RagChatEvent::Status(progress.summary().into()));
391            }
392            #[cfg(feature = "grobid")]
393            RagEvent::ReferencesFinished { report, .. } => {
394                cx.emit(RagChatEvent::Status(report.summary().into()));
395            }
396            #[cfg(feature = "grobid")]
397            RagEvent::ReferencesStopped { .. } => {
398                cx.emit(RagChatEvent::Status("Reference extraction stopped".into()));
399            }
400            #[cfg(feature = "grobid")]
401            RagEvent::ReferencesFailed { message, .. } => {
402                cx.emit(RagChatEvent::Status(message.into()));
403            }
404            RagEvent::Sessions(_)
405            | RagEvent::Library(_)
406            | RagEvent::SyncStarted { .. }
407            | RagEvent::SyncProgress { .. } => {}
408        }
409        cx.notify();
410    }
411
412    fn matches(&self, request: RequestId) -> bool {
413        self.pending
414            .as_ref()
415            .is_some_and(|pending| pending.request == request)
416    }
417
418    fn finish_streaming(&mut self, cx: &mut Context<Self>) {
419        self.streaming = false;
420        self.composer
421            .update(cx, |composer, cx| composer.set_streaming(false, cx));
422    }
423}
424
425impl Render for RagChat {
426    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
427        let rows = if self.attachments.is_empty() {
428            None
429        } else {
430            let chips = self
431                .attachments
432                .iter()
433                .map(|attachment| {
434                    Attachment::pending(attachment.id.as_str(), attachment.label.as_str())
435                })
436                .collect::<Vec<_>>();
437            let this = cx.entity();
438            Some(AttachmentRows::new(chips).on_event(move |event, _, cx| {
439                if let AttachmentEvent::Dropped(id) = event {
440                    this.update(cx, |chat, cx| chat.detach(&id, cx));
441                }
442            }))
443        };
444
445        v_flex()
446            .size_full()
447            .child(div().flex_1().min_h(px(0.)).child(self.chat.clone()))
448            .when_some(rows, |this, rows| {
449                this.child(div().px_3().py_2().child(rows))
450            })
451            .child(self.composer.clone())
452    }
453}
454
455impl EventEmitter<RagChatEvent> for RagChat {}
456
457#[cfg(feature = "grobid")]
458fn citation_note(resolved: &[CitationAttachment], missed: &[String]) -> Option<String> {
459    if resolved.is_empty() && missed.is_empty() {
460        return None;
461    }
462    let mut lines = Vec::with_capacity(missed.len() + 1);
463    if !resolved.is_empty() {
464        let keys = resolved
465            .iter()
466            .map(|attachment| format!("@{}", attachment.key))
467            .collect::<Vec<_>>()
468            .join(", ");
469        lines.push(format!("Attached: {keys}"));
470    }
471    lines.extend(missed.iter().cloned());
472    Some(lines.join("\n"))
473}
474
475fn reply_meta(reply: &RagReply, streamed_tokens: usize) -> MessageMeta {
476    let tokens = streamed_tokens.max(reply.answer.split_whitespace().count());
477    MessageMeta {
478        model: Some(reply.model.clone()),
479        chunks: reply.chunks,
480        sources: reply.sources.clone(),
481        tokens: (tokens > 0).then_some(tokens),
482        elapsed: reply.elapsed,
483    }
484}
485
486fn loaded_messages(turns: Vec<SessionTurn>) -> Vec<NewMessage> {
487    turns
488        .into_iter()
489        .map(|turn| match turn.role {
490            ChatRole::User => NewMessage::user(turn.text),
491            ChatRole::Assistant => NewMessage::assistant(turn.text),
492            ChatRole::System => NewMessage::system(turn.text),
493        })
494        .collect()
495}