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:
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,
+ ¬e,
+ 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, ¬e, 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, ¬e, 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, ¬e_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, ¬e_to_repost, self.flags).show(ui)
- } else {
- self.show_standard(ui)
- }
+ self.show_impl(ui)
}
}