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:
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(¤t.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
+ }
+}