1use std::{
2 cell::{Ref, RefMut},
3 collections::HashSet,
4 sync::{Arc, Mutex},
5};
6
7use crate::{
8 aitk::{controllers::chat::ChatController, protocol::*},
9 utils::makepad::{events::EventExt, portal_list::ItemsRangeIter, ui_runner::DeferRedraw},
10 widgets::{
11 avatar::AvatarWidgetRefExt, chat_line::ChatLineAction,
12 message_loading::MessageLoadingWidgetRefExt,
13 },
14};
15use makepad_code_editor::code_view::CodeViewWidgetRefExt;
16use makepad_widgets::*;
17
18use super::{
19 citation::CitationAction,
20 slot::{Slot, SlotWidgetRefExt},
21 standard_message_content::StandardMessageContentWidgetRefExt,
22};
23
24script_mod! {
25 use mod.prelude.widgets.*
26 use mod.widgets.*
27
28 mod.widgets.MessagesBase = #(Messages::register_widget(vm))
29 mod.widgets.Messages = set_type_default() do mod.widgets.MessagesBase {
30 flow: Overlay,
31
32 list := PortalList {
33 grab_key_focus: true
34 scroll_bar +: {
35 bar_size: 0.0,
36 }
37 UserLine := UserLine {}
38 BotLine := BotLine {}
39 LoadingLine := LoadingLine {}
40 AppLine := AppLine {}
41 ErrorLine := ErrorLine {}
42 SystemLine := SystemLine {}
43 ToolRequestLine := ToolRequestLine {}
44 ToolResultLine := ToolResultLine {}
45 Empty := View { height: 0 }
46 }
47 View {
48 align: Align { x: 1.0, y: 1.0 },
49 jump_to_bottom := Button {
50 width: 36,
51 height: 36,
52 margin: Inset { left: 2, right: 2, top: 2, bottom: 10 },
53 icon_walk +: {
54 width: 16, height: 16
55 margin: Inset { left: 4.5, top: 6.5 },
56 }
57 draw_icon +: {
58 svg: crate_resource("self://resources/jump_to_bottom.svg")
59 color: #x1C1B1F,
60 color_hover: #x0
61 }
62 draw_bg +: {
63 pixel: fn() -> vec4 {
64 let sdf = Sdf2d.viewport(self.pos * self.rect_size);
65 let center = self.rect_size * 0.5;
66 let radius = min(self.rect_size.x self.rect_size.y) * 0.5;
67
68 sdf.circle(center.x center.y radius - 1.0);
69 sdf.fill_keep(#fff);
70 sdf.stroke(#xEAECF0 1.5);
71
72 return sdf.result
73 }
74 }
75 }
76 }
77 }
78}
79
80#[derive(Debug, PartialEq, Copy, Clone, Default)]
84pub enum MessagesAction {
85 Copy(usize),
87
88 Delete(usize),
90
91 EditSave(usize),
93
94 EditRegenerate(usize),
97
98 ToolApprove(usize),
100
101 ToolDeny(usize),
103
104 #[default]
105 None,
106}
107
108#[derive(Debug)]
110struct Editor {
111 index: usize,
112 buffer: String,
113}
114
115pub trait CustomContent {
116 fn content_widget(
117 &mut self,
118 cx: &mut Cx,
119 previous_widget: WidgetRef,
120 content: &MessageContent,
121 ) -> Option<WidgetRef>;
122}
123
124#[derive(Script, Widget, ScriptHook)]
129pub struct Messages {
130 #[deref]
131 deref: View,
132 #[source]
133 source: ScriptObjectRef,
134
135 #[rust]
136 pub chat_controller: Option<Arc<Mutex<ChatController>>>,
138
139 #[rust]
140 current_editor: Option<Editor>,
141
142 #[rust]
143 is_list_end_drawn: bool,
144
145 #[rust]
150 visible_range: Option<(usize, usize)>,
151
152 #[rust]
153 list_height: f64,
154
155 #[rust]
156 needs_extra_draw_pass: bool,
157
158 #[rust]
159 custom_contents: Vec<Box<dyn CustomContent>>,
160
161 #[rust]
163 expanded_error_details: HashSet<usize>,
164}
165
166impl Widget for Messages {
167 fn handle_event(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
168 self.ui_runner().handle(cx, event, scope, self);
169 self.deref.handle_event(cx, event, scope);
170 self.handle_list(cx, event, scope);
171
172 let jump_to_bottom = self.button(cx, ids!(jump_to_bottom));
173
174 if jump_to_bottom.clicked(event.actions()) {
175 self.animated_scroll_to_bottom(cx);
176 self.redraw(cx);
177 }
178
179 for action in event.widget_actions() {
180 if let CitationAction::Open(url) = action.cast() {
181 let _ = robius_open::Uri::new(url.as_str()).open();
182 }
183 }
184 }
185
186 fn draw_walk(&mut self, cx: &mut Cx2d, scope: &mut Scope, walk: Walk) -> DrawStep {
187 let list = self.portal_list(cx, ids!(list));
188 let list_uid = list.widget_uid();
189
190 while let Some(widget) = self.deref.draw_walk(cx, scope, walk).step() {
191 if widget.widget_uid() == list_uid {
192 self.draw_list(cx, widget.as_portal_list());
193 }
194 }
195
196 self.button(cx, ids!(jump_to_bottom))
197 .set_visible(cx, !self.is_at_bottom());
198
199 let previous_list_height = self.list_height;
200 self.list_height = list.area().rect(cx).size.y;
201
202 self.needs_extra_draw_pass =
203 self.needs_extra_draw_pass || (self.list_height != previous_list_height);
204
205 if self.needs_extra_draw_pass {
206 self.needs_extra_draw_pass = false;
207 self.ui_runner().defer_redraw();
208 }
209
210 DrawStep::done()
211 }
212}
213
214impl Messages {
215 fn draw_list(&mut self, cx: &mut Cx2d, list_ref: PortalListRef) {
216 self.is_list_end_drawn = false;
217 self.visible_range = None;
218
219 let chat_controller = self
220 .chat_controller
221 .clone()
222 .expect("no chat controller set");
223
224 let mut chat_controller = chat_controller.lock().unwrap();
225
226 let last_message_index = chat_controller.state().messages.len().checked_sub(1);
227 let second_last_message_index = last_message_index.and_then(|i| i.checked_sub(1));
228
229 let mut did_filler_draw = false;
230 let mut second_last_message_height = 0.0;
231 let mut last_message_height = 0.0;
232
233 chat_controller
234 .dangerous_state_mut()
235 .messages
236 .push(Message {
237 from: EntityId::App,
238 content: MessageContent {
239 text: "FIL".into(),
240 ..Default::default()
241 },
242 ..Default::default()
243 });
244
245 chat_controller
246 .dangerous_state_mut()
247 .messages
248 .push(Message {
249 from: EntityId::App,
250 content: MessageContent {
251 text: "EOC".into(),
252 ..Default::default()
253 },
254 ..Default::default()
255 });
256
257 let mut list = list_ref.borrow_mut().unwrap();
258 list.set_item_range(cx, 0, chat_controller.state().messages.len());
259
260 while let Some(index) = list.next_visible_item(cx) {
261 let total = chat_controller.state().messages.len();
262 if index >= total {
263 continue;
264 }
265
266 if let Some((_start, end)) = &mut self.visible_range {
267 *end = (*end).max(index);
268 } else {
269 self.visible_range = Some((index, index));
270 }
271
272 let message = &chat_controller.state().messages[index];
273
274 let item = match &message.from {
275 EntityId::System => {
276 let item = if message.metadata.is_writing() {
277 let item = list.item(cx, index, id!(LoadingLine));
278 item.message_loading(cx, ids!(content_section.loading))
279 .animate(cx);
280 item
281 } else {
282 list.item(cx, index, id!(SystemLine))
283 };
284
285 item.avatar(cx, ids!(avatar)).borrow_mut().unwrap().avatar =
286 Some(EntityAvatar::Text("S".into()));
287 item.label(cx, ids!(name)).set_text(cx, "System");
288
289 if !message.metadata.is_writing() {
290 item.slot(cx, ids!(content))
291 .current()
292 .as_standard_message_content()
293 .set_content(cx, &message.content);
294 }
295
296 self.apply_editor_visibility(cx, &item, index);
297 item
298 }
299 EntityId::Tool => {
300 let item = if message.metadata.is_writing() {
301 let item = list.item(cx, index, id!(LoadingLine));
302 item.message_loading(cx, ids!(content_section.loading))
303 .animate(cx);
304 item
305 } else {
306 list.item(cx, index, id!(ToolResultLine))
307 };
308
309 item.avatar(cx, ids!(avatar)).borrow_mut().unwrap().avatar =
310 Some(EntityAvatar::Text("T".into()));
311 item.label(cx, ids!(name)).set_text(cx, "Tool");
312
313 if !message.metadata.is_writing() {
314 item.slot(cx, ids!(content))
315 .current()
316 .as_standard_message_content()
317 .set_content(cx, &message.content);
318 }
319
320 self.apply_editor_visibility(cx, &item, index);
321 item
322 }
323 EntityId::App => {
324 if message.content.text == "EOC" {
325 let mut item = list.item(cx, index, id!(Empty));
326 script_apply_eval!(cx, item, {
327 height: 0.1
328 });
329 item.draw_all(cx, &mut Scope::empty());
330 self.is_list_end_drawn = true;
331 item
332 } else if message.content.text == "FIL" {
333 did_filler_draw = true;
334
335 let mut item = list.item(cx, index, id!(Empty));
336
337 const MAX_SECOND_LAST_MESSAGE_VISIBILITY: f64 = 100.0;
338 const SECOND_LAST_MESSAGE_DRAW_GUARANTEE: f64 = 1.0;
339
340 let height = (self.list_height
341 - last_message_height
342 - second_last_message_height
343 .clamp(0.0, MAX_SECOND_LAST_MESSAGE_VISIBILITY)
344 - SECOND_LAST_MESSAGE_DRAW_GUARANTEE)
345 .clamp(0.0, f64::INFINITY);
346
347 script_apply_eval!(cx, item, {
348 height: #(height)
349 });
350 item
351 } else if let Some((left, right)) = message.content.text.split_once(':')
352 && let Some("error") = left
353 .split_whitespace()
354 .last()
355 .map(|s| s.to_lowercase())
356 .as_deref()
357 {
358 let item = list.item(cx, index, id!(ErrorLine));
359 item.avatar(cx, ids!(avatar)).borrow_mut().unwrap().avatar =
360 Some(EntityAvatar::Text("X".into()));
361 item.label(cx, ids!(name)).set_text(cx, left);
362
363 let error_content = MessageContent {
364 text: right.to_string(),
365 ..Default::default()
366 };
367 item.slot(cx, ids!(content))
368 .current()
369 .as_standard_message_content()
370 .set_content(cx, &error_content);
371
372 let error_text = &message.content.text;
373 let note = error_note_for(error_text);
374 let has_note = !note.is_empty();
375
376 if has_note {
377 item.label(cx, ids!(error_note)).set_text(cx, note);
378 }
379
380 let has_details = message
381 .content
382 .data
383 .as_ref()
384 .is_some_and(|d| !d.trim().is_empty());
385 let show_section = has_note || has_details;
386 item.view(cx, ids!(error_details_section))
387 .set_visible(cx, show_section);
388 item.view(cx, ids!(error_note)).set_visible(cx, has_note);
389 item.view(cx, ids!(error_details_toggle))
390 .set_visible(cx, has_details);
391
392 let is_expanded = self.expanded_error_details.contains(&index);
393 item.view(cx, ids!(error_details))
394 .set_visible(cx, is_expanded);
395 let toggle_text = if is_expanded {
396 "Hide details"
397 } else {
398 "Show details"
399 };
400 item.label(cx, ids!(toggle_label)).set_text(cx, toggle_text);
401
402 if has_details && is_expanded {
403 item.label(cx, ids!(details_text))
404 .set_text(cx, message.content.data.as_deref().unwrap_or(""));
405 }
406
407 self.apply_editor_visibility(cx, &item, index);
408 item
409 } else {
410 let item = list.item(cx, index, id!(AppLine));
411 item.avatar(cx, ids!(avatar)).borrow_mut().unwrap().avatar =
412 Some(EntityAvatar::Text("A".into()));
413
414 item.slot(cx, ids!(content))
415 .current()
416 .as_standard_message_content()
417 .set_content(cx, &message.content);
418
419 self.apply_editor_visibility(cx, &item, index);
420 item
421 }
422 }
423 EntityId::User => {
424 let item = list.item(cx, index, id!(UserLine));
425
426 item.avatar(cx, ids!(avatar)).borrow_mut().unwrap().avatar =
427 Some(EntityAvatar::Text("Y".into()));
428 item.label(cx, ids!(name)).set_text(cx, "You");
429
430 let slot_ref = item.slot(cx, ids!(content));
431 let current = slot_ref.current();
432 let mut smc = current.as_standard_message_content();
433 smc.set_content(cx, &message.content);
434
435 self.apply_editor_visibility(cx, &item, index);
436 item
437 }
438 EntityId::Bot(id) => {
439 let bot = chat_controller.state().get_bot(id);
440
441 let (name, avatar) = bot
442 .as_ref()
443 .map(|b| (b.name.clone(), b.avatar.clone()))
444 .unwrap_or_else(|| {
445 let model_name = format!("{} (unavailable)", id.id());
446 let first_char = model_name.chars().next().unwrap_or('B');
447 let avatar = EntityAvatar::Text(first_char.to_uppercase().to_string());
448 (model_name, avatar)
449 });
450
451 let is_loading = message.metadata.is_writing() && message.content.is_empty();
452
453 let item =
454 if is_loading {
455 let item = list.item(cx, index, id!(LoadingLine));
456 item.message_loading(cx, ids!(content_section.loading))
457 .animate(cx);
458 item
459 } else if !message.content.tool_calls.is_empty() {
460 let item = list.item(cx, index, id!(ToolRequestLine));
461
462 let has_pending = message.content.tool_calls.iter().any(|tc| {
463 tc.permission_status == ToolCallPermissionStatus::Pending
464 });
465 let has_denied =
466 message.content.tool_calls.iter().any(|tc| {
467 tc.permission_status == ToolCallPermissionStatus::Denied
468 });
469
470 item.view(cx, ids!(tool_actions))
471 .set_visible(cx, has_pending);
472
473 if has_denied {
474 item.view(cx, ids!(status_view)).set_visible(cx, true);
475 item.label(cx, ids!(approved_status)).set_text(cx, "Denied");
476 } else {
477 item.view(cx, ids!(status_view)).set_visible(cx, false);
478 }
479
480 item
481 } else {
482 list.item(cx, index, id!(BotLine))
483 };
484
485 item.avatar(cx, ids!(avatar)).borrow_mut().unwrap().avatar = Some(avatar);
486 item.label(cx, ids!(name)).set_text(cx, name.as_str());
487
488 if !is_loading {
489 let mut slot = item.slot(cx, ids!(content));
490 if let Some(custom_content) = self
491 .custom_contents
492 .iter_mut()
493 .find_map(|cw| cw.content_widget(cx, slot.current(), &message.content))
494 {
495 slot.replace(custom_content);
496 } else {
497 slot.restore();
498 slot.default()
499 .as_standard_message_content()
500 .set_content_with_metadata(cx, &message.content, &message.metadata);
501 }
502
503 let has_any_tool_calls = !message.content.tool_calls.is_empty();
504 if !has_any_tool_calls {
505 self.apply_editor_visibility(cx, &item, index);
506 }
507 }
508
509 item
510 }
511 };
512
513 item.draw_all(cx, &mut Scope::empty());
514
515 if let Some(second_last_message_index) = second_last_message_index
516 && index == second_last_message_index
517 {
518 if did_filler_draw {
519 self.needs_extra_draw_pass = true;
520 } else {
521 second_last_message_height = item.area().rect(cx).size.y;
522 }
523 } else if let Some(last_message_index) = last_message_index
524 && index == last_message_index
525 {
526 last_message_height = item.area().rect(cx).size.y;
527 }
528 }
529
530 if let Some(message) = chat_controller.dangerous_state_mut().messages.pop() {
531 assert!(message.from == EntityId::App);
532 assert!(message.content.text == "EOC");
533 }
534
535 if let Some(message) = chat_controller.dangerous_state_mut().messages.pop() {
536 assert!(message.from == EntityId::App);
537 assert!(message.content.text.starts_with("FIL"));
538 }
539 }
540
541 pub fn is_at_bottom(&self) -> bool {
543 self.is_list_end_drawn
544 }
545
546 pub fn instant_scroll_to_bottom(&mut self, cx: &mut Cx) {
548 let chat_controller = self
549 .chat_controller
550 .as_ref()
551 .expect("no chat controller set");
552
553 if chat_controller.lock().unwrap().state().messages.len() > 0 {
554 let list = self.portal_list(cx, ids!(list));
555
556 list.set_first_id_and_scroll(
557 chat_controller
558 .lock()
559 .unwrap()
560 .state()
561 .messages
562 .len()
563 .saturating_sub(1),
564 0.0,
565 );
566
567 self.redraw(cx);
568 }
569 }
570
571 pub fn animated_scroll_to_bottom(&mut self, cx: &mut Cx) {
576 if self.is_at_bottom() {
577 self.instant_scroll_to_bottom(cx);
578 return;
579 }
580
581 let chat_controller = self
582 .chat_controller
583 .as_ref()
584 .expect("no chat controller set");
585
586 if chat_controller.lock().unwrap().state().messages.len() > 0 {
587 let list = self.portal_list(cx, ids!(list));
588 list.smooth_scroll_to_end(cx, 100.0, None);
589 }
590 }
591
592 pub fn set_message_editor_visibility(&mut self, index: usize, visible: bool) {
597 let chat_controller = self
598 .chat_controller
599 .as_ref()
600 .expect("no chat controller set")
601 .clone();
602
603 if index >= chat_controller.lock().unwrap().state().messages.len() {
604 return;
605 }
606
607 if visible {
608 let buffer = chat_controller.lock().unwrap().state().messages[index]
609 .content
610 .text
611 .clone();
612 self.current_editor = Some(Editor { index, buffer });
613 } else if self.current_editor.as_ref().map(|e| e.index) == Some(index) {
614 self.current_editor = None;
615 }
616 }
617
618 pub fn current_editor_text(&self, cx: &Cx) -> Option<String> {
621 self.current_editor
622 .as_ref()
623 .and_then(|editor| self.portal_list(cx, ids!(list)).get_item(editor.index))
624 .map(|(_id, widget)| widget.text_input(cx, ids!(input)).text())
625 }
626
627 pub fn current_editor_index(&self) -> Option<usize> {
630 self.current_editor.as_ref().map(|e| e.index)
631 }
632
633 fn handle_list(&mut self, cx: &mut Cx, event: &Event, scope: &mut Scope) {
634 let Some(range) = self.visible_range else {
635 return;
636 };
637
638 let list = self.portal_list(cx, ids!(list));
639 let range = range.0..=range.1;
640
641 for (index, item) in ItemsRangeIter::new(list, range) {
642 for action in item.filter_actions(event.actions()) {
643 match action.cast() {
644 ChatLineAction::Copy => {
645 cx.widget_action(self.widget_uid(), MessagesAction::Copy(index));
646 }
647 ChatLineAction::Delete => {
648 cx.widget_action(self.widget_uid(), MessagesAction::Delete(index));
649 }
650 ChatLineAction::Edit => {
651 self.set_message_editor_visibility(index, true);
652 self.redraw(cx);
653 }
654 ChatLineAction::EditCancel => {
655 self.set_message_editor_visibility(index, false);
656 self.redraw(cx);
657 }
658 ChatLineAction::Save => {
659 cx.widget_action(self.widget_uid(), MessagesAction::EditSave(index));
660 }
661 ChatLineAction::SaveAndRegenerate => {
662 cx.widget_action(self.widget_uid(), MessagesAction::EditRegenerate(index));
663 }
664 ChatLineAction::ToolApprove => {
665 cx.widget_action(self.widget_uid(), MessagesAction::ToolApprove(index));
666 }
667 ChatLineAction::ToolDeny => {
668 cx.widget_action(self.widget_uid(), MessagesAction::ToolDeny(index));
669 }
670 ChatLineAction::EditorChanged => {
671 let text = item.text_input(cx, ids!(input)).text();
672 self.current_editor.as_mut().unwrap().buffer = text;
673 }
674 ChatLineAction::ErrorDetailsToggle => {
675 if !self.expanded_error_details.remove(&index) {
676 self.expanded_error_details.insert(index);
677 }
678 self.redraw(cx);
679 }
680 ChatLineAction::None => {}
681 }
682 }
683 }
684
685 for action in event.actions() {
687 let Some(wa) = action.downcast_ref::<WidgetAction>() else {
688 continue;
689 };
690 if !matches!(
691 wa.action.downcast_ref::<ButtonAction>(),
692 Some(ButtonAction::Clicked(_))
693 ) {
694 continue;
695 }
696 let tree = cx.widget_tree();
697 let path = tree.path_to(wa.widget_uid);
698 if !path.contains(&id!(copy_code_button)) {
699 continue;
700 }
701 let cv = tree.find_flood(wa.widget_uid, ids!(code_view));
702 let text = cv.as_code_view().text();
703 cx.copy_to_clipboard(&text);
704 }
705 }
706
707 fn apply_editor_visibility(&mut self, cx: &mut Cx, widget: &WidgetRef, index: usize) {
708 let editor = widget.view(cx, ids!(editor));
709 let edit_actions = widget.view(cx, ids!(edit_actions));
710 let content_section = widget.view(cx, ids!(content_section));
711
712 let is_current_editor = self.current_editor.as_ref().map(|e| e.index) == Some(index);
713
714 edit_actions.set_visible(cx, is_current_editor);
715 editor.set_visible(cx, is_current_editor);
716 content_section.set_visible(cx, !is_current_editor);
717
718 if is_current_editor {
719 editor
720 .text_input(cx, ids!(input))
721 .set_text(cx, &self.current_editor.as_ref().unwrap().buffer);
722 }
723 }
724
725 pub fn register_custom_content<T: CustomContent + 'static>(&mut self, widget: T) {
727 self.custom_contents.push(Box::new(widget));
728 }
729}
730
731fn extract_status_code(error_text: &str) -> Option<u16> {
734 let mut tokens = error_text.split_whitespace();
735 while let Some(token) = tokens.next() {
736 if token.eq_ignore_ascii_case("status") {
737 if let Some(code) = tokens.next().and_then(|t| t.parse::<u16>().ok()) {
738 return Some(code);
739 }
740 }
741 }
742 None
743}
744
745fn error_note_for(error_text: &str) -> &'static str {
748 match extract_status_code(error_text) {
749 Some(429) => {
750 "This usually means you've hit a rate limit, \
751 run out of quota/credits, or do not have \
752 access to this resource/model in your \
753 current plan."
754 }
755 Some(401) => {
756 "This usually means your API key is invalid \
757 or expired."
758 }
759 Some(403) => {
760 "This usually means you do not have \
761 permission to access this resource."
762 }
763 Some(400) => {
764 "This might be an error on our side. If the \
765 problem persists, please file an issue on \
766 GitHub."
767 }
768 Some(500 | 502 | 503 | 504) => {
769 "A server error occurred. This is likely a \
770 temporary issue with the provider."
771 }
772 _ => "",
773 }
774}
775
776impl MessagesRef {
777 pub fn read(&self) -> Ref<'_, Messages> {
782 self.borrow().unwrap()
783 }
784
785 pub fn write(&mut self) -> RefMut<'_, Messages> {
790 self.borrow_mut().unwrap()
791 }
792
793 pub fn read_with<R>(&self, f: impl FnOnce(&Messages) -> R) -> R {
798 f(&*self.read())
799 }
800
801 pub fn write_with<R>(&mut self, f: impl FnOnce(&mut Messages) -> R) -> R {
806 f(&mut *self.write())
807 }
808}