Separate online and airplane AI profiles.
This commit is contained in:
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user