Separate online and airplane AI profiles.

This commit is contained in:
2026-07-20 11:52:00 +02:00
parent 3739da0984
commit 2d6fcafc7e
21 changed files with 1147 additions and 538 deletions

View File

@@ -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<ChatModelChoice> {
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::<Vec<_>>()
})
.collect::<Vec<_>>();
.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<Message> {
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::<Vec<_>>();
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::<std::collections::BTreeSet<_>>();
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<AiEndpointConfig, String> {
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();

View File

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

View File

@@ -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<AiModelOption>,
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<AiModelOption>,
pub airplane_endpoint_url: String,
pub airplane_endpoint_model: String,
pub airplane_model_options: Vec<AiModelOption>,
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::<Vec<_>>();
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::<Vec<_>>();
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()
}

View File

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

View File

@@ -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",