diff --git a/README.md b/README.md index 300d511..161ae9e 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The project is under active development. Core blogging workflows are broadly ava - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - Markdown/Liquid rendering with native macros, multilingual routes, feeds, sitemap, Pagefind, and incremental site generation through cancellable section task groups. - Local preview in the app or system browser. -- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using online or local OpenAI-compatible endpoints with airplane-mode gating. +- Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing. - Persistent conversational AI with safe Markdown, streamed and cancellable responses, model/session/token tracking, bounded project-aware blog tools, and localized conversation management in the Chat workspace. Allowlisted render tools add persistent native cards, charts, forms, lists, metrics, mind maps, tables, and tabs without executing assistant-provided HTML or JavaScript. - SSH-agent-based SCP or rsync publishing. - Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode. diff --git a/RUST_PLAN_CORE.md b/RUST_PLAN_CORE.md index bf330ed..210e3cc 100644 --- a/RUST_PLAN_CORE.md +++ b/RUST_PLAN_CORE.md @@ -99,9 +99,9 @@ Open: Available: -- Configurable online and airplane-mode OpenAI-compatible endpoints. -- Secure API-key storage through the operating-system keychain. -- Model catalog discovery and model selection. +- Independent online and airplane-mode OpenAI-compatible profiles, selected by the status-bar airplane switch. +- Secure keychain credentials for both profiles, optional for local endpoints. +- Model discovery without a preselected model; per-profile chat/title/image selections, explicit tool/vision overrides, and minimal chat tests. - Post translation, media translation, image alt text, post analysis, taxonomy analysis, WordPress-import taxonomy mapping, and language detection. - Explicit offline gating and user-visible errors. - Parsed input, output, cache-read, and cache-write token usage returned from every one-shot operation; persistent chat accounting is tracked in the extension plan. diff --git a/RUST_PLAN_EXTENSION.md b/RUST_PLAN_EXTENSION.md index c7f76b6..6bae293 100644 --- a/RUST_PLAN_EXTENSION.md +++ b/RUST_PLAN_EXTENSION.md @@ -40,7 +40,7 @@ Done: - Project-aware tools over statistics, FTS search, posts, media, templates, scripts, tags/categories, metadata mutation through shared engines, and allowlisted workspace navigation. - Localized Chat sidebar/editor with conversation and model controls, safe GFM text rendering with blocked external images, streaming/tool state, multiline send/stop controls, and status-bar token totals. - Fixed native A2UI cards, charts, forms, lists, metrics, mind maps, tables, and tabs with safe nested fallbacks, persistent stable-ID interaction state, debounced form values, and strictly allowlisted application actions. -- Online/airplane endpoint routing uses the shared secure endpoint and model infrastructure; unavailable modes direct the user to the existing localized AI settings. +- Online/airplane routing swaps the complete active profile—endpoint, credentials, chat/title/image models, and tool/vision overrides—through the status-bar switch; unavailable modes direct the user to the localized two-profile AI settings. ### Embeddings, semantic search, and duplicates — Done diff --git a/crates/bds-cli/src/lib.rs b/crates/bds-cli/src/lib.rs index 7f73613..2cb9024 100644 --- a/crates/bds-cli/src/lib.rs +++ b/crates/bds-cli/src/lib.rs @@ -883,6 +883,12 @@ fn config(db: &Database, command: ConfigCommand) -> Result { cli_sync::run_cli_mutation(db.conn(), || { if key == engine::settings::ONLINE_API_KEY { engine::ai::save_online_api_key(db.conn(), &value) + } else if key == engine::settings::AIRPLANE_API_KEY { + engine::ai::save_endpoint_api_key( + db.conn(), + engine::ai::AiEndpointKind::Airplane, + &value, + ) } else { engine::settings::set(db.conn(), &key, &value) } diff --git a/crates/bds-core/src/engine/ai.rs b/crates/bds-core/src/engine/ai.rs index 62d067e..c14af90 100644 --- a/crates/bds-core/src/engine/ai.rs +++ b/crates/bds-core/src/engine/ai.rs @@ -53,12 +53,28 @@ pub struct StoredAiEndpointConfig { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub struct AiSettings { pub offline_mode: bool, - pub default_model: Option, + pub system_prompt: String, + pub online: AiModeSettings, + pub airplane: AiModeSettings, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct AiModeSettings { + pub endpoint: StoredAiEndpointConfig, pub title_model: Option, pub image_model: Option, - pub system_prompt: String, - pub online_endpoint: StoredAiEndpointConfig, - pub airplane_endpoint: StoredAiEndpointConfig, + pub chat_supports_tools: Option, + pub image_supports_vision: Option, +} + +impl AiSettings { + pub fn active(&self) -> &AiModeSettings { + if self.offline_mode { + &self.airplane + } else { + &self.online + } + } } impl Default for StoredAiEndpointConfig { @@ -78,6 +94,7 @@ pub struct AiModelInfo { pub name: String, pub context_window: Option, pub max_output_tokens: Option, + pub supports_tools: bool, pub supports_vision: bool, } @@ -163,21 +180,28 @@ pub enum OneShotResponse { } pub fn load_ai_settings(conn: &Connection, offline_mode: bool) -> EngineResult { - let online_endpoint = load_endpoint(conn, AiEndpointKind::Online)?; - let airplane_endpoint = load_endpoint(conn, AiEndpointKind::Airplane)?; - let default_model = get_optional_setting(conn, "ai.default_model")?; - let title_model = get_optional_setting(conn, "ai.title_model")?; - let image_model = get_optional_setting(conn, "ai.image_model")?; + let legacy_chat_model = get_optional_setting(conn, "ai.default_model")?; + let legacy_title_model = get_optional_setting(conn, "ai.title_model")?; + let legacy_image_model = get_optional_setting(conn, "ai.image_model")?; let system_prompt = get_optional_setting(conn, "ai.system_prompt")?.unwrap_or_default(); Ok(AiSettings { offline_mode, - default_model, - title_model, - image_model, system_prompt, - online_endpoint, - airplane_endpoint, + online: load_mode_settings( + conn, + AiEndpointKind::Online, + legacy_chat_model.as_deref(), + legacy_title_model.as_deref(), + legacy_image_model.as_deref(), + )?, + airplane: load_mode_settings( + conn, + AiEndpointKind::Airplane, + legacy_chat_model.as_deref(), + legacy_title_model.as_deref(), + legacy_image_model.as_deref(), + )?, }) } @@ -196,20 +220,32 @@ pub fn save_endpoint(conn: &Connection, endpoint: &AiEndpointConfig) -> EngineRe endpoint.model.trim(), checked_at, )?; - if endpoint.kind == AiEndpointKind::Online { - if let Some(api_key) = &endpoint.api_key { - save_online_api_key_at(conn, api_key, checked_at)?; - } + set_setting(conn, "ai.default_model", "", checked_at)?; + if let Some(api_key) = &endpoint.api_key { + save_endpoint_api_key_at(conn, endpoint.kind, api_key, checked_at)?; } Ok(()) } pub fn save_online_api_key(conn: &Connection, api_key: &str) -> EngineResult<()> { - save_online_api_key_at(conn, api_key, now_unix_ms()) + save_endpoint_api_key(conn, AiEndpointKind::Online, api_key) } -fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) -> EngineResult<()> { - let entry = endpoint_keyring_entry(AiEndpointKind::Online)?; +pub fn save_endpoint_api_key( + conn: &Connection, + kind: AiEndpointKind, + api_key: &str, +) -> EngineResult<()> { + save_endpoint_api_key_at(conn, kind, api_key, now_unix_ms()) +} + +fn save_endpoint_api_key_at( + conn: &Connection, + kind: AiEndpointKind, + api_key: &str, + updated_at: i64, +) -> EngineResult<()> { + let entry = endpoint_keyring_entry(kind)?; let configured = !api_key.trim().is_empty(); if configured { entry.set_password(api_key.trim()).map_err(keyring_error)?; @@ -221,7 +257,7 @@ fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) -> } set_setting( conn, - &endpoint_setting_key(AiEndpointKind::Online, "api_key_configured"), + &endpoint_setting_key(kind, "api_key_configured"), if configured { "true" } else { "false" }, updated_at, ) @@ -229,19 +265,46 @@ fn save_online_api_key_at(conn: &Connection, api_key: &str, updated_at: i64) -> pub fn save_model_preferences( conn: &Connection, - default_model: Option<&str>, + kind: AiEndpointKind, title_model: Option<&str>, image_model: Option<&str>, - system_prompt: &str, + chat_supports_tools: Option, + image_supports_vision: Option, ) -> EngineResult<()> { let updated_at = now_unix_ms(); - set_optional_setting(conn, "ai.default_model", default_model, updated_at)?; - set_optional_setting(conn, "ai.title_model", title_model, updated_at)?; - set_optional_setting(conn, "ai.image_model", image_model, updated_at)?; - set_setting(conn, "ai.system_prompt", system_prompt, updated_at)?; + set_optional_setting( + conn, + &endpoint_setting_key(kind, "title_model"), + title_model, + updated_at, + )?; + set_optional_setting( + conn, + &endpoint_setting_key(kind, "image_model"), + image_model, + updated_at, + )?; + set_optional_bool_setting( + conn, + &endpoint_setting_key(kind, "chat_supports_tools"), + chat_supports_tools, + updated_at, + )?; + set_optional_bool_setting( + conn, + &endpoint_setting_key(kind, "image_supports_vision"), + image_supports_vision, + updated_at, + )?; + set_setting(conn, "ai.title_model", "", updated_at)?; + set_setting(conn, "ai.image_model", "", updated_at)?; Ok(()) } +pub fn save_system_prompt(conn: &Connection, system_prompt: &str) -> EngineResult<()> { + set_setting(conn, "ai.system_prompt", system_prompt, now_unix_ms()) +} + pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult { let kind = if offline_mode { AiEndpointKind::Airplane @@ -255,13 +318,19 @@ pub fn active_endpoint(conn: &Connection, offline_mode: bool) -> EngineResult EngineResult EngineResult> { - validate_endpoint_config(endpoint)?; + validate_endpoint_access(endpoint)?; let client = build_http_client()?; let request = client.get(models_url(&endpoint.url)); let response = with_auth(request, endpoint).send()?.error_for_status()?; @@ -325,11 +394,17 @@ pub fn refresh_model_catalog(endpoint: &AiEndpointConfig) -> EngineResult EngineResult EngineResult<()> { - let _ = refresh_model_catalog(endpoint)?; +pub fn test_chat(endpoint: &AiEndpointConfig, model: &str) -> EngineResult<()> { + validate_endpoint_access(endpoint)?; + if model.trim().is_empty() { + return Err(EngineError::Validation("model is required".to_string())); + } + let payload = json!({ + "model": model.trim(), + "messages": [{"role": "user", "content": "Reply with OK."}], + "max_tokens": 8, + "stream": false, + }); + with_auth( + build_http_client()? + .post(chat_completions_url(&endpoint.url)) + .json(&payload), + endpoint, + ) + .send()? + .error_for_status()?; Ok(()) } @@ -433,17 +525,50 @@ fn load_endpoint(conn: &Connection, kind: AiEndpointKind) -> EngineResult EngineResult<()> { - if endpoint.url.trim().is_empty() { - return Err(EngineError::Validation( - "endpoint url is required".to_string(), - )); +fn load_mode_settings( + conn: &Connection, + kind: AiEndpointKind, + legacy_chat_model: Option<&str>, + legacy_title_model: Option<&str>, + legacy_image_model: Option<&str>, +) -> EngineResult { + let mut endpoint = load_endpoint(conn, kind)?; + if endpoint.model.trim().is_empty() { + endpoint.model = legacy_chat_model.unwrap_or_default().to_string(); } + Ok(AiModeSettings { + endpoint, + title_model: get_optional_setting(conn, &endpoint_setting_key(kind, "title_model"))? + .or_else(|| legacy_title_model.map(str::to_string)), + image_model: get_optional_setting(conn, &endpoint_setting_key(kind, "image_model"))? + .or_else(|| legacy_image_model.map(str::to_string)), + chat_supports_tools: get_optional_bool_setting( + conn, + &endpoint_setting_key(kind, "chat_supports_tools"), + )?, + image_supports_vision: get_optional_bool_setting( + conn, + &endpoint_setting_key(kind, "image_supports_vision"), + )?, + }) +} + +fn validate_endpoint_config(endpoint: &AiEndpointConfig) -> EngineResult<()> { + validate_endpoint_access(endpoint)?; if endpoint.model.trim().is_empty() { return Err(EngineError::Validation( "endpoint model is required".to_string(), )); } + Ok(()) +} + +fn validate_endpoint_access(endpoint: &AiEndpointConfig) -> EngineResult<()> { + if endpoint.url.trim().is_empty() { + return Err(EngineError::Validation( + "endpoint url is required".to_string(), + )); + } if endpoint.kind == AiEndpointKind::Online && endpoint .api_key @@ -463,17 +588,23 @@ fn select_model( endpoint: &AiEndpointConfig, operation: &OneShotOperation, ) -> EngineResult { + let active = settings.active(); let selected = match operation { - OneShotOperation::AnalyzeImage => settings.image_model.as_ref(), + OneShotOperation::AnalyzeImage => { + if active.image_supports_vision == Some(false) { + return Err(EngineError::Validation( + "AI unavailable - selected image model is not configured for vision" + .to_string(), + )); + } + active.image_model.as_ref() + } OneShotOperation::AnalyzeTaxonomy | OneShotOperation::MapImportTaxonomy | OneShotOperation::AnalyzePost | OneShotOperation::DetectLanguage | OneShotOperation::TranslatePost { .. } - | OneShotOperation::TranslateMedia { .. } => settings - .title_model - .as_ref() - .or(settings.default_model.as_ref()), + | OneShotOperation::TranslateMedia { .. } => active.title_model.as_ref(), } .filter(|model| !model.trim().is_empty()) .cloned() @@ -746,6 +877,22 @@ fn set_optional_setting( set_setting(conn, key, value.unwrap_or(""), updated_at) } +fn set_optional_bool_setting( + conn: &Connection, + key: &str, + value: Option, + updated_at: i64, +) -> EngineResult<()> { + set_setting( + conn, + key, + value + .map(|value| if value { "true" } else { "false" }) + .unwrap_or(""), + updated_at, + ) +} + fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult> { match setting::get_setting_by_key(conn, key) { Ok(setting) if setting.value.trim().is_empty() => Ok(None), @@ -755,6 +902,10 @@ fn get_optional_setting(conn: &Connection, key: &str) -> EngineResult EngineResult> { + Ok(get_optional_setting(conn, key)?.map(|value| value == "true")) +} + fn models_url(base_url: &str) -> String { join_openai_path(base_url, "models") } @@ -808,9 +959,9 @@ mod tests { let db = setup(); let settings = load_ai_settings(db.conn(), false).unwrap(); assert!(!settings.offline_mode); - assert!(settings.online_endpoint.url.is_empty()); - assert!(settings.airplane_endpoint.url.is_empty()); - assert!(settings.default_model.is_none()); + assert!(settings.online.endpoint.url.is_empty()); + assert!(settings.airplane.endpoint.url.is_empty()); + assert!(settings.online.title_model.is_none()); } #[test] @@ -859,24 +1010,125 @@ mod tests { let server = spawn_test_server( |request| { assert!(request.starts_with("GET /v1/models HTTP/1.1")); + assert!( + request.contains("authorization: Bearer dummy") + || request.contains("Authorization: Bearer dummy") + ); http_ok( - r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#, + r#"{"data":[{"id":"gpt-4.1-mini","name":"GPT 4.1 mini","context_window":128000,"max_output_tokens":8192,"supports_tools":true,"modalities":["text"]},{"id":"gpt-4.1","modalities":["text","vision"]}]}"#, ) }, 1, ); let models = refresh_model_catalog(&AiEndpointConfig { - kind: AiEndpointKind::Airplane, + kind: AiEndpointKind::Online, url: server, - model: "gpt-4.1-mini".to_string(), - api_key: None, + model: String::new(), + api_key: Some("dummy".to_string()), }) .unwrap(); assert_eq!(models.len(), 2); assert_eq!(models[0].name, "GPT 4.1 mini"); + assert!(models[0].supports_tools); assert!(models[1].supports_vision); } + #[test] + fn model_preferences_are_independent_per_endpoint() { + let db = setup(); + save_model_preferences( + db.conn(), + AiEndpointKind::Online, + Some("online-title"), + Some("online-image"), + Some(true), + Some(true), + ) + .unwrap(); + save_model_preferences( + db.conn(), + AiEndpointKind::Airplane, + Some("local-title"), + Some("local-image"), + Some(false), + Some(true), + ) + .unwrap(); + + let settings = load_ai_settings(db.conn(), false).unwrap(); + assert_eq!(settings.online.title_model.as_deref(), Some("online-title")); + assert_eq!( + settings.airplane.title_model.as_deref(), + Some("local-title") + ); + assert_eq!(settings.online.chat_supports_tools, Some(true)); + assert_eq!(settings.airplane.chat_supports_tools, Some(false)); + assert_eq!(settings.online.image_supports_vision, Some(true)); + assert_eq!(settings.airplane.image_supports_vision, Some(true)); + } + + #[test] + fn test_chat_sends_the_selected_model() { + let server = spawn_test_server( + |request| { + assert!(request.starts_with("POST /v1/chat/completions HTTP/1.1")); + assert!(request.contains(r#""model":"qwen-9b""#)); + assert!( + request.contains("authorization: Bearer dummy") + || request.contains("Authorization: Bearer dummy") + ); + http_ok(r#"{"choices":[{"message":{"content":"OK"}}]}"#) + }, + 1, + ); + + test_chat( + &AiEndpointConfig { + kind: AiEndpointKind::Airplane, + url: server, + model: String::new(), + api_key: Some("dummy".to_string()), + }, + "qwen-9b", + ) + .unwrap(); + } + + #[test] + fn explicit_vision_override_blocks_image_requests() { + let db = setup(); + save_endpoint( + db.conn(), + &AiEndpointConfig { + kind: AiEndpointKind::Airplane, + url: "http://localhost:11434/v1".to_string(), + model: "qwen".to_string(), + api_key: None, + }, + ) + .unwrap(); + save_model_preferences( + db.conn(), + AiEndpointKind::Airplane, + None, + Some("qwen-vl"), + Some(false), + Some(false), + ) + .unwrap(); + + let error = run_one_shot( + db.conn(), + true, + &OneShotRequest { + operation: OneShotOperation::AnalyzeImage, + content: json!({"image_data_url": "data:image/jpeg;base64,abc123"}), + }, + ) + .unwrap_err(); + assert!(error.to_string().contains("not configured for vision")); + } + #[test] #[ignore = "touches system keychain; run explicitly when validating keychain integration"] fn run_one_shot_uses_active_endpoint_and_parses_response() { @@ -909,7 +1161,15 @@ mod tests { }, ) .unwrap(); - save_model_preferences(db.conn(), None, Some("gpt-4.1-mini"), None, "").unwrap(); + save_model_preferences( + db.conn(), + AiEndpointKind::Online, + Some("gpt-4.1-mini"), + None, + None, + None, + ) + .unwrap(); let (response, usage) = run_one_shot( db.conn(), diff --git a/crates/bds-core/src/engine/chat.rs b/crates/bds-core/src/engine/chat.rs index cef1c0d..bda3f13 100644 --- a/crates/bds-core/src/engine/chat.rs +++ b/crates/bds-core/src/engine/chat.rs @@ -84,6 +84,7 @@ pub struct ChatSendOptions { pub max_output_tokens: u64, pub max_tool_rounds: usize, pub enable_tools: bool, + pub model_supports_tools: Option, pub event_handler: Option>, } @@ -96,6 +97,7 @@ impl Default for ChatSendOptions { max_output_tokens: DEFAULT_OUTPUT_TOKENS, max_tool_rounds: MAX_TOOL_ROUNDS, enable_tools: true, + model_supports_tools: None, event_handler: None, } } @@ -467,7 +469,7 @@ pub fn send_chat_message( offline_mode: bool, conversation_id: &str, content: &str, - options: ChatSendOptions, + mut options: ChatSendOptions, ) -> EngineResult { let content = content.trim(); if content.is_empty() { @@ -480,6 +482,11 @@ pub fn send_chat_message( Some(endpoint) => validate_runtime_endpoint(endpoint)?, None => ai::active_endpoint(conn, offline_mode)?, }; + if options.model_supports_tools.is_none() { + options.model_supports_tools = ai::load_ai_settings(conn, offline_mode)? + .active() + .chat_supports_tools; + } let model = options .model .clone() @@ -568,7 +575,11 @@ fn run_turns( ) -> EngineResult { let mut total_usage = TokenUsage::default(); let max_rounds = options.max_tool_rounds.min(MAX_TOOL_ROUNDS); - let supports_tools = options.enable_tools && chat_tools::model_supports_tools(conn, model)?; + let supports_tools = options.enable_tools + && match options.model_supports_tools { + Some(supports_tools) => supports_tools, + None => chat_tools::model_supports_tools(conn, model)?, + }; let catalog_model = list_models(conn)? .into_iter() .find(|candidate| candidate.id == model); @@ -841,7 +852,28 @@ fn request_completion( if let Some(api_key) = endpoint.api_key.as_deref() { request = request.bearer_auth(api_key); } - let mut response = request.send()?.error_for_status()?; + let mut response = request.send()?; + let status = response.status(); + if !status.is_success() { + let body = response.text().unwrap_or_default(); + let detail = serde_json::from_str::(&body) + .ok() + .and_then(|value| { + value + .pointer("/error/message") + .or_else(|| value.get("error")) + .or_else(|| value.get("message")) + .and_then(Value::as_str) + .map(str::to_string) + }) + .unwrap_or_else(|| body.trim().to_string()); + let detail = (!detail.is_empty()) + .then_some(format!(": {detail}")) + .unwrap_or_default(); + return Err(EngineError::Parse(format!( + "AI provider returned {status}{detail}" + ))); + } let is_stream = response .headers() .get(reqwest::header::CONTENT_TYPE) diff --git a/crates/bds-core/src/engine/settings.rs b/crates/bds-core/src/engine/settings.rs index 0248af4..060cc7a 100644 --- a/crates/bds-core/src/engine/settings.rs +++ b/crates/bds-core/src/engine/settings.rs @@ -7,7 +7,9 @@ use crate::util::now_unix_ms; pub const UI_LANGUAGE_KEY: &str = "ui.language"; pub const ONLINE_API_KEY: &str = "ai.endpoint.online.api_key"; +pub const AIRPLANE_API_KEY: &str = "ai.endpoint.airplane.api_key"; const ONLINE_API_KEY_CONFIGURED: &str = "ai.endpoint.online.api_key_configured"; +const AIRPLANE_API_KEY_CONFIGURED: &str = "ai.endpoint.airplane.api_key_configured"; const DEFAULTS: &[(&str, &str)] = &[ ("editor.default_mode", "markdown"), ("editor.diff_view_style", "inline"), @@ -15,9 +17,18 @@ const DEFAULTS: &[(&str, &str)] = &[ ("editor.hide_unchanged_regions", "false"), ("ai.endpoint.online.url", ""), ("ai.endpoint.online.model", ""), + ("ai.endpoint.online.title_model", ""), + ("ai.endpoint.online.image_model", ""), + ("ai.endpoint.online.chat_supports_tools", ""), + ("ai.endpoint.online.image_supports_vision", ""), (ONLINE_API_KEY, ""), ("ai.endpoint.airplane.url", ""), ("ai.endpoint.airplane.model", ""), + ("ai.endpoint.airplane.title_model", ""), + ("ai.endpoint.airplane.image_model", ""), + ("ai.endpoint.airplane.chat_supports_tools", ""), + ("ai.endpoint.airplane.image_supports_vision", ""), + (AIRPLANE_API_KEY, ""), ("ai.default_model", ""), ("ai.title_model", ""), ("ai.image_model", ""), @@ -37,9 +48,14 @@ pub fn get(conn: &Connection, key: &str) -> EngineResult> { } pub fn get_effective(conn: &Connection, key: &str) -> EngineResult> { - if key == ONLINE_API_KEY { + if key == ONLINE_API_KEY || key == AIRPLANE_API_KEY { + let configured_key = if key == ONLINE_API_KEY { + ONLINE_API_KEY_CONFIGURED + } else { + AIRPLANE_API_KEY_CONFIGURED + }; return Ok(Some( - get(conn, ONLINE_API_KEY_CONFIGURED)? + get(conn, configured_key)? .filter(|value| value == "true") .map_or_else(String::new, |_| "configured".to_string()), )); @@ -69,6 +85,8 @@ pub fn list_effective(conn: &Connection) -> EngineResult EngineResult vec![ - ( - "Airplane mode", - "ai.airplane_mode", - FieldKind::Bool, - self.airplane.to_string(), - ), ( "Online endpoint URL", "ai.endpoint.online.url", FieldKind::Text, - ai.online_endpoint.url, + ai.online.endpoint.url, ), ( - "Online endpoint model", + "Online chat model", "ai.endpoint.online.model", FieldKind::Text, - ai.online_endpoint.model, + ai.online.endpoint.model, ), ( "Online API key", "ai.endpoint.online.api_key", FieldKind::ReadOnly, - if ai.online_endpoint.api_key_configured { + if ai.online.endpoint.api_key_configured { "Configured in the system keychain".into() } else { "Configure in the desktop application".into() }, ), + ( + "Online title model", + "ai.endpoint.online.title_model", + FieldKind::Text, + ai.online.title_model.unwrap_or_default(), + ), + ( + "Online image model", + "ai.endpoint.online.image_model", + FieldKind::Text, + ai.online.image_model.unwrap_or_default(), + ), + ( + "Online chat supports tools", + "ai.endpoint.online.chat_supports_tools", + FieldKind::Bool, + ai.online.chat_supports_tools.unwrap_or(false).to_string(), + ), + ( + "Online image supports vision", + "ai.endpoint.online.image_supports_vision", + FieldKind::Bool, + ai.online.image_supports_vision.unwrap_or(false).to_string(), + ), ( "Airplane endpoint URL", "ai.endpoint.airplane.url", FieldKind::Text, - ai.airplane_endpoint.url, + ai.airplane.endpoint.url, ), ( - "Airplane endpoint model", + "Airplane chat model", "ai.endpoint.airplane.model", FieldKind::Text, - ai.airplane_endpoint.model, + ai.airplane.endpoint.model, ), ( - "Default model", - "ai.default_model", - FieldKind::Text, - ai.default_model.unwrap_or_default(), + "Airplane API key", + "ai.endpoint.airplane.api_key", + FieldKind::ReadOnly, + if ai.airplane.endpoint.api_key_configured { + "Configured in the system keychain".into() + } else { + "Configure in the desktop application".into() + }, ), ( - "Title model", - "ai.title_model", + "Airplane title model", + "ai.endpoint.airplane.title_model", FieldKind::Text, - ai.title_model.unwrap_or_default(), + ai.airplane.title_model.unwrap_or_default(), ), ( - "Image model", - "ai.image_model", + "Airplane image model", + "ai.endpoint.airplane.image_model", FieldKind::Text, - ai.image_model.unwrap_or_default(), + ai.airplane.image_model.unwrap_or_default(), + ), + ( + "Airplane chat supports tools", + "ai.endpoint.airplane.chat_supports_tools", + FieldKind::Bool, + ai.airplane.chat_supports_tools.unwrap_or(false).to_string(), + ), + ( + "Airplane image supports vision", + "ai.endpoint.airplane.image_supports_vision", + FieldKind::Bool, + ai.airplane + .image_supports_vision + .unwrap_or(false) + .to_string(), ), ( "System prompt", @@ -2056,14 +2093,8 @@ impl TuiApp { || key.starts_with("publishing.") || key.starts_with("ai.endpoint.") || key.starts_with("mcp.agent.") - || matches!( - key, - "ai.airplane_mode" - | "ai.default_model" - | "ai.title_model" - | "ai.image_model" - | "ai.system_prompt" - ) { + || key == "ai.system_prompt" + { fallback } else { engine::settings::get_effective(db.conn(), key)?.unwrap_or(fallback) @@ -2304,25 +2335,27 @@ impl TuiApp { kind, url: url.into(), model: model.into(), - api_key: if kind == AiEndpointKind::Online { - engine::ai::load_endpoint_api_key(kind)? - } else { - None - }, + api_key: engine::ai::load_endpoint_api_key(kind)?, }, )?; } + let prefix = format!("ai.endpoint.{}", kind.as_str()); + engine::ai::save_model_preferences( + db.conn(), + kind, + optional_str(self.setting_value(&format!("{prefix}.title_model"))), + optional_str(self.setting_value(&format!("{prefix}.image_model"))), + Some( + self.setting_value(&format!("{prefix}.chat_supports_tools")) + .eq_ignore_ascii_case("true"), + ), + Some( + self.setting_value(&format!("{prefix}.image_supports_vision")) + .eq_ignore_ascii_case("true"), + ), + )?; } - engine::ai::save_model_preferences( - db.conn(), - optional_str(self.setting_value("ai.default_model")), - optional_str(self.setting_value("ai.title_model")), - optional_str(self.setting_value("ai.image_model")), - self.setting_value("ai.system_prompt"), - )?; - self.airplane = self - .setting_value("ai.airplane_mode") - .eq_ignore_ascii_case("true"); + engine::ai::save_system_prompt(db.conn(), self.setting_value("ai.system_prompt"))?; Ok(()) } @@ -2496,15 +2529,20 @@ fn setting_field_label(locale: UiLocale, key: &str, fallback: &str) -> String { "meta.blogmark_category" => "settings.blogmarkCategory", "meta.categories" => "editor.categories", "meta.category_editing" => "tui.categoryEditing", - "ai.airplane_mode" => "settings.offlineMode", "ai.endpoint.online.url" => "settings.onlineEndpointUrl", - "ai.endpoint.online.model" => "settings.onlineEndpointModel", + "ai.endpoint.online.model" => "settings.onlineChatModel", "ai.endpoint.online.api_key" => "settings.onlineApiKey", + "ai.endpoint.online.title_model" => "settings.onlineTitleModel", + "ai.endpoint.online.image_model" => "settings.onlineImageModel", + "ai.endpoint.online.chat_supports_tools" => "settings.onlineModelSupportsTools", + "ai.endpoint.online.image_supports_vision" => "settings.onlineModelSupportsVision", "ai.endpoint.airplane.url" => "settings.airplaneEndpointUrl", - "ai.endpoint.airplane.model" => "settings.airplaneEndpointModel", - "ai.default_model" => "settings.defaultModel", - "ai.title_model" => "settings.titleModel", - "ai.image_model" => "settings.imageAnalysisModel", + "ai.endpoint.airplane.model" => "settings.airplaneChatModel", + "ai.endpoint.airplane.api_key" => "settings.airplaneApiKey", + "ai.endpoint.airplane.title_model" => "settings.airplaneTitleModel", + "ai.endpoint.airplane.image_model" => "settings.airplaneImageModel", + "ai.endpoint.airplane.chat_supports_tools" => "settings.airplaneModelSupportsTools", + "ai.endpoint.airplane.image_supports_vision" => "settings.airplaneModelSupportsVision", "ai.system_prompt" => "settings.systemPrompt", "technology.runtime" => "tui.settingRuntime", "meta.semantic_similarity_enabled" => "settings.semanticSimilarityEnabled", @@ -4083,16 +4121,9 @@ mod tests { .find(|field| field.key == "ai.endpoint.airplane.model") .unwrap() .value = "local-model".into(); - remote - .settings_fields - .iter_mut() - .find(|field| field.key == "ai.default_model") - .unwrap() - .value = "local-model".into(); remote.save_settings().unwrap(); let ai = engine::ai::load_ai_settings(fixture.db().conn(), false).unwrap(); - assert_eq!(ai.airplane_endpoint.model, "local-model"); - assert_eq!(ai.default_model.as_deref(), Some("local-model")); + assert_eq!(ai.airplane.endpoint.model, "local-model"); } #[test] diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index ea1674d..4ec6bf7 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -47,7 +47,8 @@ use crate::views::{ post_editor::{LinkedMediaItem, PostEditorMsg, PostEditorState, ResolvedPostLink}, script_editor::{ScriptEditorMsg, ScriptEditorState}, settings_view::{ - AiModelOption, SettingsCategoryRow, SettingsMsg, SettingsViewState, default_category_rows, + AiModeViewState, AiModelOption, SettingsCategoryRow, SettingsMsg, SettingsViewState, + default_category_rows, }, site_validation::SiteValidationState, tags_view::{self, TagsMsg, TagsSection, TagsViewState}, @@ -1501,13 +1502,14 @@ impl BdsApp { conversation_id, result, } => { + let locale = self.ui_locale; if let Some(state) = self.chat_editors.get_mut(&conversation_id) { state.streaming = false; state.clear_streaming(); state.active_tool = None; match result { Ok(_) => state.error = None, - Err(error) => state.error = Some(error), + Err(error) => state.error = Some(localize_chat_error(locale, &error)), } if let Some(db) = &self.db { state.set_messages( @@ -2572,6 +2574,22 @@ impl BdsApp { // ── Settings ── Message::SetOfflineMode(mode) => { self.offline_mode = mode; + let models = self.chat_model_options(); + let selected = self.db.as_ref().and_then(|db| { + ai::load_ai_settings(db.conn(), mode) + .ok() + .map(|settings| settings.active().endpoint.model.clone()) + .filter(|model| !model.trim().is_empty()) + }); + for (id, state) in &mut self.chat_editors { + state.model_options = models.clone(); + if let Some(model) = selected.as_ref() { + state.conversation.model = Some(model.clone()); + if let Some(db) = &self.db { + let _ = engine::chat::set_conversation_model(db.conn(), id, model); + } + } + } self.sync_menu_state(); Task::none() } @@ -3546,62 +3564,33 @@ impl BdsApp { fn chat_model_options(&self) -> Vec { let mut models = self - .db + .settings_state .as_ref() - .and_then(|db| engine::chat::list_models(db.conn()).ok()) - .into_iter() - .flatten() - .map(|model| { - let context = model.context_window.div_ceil(1_000).to_string(); - let output = model.max_output_tokens.div_ceil(1_000).to_string(); - let capability = t( - self.ui_locale, - if model.supports_tools { - "chat.model.tools" - } else { - "chat.model.textOnly" - }, - ); - ChatModelChoice { - id: model.id, - label: tw( - self.ui_locale, - "chat.model.option", - &[ - ("provider", &model.provider), - ("name", &model.name), - ("context", &context), - ("output", &output), - ("capability", &capability), - ], - ), - } + .map(|state| { + let mode = if self.offline_mode { + &state.airplane_ai + } else { + &state.online_ai + }; + mode.model_options + .iter() + .map(|model| ChatModelChoice { + id: model.id.clone(), + label: model.label.clone(), + }) + .collect::>() }) - .collect::>(); + .unwrap_or_default(); if let Some(db) = &self.db && let Ok(settings) = ai::load_ai_settings(db.conn(), self.offline_mode) { - if let Some(model) = settings.default_model - && !models.iter().any(|choice| choice.id == model) - { + let model = settings.active().endpoint.model.clone(); + if !model.trim().is_empty() && !models.iter().any(|choice| choice.id == model) { models.push(ChatModelChoice { id: model.clone(), label: model, }); } - let endpoint_model = if self.offline_mode { - settings.airplane_endpoint.model - } else { - settings.online_endpoint.model - }; - if !endpoint_model.trim().is_empty() - && !models.iter().any(|choice| choice.id == endpoint_model) - { - models.push(ChatModelChoice { - id: endpoint_model.clone(), - label: endpoint_model, - }); - } } models.sort_by(|left, right| left.label.cmp(&right.label)); models.dedup_by(|left, right| left.id == right.id); @@ -3610,10 +3599,17 @@ impl BdsApp { fn create_chat_conversation(&mut self) -> Task { let model = self - .chat_model_options() - .into_iter() - .next() - .map(|choice| choice.id); + .db + .as_ref() + .and_then(|db| ai::load_ai_settings(db.conn(), self.offline_mode).ok()) + .map(|settings| settings.active().endpoint.model.clone()) + .filter(|model| !model.trim().is_empty()) + .or_else(|| { + self.chat_model_options() + .into_iter() + .next() + .map(|choice| choice.id) + }); let title = model.as_deref().map_or_else( || t(self.ui_locale, "chat.new"), |model| tw(self.ui_locale, "chat.newWithModel", &[("model", model)]), @@ -3735,6 +3731,7 @@ impl BdsApp { conversation_id, message, } => { + let message = localize_chat_error(self.ui_locale, &message); if let Some(state) = self.chat_editors.get_mut(&conversation_id) { state.error = Some(message); } @@ -3939,15 +3936,15 @@ impl BdsApp { .iter() .any(|tab| tab.tab_type == TabType::Settings) { - self.settings_state = None; - if let Some(tab) = self - .tabs - .iter() - .find(|tab| tab.tab_type == TabType::Settings) - .cloned() - { - self.load_editor_for_tab(&tab); + let active_section = self + .settings_state + .as_ref() + .and_then(|state| state.active_section.clone()); + let mut state = self.hydrate_settings_state(); + if let Some(section) = active_section { + state.focus_section(section); } + self.settings_state = Some(state); } false } @@ -6822,28 +6819,8 @@ impl BdsApp { state.system_prompt = iced::widget::text_editor::Content::with_text(&setting.value); } if let Ok(ai_settings) = ai::load_ai_settings(db.conn(), self.offline_mode) { - state.online_endpoint_url = ai_settings.online_endpoint.url; - state.online_endpoint_model = ai_settings.online_endpoint.model; - state.online_api_key_configured = ai_settings.online_endpoint.api_key_configured; - if !state.online_endpoint_model.is_empty() { - state.online_model_options = vec![AiModelOption { - id: state.online_endpoint_model.clone(), - label: state.online_endpoint_model.clone(), - supports_vision: false, - }]; - } - state.airplane_endpoint_url = ai_settings.airplane_endpoint.url; - state.airplane_endpoint_model = ai_settings.airplane_endpoint.model; - if !state.airplane_endpoint_model.is_empty() { - state.airplane_model_options = vec![AiModelOption { - id: state.airplane_endpoint_model.clone(), - label: state.airplane_endpoint_model.clone(), - supports_vision: false, - }]; - } - state.default_model = ai_settings.default_model.unwrap_or_default(); - state.title_model = ai_settings.title_model.unwrap_or_default(); - state.image_model = ai_settings.image_model.unwrap_or_default(); + state.online_ai = Self::ai_mode_view_state(ai_settings.online); + state.airplane_ai = Self::ai_mode_view_state(ai_settings.airplane); } state.mcp_enabled = engine::settings::get_effective(db.conn(), "mcp.http.enabled") .ok() @@ -6874,7 +6851,6 @@ impl BdsApp { }) .collect(); } - state.offline_mode = self.offline_mode; state } @@ -7507,51 +7483,58 @@ impl BdsApp { state.ssh_username.clear(); state.ssh_remote_path.clear(); } - SettingsMsg::OfflineModeChanged(b) => { - state.offline_mode = b; - return Task::done(Message::SetOfflineMode(b)); + SettingsMsg::AiEndpointUrlChanged(kind, value) => { + Self::ai_mode_state_mut(state, kind).endpoint_url = value; } - SettingsMsg::OnlineEndpointUrlChanged(value) => { - state.online_endpoint_url = value; + SettingsMsg::AiApiKeyChanged(kind, value) => { + Self::ai_mode_state_mut(state, kind).api_key_input = value; } - SettingsMsg::OnlineEndpointModelChanged(value) => { - state.online_endpoint_model = value; + SettingsMsg::AiChatModelChanged(kind, value) => { + let mode = Self::ai_mode_state_mut(state, kind); + mode.chat_supports_tools = mode + .model_options + .iter() + .find(|option| option.id == value) + .is_some_and(|option| option.supports_tools); + mode.chat_model = value; } - SettingsMsg::OnlineApiKeyChanged(value) => { - state.online_api_key_input = value; + SettingsMsg::AiTitleModelChanged(kind, value) => { + Self::ai_mode_state_mut(state, kind).title_model = value; } - SettingsMsg::AirplaneEndpointUrlChanged(value) => { - state.airplane_endpoint_url = value; + SettingsMsg::AiImageModelChanged(kind, value) => { + let mode = Self::ai_mode_state_mut(state, kind); + mode.image_supports_vision = mode + .model_options + .iter() + .find(|option| option.id == value) + .is_some_and(|option| option.supports_vision); + mode.image_model = value; } - SettingsMsg::AirplaneEndpointModelChanged(value) => { - state.airplane_endpoint_model = value; + SettingsMsg::AiToolsChanged(kind, value) => { + Self::ai_mode_state_mut(state, kind).chat_supports_tools = value; } - SettingsMsg::DefaultModelChanged(value) => { - state.default_model = value; + SettingsMsg::AiVisionChanged(kind, value) => { + Self::ai_mode_state_mut(state, kind).image_supports_vision = value; } - SettingsMsg::TitleModelChanged(value) => { - state.title_model = value; - } - SettingsMsg::ImageModelChanged(value) => { - state.image_model = value; - } - SettingsMsg::RefreshOnlineModels => { + SettingsMsg::RefreshAiModels(kind) => { if let Some(db) = &self.db { - match Self::refresh_ai_models(db, state, AiEndpointKind::Online) { - Ok(()) => { - self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) - } - Err(error) => self.notify_operation_failed("common.save", error), + match Self::refresh_ai_models(db, state, kind) { + Ok(()) => self.notify( + ToastLevel::Success, + &t(self.ui_locale, "settings.modelsLoaded"), + ), + Err(error) => self.notify_operation_failed("settings.refreshModels", error), } } } - SettingsMsg::RefreshAirplaneModels => { + SettingsMsg::TestAi(kind) => { if let Some(db) = &self.db { - match Self::refresh_ai_models(db, state, AiEndpointKind::Airplane) { - Ok(()) => { - self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")) - } - Err(error) => self.notify_operation_failed("common.save", error), + match Self::test_ai_settings(db, state, kind) { + Ok(()) => self.notify( + ToastLevel::Success, + &t(self.ui_locale, "settings.testChatSuccess"), + ), + Err(error) => self.notify_operation_failed("settings.testChat", error), } } } @@ -8160,37 +8143,14 @@ impl BdsApp { } } TabType::Settings if self.settings_state.is_none() => { - let mut state = SettingsViewState::default(); - if let Some(ref project) = self.active_project { - state.project_name = project.name.clone(); - state.project_description = iced::widget::text_editor::Content::with_text( - &project.description.clone().unwrap_or_default(), - ); - state.data_path = project.data_path.clone().unwrap_or_default(); - } - if let Some(ref data_dir) = self.data_dir { - if let Ok(meta) = engine::meta::read_project_json(data_dir) { - state.public_url = meta.public_url.unwrap_or_default(); - state.default_author = meta.default_author.unwrap_or_default(); - state.max_posts_per_page = meta.max_posts_per_page.to_string(); - state.image_import_concurrency = meta.image_import_concurrency.to_string(); - } - if let Ok(pub_prefs) = engine::meta::read_publishing_json(data_dir) { - state.ssh_host = pub_prefs.ssh_host.unwrap_or_default(); - state.ssh_username = pub_prefs.ssh_user.unwrap_or_default(); - state.ssh_remote_path = pub_prefs.ssh_remote_path.unwrap_or_default(); - state.ssh_mode = format!("{:?}", pub_prefs.ssh_mode).to_lowercase(); - } - } - state.offline_mode = self.offline_mode; - self.settings_state = Some(state); + self.settings_state = Some(self.hydrate_settings_state()); } _ => {} } } fn refresh_ai_models( - db: &Database, + _db: &Database, state: &mut SettingsViewState, kind: AiEndpointKind, ) -> Result<(), String> { @@ -8201,92 +8161,122 @@ impl BdsApp { .map(|model| AiModelOption { id: model.id, label: model.name, + supports_tools: model.supports_tools, supports_vision: model.supports_vision, }) .collect::>(); - match kind { - AiEndpointKind::Online => state.online_model_options = options, - AiEndpointKind::Airplane => state.airplane_model_options = options, + Self::ai_mode_state_mut(state, kind).model_options = options; + Ok(()) + } + + fn test_ai_settings( + _db: &Database, + state: &SettingsViewState, + kind: AiEndpointKind, + ) -> Result<(), String> { + let endpoint = Self::compose_ai_endpoint(state, kind)?; + let mode = Self::ai_mode_state(state, kind); + let models = [&mode.chat_model, &mode.title_model, &mode.image_model] + .into_iter() + .filter(|model| !model.trim().is_empty()) + .collect::>(); + if models.is_empty() { + return Err("select at least one model".to_string()); + } + for model in models { + ai::test_chat(&endpoint, model).map_err(|error| error.to_string())?; } - let _ = db; Ok(()) } fn save_ai_settings_state(db: &Database, state: &mut SettingsViewState) -> Result<(), String> { - if Self::endpoint_has_configuration(state, AiEndpointKind::Online) { - let online_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Online)?; - ai::test_endpoint(&online_endpoint).map_err(|error| error.to_string())?; - ai::save_endpoint(db.conn(), &online_endpoint).map_err(|error| error.to_string())?; - state.online_api_key_input.clear(); - state.online_api_key_configured = true; + for kind in [AiEndpointKind::Online, AiEndpointKind::Airplane] { + if Self::endpoint_has_configuration(state, kind) { + let endpoint = Self::compose_ai_endpoint(state, kind)?; + ai::save_endpoint(db.conn(), &endpoint).map_err(|error| error.to_string())?; + } + let mode = Self::ai_mode_state(state, kind); + ai::save_model_preferences( + db.conn(), + kind, + (!mode.title_model.trim().is_empty()).then_some(mode.title_model.as_str()), + (!mode.image_model.trim().is_empty()).then_some(mode.image_model.as_str()), + Some(mode.chat_supports_tools), + Some(mode.image_supports_vision), + ) + .map_err(|error| error.to_string())?; } - if Self::endpoint_has_configuration(state, AiEndpointKind::Airplane) { - let airplane_endpoint = Self::compose_ai_endpoint(state, AiEndpointKind::Airplane)?; - ai::test_endpoint(&airplane_endpoint).map_err(|error| error.to_string())?; - ai::save_endpoint(db.conn(), &airplane_endpoint).map_err(|error| error.to_string())?; + ai::save_system_prompt(db.conn(), &state.system_prompt.text()) + .map_err(|error| error.to_string())?; + for kind in [AiEndpointKind::Online, AiEndpointKind::Airplane] { + let mode = Self::ai_mode_state_mut(state, kind); + if !mode.api_key_input.trim().is_empty() { + mode.api_key_configured = true; + } + mode.api_key_input.clear(); } - ai::save_model_preferences( - db.conn(), - (!state.default_model.trim().is_empty()).then_some(state.default_model.as_str()), - (!state.title_model.trim().is_empty()).then_some(state.title_model.as_str()), - (!state.image_model.trim().is_empty()).then_some(state.image_model.as_str()), - &state.system_prompt.text(), - ) - .map_err(|error| error.to_string())?; Ok(()) } fn endpoint_has_configuration(state: &SettingsViewState, kind: AiEndpointKind) -> bool { - match kind { - AiEndpointKind::Online => { - !state.online_endpoint_url.trim().is_empty() - || !state.online_endpoint_model.trim().is_empty() - || !state.online_api_key_input.trim().is_empty() - || state.online_api_key_configured - } - AiEndpointKind::Airplane => { - !state.airplane_endpoint_url.trim().is_empty() - || !state.airplane_endpoint_model.trim().is_empty() - } - } + let mode = Self::ai_mode_state(state, kind); + !mode.endpoint_url.trim().is_empty() + || !mode.chat_model.trim().is_empty() + || !mode.api_key_input.trim().is_empty() + || mode.api_key_configured } fn compose_ai_endpoint( state: &SettingsViewState, kind: AiEndpointKind, ) -> Result { - let (url, model, configured) = match kind { - AiEndpointKind::Online => ( - state.online_endpoint_url.trim().to_string(), - state.online_endpoint_model.trim().to_string(), - state.online_api_key_configured, - ), - AiEndpointKind::Airplane => ( - state.airplane_endpoint_url.trim().to_string(), - state.airplane_endpoint_model.trim().to_string(), - false, - ), - }; - let api_key = if kind == AiEndpointKind::Online { - let input = state.online_api_key_input.trim(); - if !input.is_empty() { - Some(input.to_string()) - } else if configured { - ai::load_endpoint_api_key(kind).map_err(|error| error.to_string())? - } else { - None - } + let mode = Self::ai_mode_state(state, kind); + let input = mode.api_key_input.trim(); + let api_key = if !input.is_empty() { + Some(input.to_string()) + } else if mode.api_key_configured { + ai::load_endpoint_api_key(kind).map_err(|error| error.to_string())? } else { None }; Ok(AiEndpointConfig { kind, - url, - model, + url: mode.endpoint_url.trim().to_string(), + model: mode.chat_model.trim().to_string(), api_key, }) } + fn ai_mode_state(state: &SettingsViewState, kind: AiEndpointKind) -> &AiModeViewState { + match kind { + AiEndpointKind::Online => &state.online_ai, + AiEndpointKind::Airplane => &state.airplane_ai, + } + } + + fn ai_mode_state_mut( + state: &mut SettingsViewState, + kind: AiEndpointKind, + ) -> &mut AiModeViewState { + match kind { + AiEndpointKind::Online => &mut state.online_ai, + AiEndpointKind::Airplane => &mut state.airplane_ai, + } + } + + fn ai_mode_view_state(settings: ai::AiModeSettings) -> AiModeViewState { + AiModeViewState { + endpoint_url: settings.endpoint.url, + chat_model: settings.endpoint.model, + title_model: settings.title_model.unwrap_or_default(), + image_model: settings.image_model.unwrap_or_default(), + api_key_configured: settings.endpoint.api_key_configured, + chat_supports_tools: settings.chat_supports_tools.unwrap_or(false), + image_supports_vision: settings.image_supports_vision.unwrap_or(false), + ..Default::default() + } + } + fn ensure_preview_server(&mut self) -> Result<(), String> { let Some(project) = self.active_project.as_ref() else { return Err(t(self.ui_locale, "engine.generateSiteNoProject")); @@ -8995,6 +8985,16 @@ fn language_label(locale: UiLocale, code: &str) -> String { } } +fn localize_chat_error(locale: UiLocale, error: &str) -> String { + if error.contains("AI unavailable - configure") { + t(locale, "chat.unavailable.guidance") + } else if let Some(detail) = error.strip_prefix("parse error: AI provider returned ") { + format!("{} {detail}", t(locale, "chat.providerError")) + } else { + error.to_string() + } +} + fn remote_error_closes_connection(code: &str) -> bool { code == "connection_lost" } @@ -9003,10 +9003,11 @@ fn remote_error_closes_connection(code: &str) -> bool { mod tests { use super::{ BdsApp, Message, POST_AUTO_SAVE_DELAY_MS, PersistedMediaState, PersistedPostState, - PostStatus, SettingsMsg, month_abbreviation, persist_media_editor_state_impl, - persist_post_editor_preview_state_impl, persist_post_editor_state_impl, - remote_error_closes_connection, save_editor_settings_state_impl, - save_script_editor_state_impl, save_template_editor_state_impl, + PostStatus, SettingsMsg, localize_chat_error, month_abbreviation, + persist_media_editor_state_impl, persist_post_editor_preview_state_impl, + persist_post_editor_state_impl, remote_error_closes_connection, + save_editor_settings_state_impl, save_script_editor_state_impl, + save_template_editor_state_impl, }; use crate::i18n::t; use crate::platform::menu::MenuAction; @@ -9020,7 +9021,7 @@ mod tests { use crate::views::modal; use crate::views::post_editor::{PostEditorMsg, PostEditorState}; use crate::views::script_editor::{ScriptEditorMsg, ScriptEditorState}; - use crate::views::settings_view::SettingsViewState; + use crate::views::settings_view::{AiModeViewState, SettingsSection, SettingsViewState}; use crate::views::template_editor::TemplateEditorState; use bds_core::db::Database; use bds_core::db::fts::ensure_fts_tables; @@ -9029,7 +9030,7 @@ mod tests { use bds_core::engine::task::{TaskStatus, TaskStatus::*}; use bds_core::engine::{ai, chat, media, menu, meta, post, script, template, wordpress_import}; use bds_core::i18n::UiLocale; - use bds_core::model::{ChatRole, Project, ScriptKind, TemplateKind}; + use bds_core::model::{ChatRole, DomainEvent, Project, ScriptKind, TemplateKind}; use chrono::{Datelike, TimeZone}; use std::io::{Read, Write}; use std::net::TcpListener; @@ -9922,8 +9923,11 @@ mod tests { fn save_ai_settings_allows_airplane_only_configuration() { let (db, _project, _tmp) = setup(); let mut state = SettingsViewState { - airplane_endpoint_url: spawn_models_server(), - airplane_endpoint_model: "llama3.2".to_string(), + airplane_ai: AiModeViewState { + endpoint_url: spawn_models_server(), + chat_model: "llama3.2".to_string(), + ..Default::default() + }, system_prompt: iced::widget::text_editor::Content::with_text("Use JSON only."), ..SettingsViewState::default() }; @@ -9931,13 +9935,86 @@ mod tests { BdsApp::save_ai_settings_state(&db, &mut state).unwrap(); let settings = ai::load_ai_settings(db.conn(), false).unwrap(); - assert!(settings.online_endpoint.url.is_empty()); - assert!(settings.online_endpoint.model.is_empty()); - assert_eq!(settings.airplane_endpoint.url, state.airplane_endpoint_url); - assert_eq!(settings.airplane_endpoint.model, "llama3.2"); + assert!(settings.online.endpoint.url.is_empty()); + assert!(settings.online.endpoint.model.is_empty()); + assert_eq!( + settings.airplane.endpoint.url, + state.airplane_ai.endpoint_url + ); + assert_eq!(settings.airplane.endpoint.model, "llama3.2"); assert_eq!(settings.system_prompt.trim_end(), "Use JSON only."); } + #[test] + fn settings_change_rehydrates_ai_configuration() { + let (db, project, tmp) = setup(); + bds_core::engine::settings::set( + db.conn(), + "ai.endpoint.online.url", + "http://127.0.0.1:9000/v1", + ) + .unwrap(); + bds_core::engine::settings::set( + db.conn(), + "ai.endpoint.online.model", + "mlx-community--gemma-4-12B-8bit", + ) + .unwrap(); + bds_core::engine::settings::set(db.conn(), "ai.endpoint.online.api_key_configured", "true") + .unwrap(); + let mut app = make_app(db, project, &tmp); + app.tabs.push(Tab { + id: "settings".to_string(), + tab_type: TabType::Settings, + title: "Settings".to_string(), + is_transient: false, + is_dirty: false, + }); + let mut settings_state = SettingsViewState::default(); + settings_state.focus_section(SettingsSection::AI); + app.settings_state = Some(settings_state); + + app.handle_domain_event(DomainEvent::SettingsChanged { + project_id: None, + key: "ai.endpoint.online.url".to_string(), + }); + + let state = app.settings_state.as_ref().unwrap(); + assert_eq!(state.online_ai.endpoint_url, "http://127.0.0.1:9000/v1"); + assert_eq!( + state.online_ai.chat_model, + "mlx-community--gemma-4-12B-8bit" + ); + assert!(state.online_ai.api_key_configured); + assert_eq!(state.active_section, Some(SettingsSection::AI)); + } + + #[test] + fn ai_unavailable_chat_errors_are_localized() { + let raw = "validation error: AI unavailable - configure online endpoint in Settings"; + for locale in UiLocale::all() { + assert_eq!( + localize_chat_error(*locale, raw), + t(*locale, "chat.unavailable.guidance") + ); + } + let provider = + "parse error: AI provider returned 400 Bad Request: tokenizer.chat_template is not set"; + for locale in UiLocale::all() { + assert_eq!( + localize_chat_error(*locale, provider), + format!( + "{} 400 Bad Request: tokenizer.chat_template is not set", + t(*locale, "chat.providerError") + ) + ); + } + assert_eq!( + localize_chat_error(UiLocale::En, "connection refused"), + "connection refused" + ); + } + #[test] fn media_editor_save_flow_persists_changes() { let (db, project, tmp) = setup(); diff --git a/crates/bds-ui/src/components/inputs.rs b/crates/bds-ui/src/components/inputs.rs index e1f319b..dd6e26d 100644 --- a/crates/bds-ui/src/components/inputs.rs +++ b/crates/bds-ui/src/components/inputs.rs @@ -201,6 +201,31 @@ pub fn labeled_input<'a, Message: Clone + 'a>( .into() } +/// A labeled password input field. +pub fn labeled_secure_input<'a, Message: Clone + 'a>( + label: &str, + placeholder: &str, + value: &str, + on_change: impl Fn(String) -> Message + 'a, +) -> Element<'a, Message> { + column![ + text(label.to_string()) + .size(12) + .color(LABEL_COLOR) + .shaping(Shaping::Advanced), + text_input(placeholder, value) + .on_input(on_change) + .secure(true) + .size(14) + .padding([8, 10]) + .width(Length::Fill) + .style(field_style), + ] + .spacing(6) + .width(Length::Fill) + .into() +} + /// A labeled select/dropdown field. pub fn labeled_select<'a, T, Message>( label: &str, diff --git a/crates/bds-ui/src/views/settings_view.rs b/crates/bds-ui/src/views/settings_view.rs index 8b54c1a..1568e7a 100644 --- a/crates/bds-ui/src/views/settings_view.rs +++ b/crates/bds-ui/src/views/settings_view.rs @@ -2,6 +2,7 @@ use iced::widget::text::Shaping; use iced::widget::{button, column, container, row, scrollable, text, text_editor, text_input}; use iced::{Alignment, Color, Element, Length}; +use bds_core::engine::ai::AiEndpointKind; use bds_core::i18n::UiLocale; use crate::app::Message; @@ -12,6 +13,7 @@ use crate::i18n::{t, tw}; pub struct AiModelOption { pub id: String, pub label: String, + pub supports_tools: bool, pub supports_vision: bool, } @@ -21,6 +23,19 @@ impl std::fmt::Display for AiModelOption { } } +#[derive(Debug, Clone, Default)] +pub struct AiModeViewState { + pub endpoint_url: String, + pub chat_model: String, + pub title_model: String, + pub image_model: String, + pub api_key_input: String, + pub api_key_configured: bool, + pub model_options: Vec, + pub chat_supports_tools: bool, + pub image_supports_vision: bool, +} + /// Collapsible section identifiers per editor_settings.allium. #[derive(Debug, Clone, PartialEq, Eq, Hash)] pub enum SettingsSection { @@ -136,18 +151,8 @@ pub struct SettingsViewState { pub ssh_username: String, pub ssh_remote_path: String, // AI - pub offline_mode: bool, - pub online_endpoint_url: String, - pub online_endpoint_model: String, - pub online_api_key_input: String, - pub online_api_key_configured: bool, - pub online_model_options: Vec, - pub airplane_endpoint_url: String, - pub airplane_endpoint_model: String, - pub airplane_model_options: Vec, - pub default_model: String, - pub title_model: String, - pub image_model: String, + pub online_ai: AiModeViewState, + pub airplane_ai: AiModeViewState, pub system_prompt: text_editor::Content, // Technology pub semantic_similarity_enabled: bool, @@ -195,18 +200,8 @@ impl Clone for SettingsViewState { ssh_host: self.ssh_host.clone(), ssh_username: self.ssh_username.clone(), ssh_remote_path: self.ssh_remote_path.clone(), - offline_mode: self.offline_mode, - online_endpoint_url: self.online_endpoint_url.clone(), - online_endpoint_model: self.online_endpoint_model.clone(), - online_api_key_input: self.online_api_key_input.clone(), - online_api_key_configured: self.online_api_key_configured, - online_model_options: self.online_model_options.clone(), - airplane_endpoint_url: self.airplane_endpoint_url.clone(), - airplane_endpoint_model: self.airplane_endpoint_model.clone(), - airplane_model_options: self.airplane_model_options.clone(), - default_model: self.default_model.clone(), - title_model: self.title_model.clone(), - image_model: self.image_model.clone(), + online_ai: self.online_ai.clone(), + airplane_ai: self.airplane_ai.clone(), system_prompt: text_editor::Content::with_text(&self.system_prompt.text()), semantic_similarity_enabled: self.semantic_similarity_enabled, mcp_enabled: self.mcp_enabled, @@ -252,18 +247,8 @@ impl Default for SettingsViewState { ssh_host: String::new(), ssh_username: String::new(), ssh_remote_path: String::new(), - offline_mode: false, - online_endpoint_url: String::new(), - online_endpoint_model: String::new(), - online_api_key_input: String::new(), - online_api_key_configured: false, - online_model_options: Vec::new(), - airplane_endpoint_url: String::new(), - airplane_endpoint_model: String::new(), - airplane_model_options: Vec::new(), - default_model: String::new(), - title_model: String::new(), - image_model: String::new(), + online_ai: AiModeViewState::default(), + airplane_ai: AiModeViewState::default(), system_prompt: text_editor::Content::new(), semantic_similarity_enabled: false, mcp_enabled: false, @@ -343,17 +328,15 @@ pub enum SettingsMsg { SavePublishing, ClearPublishing, // AI - OfflineModeChanged(bool), - OnlineEndpointUrlChanged(String), - OnlineEndpointModelChanged(String), - OnlineApiKeyChanged(String), - RefreshOnlineModels, - AirplaneEndpointUrlChanged(String), - AirplaneEndpointModelChanged(String), - RefreshAirplaneModels, - DefaultModelChanged(String), - TitleModelChanged(String), - ImageModelChanged(String), + AiEndpointUrlChanged(AiEndpointKind, String), + AiApiKeyChanged(AiEndpointKind, String), + AiChatModelChanged(AiEndpointKind, String), + AiTitleModelChanged(AiEndpointKind, String), + AiImageModelChanged(AiEndpointKind, String), + AiToolsChanged(AiEndpointKind, bool), + AiVisionChanged(AiEndpointKind, bool), + RefreshAiModels(AiEndpointKind), + TestAi(AiEndpointKind), SystemPromptAction(text_editor::Action), SaveAi, ResetSystemPrompt, @@ -786,104 +769,17 @@ fn section_content<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Elemen } fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, Message> { - let active_model_options = if state.offline_mode { - &state.airplane_model_options - } else { - &state.online_model_options - }; - let model_options = std::iter::once(AiModelOption { - id: String::new(), - label: t(locale, "tags.noTemplate"), - supports_vision: false, - }) - .chain(active_model_options.iter().cloned()) - .collect::>(); - let image_model_options = std::iter::once(AiModelOption { - id: String::new(), - label: t(locale, "tags.noTemplate"), - supports_vision: false, - }) - .chain( - active_model_options - .iter() - .filter(|option| option.supports_vision) - .cloned(), - ) - .collect::>(); - - let offline = inputs::labeled_checkbox( - &t(locale, "settings.offlineMode"), - state.offline_mode, - |b| Message::Settings(SettingsMsg::OfflineModeChanged(b)), - ); - let online_url = inputs::labeled_input( - &t(locale, "settings.onlineEndpointUrl"), + let online = ai_mode_block( + &state.online_ai, + AiEndpointKind::Online, + locale, "https://api.example.com/v1", - &state.online_endpoint_url, - |value| Message::Settings(SettingsMsg::OnlineEndpointUrlChanged(value)), ); - let online_model = inputs::labeled_input( - &t(locale, "settings.onlineEndpointModel"), - "gpt-4.1-mini", - &state.online_endpoint_model, - |value| Message::Settings(SettingsMsg::OnlineEndpointModelChanged(value)), - ); - let keychain_placeholder = t(locale, "settings.keychainConfigured"); - let online_api_key = inputs::labeled_input( - &t(locale, "settings.onlineApiKey"), - if state.online_api_key_configured { - &keychain_placeholder - } else { - "sk-..." - }, - &state.online_api_key_input, - |value| Message::Settings(SettingsMsg::OnlineApiKeyChanged(value)), - ); - let online_refresh = button(text(t(locale, "settings.refreshModels")).size(13)) - .on_press(Message::Settings(SettingsMsg::RefreshOnlineModels)) - .style(inputs::secondary_button) - .padding([6, 16]); - - let airplane_url = inputs::labeled_input( - &t(locale, "settings.airplaneEndpointUrl"), + let airplane = ai_mode_block( + &state.airplane_ai, + AiEndpointKind::Airplane, + locale, "http://localhost:11434/v1", - &state.airplane_endpoint_url, - |value| Message::Settings(SettingsMsg::AirplaneEndpointUrlChanged(value)), - ); - let airplane_model = inputs::labeled_input( - &t(locale, "settings.airplaneEndpointModel"), - "llama3.2", - &state.airplane_endpoint_model, - |value| Message::Settings(SettingsMsg::AirplaneEndpointModelChanged(value)), - ); - let airplane_refresh = button(text(t(locale, "settings.refreshModels")).size(13)) - .on_press(Message::Settings(SettingsMsg::RefreshAirplaneModels)) - .style(inputs::secondary_button) - .padding([6, 16]); - - let default_model = inputs::labeled_select( - &t(locale, "settings.defaultModel"), - &model_options, - model_options - .iter() - .find(|option| option.id == state.default_model), - |option| Message::Settings(SettingsMsg::DefaultModelChanged(option.id)), - ); - let title_model = inputs::labeled_select( - &t(locale, "settings.titleModel"), - &model_options, - model_options - .iter() - .find(|option| option.id == state.title_model), - |option| Message::Settings(SettingsMsg::TitleModelChanged(option.id)), - ); - let image_model = inputs::labeled_select( - &t(locale, "settings.imageAnalysisModel"), - &image_model_options, - image_model_options - .iter() - .find(|option| option.id == state.image_model), - |option| Message::Settings(SettingsMsg::ImageModelChanged(option.id)), ); let prompt = column![ text(t(locale, "settings.systemPrompt")) @@ -909,27 +805,118 @@ fn section_ai<'a>(state: &'a SettingsViewState, locale: UiLocale) -> Element<'a, ] .spacing(8); - column![ - offline, - text(t(locale, "settings.onlineEndpointSection")) - .size(12) - .color(inputs::LABEL_COLOR), - online_url, - row![online_model, online_api_key].spacing(12), - online_refresh, - text(t(locale, "settings.airplaneEndpointSection")) - .size(12) - .color(inputs::LABEL_COLOR), - airplane_url, - row![airplane_model, airplane_refresh].spacing(12), - default_model, - title_model, - image_model, - prompt, - btns, - ] - .spacing(8) - .padding([0, 16]) + column![online, airplane, prompt, btns,] + .spacing(8) + .padding([0, 16]) + .into() +} + +fn ai_mode_block<'a>( + state: &'a AiModeViewState, + kind: AiEndpointKind, + locale: UiLocale, + url_placeholder: &'a str, +) -> Element<'a, Message> { + let mut options = state.model_options.clone(); + for model in [&state.chat_model, &state.title_model, &state.image_model] { + if !model.is_empty() && !options.iter().any(|option| option.id == *model) { + options.push(AiModelOption { + id: model.clone(), + label: model.clone(), + supports_tools: false, + supports_vision: false, + }); + } + } + options.sort_by(|left, right| left.label.cmp(&right.label)); + options.insert( + 0, + AiModelOption { + id: String::new(), + label: t(locale, "tags.noTemplate"), + supports_tools: false, + supports_vision: false, + }, + ); + let selected = |id: &str| options.iter().find(|option| option.id == id); + let section_key = match kind { + AiEndpointKind::Online => "settings.onlineEndpointSection", + AiEndpointKind::Airplane => "settings.airplaneEndpointSection", + }; + let url_key = match kind { + AiEndpointKind::Online => "settings.onlineEndpointUrl", + AiEndpointKind::Airplane => "settings.airplaneEndpointUrl", + }; + let api_key = match kind { + AiEndpointKind::Online => "settings.onlineApiKey", + AiEndpointKind::Airplane => "settings.airplaneApiKey", + }; + let keychain_placeholder = t(locale, "settings.keychainConfigured"); + + inputs::card( + column![ + text(t(locale, section_key)).size(15), + row![ + inputs::labeled_input( + &t(locale, url_key), + url_placeholder, + &state.endpoint_url, + move |value| { + Message::Settings(SettingsMsg::AiEndpointUrlChanged(kind, value)) + } + ), + button(text(t(locale, "settings.refreshModels")).size(13)) + .on_press(Message::Settings(SettingsMsg::RefreshAiModels(kind))) + .style(inputs::secondary_button) + .padding([6, 16]), + ] + .spacing(12) + .align_y(Alignment::End), + inputs::labeled_secure_input( + &t(locale, api_key), + if state.api_key_configured { + &keychain_placeholder + } else { + "sk-..." + }, + &state.api_key_input, + move |value| Message::Settings(SettingsMsg::AiApiKeyChanged(kind, value)), + ), + inputs::labeled_select( + &t(locale, "settings.chatModel"), + &options, + selected(&state.chat_model), + move |option| Message::Settings(SettingsMsg::AiChatModelChanged(kind, option.id)), + ), + inputs::labeled_checkbox( + &t(locale, "settings.modelSupportsTools"), + state.chat_supports_tools, + move |value| Message::Settings(SettingsMsg::AiToolsChanged(kind, value)), + ), + inputs::labeled_select( + &t(locale, "settings.titleModel"), + &options, + selected(&state.title_model), + move |option| Message::Settings(SettingsMsg::AiTitleModelChanged(kind, option.id)), + ), + inputs::labeled_select( + &t(locale, "settings.imageAnalysisModel"), + &options, + selected(&state.image_model), + move |option| Message::Settings(SettingsMsg::AiImageModelChanged(kind, option.id)), + ), + inputs::labeled_checkbox( + &t(locale, "settings.modelSupportsVision"), + state.image_supports_vision, + move |value| Message::Settings(SettingsMsg::AiVisionChanged(kind, value)), + ), + button(text(t(locale, "settings.testChat")).size(13)) + .on_press(Message::Settings(SettingsMsg::TestAi(kind))) + .style(inputs::secondary_button) + .padding([6, 16]), + ] + .spacing(8), + ) .into() } diff --git a/crates/bds-ui/src/views/workspace.rs b/crates/bds-ui/src/views/workspace.rs index 661c9e2..eac958e 100644 --- a/crates/bds-ui/src/views/workspace.rs +++ b/crates/bds-ui/src/views/workspace.rs @@ -649,14 +649,14 @@ fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool) return false; }; - if offline_mode { - !state.airplane_endpoint_url.trim().is_empty() - && !state.airplane_endpoint_model.trim().is_empty() + let mode = if offline_mode { + &state.airplane_ai } else { - !state.online_endpoint_url.trim().is_empty() - && !state.online_endpoint_model.trim().is_empty() - && (state.online_api_key_configured || !state.online_api_key_input.trim().is_empty()) - } + &state.online_ai + }; + !mode.endpoint_url.trim().is_empty() + && !mode.chat_model.trim().is_empty() + && (offline_mode || mode.api_key_configured || !mode.api_key_input.trim().is_empty()) } fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> { @@ -965,14 +965,14 @@ mod tests { assert!(!is_ai_enabled(Some(&settings), false)); assert!(!is_ai_enabled(Some(&settings), true)); - settings.online_endpoint_url = "https://api.example.com/v1".to_string(); - settings.online_endpoint_model = "gpt-4.1-mini".to_string(); - settings.online_api_key_configured = true; + settings.online_ai.endpoint_url = "https://api.example.com/v1".to_string(); + settings.online_ai.chat_model = "gpt-4.1-mini".to_string(); + settings.online_ai.api_key_configured = true; assert!(is_ai_enabled(Some(&settings), false)); assert!(!is_ai_enabled(Some(&settings), true)); - settings.airplane_endpoint_url = "http://localhost:11434/v1".to_string(); - settings.airplane_endpoint_model = "llama3.2".to_string(); + settings.airplane_ai.endpoint_url = "http://localhost:11434/v1".to_string(); + settings.airplane_ai.chat_model = "llama3.2".to_string(); assert!(is_ai_enabled(Some(&settings), true)); } } diff --git a/crates/bds-ui/tests/m2_validation.rs b/crates/bds-ui/tests/m2_validation.rs index 2f001ea..51e5ede 100644 --- a/crates/bds-ui/tests/m2_validation.rs +++ b/crates/bds-ui/tests/m2_validation.rs @@ -27,6 +27,7 @@ fn dialog_keys_exist_in_all_locales() { #[test] fn toast_keys_exist_in_all_locales() { let keys = [ + "editor.saved", "projectSelector.toast.switched", "projectSelector.toast.switchFailed", "projectSelector.toast.created", diff --git a/locales/ui/de.ftl b/locales/ui/de.ftl index ce4e4ea..55c83bd 100644 --- a/locales/ui/de.ftl +++ b/locales/ui/de.ftl @@ -119,6 +119,7 @@ chat-new = Neue Unterhaltung chat-newWithModel = Unterhaltung mit { $model } chat-unavailable-title = Die dialogbasierte KI ist nicht verfügbar chat-unavailable-guidance = Konfiguriere den aktiven KI-Endpunkt und das Modell in den Einstellungen. Im Flugmodus wird nur der lokale Endpunkt verwendet. +chat-providerError = Fehler des KI-Anbieters: chat-unavailable-openSettings = KI-Einstellungen öffnen chat-model-none = Kein Modell ausgewählt chat-model-label = Modell @@ -372,6 +373,7 @@ sidebar-filter-tags = Tags sidebar-filter-categories = Kategorien sidebar-filter-calendar = Archiv sidebar-filter-noResults = Keine Treffer +editor-saved = Gespeichert. editor-title = Titel editor-titlePlaceholder = Titel eingeben... editor-slug = Slug @@ -489,10 +491,27 @@ settings-onlineEndpointSection = Online-Endpunkt settings-onlineEndpointUrl = URL des Online-Endpunkts settings-onlineEndpointModel = Modell des Online-Endpunkts settings-onlineApiKey = Online-API-Schlüssel +settings-airplaneApiKey = Lokaler API-Schlüssel (optional) settings-airplaneEndpointSection = Flugmodus-Endpunkt settings-airplaneEndpointUrl = URL des Flugmodus-Endpunkts settings-airplaneEndpointModel = Modell des Flugmodus-Endpunkts settings-refreshModels = Modelle aktualisieren +settings-chatModel = Chat-Modell +settings-modelSupportsTools = Ausgewähltes Chat-Modell unterstützt Werkzeugaufrufe +settings-modelSupportsVision = Ausgewähltes Bildmodell unterstützt Bilderkennung +settings-testChat = Chat testen +settings-modelsLoaded = Modelle geladen. +settings-testChatSuccess = Die ausgewählten Modelle haben erfolgreich geantwortet. +settings-onlineChatModel = Online-Chat-Modell +settings-onlineTitleModel = Online-Titelmodell +settings-onlineImageModel = Online-Bildmodell +settings-onlineModelSupportsTools = Online-Chat-Modell unterstützt Werkzeugaufrufe +settings-onlineModelSupportsVision = Online-Bildmodell unterstützt Bilderkennung +settings-airplaneChatModel = Lokales Chat-Modell +settings-airplaneTitleModel = Lokales Titelmodell +settings-airplaneImageModel = Lokales Bildmodell +settings-airplaneModelSupportsTools = Lokales Chat-Modell unterstützt Werkzeugaufrufe +settings-airplaneModelSupportsVision = Lokales Bildmodell unterstützt Bilderkennung settings-defaultModel = Standardmodell settings-titleModel = Titelmodell settings-imageAnalysisModel = Bildanalysemodell diff --git a/locales/ui/en.ftl b/locales/ui/en.ftl index 7e35b59..2e555b2 100644 --- a/locales/ui/en.ftl +++ b/locales/ui/en.ftl @@ -119,6 +119,7 @@ chat-new = New Chat chat-newWithModel = Chat with { $model } chat-unavailable-title = Conversational AI is unavailable chat-unavailable-guidance = Configure the active AI endpoint and model in Settings. Airplane mode uses only the local endpoint. +chat-providerError = AI provider error: chat-unavailable-openSettings = Open AI Settings chat-model-none = No model selected chat-model-label = Model @@ -363,6 +364,7 @@ sidebar-filter-tags = Tags sidebar-filter-categories = Categories sidebar-filter-calendar = Archive sidebar-filter-noResults = No matching items +editor-saved = Saved. editor-title = Title editor-titlePlaceholder = Enter title... editor-slug = Slug @@ -489,10 +491,27 @@ settings-onlineEndpointSection = Online Endpoint settings-onlineEndpointUrl = Online Endpoint URL settings-onlineEndpointModel = Online Endpoint Model settings-onlineApiKey = Online API Key +settings-airplaneApiKey = Local API Key (Optional) settings-airplaneEndpointSection = Airplane Endpoint settings-airplaneEndpointUrl = Airplane Endpoint URL settings-airplaneEndpointModel = Airplane Endpoint Model settings-refreshModels = Refresh Models +settings-chatModel = Chat Model +settings-modelSupportsTools = Selected chat model supports tool calls +settings-modelSupportsVision = Selected image model supports vision +settings-testChat = Test Chat +settings-modelsLoaded = Models loaded. +settings-testChatSuccess = Selected models responded successfully. +settings-onlineChatModel = Online Chat Model +settings-onlineTitleModel = Online Title Model +settings-onlineImageModel = Online Image Model +settings-onlineModelSupportsTools = Online chat model supports tool calls +settings-onlineModelSupportsVision = Online image model supports vision +settings-airplaneChatModel = Local Chat Model +settings-airplaneTitleModel = Local Title Model +settings-airplaneImageModel = Local Image Model +settings-airplaneModelSupportsTools = Local chat model supports tool calls +settings-airplaneModelSupportsVision = Local image model supports vision settings-defaultModel = Default Model settings-titleModel = Title Model settings-imageAnalysisModel = Image Analysis Model diff --git a/locales/ui/es.ftl b/locales/ui/es.ftl index e6f341f..3d9b83e 100644 --- a/locales/ui/es.ftl +++ b/locales/ui/es.ftl @@ -119,6 +119,7 @@ chat-new = Nueva conversación chat-newWithModel = Conversación con { $model } chat-unavailable-title = La IA conversacional no está disponible chat-unavailable-guidance = Configura el punto de acceso de IA activo y el modelo en Ajustes. El modo avión usa únicamente el punto de acceso local. +chat-providerError = Error del proveedor de IA: chat-unavailable-openSettings = Abrir ajustes de IA chat-model-none = Ningún modelo seleccionado chat-model-label = Modelo @@ -372,6 +373,7 @@ sidebar-filter-tags = Etiquetas sidebar-filter-categories = Categorías sidebar-filter-calendar = Archivo sidebar-filter-noResults = Sin resultados +editor-saved = Guardado. editor-title = Título editor-titlePlaceholder = Introducir título... editor-slug = Slug @@ -489,10 +491,27 @@ settings-onlineEndpointSection = Extremo en línea settings-onlineEndpointUrl = URL del extremo en línea settings-onlineEndpointModel = Modelo del extremo en línea settings-onlineApiKey = Clave API en línea +settings-airplaneApiKey = Clave API local (opcional) settings-airplaneEndpointSection = Extremo de modo avión settings-airplaneEndpointUrl = URL del extremo de modo avión settings-airplaneEndpointModel = Modelo del extremo de modo avión settings-refreshModels = Actualizar modelos +settings-chatModel = Modelo de chat +settings-modelSupportsTools = El modelo de chat seleccionado admite llamadas a herramientas +settings-modelSupportsVision = El modelo de imagen seleccionado admite visión +settings-testChat = Probar chat +settings-modelsLoaded = Modelos cargados. +settings-testChatSuccess = Los modelos seleccionados respondieron correctamente. +settings-onlineChatModel = Modelo de chat en línea +settings-onlineTitleModel = Modelo de títulos en línea +settings-onlineImageModel = Modelo de imágenes en línea +settings-onlineModelSupportsTools = El modelo de chat en línea admite llamadas a herramientas +settings-onlineModelSupportsVision = El modelo de imágenes en línea admite visión +settings-airplaneChatModel = Modelo de chat local +settings-airplaneTitleModel = Modelo de títulos local +settings-airplaneImageModel = Modelo de imágenes local +settings-airplaneModelSupportsTools = El modelo de chat local admite llamadas a herramientas +settings-airplaneModelSupportsVision = El modelo de imágenes local admite visión settings-defaultModel = Modelo predeterminado settings-titleModel = Modelo de títulos settings-imageAnalysisModel = Modelo de análisis de imágenes diff --git a/locales/ui/fr.ftl b/locales/ui/fr.ftl index 2bd3f1b..869213b 100644 --- a/locales/ui/fr.ftl +++ b/locales/ui/fr.ftl @@ -119,6 +119,7 @@ chat-new = Nouvelle conversation chat-newWithModel = Conversation avec { $model } chat-unavailable-title = L’IA conversationnelle est indisponible chat-unavailable-guidance = Configurez le point d’accès IA actif et le modèle dans les réglages. Le mode avion utilise uniquement le point d’accès local. +chat-providerError = Erreur du fournisseur d’IA : chat-unavailable-openSettings = Ouvrir les réglages IA chat-model-none = Aucun modèle sélectionné chat-model-label = Modèle @@ -372,6 +373,7 @@ sidebar-filter-tags = Tags sidebar-filter-categories = Catégories sidebar-filter-calendar = Archives sidebar-filter-noResults = Aucun résultat +editor-saved = Enregistré. editor-title = Titre editor-titlePlaceholder = Saisir le titre... editor-slug = Slug @@ -489,10 +491,27 @@ settings-onlineEndpointSection = Point de terminaison en ligne settings-onlineEndpointUrl = URL du point de terminaison en ligne settings-onlineEndpointModel = Modèle du point de terminaison en ligne settings-onlineApiKey = Clé API en ligne +settings-airplaneApiKey = Clé API locale (facultative) settings-airplaneEndpointSection = Point de terminaison mode avion settings-airplaneEndpointUrl = URL du point de terminaison mode avion settings-airplaneEndpointModel = Modèle du point de terminaison mode avion settings-refreshModels = Rafraîchir les modèles +settings-chatModel = Modèle de discussion +settings-modelSupportsTools = Le modèle de discussion sélectionné prend en charge les appels d’outils +settings-modelSupportsVision = Le modèle d’image sélectionné prend en charge la vision +settings-testChat = Tester la discussion +settings-modelsLoaded = Modèles chargés. +settings-testChatSuccess = Les modèles sélectionnés ont répondu correctement. +settings-onlineChatModel = Modèle de discussion en ligne +settings-onlineTitleModel = Modèle de titre en ligne +settings-onlineImageModel = Modèle d’image en ligne +settings-onlineModelSupportsTools = Le modèle de discussion en ligne prend en charge les appels d’outils +settings-onlineModelSupportsVision = Le modèle d’image en ligne prend en charge la vision +settings-airplaneChatModel = Modèle de discussion local +settings-airplaneTitleModel = Modèle de titre local +settings-airplaneImageModel = Modèle d’image local +settings-airplaneModelSupportsTools = Le modèle de discussion local prend en charge les appels d’outils +settings-airplaneModelSupportsVision = Le modèle d’image local prend en charge la vision settings-defaultModel = Modèle par défaut settings-titleModel = Modèle de titres settings-imageAnalysisModel = Modèle d'analyse d'image diff --git a/locales/ui/it.ftl b/locales/ui/it.ftl index 45d8d5d..f1b426e 100644 --- a/locales/ui/it.ftl +++ b/locales/ui/it.ftl @@ -119,6 +119,7 @@ chat-new = Nuova conversazione chat-newWithModel = Conversazione con { $model } chat-unavailable-title = L’IA conversazionale non è disponibile chat-unavailable-guidance = Configura l’endpoint IA attivo e il modello nelle impostazioni. La modalità aereo usa solo l’endpoint locale. +chat-providerError = Errore del provider IA: chat-unavailable-openSettings = Apri impostazioni IA chat-model-none = Nessun modello selezionato chat-model-label = Modello @@ -372,6 +373,7 @@ sidebar-filter-tags = Tag sidebar-filter-categories = Categorie sidebar-filter-calendar = Archivio sidebar-filter-noResults = Nessun risultato +editor-saved = Salvato. editor-title = Titolo editor-titlePlaceholder = Inserisci titolo... editor-slug = Slug @@ -489,10 +491,27 @@ settings-onlineEndpointSection = Endpoint online settings-onlineEndpointUrl = URL endpoint online settings-onlineEndpointModel = Modello endpoint online settings-onlineApiKey = Chiave API online +settings-airplaneApiKey = Chiave API locale (facoltativa) settings-airplaneEndpointSection = Endpoint modalità aereo settings-airplaneEndpointUrl = URL endpoint modalità aereo settings-airplaneEndpointModel = Modello endpoint modalità aereo settings-refreshModels = Aggiorna modelli +settings-chatModel = Modello chat +settings-modelSupportsTools = Il modello chat selezionato supporta le chiamate agli strumenti +settings-modelSupportsVision = Il modello immagini selezionato supporta la visione +settings-testChat = Prova chat +settings-modelsLoaded = Modelli caricati. +settings-testChatSuccess = I modelli selezionati hanno risposto correttamente. +settings-onlineChatModel = Modello chat online +settings-onlineTitleModel = Modello titoli online +settings-onlineImageModel = Modello immagini online +settings-onlineModelSupportsTools = Il modello chat online supporta le chiamate agli strumenti +settings-onlineModelSupportsVision = Il modello immagini online supporta la visione +settings-airplaneChatModel = Modello chat locale +settings-airplaneTitleModel = Modello titoli locale +settings-airplaneImageModel = Modello immagini locale +settings-airplaneModelSupportsTools = Il modello chat locale supporta le chiamate agli strumenti +settings-airplaneModelSupportsVision = Il modello immagini locale supporta la visione settings-defaultModel = Modello predefinito settings-titleModel = Modello titoli settings-imageAnalysisModel = Modello analisi immagini diff --git a/specs/ai.allium b/specs/ai.allium index 22f332d..25cc6bc 100644 --- a/specs/ai.allium +++ b/specs/ai.allium @@ -12,8 +12,12 @@ use "./media.allium" as media entity AiEndpoint { kind: online | airplane url: String - api_key: String? -- encrypted via SecureKeyStore; null for local models - model: String + api_key: String? -- encrypted via SecureKeyStore; required online, optional locally + model: String -- chat model + title_model: String? + image_model: String? + chat_supports_tools: Boolean? -- operator override of discovered capability + image_supports_vision: Boolean? -- operator override of discovered capability -- online: cloud provider (OpenAI, Anthropic-via-proxy, etc.) -- airplane: local model (Ollama, LM Studio, etc.) } @@ -81,6 +85,10 @@ surface AiEndpointSurface { endpoint.url endpoint.api_key when endpoint.api_key != null endpoint.model + endpoint.title_model when endpoint.title_model != null + endpoint.image_model when endpoint.image_model != null + endpoint.chat_supports_tools when endpoint.chat_supports_tools != null + endpoint.image_supports_vision when endpoint.image_supports_vision != null } surface AiModelSurface { @@ -177,8 +185,10 @@ surface AiConfigurationSurface { facing _: AiOperator provides: - SetAiEndpointRequested(kind, url, api_key, model) + SetAiEndpointRequested(kind, url, api_key, model, title_model, image_model, chat_supports_tools, image_supports_vision) RemoveAiEndpointRequested(kind) + RefreshEndpointModelsRequested(kind, url, api_key) + TestEndpointModelsRequested(kind) RefreshModelCatalogRequested(source) } @@ -221,13 +231,17 @@ config { } rule SetAiEndpoint { - when: SetAiEndpointRequested(kind, url, api_key, model) + when: SetAiEndpointRequested(kind, url, api_key, model, title_model, image_model, chat_supports_tools, image_supports_vision) ensures: let endpoint = AiEndpoint.created( kind: kind, url: url, api_key: api_key, - model: model + model: model, + title_model: title_model, + image_model: image_model, + chat_supports_tools: chat_supports_tools, + image_supports_vision: image_supports_vision ) endpoint.kind = kind } @@ -335,6 +349,22 @@ rule RefreshModelCatalog { ensures: ModelCatalogUpdated() } +rule RefreshEndpointModels { + when: RefreshEndpointModelsRequested(kind, url, api_key) + requires: url != "" + requires: kind = airplane or api_key != null + -- Discovery calls GET /models and does not require a selected model. + ensures: EndpointModelsLoaded(kind) +} + +rule TestEndpointModels { + when: TestEndpointModelsRequested(kind) + requires: exists endpoint in AiEndpoints where endpoint.kind = kind + -- Sends a minimal chat-completions request to each distinct configured + -- chat, title, and image model for this endpoint. + ensures: EndpointModelsResponded(kind) +} + invariant AirplaneModeGating { -- Endpoint routing based on airplane (offline) mode: -- airplane_mode = true -> use airplane endpoint (local model) @@ -347,8 +377,8 @@ invariant AirplaneModeGating { invariant AirplaneModeModelSwap { -- In airplane mode, cloud models are never contacted. - -- Chat uses the configured offline chat model when needed. - -- Image analysis uses the configured offline vision-capable model when needed. + -- Chat, title generation, and image analysis use the models and capability + -- overrides configured for the active endpoint profile. -- If no suitable offline model is configured, the operation fails with -- actionable guidance instead of silently falling back to the online endpoint. } @@ -356,9 +386,11 @@ invariant AirplaneModeModelSwap { invariant TwoEndpointModel { -- Two configurable OpenAI-compatible endpoints: -- online: for cloud providers (requires API key) - -- airplane: for local models (no API key required) + -- airplane: for local models (API key optional) -- Both use the OpenAI Chat Completions wire format. - -- Endpoint selection is configurable rather than tied to hard-coded providers. + -- URL, credentials, chat/title/image models, and capability overrides are + -- independent. Endpoint selection is controlled only by the status-bar + -- airplane-mode switch, not by a duplicate Settings toggle. } invariant AdvisoryModelCatalog { @@ -387,6 +419,12 @@ invariant VisionCapabilityGate { -- before the runtime sends multimodal requests to them. } +invariant OperatorCapabilityOverrides { + -- Discovered tool and vision capabilities prefill the profile controls. + -- Explicit operator overrides take precedence at runtime so models with + -- incomplete metadata can enable or disable tools and image input safely. +} + invariant ChatContextTruncation { -- Chat requests are trimmed to fit within the selected model's context window. -- Oldest user/assistant pairs are dropped first. diff --git a/specs/editor_settings.allium b/specs/editor_settings.allium index 115ebd1..c0939cf 100644 --- a/specs/editor_settings.allium +++ b/specs/editor_settings.allium @@ -53,17 +53,21 @@ value SettingsCategoryRow { } value SettingsAISection { - anthropic_api_key: String? -- masked + Change/Save - mistral_api_key: String? -- masked + Change/Save - ollama_enabled: Boolean -- toggle, shows model capabilities table (tools/vision) - lm_studio_enabled: Boolean -- toggle, shows model capabilities table - offline_mode: Boolean -- toggle, switches between online and airplane endpoint - default_model: String? -- select from active endpoint models - title_model: String? -- select - image_analysis_model: String? -- select (vision-capable only) + online: SettingsAIEndpoint + airplane: SettingsAIEndpoint system_prompt: String -- textarea (12 rows) + Save + Reset to Default } +value SettingsAIEndpoint { + url: String + api_key: String? -- masked; optional for airplane endpoint + chat_model: String? + title_model: String? + image_model: String? + chat_supports_tools: Boolean + image_supports_vision: Boolean +} + value SettingsTechnologySection { semantic_similarity_enabled: Boolean -- checkbox: enable duplicate search + related-post embeddings -- Scripting runtime is fixed at the application layer; no runtime switch is exposed. @@ -130,8 +134,9 @@ surface SettingsViewSurface { SettingsCategoryUpdated(category_row) SettingsCategoryRemoved(category_name) SettingsCategoriesResetToDefaults() - SettingsAIApiKeySaved(provider, key) - SettingsAIModelRefreshRequested(endpoint) + SettingsAIProfileSaved(kind, endpoint) + SettingsAIModelRefreshRequested(kind, url, api_key) + SettingsAIChatTestRequested(kind) SettingsAISystemPromptSaved(prompt) SettingsAISystemPromptReset() SettingsPublishingSaved(publishing_data) @@ -179,23 +184,27 @@ surface SettingsViewSurface { -- "Reset to Defaults" restores default categories set. @guarantee AISection - -- Section 4: Anthropic API key, Mistral API key (both masked + Change/Save), - -- Ollama toggle (with model capabilities table: tools/vision), - -- LM Studio toggle (with capabilities table), - -- Offline mode toggle (with dedicated model selectors for chat/title/image), - -- Default model (with catalog info: max output, context window), - -- Title model, Image analysis model, + -- Section 4: independent Online and Airplane endpoint cards. Each has + -- URL, masked API key (optional locally), Refresh Models, chat/title/image + -- selectors, explicit tools/vision capability overrides, and Test Chat. + -- Settings has no airplane-mode toggle; the status-bar switch selects + -- the active profile and immediately swaps chat/model routing. -- System prompt (textarea config.settings_system_prompt_rows rows + Save + Reset). @guarantee AIApiKeyValidation - -- On Save: test API call to endpoint before persisting. - -- On failure: toast error message, key not saved. - -- On success: key encrypted via SecureKeyStore, masked display shown. + -- API keys are encrypted via SecureKeyStore and displayed masked. + -- Online profiles require a key; airplane profiles permit authenticated + -- or unauthenticated local OpenAI-compatible endpoints. @guarantee AIModelRefresh -- Refresh Models button per endpoint fetches model list from URL. - -- Model selector populated from fetched list. - -- Per-model info: max output tokens, context window (when available). + -- Discovery requires URL and online credentials but no preselected model. + -- All three role selectors are populated independently from the result; + -- advertised capability metadata prefills tools and vision overrides. + + @guarantee AIChatTest + -- Test Chat sends a minimal request through every distinct model selected + -- for chat, title generation, or image analysis in the chosen profile. @guarantee TechnologySection -- Section 5: Semantic Similarity toggle