1use 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#[derive(Clone, Debug, PartialEq)]
30pub enum RagChatEvent {
31 Status(SharedString),
33 SourceClicked(SourceRef),
35 Copied(MessageId),
37}
38
39struct PendingTurn {
40 request: RequestId,
41 message: MessageId,
42 tokens: usize,
43}
44
45pub 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 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 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 pub fn chat(&self) -> &Entity<ChatView> {
104 &self.chat
105 }
106
107 pub fn composer(&self) -> &Entity<Composer> {
109 &self.composer
110 }
111
112 pub fn controller(&self) -> &Entity<RagController> {
114 &self.controller
115 }
116
117 pub fn is_streaming(&self) -> bool {
119 self.streaming
120 }
121
122 pub fn attachments(&self) -> &[PendingAttachment] {
124 &self.attachments
125 }
126
127 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 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 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 pub fn clear_attachments(&mut self, cx: &mut Context<Self>) {
163 self.attachments.clear();
164 cx.notify();
165 }
166
167 pub fn ask(&mut self, prompt: impl Into<String>, cx: &mut Context<Self>) {
170 self.send_prompt(prompt.into(), true, cx);
171 }
172
173 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 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 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}