1use std::{
2 cell::{Ref, RefMut},
3 collections::HashMap,
4};
5
6use crate::{
7 protocol::*,
8 utils::makepad::{EventExt, ItemsRangeIter},
9 widgets::{avatar::AvatarWidgetRefExt, message_loading::MessageLoadingWidgetRefExt},
10};
11use makepad_code_editor::code_view::CodeViewWidgetRefExt;
12use makepad_widgets::*;
13
14use super::{
15 citation::CitationAction, slot::SlotWidgetRefExt,
16 standard_message_content::StandardMessageContentWidgetRefExt,
17};
18
19live_design! {
20 use link::theme::*;
21 use link::widgets::*;
22 use link::moly_kit_theme::*;
23 use link::shaders::*;
24
25 use crate::widgets::chat_lines::*;
26 use crate::clients::deep_inquire::widgets::deep_inquire_content::*;
27
28 pub Messages = {{Messages}} {
29 flow: Overlay,
30
31 deep_inquire_content: <DeepInquireContent> {}
34
35 list = <PortalList> {
36 grab_key_focus: true
37 scroll_bar: {
38 bar_size: 0.0,
39 }
40 UserLine = <UserLine> {}
41 BotLine = <BotLine> {}
42 LoadingLine = <LoadingLine> {}
43 AppLine = <AppLine> {}
44 ErrorLine = <ErrorLine> {}
45 SystemLine = <SystemLine> {}
46 ToolRequestLine = <ToolRequestLine> {}
47 ToolResultLine = <ToolResultLine> {}
48
49 EndOfChat = <View> {height: 0.1}
53 }
54 <View> {
55 align: {x: 1.0, y: 1.0},
56 jump_to_bottom = <Button> {
57 width: 36,
58 height: 36,
59 margin: {left: 2, right: 2, top: 2, bottom: 10},
60 icon_walk: {
61 width: 16, height: 16
62 margin: {left: 4.5, top: 6.5},
63 }
64 draw_icon: {
65 svg_file: dep("crate://self/resources/jump_to_bottom.svg")
66 color: #1C1B1F,
67 color_hover: #x0
68 }
69 draw_bg: {
70 fn pixel(self) -> vec4 {
71 let sdf = Sdf2d::viewport(self.pos * self.rect_size);
72 let center = self.rect_size * 0.5;
73 let radius = min(self.rect_size.x, self.rect_size.y) * 0.5;
74
75 sdf.circle(center.x, center.y, radius - 1.0);
76 sdf.fill_keep(#fff);
77 sdf.stroke(#EAECF0, 1.5);
78
79 return sdf.result
80 }
81 }
82 }
83 }
84 }
85}
86
87#[derive(Debug, PartialEq, Copy, Clone, DefaultNone)]
91pub enum MessagesAction {
92 Copy(usize),
94
95 Delete(usize),
97
98 EditSave(usize),
100
101 EditRegenerate(usize),
104
105 ToolApprove(usize),
107
108 ToolDeny(usize),
110
111 None,
112}
113
114#[derive(Debug)]
116struct Editor {
117 index: usize,
118 buffer: String,
119}
120
121#[derive(Live, Widget)]
125pub struct Messages {
126 #[deref]
127 deref: View,
128
129 #[rust]
131 pub messages: Vec<Message>,
132
133 #[rust]
135 pub bot_context: Option<BotContext>,
136
137 #[rust]
143 pub templates: HashMap<LiveId, LivePtr>,
144
145 #[live]
146 deep_inquire_content: LivePtr,
147
148 #[rust]
149 current_editor: Option<Editor>,
150
151 #[rust]
152 is_list_end_drawn: bool,
153
154 #[rust]
159 visible_range: Option<(usize, usize)>,
160
161 #[rust]
163 should_defer_scroll_to_bottom: bool,
164
165 #[rust]
166 hovered_index: Option<usize>,
167
168 #[rust]
169 user_scrolled: bool,
170
171 #[rust]
172 sticking_to_bottom: bool,
173}
174
175impl Widget for Messages {
176 fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
177 self.deref.handle_event(cx, event, scope);
178 self.handle_list(cx, event, scope);
179
180 let jump_to_bottom = self.button(id!(jump_to_bottom));
181
182 if jump_to_bottom.clicked(event.actions()) {
183 self.scroll_to_bottom(cx, false);
184 self.user_scrolled = false;
187 self.sticking_to_bottom = false;
188 self.redraw(cx);
189 }
190
191 for action in event.widget_actions() {
192 if let CitationAction::Open(url) = action.cast() {
193 let _ = robius_open::Uri::new(url.as_str()).open();
194 }
195 }
196 }
197
198 fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
199 let list_uid = self.portal_list(id!(list)).widget_uid();
200
201 while let Some(widget) = self.deref.draw_walk(cx, scope, walk).step() {
202 if widget.widget_uid() == list_uid {
203 self.draw_list(cx, widget.as_portal_list());
204 }
205 }
206
207 DrawStep::done()
208 }
209}
210
211impl Messages {
212 fn draw_list(&mut self, cx: &mut Cx2d, list_ref: PortalListRef) {
213 self.is_list_end_drawn = false;
214 self.visible_range = None;
215
216 self.messages.push(Message {
219 from: EntityId::App,
220 content: MessageContent {
222 text: "EOC".into(),
223 ..Default::default()
224 },
225 ..Default::default()
226 });
227
228 if self.should_defer_scroll_to_bottom {
229 list_ref.set_first_id(self.messages.len().saturating_sub(1));
232 self.should_defer_scroll_to_bottom = false;
233 }
234
235 let context = self.bot_context.clone().expect("no bot client set");
236 let mut client = context.client();
237
238 let mut list = list_ref.borrow_mut().unwrap();
239 list.set_item_range(cx, 0, self.messages.len());
240
241 while let Some(index) = list.next_visible_item(cx) {
242 if index >= self.messages.len() {
243 continue;
244 }
245
246 if let Some((_start, end)) = &mut self.visible_range {
247 *end = (*end).max(index);
248 } else {
249 self.visible_range = Some((index, index));
250 }
251
252 let message = &self.messages[index];
253
254 match &message.from {
255 EntityId::System => {
256 let item = if message.metadata.is_writing() {
258 let item = list.item(cx, index, live_id!(LoadingLine));
260 item.message_loading(id!(content_section.loading))
261 .animate(cx);
262 item
263 } else {
264 list.item(cx, index, live_id!(SystemLine))
265 };
266
267 item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
268 Some(Picture::Grapheme("S".into()));
269 item.label(id!(name)).set_text(cx, "System");
270
271 if !message.metadata.is_writing() {
272 item.slot(id!(content))
273 .current()
274 .as_standard_message_content()
275 .set_content(cx, &message.content);
276 }
277
278 self.apply_actions_and_editor_visibility(cx, &item, index);
279 item.draw_all(cx, &mut Scope::empty());
280 }
281 EntityId::Tool => {
282 let item = if message.metadata.is_writing() {
284 let item = list.item(cx, index, live_id!(LoadingLine));
286 item.message_loading(id!(content_section.loading))
287 .animate(cx);
288 item
289 } else {
290 list.item(cx, index, live_id!(ToolResultLine))
291 };
292
293 item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
294 Some(Picture::Grapheme("T".into()));
295 item.label(id!(name)).set_text(cx, "Tool");
296
297 if !message.metadata.is_writing() {
298 item.slot(id!(content))
299 .current()
300 .as_standard_message_content()
301 .set_content(cx, &message.content);
302 }
303
304 self.apply_actions_and_editor_visibility(cx, &item, index);
305 item.draw_all(cx, &mut Scope::empty());
306 }
307 EntityId::App => {
308 if message.content.text == "EOC" {
310 let item = list.item(cx, index, live_id!(EndOfChat));
311 item.draw_all(cx, &mut Scope::empty());
312 self.is_list_end_drawn = true;
313 continue;
314 }
315
316 if let Some((left, right)) = message.content.text.split_once(':') {
318 if let Some("error") = left
319 .split_whitespace()
320 .last()
321 .map(|s| s.to_lowercase())
322 .as_deref()
323 {
324 let item = list.item(cx, index, live_id!(ErrorLine));
325 item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
326 Some(Picture::Grapheme("X".into()));
327 item.label(id!(name)).set_text(cx, left);
328
329 let error_content = MessageContent {
330 text: right.to_string(),
331 ..Default::default()
332 };
333 item.slot(id!(content))
334 .current()
335 .as_standard_message_content()
336 .set_content(cx, &error_content);
337
338 self.apply_actions_and_editor_visibility(cx, &item, index);
339 item.draw_all(cx, &mut Scope::empty());
340 continue;
341 }
342 }
343
344 let item = list.item(cx, index, live_id!(AppLine));
346 item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
347 Some(Picture::Grapheme("A".into()));
348
349 item.slot(id!(content))
350 .current()
351 .as_standard_message_content()
352 .set_content(cx, &message.content);
353
354 self.apply_actions_and_editor_visibility(cx, &item, index);
355 item.draw_all(cx, &mut Scope::empty());
356 }
357 EntityId::User => {
358 let item = list.item(cx, index, live_id!(UserLine));
359
360 item.avatar(id!(avatar)).borrow_mut().unwrap().avatar =
361 Some(Picture::Grapheme("Y".into()));
362 item.label(id!(name)).set_text(cx, "You");
363
364 item.slot(id!(content))
365 .current()
366 .as_standard_message_content()
367 .set_content(cx, &message.content);
368
369 self.apply_actions_and_editor_visibility(cx, &item, index);
370 item.draw_all(cx, &mut Scope::empty());
371 }
372 EntityId::Bot(id) => {
373 let bot = context.get_bot(id);
374
375 let (name, avatar) = bot
376 .as_ref()
377 .map(|b| (b.name.as_str(), b.avatar.clone()))
378 .unwrap_or(("Unknown bot", Picture::Grapheme("B".into())));
379
380 let item =
381 if message.metadata.is_writing() && message.content.is_empty() {
382 let item = list.item(cx, index, live_id!(LoadingLine));
383 item.message_loading(id!(content_section.loading))
384 .animate(cx);
385 item
386 } else if !message.content.tool_calls.is_empty() {
387 let item = list.item(cx, index, live_id!(ToolRequestLine));
388
389 let has_pending = message.content.tool_calls.iter().any(|tc| {
391 tc.permission_status == ToolCallPermissionStatus::Pending
392 });
393 let has_denied =
394 message.content.tool_calls.iter().any(|tc| {
395 tc.permission_status == ToolCallPermissionStatus::Denied
396 });
397
398 item.view(id!(tool_actions)).set_visible(cx, has_pending);
400
401 if has_denied {
403 item.view(id!(status_view)).set_visible(cx, true);
404 item.label(id!(approved_status)).set_text(cx, "Denied");
405 } else {
406 item.view(id!(status_view)).set_visible(cx, false);
407 }
408
409 item
410 } else {
411 list.item(cx, index, live_id!(BotLine))
412 };
413
414 item.avatar(id!(avatar)).borrow_mut().unwrap().avatar = Some(avatar);
415 item.label(id!(name)).set_text(cx, name);
416
417 let mut slot = item.slot(id!(content));
418 if let Some(custom_content) = client.content_widget(
419 cx,
420 slot.current().clone(),
421 &self.templates,
422 &message.content,
423 ) {
424 slot.replace(custom_content);
425 } else {
426 slot.restore();
429 slot.default()
430 .as_standard_message_content()
431 .set_content_with_metadata(cx, &message.content, &message.metadata);
432 }
433
434 let has_any_tool_calls = !message.content.tool_calls.is_empty();
435 if has_any_tool_calls {
439 item.draw_all(cx, &mut Scope::empty());
440 } else {
441 self.apply_actions_and_editor_visibility(cx, &item, index);
442 item.draw_all(cx, &mut Scope::empty());
443 }
444 }
445 }
446 }
447
448 if let Some(message) = self.messages.pop() {
449 assert!(message.from == EntityId::App);
450 assert!(message.content.text == "EOC");
451 }
452
453 self.button(id!(jump_to_bottom))
454 .set_visible(cx, !self.is_at_bottom() && !self.sticking_to_bottom);
455 }
456
457 pub fn is_at_bottom(&self) -> bool {
459 self.is_list_end_drawn
460 }
461
462 pub fn user_scrolled(&self) -> bool {
463 self.user_scrolled
464 }
465
466 pub fn scroll_to_bottom(&mut self, cx: &mut Cx, triggered_by_stream: bool) {
468 if self.messages.len() > 0 {
469 let list = self.portal_list(id!(list));
470
471 if triggered_by_stream {
472 list.set_first_id_and_scroll(self.messages.len().saturating_sub(1), 0.0);
474 } else {
475 list.smooth_scroll_to_end(cx, 100.0, None);
476 }
477 self.sticking_to_bottom = triggered_by_stream;
478 }
479 }
480
481 pub fn set_message_editor_visibility(&mut self, index: usize, visible: bool) {
487 if index >= self.messages.len() {
488 return;
489 }
490
491 if visible {
492 let buffer = self.messages[index].content.text.clone();
493 self.current_editor = Some(Editor { index, buffer });
494 } else if self.current_editor.as_ref().map(|e| e.index) == Some(index) {
495 self.current_editor = None;
496 }
497 }
498
499 pub fn current_editor_text(&self) -> Option<String> {
501 self.current_editor
502 .as_ref()
503 .and_then(|editor| self.portal_list(id!(list)).get_item(editor.index))
504 .map(|(_id, widget)| widget.text_input(id!(input)).text())
505 }
506
507 pub fn current_editor_index(&self) -> Option<usize> {
509 self.current_editor.as_ref().map(|e| e.index)
510 }
511
512 fn handle_list(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
513 let Some(range) = self.visible_range else {
514 return;
515 };
516
517 let list = self.portal_list(id!(list));
518 let range = range.0..=range.1;
519
520 for (index, item) in ItemsRangeIter::new(list, range) {
522 if let Event::MouseMove(event) = event {
523 if item.area().rect(cx).contains(event.abs) {
524 self.hovered_index = Some(index);
525 item.redraw(cx);
526 }
527 }
528
529 let actions = event.actions();
530
531 if item.button(id!(copy)).clicked(actions) {
532 cx.widget_action(self.widget_uid(), &scope.path, MessagesAction::Copy(index));
533 }
534
535 if item.button(id!(delete)).clicked(actions) {
536 cx.widget_action(
537 self.widget_uid(),
538 &scope.path,
539 MessagesAction::Delete(index),
540 );
541 }
542
543 if item.button(id!(edit)).clicked(actions) {
544 self.set_message_editor_visibility(index, true);
545 self.redraw(cx);
546 }
547
548 if item.button(id!(edit_actions.cancel)).clicked(actions) {
549 self.set_message_editor_visibility(index, false);
550 self.redraw(cx);
551 }
552
553 if item.button(id!(edit_actions.save)).clicked(actions) {
556 cx.widget_action(
557 self.widget_uid(),
558 &scope.path,
559 MessagesAction::EditSave(index),
560 );
561 }
562
563 if item
564 .button(id!(edit_actions.save_and_regenerate))
565 .clicked(actions)
566 {
567 cx.widget_action(
568 self.widget_uid(),
569 &scope.path,
570 MessagesAction::EditRegenerate(index),
571 );
572 }
573
574 if item.button(id!(tool_actions.approve)).clicked(actions) {
575 cx.widget_action(
576 self.widget_uid(),
577 &scope.path,
578 MessagesAction::ToolApprove(index),
579 );
580 }
581
582 if item.button(id!(tool_actions.deny)).clicked(actions) {
583 cx.widget_action(
584 self.widget_uid(),
585 &scope.path,
586 MessagesAction::ToolDeny(index),
587 );
588 }
589
590 if let Some(change) = item.text_input(id!(input)).changed(actions) {
591 self.current_editor.as_mut().unwrap().buffer = change;
592 }
593 }
594
595 if let Some(wa) = event.actions().widget_action(id!(copy_code_button)) {
598 if wa.widget().as_button().pressed(event.actions()) {
599 let code_view = wa.widget_nth(2).widget(id!(code_view));
601 let text_to_copy = code_view.as_code_view().text();
602 cx.copy_to_clipboard(&text_to_copy);
603 }
604 }
605
606 match event.hits(cx, self.area()) {
609 Hit::FingerScroll(_e) => {
610 self.user_scrolled = true;
611 self.sticking_to_bottom = false;
612 }
613 _ => {}
614 }
615 }
616
617 fn apply_actions_and_editor_visibility(
618 &mut self,
619 cx: &mut Cx,
620 widget: &WidgetRef,
621 index: usize,
622 ) {
623 let editor = widget.view(id!(editor));
624 let actions = widget.view(id!(actions));
625 let edit_actions = widget.view(id!(edit_actions));
626 let content_section = widget.view(id!(content_section));
627
628 let is_hovered = self.hovered_index == Some(index);
629 let is_current_editor = self.current_editor.as_ref().map(|e| e.index) == Some(index);
630
631 edit_actions.set_visible(cx, is_current_editor);
632 editor.set_visible(cx, is_current_editor);
633 actions.set_visible(cx, !is_current_editor && is_hovered);
634 content_section.set_visible(cx, !is_current_editor);
635
636 if is_current_editor {
637 editor
638 .text_input(id!(input))
639 .set_text(cx, &self.current_editor.as_ref().unwrap().buffer);
640 }
641 }
642
643 pub fn set_messages(&mut self, messages: Vec<Message>, scroll_to_bottom: bool) {
645 self.messages = messages;
646 self.should_defer_scroll_to_bottom = scroll_to_bottom;
647 }
648
649 pub fn reset_scroll_state(&mut self) {
650 self.user_scrolled = false;
651 self.sticking_to_bottom = false;
652 }
653}
654
655impl MessagesRef {
656 pub fn read(&self) -> Ref<'_, Messages> {
660 self.borrow().unwrap()
661 }
662
663 pub fn write(&mut self) -> RefMut<'_, Messages> {
667 self.borrow_mut().unwrap()
668 }
669
670 pub fn read_with<R>(&self, f: impl FnOnce(&Messages) -> R) -> R {
674 f(&*self.read())
675 }
676
677 pub fn write_with<R>(&mut self, f: impl FnOnce(&mut Messages) -> R) -> R {
681 f(&mut *self.write())
682 }
683}
684
685impl LiveHook for Messages {
686 fn after_new_from_doc(&mut self, _cx: &mut Cx) {
687 self.templates
688 .insert(live_id!(DeepInquireContent), self.deep_inquire_content);
689 }
690}