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 ¬e, 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 }