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",
"egui",
"egui-wgpu",
"enostr",
"futures",
"hex",
"nostrdb",

View File

@@ -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 }

View File

@@ -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()

View File

@@ -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()]
}

View File

@@ -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)]

View File

@@ -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)
}
}