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::{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#[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 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 pub fn chat(&self) -> &Entity<ChatView> {
110 &self.chat
111 }
112
113 pub fn composer(&self) -> &Entity<Composer> {
115 &self.composer
116 }
117
118 pub fn controller(&self) -> &Entity<RagController> {
120 &self.controller
121 }
122
123 pub fn is_streaming(&self) -> bool {
125 self.streaming
126 }
127
128 pub fn attachments(&self) -> &[PendingAttachment] {
130 &self.attachments
131 }
132
133 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 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 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 pub fn clear_attachments(&mut self, cx: &mut Context<Self>) {
169 self.attachments.clear();
170 cx.notify();
171 }
172
173 pub fn ask(&mut self, prompt: impl Into<String>, cx: &mut Context<Self>) {
176 self.send_prompt(prompt.into(), true, cx);
177 }
178
179 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 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 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}