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