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