notedeck

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

lib.rs (13844B)


      1 use async_openai::{
      2     config::OpenAIConfig,
      3     types::{ChatCompletionRequestMessage, CreateChatCompletionRequest},
      4     Client,
      5 };
      6 use chrono::{Duration, Local};
      7 use egui_wgpu::RenderState;
      8 use enostr::KeypairUnowned;
      9 use futures::StreamExt;
     10 use nostrdb::Transaction;
     11 use notedeck::{AppAction, AppContext};
     12 use notedeck_ui::jobs::JobsCache;
     13 use std::collections::HashMap;
     14 use std::string::ToString;
     15 use std::sync::mpsc::{self, Receiver};
     16 use std::sync::Arc;
     17 
     18 pub use avatar::DaveAvatar;
     19 pub use config::ModelConfig;
     20 pub use messages::{DaveApiResponse, Message};
     21 pub use quaternion::Quaternion;
     22 pub use tools::{
     23     PartialToolCall, QueryCall, QueryResponse, Tool, ToolCall, ToolCalls, ToolResponse,
     24     ToolResponses,
     25 };
     26 pub use ui::{DaveAction, DaveResponse, DaveUi};
     27 pub use vec3::Vec3;
     28 
     29 mod avatar;
     30 mod config;
     31 mod messages;
     32 mod quaternion;
     33 mod tools;
     34 mod ui;
     35 mod vec3;
     36 
     37 pub struct Dave {
     38     chat: Vec<Message>,
     39     /// A 3d representation of dave.
     40     avatar: Option<DaveAvatar>,
     41     input: String,
     42     tools: Arc<HashMap<String, Tool>>,
     43     client: async_openai::Client<OpenAIConfig>,
     44     incoming_tokens: Option<Receiver<DaveApiResponse>>,
     45     model_config: ModelConfig,
     46     jobs: JobsCache,
     47 }
     48 
     49 /// Calculate an anonymous user_id from a keypair
     50 fn calculate_user_id(keypair: KeypairUnowned) -> String {
     51     use sha2::{Digest, Sha256};
     52     // pubkeys have degraded privacy, don't do that
     53     let key_input = keypair
     54         .secret_key
     55         .map(|sk| sk.as_secret_bytes())
     56         .unwrap_or(keypair.pubkey.bytes());
     57     let hex_key = hex::encode(key_input);
     58     let input = format!("{hex_key}notedeck_dave_user_id");
     59     hex::encode(Sha256::digest(input))
     60 }
     61 
     62 impl Dave {
     63     pub fn avatar_mut(&mut self) -> Option<&mut DaveAvatar> {
     64         self.avatar.as_mut()
     65     }
     66 
     67     fn system_prompt() -> Message {
     68         let now = Local::now();
     69         let yesterday = now - Duration::hours(24);
     70         let date = now.format("%Y-%m-%d %H:%M:%S");
     71         let timestamp = now.timestamp();
     72         let yesterday_timestamp = yesterday.timestamp();
     73 
     74         Message::System(format!(
     75             r#"
     76 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'.
     77 
     78 - The current date is {date} ({timestamp} unix timestamp if needed for queries).
     79 
     80 - Yesterday (-24hrs) was {yesterday_timestamp}. You can use this in combination with `since` queries for pulling notes for summarizing notes the user might have missed while they were away.
     81 
     82 # Response Guidelines
     83 
     84 - You *MUST* call the present_notes tool with a list of comma-separated note id references when referring to notes so that the UI can display them. Do *NOT* include note id references in the text response, but you *SHOULD* use ^1, ^2, etc to reference note indices passed to present_notes.
     85 - 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.
     86 - 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.
     87 "#
     88         ))
     89     }
     90 
     91     pub fn new(render_state: Option<&RenderState>) -> Self {
     92         let model_config = ModelConfig::default();
     93         //let model_config = ModelConfig::ollama();
     94         let client = Client::with_config(model_config.to_api());
     95 
     96         let input = "".to_string();
     97         let avatar = render_state.map(DaveAvatar::new);
     98         let mut tools: HashMap<String, Tool> = HashMap::new();
     99         for tool in tools::dave_tools() {
    100             tools.insert(tool.name().to_string(), tool);
    101         }
    102 
    103         Dave {
    104             client,
    105             avatar,
    106             incoming_tokens: None,
    107             tools: Arc::new(tools),
    108             input,
    109             model_config,
    110             chat: vec![],
    111             jobs: JobsCache::default(),
    112         }
    113     }
    114 
    115     /// Process incoming tokens from the ai backend
    116     fn process_events(&mut self, app_ctx: &AppContext) -> bool {
    117         // Should we continue sending requests? Set this to true if
    118         // we have tool responses to send back to the ai
    119         let mut should_send = false;
    120 
    121         let Some(recvr) = &self.incoming_tokens else {
    122             return should_send;
    123         };
    124 
    125         while let Ok(res) = recvr.try_recv() {
    126             if let Some(avatar) = &mut self.avatar {
    127                 avatar.random_nudge();
    128             }
    129             match res {
    130                 DaveApiResponse::Failed(err) => self.chat.push(Message::Error(err)),
    131 
    132                 DaveApiResponse::Token(token) => match self.chat.last_mut() {
    133                     Some(Message::Assistant(msg)) => *msg = msg.clone() + &token,
    134                     Some(_) => self.chat.push(Message::Assistant(token)),
    135                     None => {}
    136                 },
    137 
    138                 DaveApiResponse::ToolCalls(toolcalls) => {
    139                     tracing::info!("got tool calls: {:?}", toolcalls);
    140                     self.chat.push(Message::ToolCalls(toolcalls.clone()));
    141 
    142                     let txn = Transaction::new(app_ctx.ndb).unwrap();
    143                     for call in &toolcalls {
    144                         // execute toolcall
    145                         match call.calls() {
    146                             ToolCalls::PresentNotes(present) => {
    147                                 self.chat.push(Message::ToolResponse(ToolResponse::new(
    148                                     call.id().to_owned(),
    149                                     ToolResponses::PresentNotes(present.note_ids.len() as i32),
    150                                 )));
    151 
    152                                 should_send = true;
    153                             }
    154 
    155                             ToolCalls::Invalid(invalid) => {
    156                                 should_send = true;
    157 
    158                                 self.chat.push(Message::tool_error(
    159                                     call.id().to_string(),
    160                                     invalid.error.clone(),
    161                                 ));
    162                             }
    163 
    164                             ToolCalls::Query(search_call) => {
    165                                 should_send = true;
    166 
    167                                 let resp = search_call.execute(&txn, app_ctx.ndb);
    168                                 self.chat.push(Message::ToolResponse(ToolResponse::new(
    169                                     call.id().to_owned(),
    170                                     ToolResponses::Query(resp),
    171                                 )))
    172                             }
    173                         }
    174                     }
    175                 }
    176             }
    177         }
    178 
    179         should_send
    180     }
    181 
    182     fn ui(&mut self, app_ctx: &mut AppContext, ui: &mut egui::Ui) -> DaveResponse {
    183         DaveUi::new(self.model_config.trial, &self.chat, &mut self.input).ui(
    184             app_ctx,
    185             &mut self.jobs,
    186             ui,
    187         )
    188     }
    189 
    190     fn handle_new_chat(&mut self) {
    191         self.chat = vec![];
    192         self.input.clear();
    193     }
    194 
    195     /// Handle a user send action triggered by the ui
    196     fn handle_user_send(&mut self, app_ctx: &AppContext, ui: &egui::Ui) {
    197         self.chat.push(Message::User(self.input.clone()));
    198         self.send_user_message(app_ctx, ui.ctx());
    199         self.input.clear();
    200     }
    201 
    202     fn send_user_message(&mut self, app_ctx: &AppContext, ctx: &egui::Context) {
    203         let messages: Vec<ChatCompletionRequestMessage> = {
    204             let txn = Transaction::new(app_ctx.ndb).expect("txn");
    205             self.chat
    206                 .iter()
    207                 .filter_map(|c| c.to_api_msg(&txn, app_ctx.ndb))
    208                 .collect()
    209         };
    210         tracing::debug!("sending messages, latest: {:?}", messages.last().unwrap());
    211 
    212         let user_id = calculate_user_id(app_ctx.accounts.get_selected_account().keypair());
    213 
    214         let ctx = ctx.clone();
    215         let client = self.client.clone();
    216         let tools = self.tools.clone();
    217         let model_name = self.model_config.model().to_owned();
    218 
    219         let (tx, rx) = mpsc::channel();
    220         self.incoming_tokens = Some(rx);
    221 
    222         tokio::spawn(async move {
    223             let mut token_stream = match client
    224                 .chat()
    225                 .create_stream(CreateChatCompletionRequest {
    226                     model: model_name,
    227                     stream: Some(true),
    228                     messages,
    229                     tools: Some(tools::dave_tools().iter().map(|t| t.to_api()).collect()),
    230                     user: Some(user_id),
    231                     ..Default::default()
    232                 })
    233                 .await
    234             {
    235                 Err(err) => {
    236                     tracing::error!("openai chat error: {err}");
    237                     return;
    238                 }
    239 
    240                 Ok(stream) => stream,
    241             };
    242 
    243             let mut all_tool_calls: HashMap<u32, PartialToolCall> = HashMap::new();
    244 
    245             while let Some(token) = token_stream.next().await {
    246                 let token = match token {
    247                     Ok(token) => token,
    248                     Err(err) => {
    249                         tracing::error!("failed to get token: {err}");
    250                         let _ = tx.send(DaveApiResponse::Failed(err.to_string()));
    251                         return;
    252                     }
    253                 };
    254 
    255                 for choice in &token.choices {
    256                     let resp = &choice.delta;
    257 
    258                     // if we have tool call arg chunks, collect them here
    259                     if let Some(tool_calls) = &resp.tool_calls {
    260                         for tool in tool_calls {
    261                             let entry = all_tool_calls.entry(tool.index).or_default();
    262 
    263                             if let Some(id) = &tool.id {
    264                                 entry.id_mut().get_or_insert(id.clone());
    265                             }
    266 
    267                             if let Some(name) = tool.function.as_ref().and_then(|f| f.name.as_ref())
    268                             {
    269                                 entry.name_mut().get_or_insert(name.to_string());
    270                             }
    271 
    272                             if let Some(argchunk) =
    273                                 tool.function.as_ref().and_then(|f| f.arguments.as_ref())
    274                             {
    275                                 entry
    276                                     .arguments_mut()
    277                                     .get_or_insert_with(String::new)
    278                                     .push_str(argchunk);
    279                             }
    280                         }
    281                     }
    282 
    283                     if let Some(content) = &resp.content {
    284                         if let Err(err) = tx.send(DaveApiResponse::Token(content.to_owned())) {
    285                             tracing::error!("failed to send dave response token to ui: {err}");
    286                         }
    287                         ctx.request_repaint();
    288                     }
    289                 }
    290             }
    291 
    292             let mut parsed_tool_calls = vec![];
    293             for (_index, partial) in all_tool_calls {
    294                 let Some(unknown_tool_call) = partial.complete() else {
    295                     tracing::error!("could not complete partial tool call: {:?}", partial);
    296                     continue;
    297                 };
    298 
    299                 match unknown_tool_call.parse(&tools) {
    300                     Ok(tool_call) => {
    301                         parsed_tool_calls.push(tool_call);
    302                     }
    303                     Err(err) => {
    304                         // TODO: we should be
    305                         tracing::error!(
    306                             "failed to parse tool call {:?}: {}",
    307                             unknown_tool_call,
    308                             err,
    309                         );
    310 
    311                         if let Some(id) = partial.id() {
    312                             // we have an id, so we can communicate the error
    313                             // back to the ai
    314                             parsed_tool_calls.push(ToolCall::invalid(
    315                                 id.to_string(),
    316                                 partial.name,
    317                                 partial.arguments,
    318                                 err.to_string(),
    319                             ));
    320                         }
    321                     }
    322                 };
    323             }
    324 
    325             if !parsed_tool_calls.is_empty() {
    326                 tx.send(DaveApiResponse::ToolCalls(parsed_tool_calls))
    327                     .unwrap();
    328                 ctx.request_repaint();
    329             }
    330 
    331             tracing::debug!("stream closed");
    332         });
    333     }
    334 }
    335 
    336 impl notedeck::App for Dave {
    337     fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> Option<AppAction> {
    338         /*
    339         self.app
    340             .frame_history
    341             .on_new_frame(ctx.input(|i| i.time), frame.info().cpu_usage);
    342         */
    343         let mut app_action: Option<AppAction> = None;
    344 
    345         // always insert system prompt if we have no context
    346         if self.chat.is_empty() {
    347             self.chat.push(Dave::system_prompt());
    348         }
    349 
    350         //update_dave(self, ctx, ui.ctx());
    351         let should_send = self.process_events(ctx);
    352         if let Some(action) = self.ui(ctx, ui).action {
    353             match action {
    354                 DaveAction::ToggleChrome => {
    355                     app_action = Some(AppAction::ToggleChrome);
    356                 }
    357                 DaveAction::Note(n) => {
    358                     app_action = Some(AppAction::Note(n));
    359                 }
    360                 DaveAction::NewChat => {
    361                     self.handle_new_chat();
    362                 }
    363                 DaveAction::Send => {
    364                     self.handle_user_send(ctx, ui);
    365                 }
    366             }
    367         }
    368 
    369         if should_send {
    370             self.send_user_message(ctx, ui.ctx());
    371         }
    372 
    373         app_action
    374     }
    375 }