notedeck

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

dave.rs (16561B)


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