dave: initial note rendering

Signed-off-by: William Casarin <jb55@jb55.com>
This commit is contained in:
William Casarin
2025-04-18 17:03:59 -07:00
parent 43310b271e
commit f496d4b8c4
6 changed files with 205 additions and 72 deletions

1
Cargo.lock generated
View File

@@ -3272,6 +3272,7 @@ dependencies = [
"eframe", "eframe",
"egui", "egui",
"egui-wgpu", "egui-wgpu",
"enostr",
"futures", "futures",
"hex", "hex",
"nostrdb", "nostrdb",

View File

@@ -10,6 +10,7 @@ notedeck = { workspace = true }
notedeck_ui = { workspace = true } notedeck_ui = { workspace = true }
eframe = { workspace = true } eframe = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
enostr = { workspace = true }
tracing = { workspace = true } tracing = { workspace = true }
egui-wgpu = { workspace = true } egui-wgpu = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }

View File

@@ -8,8 +8,8 @@ use egui::{Align, Key, KeyboardShortcut, Layout, Modifiers};
use egui_wgpu::RenderState; use egui_wgpu::RenderState;
use futures::StreamExt; use futures::StreamExt;
use nostrdb::Transaction; use nostrdb::Transaction;
use notedeck::AppContext; use notedeck::{AppContext, NoteContext};
use notedeck_ui::icons::search_icon; use notedeck_ui::{icons::search_icon, NoteOptions};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::mpsc::{self, Receiver}; use std::sync::mpsc::{self, Receiver};
use std::sync::Arc; use std::sync::Arc;
@@ -69,7 +69,7 @@ impl Dave {
let system_prompt = Message::System(format!( let system_prompt = Message::System(format!(
r#" 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). - 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 # Response Guidelines
- You *MUST* include nostr:nevent references when referring to notes - 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 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 { for call in &toolcalls {
// execute toolcall // execute toolcall
match call.calls() { match call.calls() {
ToolCalls::PresentNotes(_note_ids) => {
self.chat.push(Message::ToolResponse(ToolResponse::new(
call.id().to_owned(),
ToolResponses::PresentNotes,
)))
}
ToolCalls::Query(search_call) => { ToolCalls::Query(search_call) => {
let resp = search_call.execute(&txn, app_ctx.ndb); let resp = search_call.execute(&txn, app_ctx.ndb);
self.chat.push(Message::ToolResponse(ToolResponse::new( 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 // Scroll area for chat messages
egui::Frame::NONE.show(ui, |ui| { egui::Frame::NONE.show(ui, |ui| {
ui.with_layout(Layout::bottom_up(Align::Min), |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| { .show(ui, |ui| {
Self::chat_frame(ui.ctx()).show(ui, |ui| { Self::chat_frame(ui.ctx()).show(ui, |ui| {
ui.vertical(|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 { for message in &self.chat {
match message { match message {
Message::User(msg) => self.user_chat(msg, ui), 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 // have a debug option to show this
} }
Message::ToolCalls(toolcalls) => { 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| { ui.vertical(|ui| {
for call in toolcalls { for call in toolcalls {
match call.calls() { 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) => { ToolCalls::Query(search_call) => {
ui.horizontal(|ui| { ui.horizontal(|ui| {
egui::Frame::new() egui::Frame::new()

View File

@@ -1,5 +1,6 @@
use async_openai::types::*; use async_openai::types::*;
use chrono::DateTime; use chrono::DateTime;
use enostr::NoteId;
use nostrdb::{Ndb, Note, NoteKey, Transaction}; use nostrdb::{Ndb, Note, NoteKey, Transaction};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -76,6 +77,7 @@ pub struct QueryResponse {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ToolResponses { pub enum ToolResponses {
Query(QueryResponse), Query(QueryResponse),
PresentNotes,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -116,6 +118,7 @@ impl PartialToolCall {
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ToolCalls { pub enum ToolCalls {
Query(QueryCall), Query(QueryCall),
PresentNotes(PresentNotesCall),
} }
impl ToolCalls { impl ToolCalls {
@@ -129,12 +132,14 @@ impl ToolCalls {
fn name(&self) -> &'static str { fn name(&self) -> &'static str {
match self { match self {
Self::Query(_) => "search", Self::Query(_) => "search",
Self::PresentNotes(_) => "present",
} }
} }
fn arguments(&self) -> String { fn arguments(&self) -> String {
match self { match self {
Self::Query(search) => serde_json::to_string(search).unwrap(), 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, 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 /// The parsed nostrdb query that dave wants to use to satisfy a request
#[derive(Debug, Deserialize, Serialize, Clone)] #[derive(Debug, Deserialize, Serialize, Clone)]
pub struct QueryCall { pub struct QueryCall {
@@ -385,17 +435,20 @@ impl QueryCall {
/// tool responses /// tool responses
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
struct SimpleNote { struct SimpleNote {
note_id: String,
pubkey: String, pubkey: String,
name: String, name: String,
content: String, content: String,
created_at: 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 /// Take the result of a tool response and present it to the ai so that
/// it can interepret it and take further action /// it can interepret it and take further action
fn format_tool_response_for_ai(txn: &Transaction, ndb: &Ndb, resp: &ToolResponses) -> String { fn format_tool_response_for_ai(txn: &Transaction, ndb: &Ndb, resp: &ToolResponses) -> String {
match resp { match resp {
ToolResponses::PresentNotes => "".to_string(),
ToolResponses::Query(search_r) => { ToolResponses::Query(search_r) => {
let simple_notes: Vec<SimpleNote> = search_r let simple_notes: Vec<SimpleNote> = search_r
.notes .notes
@@ -415,7 +468,8 @@ fn format_tool_response_for_ai(txn: &Transaction, ndb: &Ndb, resp: &ToolResponse
let content = note.content().to_owned(); let content = note.content().to_owned();
let pubkey = hex::encode(note.pubkey()); 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 created_at = {
let datetime = let datetime =
@@ -424,6 +478,7 @@ fn format_tool_response_for_ai(txn: &Transaction, ndb: &Ndb, resp: &ToolResponse
}; };
Some(SimpleNote { Some(SimpleNote {
note_id,
pubkey, pubkey,
name, name,
content, 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 { match kind {
1 => "microblog".to_string(), 1 => "microblog".to_string(),
0 => "profile".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 { fn query_tool() -> Tool {
Tool { Tool {
name: "query", name: "query",
@@ -505,5 +577,5 @@ fn query_tool() -> Tool {
} }
pub fn dave_tools() -> Vec<Tool> { pub fn dave_tools() -> Vec<Tool> {
vec![query_tool()] vec![query_tool(), present_tool()]
} }

View File

@@ -95,27 +95,10 @@ pub fn render_note_preview(
*/ */
}; };
egui::Frame::new() NoteView::new(note_context, cur_acc, &note, note_options)
.fill(ui.visuals().noninteractive().weak_bg_fill) .preview_style()
.inner_margin(egui::Margin::same(8)) .parent(parent)
.outer_margin(egui::Margin::symmetric(0, 8)) .show(ui)
.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
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]

View File

@@ -29,6 +29,7 @@ pub struct NoteView<'a, 'd> {
cur_acc: &'a Option<KeypairUnowned<'a>>, cur_acc: &'a Option<KeypairUnowned<'a>>,
parent: Option<NoteKey>, parent: Option<NoteKey>,
note: &'a nostrdb::Note<'a>, note: &'a nostrdb::Note<'a>,
framed: bool,
flags: NoteOptions, flags: NoteOptions,
} }
@@ -68,6 +69,7 @@ impl<'a, 'd> NoteView<'a, 'd> {
) -> Self { ) -> Self {
flags.set_actionbar(true); flags.set_actionbar(true);
flags.set_note_previews(true); flags.set_note_previews(true);
let framed = false;
let parent: Option<NoteKey> = None; let parent: Option<NoteKey> = None;
Self { Self {
@@ -76,9 +78,20 @@ impl<'a, 'd> NoteView<'a, 'd> {
parent, parent,
note, note,
flags, 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 { pub fn textmode(mut self, enable: bool) -> Self {
self.options_mut().set_textmode(enable); self.options_mut().set_textmode(enable);
self self
@@ -89,6 +102,11 @@ impl<'a, 'd> NoteView<'a, 'd> {
self self
} }
pub fn frame(mut self, enable: bool) -> Self {
self.framed = enable;
self
}
pub fn small_pfp(mut self, enable: bool) -> Self { pub fn small_pfp(mut self, enable: bool) -> Self {
self.options_mut().set_small_pfp(enable); self.options_mut().set_small_pfp(enable);
self 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 { pub fn show(&mut self, ui: &mut egui::Ui) -> NoteResponse {
if self.options().has_textmode() { if self.options().has_textmode() {
NoteResponse::new(self.textmode_ui(ui)) 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 { } else {
let txn = self.note.txn().expect("txn"); self.show_impl(ui)
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)
}
} }
} }