notedeck

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

commit f496d4b8c4b574ce02d8ffdfbd1dd2198ebdab64
parent 43310b271ee27153b1ecb58badc9ca0906c0f2f4
Author: William Casarin <jb55@jb55.com>
Date:   Fri, 18 Apr 2025 17:03:59 -0700

dave: initial note rendering

Signed-off-by: William Casarin <jb55@jb55.com>

Diffstat:
MCargo.lock | 1+
Mcrates/notedeck_dave/Cargo.toml | 1+
Mcrates/notedeck_dave/src/lib.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++----------
Mcrates/notedeck_dave/src/tools.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mcrates/notedeck_ui/src/note/contents.rs | 25++++---------------------
Mcrates/notedeck_ui/src/note/mod.rs | 108++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------------
6 files changed, 205 insertions(+), 72 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -3272,6 +3272,7 @@ dependencies = [ "eframe", "egui", "egui-wgpu", + "enostr", "futures", "hex", "nostrdb", diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml @@ -10,6 +10,7 @@ notedeck = { workspace = true } notedeck_ui = { workspace = true } eframe = { workspace = true } tokio = { workspace = true } +enostr = { workspace = true } tracing = { workspace = true } egui-wgpu = { workspace = true } serde_json = { workspace = true } diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -8,8 +8,8 @@ use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers}; use egui_wgpu::RenderState; use futures::StreamExt; use nostrdb::Transaction; -use notedeck::AppContext; -use notedeck_ui::icons::search_icon; +use notedeck::{AppContext, NoteContext}; +use notedeck_ui::{icons::search_icon, NoteOptions}; use std::collections::HashMap; use std::sync::mpsc::{self, Receiver}; use std::sync::Arc; @@ -69,7 +69,7 @@ impl Dave { let system_prompt = Message::System(format!( r#" -You are an AI agent for the nostr protocol called Dave, created by Damus. nostr is a decentralized social media and internet communications protocol. You are embedded in a nostr browser called 'Damus Notedeck'. The returned note results are formatted into clickable note widgets. This happens when a nostr-uri is detected (ie: nostr:neventnevent1y4mvl8046gjsvdvztnp7jvs7w29pxcmkyj5p58m7t0dmjc8qddzsje0zmj). When referencing notes, ensure that this uri is included in the response so notes can be rendered inline. +You are an AI agent for the nostr protocol called Dave, created by Damus. nostr is a decentralized social media and internet communications protocol. You are embedded in a nostr browser called 'Damus Notedeck'. - The current date is {date} ({timestamp} unix timestamp if needed for queries). @@ -79,8 +79,9 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr # Response Guidelines -- You *MUST* include nostr:nevent references when referring to notes -- When a user asks for a digest instead of specific query terms, make sure to include both `since` and `until` to pull notes for the correct range. +- You *MUST* call the present_notes tool with a list of comma-separated nevent references when referring to notes so that the UI can display them. Do *NOT* include nevent references in the text response, but you *SHOULD* use ^1, ^2, etc to reference note indices passed to present_notes. +- When a user asks for a digest instead of specific query terms, make sure to include both since and until to pull notes for the correct range. +- When tasked with open-ended queries such as looking for interesting notes or summarizing the day, make sure to add enough notes to the context (limit: 100-200) so that it returns enough data for summarization. "# )); @@ -123,6 +124,13 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr for call in &toolcalls { // execute toolcall match call.calls() { + ToolCalls::PresentNotes(_note_ids) => { + self.chat.push(Message::ToolResponse(ToolResponse::new( + call.id().to_owned(), + ToolResponses::PresentNotes, + ))) + } + ToolCalls::Query(search_call) => { let resp = search_call.execute(&txn, app_ctx.ndb); self.chat.push(Message::ToolResponse(ToolResponse::new( @@ -159,7 +167,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }) } - fn render(&mut self, app_ctx: &AppContext, ui: &mut egui::Ui) { + fn render(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) { // Scroll area for chat messages egui::Frame::NONE.show(ui, |ui| { ui.with_layout(Layout::bottom_up(Align::Min), |ui| { @@ -186,7 +194,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr .show(ui, |ui| { Self::chat_frame(ui.ctx()).show(ui, |ui| { ui.vertical(|ui| { - self.render_chat(ui); + self.render_chat(app_ctx, ui); }); }); }); @@ -194,7 +202,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr }); } - fn render_chat(&self, ui: &mut egui::Ui) { + fn render_chat(&self, ctx: &mut AppContext, ui: &mut egui::Ui) { for message in &self.chat { match message { Message::User(msg) => self.user_chat(msg, ui), @@ -205,7 +213,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr // have a debug option to show this } Message::ToolCalls(toolcalls) => { - Self::tool_call_ui(toolcalls, ui); + Self::tool_call_ui(ctx, toolcalls, ui); } } } @@ -232,10 +240,44 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr } } - fn tool_call_ui(toolcalls: &[ToolCall], ui: &mut egui::Ui) { + fn tool_call_ui(ctx: &mut AppContext, toolcalls: &[ToolCall], ui: &mut egui::Ui) { ui.vertical(|ui| { for call in toolcalls { match call.calls() { + ToolCalls::PresentNotes(call) => { + let mut note_context = NoteContext { + ndb: ctx.ndb, + img_cache: ctx.img_cache, + note_cache: ctx.note_cache, + zaps: ctx.zaps, + pool: ctx.pool, + }; + + let txn = Transaction::new(note_context.ndb).unwrap(); + + egui::ScrollArea::horizontal().show(ui, |ui| { + ui.horizontal(|ui| { + for note_id in &call.note_ids { + let Ok(note) = + note_context.ndb.get_note_by_id(&txn, note_id.bytes()) + else { + continue; + }; + + // TODO: remove current account thing, just add to note context + notedeck_ui::NoteView::new( + &mut note_context, + &None, + &note, + NoteOptions::default(), + ) + .preview_style() + .show(ui); + } + }); + }); + } + ToolCalls::Query(search_call) => { ui.horizontal(|ui| { egui::Frame::new() diff --git a/crates/notedeck_dave/src/tools.rs b/crates/notedeck_dave/src/tools.rs @@ -1,5 +1,6 @@ use async_openai::types::*; use chrono::DateTime; +use enostr::NoteId; use nostrdb::{Ndb, Note, NoteKey, Transaction}; use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; @@ -76,6 +77,7 @@ pub struct QueryResponse { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ToolResponses { Query(QueryResponse), + PresentNotes, } #[derive(Debug, Clone)] @@ -116,6 +118,7 @@ impl PartialToolCall { #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ToolCalls { Query(QueryCall), + PresentNotes(PresentNotesCall), } impl ToolCalls { @@ -129,12 +132,14 @@ impl ToolCalls { fn name(&self) -> &'static str { match self { Self::Query(_) => "search", + Self::PresentNotes(_) => "present", } } fn arguments(&self) -> String { match self { Self::Query(search) => serde_json::to_string(search).unwrap(), + Self::PresentNotes(call) => serde_json::to_string(&call.to_simple()).unwrap(), } } } @@ -289,6 +294,51 @@ pub enum QueryContext { Any, } +/// Called by dave when he wants to display notes on the screen +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PresentNotesCall { + pub note_ids: Vec<NoteId>, +} + +impl PresentNotesCall { + fn to_simple(&self) -> PresentNotesCallSimple { + let note_ids = self + .note_ids + .iter() + .map(|nid| hex::encode(nid.bytes())) + .collect::<Vec<_>>() + .join(","); + + PresentNotesCallSimple { note_ids } + } +} + +/// Called by dave when he wants to display notes on the screen +#[derive(Debug, Deserialize, Serialize, Clone)] +pub struct PresentNotesCallSimple { + note_ids: String, +} + +impl PresentNotesCall { + fn parse(args: &str) -> Result<ToolCalls, ToolCallError> { + match serde_json::from_str::<PresentNotesCallSimple>(args) { + Ok(call) => { + let note_ids = call + .note_ids + .split(",") + .filter_map(|n| NoteId::from_hex(n).ok()) + .collect(); + + Ok(ToolCalls::PresentNotes(PresentNotesCall { note_ids })) + } + Err(e) => Err(ToolCallError::ArgParseFailure(format!( + "Failed to parse args: '{}', error: {}", + args, e + ))), + } + } +} + /// The parsed nostrdb query that dave wants to use to satisfy a request #[derive(Debug, Deserialize, Serialize, Clone)] pub struct QueryCall { @@ -385,17 +435,20 @@ impl QueryCall { /// tool responses #[derive(Debug, Serialize)] struct SimpleNote { + note_id: String, pubkey: String, name: String, content: String, created_at: String, - note_kind: String, // todo: add replying to + note_kind: u64, // todo: add replying to } /// Take the result of a tool response and present it to the ai so that /// it can interepret it and take further action fn format_tool_response_for_ai(txn: &Transaction, ndb: &Ndb, resp: &ToolResponses) -> String { match resp { + ToolResponses::PresentNotes => "".to_string(), + ToolResponses::Query(search_r) => { let simple_notes: Vec<SimpleNote> = search_r .notes @@ -415,7 +468,8 @@ fn format_tool_response_for_ai(txn: &Transaction, ndb: &Ndb, resp: &ToolResponse let content = note.content().to_owned(); let pubkey = hex::encode(note.pubkey()); - let note_kind = note_kind_desc(note.kind() as u64); + let note_kind = note.kind() as u64; + let note_id = hex::encode(note.id()); let created_at = { let datetime = @@ -424,6 +478,7 @@ fn format_tool_response_for_ai(txn: &Transaction, ndb: &Ndb, resp: &ToolResponse }; Some(SimpleNote { + note_id, pubkey, name, content, @@ -438,7 +493,7 @@ fn format_tool_response_for_ai(txn: &Transaction, ndb: &Ndb, resp: &ToolResponse } } -fn note_kind_desc(kind: u64) -> String { +fn _note_kind_desc(kind: u64) -> String { match kind { 1 => "microblog".to_string(), 0 => "profile".to_string(), @@ -446,6 +501,23 @@ fn note_kind_desc(kind: u64) -> String { } } +fn present_tool() -> Tool { + Tool { + name: "present_notes", + parse_call: PresentNotesCall::parse, + description: "A tool for presenting notes to the user for display. Should be called at the end of a response so that the UI can present the notes referred to in the previous message.", + arguments: vec![ + ToolArg { + name: "note_ids", + description: "A comma-separated list of hex note ids", + typ: ArgType::String, + required: true, + default: None + } + ] + } +} + fn query_tool() -> Tool { Tool { name: "query", @@ -505,5 +577,5 @@ fn query_tool() -> Tool { } pub fn dave_tools() -> Vec<Tool> { - vec![query_tool()] + vec![query_tool(), present_tool()] } diff --git a/crates/notedeck_ui/src/note/contents.rs b/crates/notedeck_ui/src/note/contents.rs @@ -95,27 +95,10 @@ pub fn render_note_preview( */ }; - egui::Frame::new() - .fill(ui.visuals().noninteractive().weak_bg_fill) - .inner_margin(egui::Margin::same(8)) - .outer_margin(egui::Margin::symmetric(0, 8)) - .corner_radius(egui::CornerRadius::same(10)) - .stroke(egui::Stroke::new( - 1.0, - ui.visuals().noninteractive().bg_stroke.color, - )) - .show(ui, |ui| { - NoteView::new(note_context, cur_acc, &note, note_options) - .actionbar(false) - .small_pfp(true) - .wide(true) - .note_previews(false) - .options_button(true) - .parent(parent) - .is_preview(true) - .show(ui) - }) - .inner + NoteView::new(note_context, cur_acc, &note, note_options) + .preview_style() + .parent(parent) + .show(ui) } #[allow(clippy::too_many_arguments)] diff --git a/crates/notedeck_ui/src/note/mod.rs b/crates/notedeck_ui/src/note/mod.rs @@ -29,6 +29,7 @@ pub struct NoteView<'a, 'd> { cur_acc: &'a Option<KeypairUnowned<'a>>, parent: Option<NoteKey>, note: &'a nostrdb::Note<'a>, + framed: bool, flags: NoteOptions, } @@ -68,6 +69,7 @@ impl<'a, 'd> NoteView<'a, 'd> { ) -> Self { flags.set_actionbar(true); flags.set_note_previews(true); + let framed = false; let parent: Option<NoteKey> = None; Self { @@ -76,9 +78,20 @@ impl<'a, 'd> NoteView<'a, 'd> { parent, note, flags, + framed, } } + pub fn preview_style(self) -> Self { + self.actionbar(false) + .small_pfp(true) + .frame(true) + .wide(true) + .note_previews(false) + .options_button(true) + .is_preview(true) + } + pub fn textmode(mut self, enable: bool) -> Self { self.options_mut().set_textmode(enable); self @@ -89,6 +102,11 @@ impl<'a, 'd> NoteView<'a, 'd> { self } + pub fn frame(mut self, enable: bool) -> Self { + self.framed = enable; + self + } + pub fn small_pfp(mut self, enable: bool) -> Self { self.options_mut().set_small_pfp(enable); self @@ -256,47 +274,63 @@ impl<'a, 'd> NoteView<'a, 'd> { } } + pub fn show_impl(&mut self, ui: &mut egui::Ui) -> NoteResponse { + let txn = self.note.txn().expect("txn"); + if let Some(note_to_repost) = get_reposted_note(self.note_context.ndb, txn, self.note) { + let profile = self + .note_context + .ndb + .get_profile_by_pubkey(txn, self.note.pubkey()); + + let style = NotedeckTextStyle::Small; + ui.horizontal(|ui| { + ui.vertical(|ui| { + ui.add_space(2.0); + ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode)); + }); + ui.add_space(6.0); + let resp = ui.add(one_line_display_name_widget( + ui.visuals(), + get_display_name(profile.as_ref().ok()), + style, + )); + if let Ok(rec) = &profile { + resp.on_hover_ui_at_pointer(|ui| { + ui.set_max_width(300.0); + ui.add(ProfilePreview::new(rec, self.note_context.img_cache)); + }); + } + let color = ui.style().visuals.noninteractive().fg_stroke.color; + ui.add_space(4.0); + ui.label( + RichText::new("Reposted") + .color(color) + .text_style(style.text_style()), + ); + }); + NoteView::new(self.note_context, self.cur_acc, &note_to_repost, self.flags).show(ui) + } else { + self.show_standard(ui) + } + } + pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse { if self.options().has_textmode() { NoteResponse::new(self.textmode_ui(ui)) + } else if self.framed { + egui::Frame::new() + .fill(ui.visuals().noninteractive().weak_bg_fill) + .inner_margin(egui::Margin::same(8)) + .outer_margin(egui::Margin::symmetric(0, 8)) + .corner_radius(egui::CornerRadius::same(10)) + .stroke(egui::Stroke::new( + 1.0, + ui.visuals().noninteractive().bg_stroke.color, + )) + .show(ui, |ui| self.show_impl(ui)) + .inner } else { - let txn = self.note.txn().expect("txn"); - if let Some(note_to_repost) = get_reposted_note(self.note_context.ndb, txn, self.note) { - let profile = self - .note_context - .ndb - .get_profile_by_pubkey(txn, self.note.pubkey()); - - let style = NotedeckTextStyle::Small; - ui.horizontal(|ui| { - ui.vertical(|ui| { - ui.add_space(2.0); - ui.add_sized([20.0, 20.0], repost_icon(ui.visuals().dark_mode)); - }); - ui.add_space(6.0); - let resp = ui.add(one_line_display_name_widget( - ui.visuals(), - get_display_name(profile.as_ref().ok()), - style, - )); - if let Ok(rec) = &profile { - resp.on_hover_ui_at_pointer(|ui| { - ui.set_max_width(300.0); - ui.add(ProfilePreview::new(rec, self.note_context.img_cache)); - }); - } - let color = ui.style().visuals.noninteractive().fg_stroke.color; - ui.add_space(4.0); - ui.label( - RichText::new("Reposted") - .color(color) - .text_style(style.text_style()), - ); - }); - NoteView::new(self.note_context, self.cur_acc, &note_to_repost, self.flags).show(ui) - } else { - self.show_standard(ui) - } + self.show_impl(ui) } }