notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

dave.rs (16713B)


      1 use crate::{
      2     messages::Message,
      3     tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse},
      4 };
      5 use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
      6 use nostrdb::{Ndb, Transaction};
      7 use notedeck::{
      8     tr, Accounts, AppContext, Images, Localization, MediaJobSender, NoteAction, NoteContext,
      9 };
     10 use notedeck_ui::{app_images, icons::search_icon, NoteOptions, ProfilePic};
     11 
     12 /// DaveUi holds all of the data it needs to render itself
     13 pub struct DaveUi<'a> {
     14     chat: &'a [Message],
     15     trial: bool,
     16     input: &'a mut String,
     17 }
     18 
     19 /// The response the app generates. The response contains an optional
     20 /// action to take.
     21 #[derive(Default, Debug)]
     22 pub struct DaveResponse {
     23     pub action: Option<DaveAction>,
     24 }
     25 
     26 impl DaveResponse {
     27     fn new(action: DaveAction) -> Self {
     28         DaveResponse {
     29             action: Some(action),
     30         }
     31     }
     32 
     33     fn note(action: NoteAction) -> DaveResponse {
     34         Self::new(DaveAction::Note(action))
     35     }
     36 
     37     fn or(self, r: DaveResponse) -> DaveResponse {
     38         DaveResponse {
     39             action: self.action.or(r.action),
     40         }
     41     }
     42 
     43     /// Generate a send response to the controller
     44     fn send() -> Self {
     45         Self::new(DaveAction::Send)
     46     }
     47 
     48     fn none() -> Self {
     49         DaveResponse::default()
     50     }
     51 }
     52 
     53 /// The actions the app generates. No default action is specfied in the
     54 /// UI code. This is handled by the app logic, however it chooses to
     55 /// process this message.
     56 #[derive(Debug)]
     57 pub enum DaveAction {
     58     /// The action generated when the user sends a message to dave
     59     Send,
     60     NewChat,
     61     ToggleChrome,
     62     Note(NoteAction),
     63 }
     64 
     65 impl<'a> DaveUi<'a> {
     66     pub fn new(trial: bool, chat: &'a [Message], input: &'a mut String) -> Self {
     67         DaveUi { trial, chat, input }
     68     }
     69 
     70     fn chat_margin(ctx: &egui::Context) -> i8 {
     71         if notedeck::ui::is_narrow(ctx) {
     72             20
     73         } else {
     74             100
     75         }
     76     }
     77 
     78     fn chat_frame(ctx: &egui::Context) -> egui::Frame {
     79         let margin = Self::chat_margin(ctx);
     80         egui::Frame::new().inner_margin(egui::Margin {
     81             left: margin,
     82             right: margin,
     83             top: 50,
     84             bottom: 0,
     85         })
     86     }
     87 
     88     /// The main render function. Call this to render Dave
     89     pub fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
     90         let action = top_buttons_ui(app_ctx, ui);
     91 
     92         egui::Frame::NONE
     93             .show(ui, |ui| {
     94                 ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
     95                     let margin = Self::chat_margin(ui.ctx());
     96 
     97                     let r = egui::Frame::new()
     98                         .outer_margin(egui::Margin {
     99                             left: margin,
    100                             right: margin,
    101                             top: 0,
    102                             bottom: 100,
    103                         })
    104                         .inner_margin(egui::Margin::same(8))
    105                         .fill(ui.visuals().extreme_bg_color)
    106                         .corner_radius(12.0)
    107                         .show(ui, |ui| self.inputbox(app_ctx.i18n, ui))
    108                         .inner;
    109 
    110                     let note_action = egui::ScrollArea::vertical()
    111                         .stick_to_bottom(true)
    112                         .auto_shrink([false; 2])
    113                         .show(ui, |ui| {
    114                             Self::chat_frame(ui.ctx())
    115                                 .show(ui, |ui| {
    116                                     ui.vertical(|ui| self.render_chat(app_ctx, ui)).inner
    117                                 })
    118                                 .inner
    119                         })
    120                         .inner;
    121 
    122                     if let Some(action) = note_action {
    123                         DaveResponse::note(action)
    124                     } else {
    125                         r
    126                     }
    127                 })
    128                 .inner
    129             })
    130             .inner
    131             .or(DaveResponse { action })
    132     }
    133 
    134     fn error_chat(&self, i18n: &mut Localization, err: &str, ui: &mut egui::Ui) {
    135         if self.trial {
    136             ui.add(egui::Label::new(
    137                 egui::RichText::new(
    138                     tr!(i18n, "The Dave Nostr AI assistant trial has ended :(. Thanks for testing! Zap-enabled Dave coming soon!", "Message shown when Dave trial period has ended"),
    139                 )
    140                 .weak(),
    141             ));
    142         } else {
    143             ui.add(egui::Label::new(
    144                 egui::RichText::new(format!("An error occured: {err}")).weak(),
    145             ));
    146         }
    147     }
    148 
    149     /// Render a chat message (user, assistant, tool call/response, etc)
    150     fn render_chat(&self, ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<NoteAction> {
    151         let mut action: Option<NoteAction> = None;
    152         for message in self.chat {
    153             let r = match message {
    154                 Message::Error(err) => {
    155                     self.error_chat(ctx.i18n, err, ui);
    156                     None
    157                 }
    158                 Message::User(msg) => {
    159                     self.user_chat(msg, ui);
    160                     None
    161                 }
    162                 Message::Assistant(msg) => {
    163                     self.assistant_chat(msg, ui);
    164                     None
    165                 }
    166                 Message::ToolResponse(msg) => {
    167                     Self::tool_response_ui(msg, ui);
    168                     None
    169                 }
    170                 Message::System(_msg) => {
    171                     // system prompt is not rendered. Maybe we could
    172                     // have a debug option to show this
    173                     None
    174                 }
    175                 Message::ToolCalls(toolcalls) => Self::tool_calls_ui(ctx, toolcalls, ui),
    176             };
    177 
    178             if r.is_some() {
    179                 action = r;
    180             }
    181         }
    182 
    183         action
    184     }
    185 
    186     fn tool_response_ui(_tool_response: &ToolResponse, _ui: &mut egui::Ui) {
    187         //ui.label(format!("tool_response: {:?}", tool_response));
    188     }
    189 
    190     fn search_call_ui(ctx: &mut AppContext, query_call: &QueryCall, ui: &mut egui::Ui) {
    191         ui.add(search_icon(16.0, 16.0));
    192         ui.add_space(8.0);
    193 
    194         query_call_ui(
    195             ctx.img_cache,
    196             ctx.ndb,
    197             query_call,
    198             ctx.media_jobs.sender(),
    199             ui,
    200         );
    201     }
    202 
    203     /// The ai has asked us to render some notes, so we do that here
    204     fn present_notes_ui(
    205         ctx: &mut AppContext,
    206         call: &PresentNotesCall,
    207         ui: &mut egui::Ui,
    208     ) -> Option<NoteAction> {
    209         let mut note_context = NoteContext {
    210             ndb: ctx.ndb,
    211             accounts: ctx.accounts,
    212             img_cache: ctx.img_cache,
    213             note_cache: ctx.note_cache,
    214             zaps: ctx.zaps,
    215             pool: ctx.pool,
    216             jobs: ctx.media_jobs.sender(),
    217             unknown_ids: ctx.unknown_ids,
    218             clipboard: ctx.clipboard,
    219             i18n: ctx.i18n,
    220             global_wallet: ctx.global_wallet,
    221         };
    222 
    223         let txn = Transaction::new(note_context.ndb).unwrap();
    224 
    225         egui::ScrollArea::horizontal()
    226             .max_height(400.0)
    227             .show(ui, |ui| {
    228                 ui.with_layout(Layout::left_to_right(Align::Min), |ui| {
    229                     ui.spacing_mut().item_spacing.x = 10.0;
    230                     let mut action: Option<NoteAction> = None;
    231 
    232                     for note_id in &call.note_ids {
    233                         let Ok(note) = note_context.ndb.get_note_by_id(&txn, note_id.bytes())
    234                         else {
    235                             continue;
    236                         };
    237 
    238                         let r = ui
    239                             .allocate_ui_with_layout(
    240                                 [400.0, 400.0].into(),
    241                                 Layout::centered_and_justified(ui.layout().main_dir()),
    242                                 |ui| {
    243                                     notedeck_ui::NoteView::new(
    244                                         &mut note_context,
    245                                         &note,
    246                                         NoteOptions::default(),
    247                                     )
    248                                     .preview_style()
    249                                     .hide_media(true)
    250                                     .show(ui)
    251                                 },
    252                             )
    253                             .inner;
    254 
    255                         if r.action.is_some() {
    256                             action = r.action;
    257                         }
    258                     }
    259 
    260                     action
    261                 })
    262                 .inner
    263             })
    264             .inner
    265     }
    266 
    267     fn tool_calls_ui(
    268         ctx: &mut AppContext,
    269         toolcalls: &[ToolCall],
    270         ui: &mut egui::Ui,
    271     ) -> Option<NoteAction> {
    272         let mut note_action: Option<NoteAction> = None;
    273 
    274         ui.vertical(|ui| {
    275             for call in toolcalls {
    276                 match call.calls() {
    277                     ToolCalls::PresentNotes(call) => {
    278                         let r = Self::present_notes_ui(ctx, call, ui);
    279                         if r.is_some() {
    280                             note_action = r;
    281                         }
    282                     }
    283                     ToolCalls::Invalid(err) => {
    284                         ui.label(format!("invalid tool call: {err:?}"));
    285                     }
    286                     ToolCalls::Query(search_call) => {
    287                         ui.allocate_ui_with_layout(
    288                             egui::vec2(ui.available_size().x, 32.0),
    289                             Layout::left_to_right(Align::Center),
    290                             |ui| {
    291                                 Self::search_call_ui(ctx, search_call, ui);
    292                             },
    293                         );
    294                     }
    295                 }
    296             }
    297         });
    298 
    299         note_action
    300     }
    301 
    302     fn inputbox(&mut self, i18n: &mut Localization, ui: &mut egui::Ui) -> DaveResponse {
    303         //ui.add_space(Self::chat_margin(ui.ctx()) as f32);
    304         ui.horizontal(|ui| {
    305             ui.with_layout(Layout::right_to_left(Align::Max), |ui| {
    306                 let mut dave_response = DaveResponse::none();
    307                 if ui
    308                     .add(egui::Button::new(tr!(
    309                         i18n,
    310                         "Ask",
    311                         "Button to send message to Dave AI assistant"
    312                     )))
    313                     .clicked()
    314                 {
    315                     dave_response = DaveResponse::send();
    316                 }
    317 
    318                 let r = ui.add(
    319                     egui::TextEdit::multiline(self.input)
    320                         .desired_width(f32::INFINITY)
    321                         .return_key(KeyboardShortcut::new(
    322                             Modifiers {
    323                                 shift: true,
    324                                 ..Default::default()
    325                             },
    326                             Key::Enter,
    327                         ))
    328                         .hint_text(
    329                             egui::RichText::new(tr!(
    330                                 i18n,
    331                                 "Ask dave anything...",
    332                                 "Placeholder text for Dave AI input field"
    333                             ))
    334                             .weak(),
    335                         )
    336                         .frame(false),
    337                 );
    338                 notedeck_ui::include_input(ui, &r);
    339 
    340                 if r.has_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
    341                     DaveResponse::send()
    342                 } else {
    343                     dave_response
    344                 }
    345             })
    346             .inner
    347         })
    348         .inner
    349     }
    350 
    351     fn user_chat(&self, msg: &str, ui: &mut egui::Ui) {
    352         ui.with_layout(egui::Layout::right_to_left(egui::Align::TOP), |ui| {
    353             egui::Frame::new()
    354                 .inner_margin(10.0)
    355                 .corner_radius(10.0)
    356                 .fill(ui.visuals().widgets.inactive.weak_bg_fill)
    357                 .show(ui, |ui| {
    358                     ui.label(msg);
    359                 })
    360         });
    361     }
    362 
    363     fn assistant_chat(&self, msg: &str, ui: &mut egui::Ui) {
    364         ui.horizontal_wrapped(|ui| {
    365             ui.add(egui::Label::new(msg).wrap_mode(egui::TextWrapMode::Wrap));
    366         });
    367     }
    368 }
    369 
    370 fn new_chat_button() -> impl egui::Widget {
    371     move |ui: &mut egui::Ui| {
    372         let img_size = 24.0;
    373         let max_size = 32.0;
    374 
    375         let img = app_images::new_message_image().max_width(img_size);
    376 
    377         let helper = notedeck_ui::anim::AnimationHelper::new(
    378             ui,
    379             "new-chat-button",
    380             egui::vec2(max_size, max_size),
    381         );
    382 
    383         let cur_img_size = helper.scale_1d_pos(img_size);
    384         img.paint_at(
    385             ui,
    386             helper
    387                 .get_animation_rect()
    388                 .shrink((max_size - cur_img_size) / 2.0),
    389         );
    390 
    391         helper.take_animation_response()
    392     }
    393 }
    394 
    395 fn query_call_ui(
    396     cache: &mut notedeck::Images,
    397     ndb: &Ndb,
    398     query: &QueryCall,
    399     jobs: &MediaJobSender,
    400     ui: &mut egui::Ui,
    401 ) {
    402     ui.spacing_mut().item_spacing.x = 8.0;
    403     if let Some(pubkey) = query.author() {
    404         let txn = Transaction::new(ndb).unwrap();
    405         pill_label_ui(
    406             "author",
    407             move |ui| {
    408                 ui.add(
    409                     &mut ProfilePic::from_profile_or_default(
    410                         cache,
    411                         jobs,
    412                         ndb.get_profile_by_pubkey(&txn, pubkey.bytes())
    413                             .ok()
    414                             .as_ref(),
    415                     )
    416                     .size(ProfilePic::small_size() as f32),
    417                 );
    418             },
    419             ui,
    420         );
    421     }
    422 
    423     if let Some(limit) = query.limit {
    424         pill_label("limit", &limit.to_string(), ui);
    425     }
    426 
    427     if let Some(since) = query.since {
    428         pill_label("since", &since.to_string(), ui);
    429     }
    430 
    431     if let Some(kind) = query.kind {
    432         pill_label("kind", &kind.to_string(), ui);
    433     }
    434 
    435     if let Some(until) = query.until {
    436         pill_label("until", &until.to_string(), ui);
    437     }
    438 
    439     if let Some(search) = query.search.as_ref() {
    440         pill_label("search", search, ui);
    441     }
    442 }
    443 
    444 fn pill_label(name: &str, value: &str, ui: &mut egui::Ui) {
    445     pill_label_ui(
    446         name,
    447         move |ui| {
    448             ui.label(value);
    449         },
    450         ui,
    451     );
    452 }
    453 
    454 fn pill_label_ui(name: &str, mut value: impl FnMut(&mut egui::Ui), ui: &mut egui::Ui) {
    455     egui::Frame::new()
    456         .fill(ui.visuals().noninteractive().bg_fill)
    457         .inner_margin(egui::Margin::same(4))
    458         .corner_radius(egui::CornerRadius::same(10))
    459         .stroke(egui::Stroke::new(
    460             1.0,
    461             ui.visuals().noninteractive().bg_stroke.color,
    462         ))
    463         .show(ui, |ui| {
    464             egui::Frame::new()
    465                 .fill(ui.visuals().noninteractive().weak_bg_fill)
    466                 .inner_margin(egui::Margin::same(4))
    467                 .corner_radius(egui::CornerRadius::same(10))
    468                 .stroke(egui::Stroke::new(
    469                     1.0,
    470                     ui.visuals().noninteractive().bg_stroke.color,
    471                 ))
    472                 .show(ui, |ui| {
    473                     ui.label(name);
    474                 });
    475 
    476             value(ui);
    477         });
    478 }
    479 
    480 fn top_buttons_ui(app_ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<DaveAction> {
    481     // Scroll area for chat messages
    482     let mut action: Option<DaveAction> = None;
    483     let mut rect = ui.available_rect_before_wrap();
    484     rect = rect.translate(egui::vec2(20.0, 20.0));
    485     rect.set_height(32.0);
    486     rect.set_width(32.0);
    487 
    488     let txn = Transaction::new(app_ctx.ndb).unwrap();
    489     let r = ui
    490         .put(
    491             rect,
    492             &mut pfp_button(
    493                 &txn,
    494                 app_ctx.accounts,
    495                 app_ctx.img_cache,
    496                 app_ctx.ndb,
    497                 app_ctx.media_jobs.sender(),
    498             ),
    499         )
    500         .on_hover_cursor(egui::CursorIcon::PointingHand);
    501 
    502     if r.clicked() {
    503         action = Some(DaveAction::ToggleChrome);
    504     }
    505 
    506     rect = rect.translate(egui::vec2(30.0, 0.0));
    507     let r = ui.put(rect, new_chat_button());
    508 
    509     if r.clicked() {
    510         action = Some(DaveAction::NewChat);
    511     }
    512 
    513     action
    514 }
    515 
    516 fn pfp_button<'me, 'a>(
    517     txn: &'a Transaction,
    518     accounts: &Accounts,
    519     img_cache: &'me mut Images,
    520     ndb: &Ndb,
    521     jobs: &'me MediaJobSender,
    522 ) -> ProfilePic<'me, 'a> {
    523     let account = accounts.get_selected_account();
    524     let profile = ndb
    525         .get_profile_by_pubkey(txn, account.key.pubkey.bytes())
    526         .ok();
    527 
    528     ProfilePic::from_profile_or_default(img_cache, jobs, profile.as_ref())
    529         .size(24.0)
    530         .sense(egui::Sense::click())
    531 }