notedeck

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

commit 5be02ab0139a498042b89f9d801b138c85f9aa55
parent 0ddc1b1769aca96a7ef61c53eaeb97e302ea5e2f
Author: William Casarin <jb55@jb55.com>
Date:   Sat, 24 Jan 2026 15:53:32 -0800

dave: add AI provider settings UI

Add a settings panel accessible via gear button in Dave chat header.
Supports OpenAI, Anthropic, and Ollama providers with configurable
model, endpoint, and API key fields.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_dave/src/config.rs | 162+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcrates/notedeck_dave/src/lib.rs | 50++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/notedeck_dave/src/ui/dave.rs | 48+++++++++++++++++++++++++++++++++++++++++++++++-
Mcrates/notedeck_dave/src/ui/mod.rs | 2++
Acrates/notedeck_dave/src/ui/settings.rs | 193+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 452 insertions(+), 3 deletions(-)

diff --git a/crates/notedeck_dave/src/config.rs b/crates/notedeck_dave/src/config.rs @@ -1,5 +1,126 @@ use async_openai::config::OpenAIConfig; +/// Available AI providers for Dave +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AiProvider { + #[default] + OpenAI, + Anthropic, + Ollama, +} + +impl AiProvider { + pub const ALL: [AiProvider; 3] = [ + AiProvider::OpenAI, + AiProvider::Anthropic, + AiProvider::Ollama, + ]; + + pub fn name(&self) -> &'static str { + match self { + AiProvider::OpenAI => "OpenAI", + AiProvider::Anthropic => "Anthropic", + AiProvider::Ollama => "Ollama", + } + } + + pub fn default_model(&self) -> &'static str { + match self { + AiProvider::OpenAI => "gpt-4o", + AiProvider::Anthropic => "claude-sonnet-4-20250514", + AiProvider::Ollama => "hhao/qwen2.5-coder-tools:latest", + } + } + + pub fn default_endpoint(&self) -> Option<&'static str> { + match self { + AiProvider::OpenAI => None, + AiProvider::Anthropic => Some("https://api.anthropic.com/v1"), + AiProvider::Ollama => Some("http://localhost:11434/v1"), + } + } + + pub fn requires_api_key(&self) -> bool { + match self { + AiProvider::OpenAI | AiProvider::Anthropic => true, + AiProvider::Ollama => false, + } + } + + pub fn available_models(&self) -> &'static [&'static str] { + match self { + AiProvider::OpenAI => &["gpt-4o", "gpt-4o-mini", "gpt-4-turbo", "gpt-3.5-turbo"], + AiProvider::Anthropic => &[ + "claude-sonnet-4-20250514", + "claude-opus-4-20250514", + "claude-3-5-sonnet-20241022", + "claude-3-5-haiku-20241022", + ], + AiProvider::Ollama => &[ + "hhao/qwen2.5-coder-tools:latest", + "llama3.2:latest", + "mistral:latest", + "codellama:latest", + ], + } + } +} + +/// User-configurable settings for Dave AI +#[derive(Debug, Clone)] +pub struct DaveSettings { + pub provider: AiProvider, + pub model: String, + pub endpoint: Option<String>, + pub api_key: Option<String>, +} + +impl Default for DaveSettings { + fn default() -> Self { + DaveSettings { + provider: AiProvider::default(), + model: AiProvider::default().default_model().to_string(), + endpoint: None, + api_key: None, + } + } +} + +impl DaveSettings { + /// Create settings with provider defaults applied + pub fn with_provider(provider: AiProvider) -> Self { + DaveSettings { + provider, + model: provider.default_model().to_string(), + endpoint: provider.default_endpoint().map(|s| s.to_string()), + api_key: None, + } + } + + /// Create settings from an existing ModelConfig (preserves env var values) + pub fn from_model_config(config: &ModelConfig) -> Self { + let provider = match config.backend { + BackendType::OpenAI => AiProvider::OpenAI, + BackendType::Claude => AiProvider::Anthropic, + }; + + let api_key = match provider { + AiProvider::Anthropic => config.anthropic_api_key.clone(), + _ => config.api_key().map(|s| s.to_string()), + }; + + DaveSettings { + provider, + model: config.model().to_string(), + endpoint: config + .endpoint() + .map(|s| s.to_string()) + .or_else(|| provider.default_endpoint().map(|s| s.to_string())), + api_key, + } + } +} + #[derive(Debug)] pub struct ModelConfig { pub trial: bool, @@ -51,6 +172,14 @@ impl ModelConfig { &self.model } + pub fn endpoint(&self) -> Option<&str> { + self.endpoint.as_deref() + } + + pub fn api_key(&self) -> Option<&str> { + self.api_key.as_deref() + } + pub fn ollama() -> Self { ModelConfig { trial: false, @@ -60,6 +189,39 @@ impl ModelConfig { } } + /// Create a ModelConfig from DaveSettings + pub fn from_settings(settings: &DaveSettings) -> Self { + // If settings have an API key, we're not in trial mode + // For Ollama, trial is always false since no key is required + let trial = settings.provider.requires_api_key() && settings.api_key.is_none(); + + let backend = match settings.provider { + AiProvider::OpenAI | AiProvider::Ollama => BackendType::OpenAI, + AiProvider::Anthropic => BackendType::Claude, + }; + + let anthropic_api_key = if settings.provider == AiProvider::Anthropic { + settings.api_key.clone() + } else { + None + }; + + let api_key = if settings.provider != AiProvider::Anthropic { + settings.api_key.clone() + } else { + None + }; + + ModelConfig { + trial, + backend, + endpoint: settings.endpoint.clone(), + model: settings.model.clone(), + api_key, + anthropic_api_key, + } + } + pub fn to_api(&self) -> OpenAIConfig { let mut cfg = OpenAIConfig::new(); if let Some(endpoint) = &self.endpoint { diff --git a/crates/notedeck_dave/src/lib.rs b/crates/notedeck_dave/src/lib.rs @@ -25,7 +25,7 @@ use std::sync::mpsc; use std::sync::Arc; pub use avatar::DaveAvatar; -pub use config::ModelConfig; +pub use config::{AiProvider, DaveSettings, ModelConfig}; pub use messages::{DaveApiResponse, Message}; pub use quaternion::Quaternion; pub use session::{ChatSession, SessionId, SessionManager}; @@ -33,7 +33,10 @@ pub use tools::{ PartialToolCall, QueryCall, QueryResponse, Tool, ToolCall, ToolCalls, ToolResponse, ToolResponses, }; -pub use ui::{DaveAction, DaveResponse, DaveUi, SessionListAction, SessionListUi}; +pub use ui::{ + DaveAction, DaveResponse, DaveSettingsPanel, DaveUi, SessionListAction, SessionListUi, + SettingsPanelAction, +}; pub use vec3::Vec3; pub struct Dave { @@ -49,6 +52,10 @@ pub struct Dave { model_config: ModelConfig, /// Whether to show session list on mobile show_session_list: bool, + /// User settings + settings: DaveSettings, + /// Settings panel UI state + settings_panel: DaveSettingsPanel, } /// Calculate an anonymous user_id from a keypair @@ -104,6 +111,8 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr tools.insert(tool.name().to_string(), tool); } + let settings = DaveSettings::from_model_config(&model_config); + Dave { client, avatar, @@ -111,9 +120,22 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr tools: Arc::new(tools), model_config, show_session_list: false, + settings, + settings_panel: DaveSettingsPanel::new(), } } + /// Get current settings for persistence + pub fn settings(&self) -> &DaveSettings { + &self.settings + } + + /// Apply new settings. Note: Provider changes require app restart to take effect. + pub fn apply_settings(&mut self, settings: DaveSettings) { + self.model_config = ModelConfig::from_settings(&settings); + self.settings = settings; + } + /// Process incoming tokens from the ai backend fn process_events(&mut self, app_ctx: &AppContext) -> bool { // Should we continue sending requests? Set this to true if @@ -450,6 +472,7 @@ You are an AI agent for the nostr protocol called Dave, created by Damus. nostr impl notedeck::App for Dave { fn update(&mut self, ctx: &mut AppContext<'_>, ui: &mut egui::Ui) -> AppResponse { let mut app_action: Option<AppAction> = None; + let mut dave_action: Option<DaveAction> = None; // always insert system prompt if we have no context in active session if let Some(session) = self.session_manager.get_active_mut() { @@ -458,6 +481,19 @@ impl notedeck::App for Dave { } } + // Render settings panel and handle its actions + if let Some(settings_action) = self.settings_panel.ui(ui.ctx()) { + match settings_action { + SettingsPanelAction::Save(new_settings) => { + self.apply_settings(new_settings.clone()); + dave_action = Some(DaveAction::UpdateSettings(new_settings)); + } + SettingsPanelAction::Cancel => { + // Panel closed, nothing to do + } + } + } + //update_dave(self, ctx, ui.ctx()); let should_send = self.process_events(ctx); if let Some(action) = self.ui(ctx, ui).action { @@ -477,6 +513,12 @@ impl notedeck::App for Dave { DaveAction::ShowSessionList => { self.show_session_list = !self.show_session_list; } + DaveAction::OpenSettings => { + self.settings_panel.open(&self.settings); + } + DaveAction::UpdateSettings(settings) => { + dave_action = Some(DaveAction::UpdateSettings(settings)); + } } } @@ -484,6 +526,10 @@ impl notedeck::App for Dave { self.send_user_message(ctx, ui.ctx()); } + // If we have a dave action that needs to bubble up, we can't return it + // through AppResponse directly, but parent apps can check settings() + let _ = dave_action; // Parent app can poll settings() after update + AppResponse::action(app_action) } } diff --git a/crates/notedeck_dave/src/ui/dave.rs b/crates/notedeck_dave/src/ui/dave.rs @@ -1,4 +1,5 @@ use crate::{ + config::DaveSettings, messages::Message, tools::{PresentNotesCall, QueryCall, ToolCall, ToolCalls, ToolResponse}, }; @@ -7,7 +8,7 @@ use nostrdb::{Ndb, Transaction}; use notedeck::{ tr, Accounts, AppContext, Images, Localization, MediaJobSender, NoteAction, NoteContext, }; -use notedeck_ui::{icons::search_icon, NoteOptions, ProfilePic}; +use notedeck_ui::{app_images, icons::search_icon, NoteOptions, ProfilePic}; /// DaveUi holds all of the data it needs to render itself pub struct DaveUi<'a> { @@ -62,6 +63,10 @@ pub enum DaveAction { Note(NoteAction), /// Toggle showing the session list (for mobile navigation) ShowSessionList, + /// Open the settings panel + OpenSettings, + /// Settings were updated and should be persisted + UpdateSettings(DaveSettings), } impl<'a> DaveUi<'a> { @@ -369,6 +374,36 @@ impl<'a> DaveUi<'a> { } } +fn settings_button(dark_mode: bool) -> impl egui::Widget { + move |ui: &mut egui::Ui| { + let img_size = 24.0; + let max_size = 32.0; + + let img = if dark_mode { + app_images::settings_dark_image() + } else { + app_images::settings_light_image() + } + .max_width(img_size); + + let helper = notedeck_ui::anim::AnimationHelper::new( + ui, + "settings-button", + egui::vec2(max_size, max_size), + ); + + let cur_img_size = helper.scale_1d_pos(img_size); + img.paint_at( + ui, + helper + .get_animation_rect() + .shrink((max_size - cur_img_size) / 2.0), + ); + + helper.take_animation_response() + } +} + fn query_call_ui( cache: &mut notedeck::Images, ndb: &Ndb, @@ -494,6 +529,17 @@ fn top_buttons_ui(app_ctx: &mut AppContext, ui: &mut egui::Ui) -> Option<DaveAct action = Some(DaveAction::ToggleChrome); } + // Settings button + rect = rect.translate(egui::vec2(30.0, 0.0)); + let dark_mode = ui.visuals().dark_mode; + let r = ui + .put(rect, settings_button(dark_mode)) + .on_hover_cursor(egui::CursorIcon::PointingHand); + + if r.clicked() { + action = Some(DaveAction::OpenSettings); + } + action } diff --git a/crates/notedeck_dave/src/ui/mod.rs b/crates/notedeck_dave/src/ui/mod.rs @@ -1,5 +1,7 @@ mod dave; pub mod session_list; +mod settings; pub use dave::{DaveAction, DaveResponse, DaveUi}; pub use session_list::{SessionListAction, SessionListUi}; +pub use settings::{DaveSettingsPanel, SettingsPanelAction}; diff --git a/crates/notedeck_dave/src/ui/settings.rs b/crates/notedeck_dave/src/ui/settings.rs @@ -0,0 +1,193 @@ +use crate::config::{AiProvider, DaveSettings}; + +/// Tracks the state of the settings panel +pub struct DaveSettingsPanel { + /// Whether the panel is currently open + open: bool, + /// Working copy of settings being edited + editing: DaveSettings, + /// Custom model input (when user wants to type a model not in the list) + custom_model: String, + /// Whether to use custom model input + use_custom_model: bool, +} + +/// Actions that can result from the settings panel +#[derive(Debug)] +pub enum SettingsPanelAction { + /// User saved the settings + Save(DaveSettings), + /// User cancelled the settings panel + Cancel, +} + +impl Default for DaveSettingsPanel { + fn default() -> Self { + Self::new() + } +} + +impl DaveSettingsPanel { + pub fn new() -> Self { + DaveSettingsPanel { + open: false, + editing: DaveSettings::default(), + custom_model: String::new(), + use_custom_model: false, + } + } + + pub fn is_open(&self) -> bool { + self.open + } + + /// Open the panel with a copy of current settings to edit + pub fn open(&mut self, current: &DaveSettings) { + self.editing = current.clone(); + self.custom_model = current.model.clone(); + // Check if current model is in the available list + self.use_custom_model = !current + .provider + .available_models() + .contains(&current.model.as_str()); + self.open = true; + } + + pub fn close(&mut self) { + self.open = false; + } + + /// Render the settings panel UI + pub fn ui(&mut self, ctx: &egui::Context) -> Option<SettingsPanelAction> { + let mut action: Option<SettingsPanelAction> = None; + + if !self.open { + return None; + } + + let mut open = self.open; + egui::Window::new("Dave Settings") + .open(&mut open) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ctx, |ui| { + ui.set_min_width(300.0); + + egui::Grid::new("settings_grid") + .num_columns(2) + .spacing([10.0, 8.0]) + .show(ui, |ui| { + // Provider dropdown + ui.label("Provider:"); + let prev_provider = self.editing.provider; + egui::ComboBox::from_id_salt("provider_combo") + .selected_text(self.editing.provider.name()) + .show_ui(ui, |ui| { + for provider in AiProvider::ALL { + ui.selectable_value( + &mut self.editing.provider, + provider, + provider.name(), + ); + } + }); + ui.end_row(); + + // If provider changed, reset to provider defaults + if self.editing.provider != prev_provider { + self.editing.model = self.editing.provider.default_model().to_string(); + self.editing.endpoint = self + .editing + .provider + .default_endpoint() + .map(|s| s.to_string()); + self.custom_model = self.editing.model.clone(); + self.use_custom_model = false; + } + + // Model selection + ui.label("Model:"); + ui.vertical(|ui| { + // Checkbox for custom model + ui.checkbox(&mut self.use_custom_model, "Custom model"); + + if self.use_custom_model { + // Custom text input + let response = ui.text_edit_singleline(&mut self.custom_model); + if response.changed() { + self.editing.model = self.custom_model.clone(); + } + } else { + // Dropdown with available models + egui::ComboBox::from_id_salt("model_combo") + .selected_text(&self.editing.model) + .show_ui(ui, |ui| { + for model in self.editing.provider.available_models() { + ui.selectable_value( + &mut self.editing.model, + model.to_string(), + *model, + ); + } + }); + } + }); + ui.end_row(); + + // Endpoint field + ui.label("Endpoint:"); + let mut endpoint_str = self.editing.endpoint.clone().unwrap_or_default(); + if ui.text_edit_singleline(&mut endpoint_str).changed() { + self.editing.endpoint = if endpoint_str.is_empty() { + None + } else { + Some(endpoint_str) + }; + } + ui.end_row(); + + // API Key field (only shown when required) + if self.editing.provider.requires_api_key() { + ui.label("API Key:"); + let mut key_str = self.editing.api_key.clone().unwrap_or_default(); + if ui + .add(egui::TextEdit::singleline(&mut key_str).password(true)) + .changed() + { + self.editing.api_key = if key_str.is_empty() { + None + } else { + Some(key_str) + }; + } + ui.end_row(); + } + }); + + ui.add_space(16.0); + + // Save/Cancel buttons + ui.horizontal(|ui| { + if ui.button("Save").clicked() { + action = Some(SettingsPanelAction::Save(self.editing.clone())); + } + if ui.button("Cancel").clicked() { + action = Some(SettingsPanelAction::Cancel); + } + }); + }); + + // Handle window close button + if !open { + action = Some(SettingsPanelAction::Cancel); + } + + // Close panel if we have an action + if action.is_some() { + self.close(); + } + + action + } +}