feat: more completion for M4
This commit is contained in:
@@ -2,6 +2,7 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use base64::Engine as _;
|
||||
use chrono::Datelike;
|
||||
use iced::{Element, Subscription, Task, window};
|
||||
use rusqlite::Error as SqlError;
|
||||
@@ -6815,9 +6816,20 @@ impl BdsApp {
|
||||
self.notify(ToastLevel::Error, "database unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(data_dir) = &self.data_dir else {
|
||||
self.notify(ToastLevel::Error, "project data directory unavailable");
|
||||
return Task::none();
|
||||
};
|
||||
let Some(state) = self.media_editors.get(media_id).cloned() else {
|
||||
return Task::none();
|
||||
};
|
||||
let image_data_url = match build_ai_image_data_url(data_dir, &state.media_id, &state.file_path, &state.mime_type) {
|
||||
Ok(value) => value,
|
||||
Err(error) => {
|
||||
self.notify(ToastLevel::Error, &error);
|
||||
return Task::none();
|
||||
}
|
||||
};
|
||||
let request = ai::OneShotRequest {
|
||||
operation: ai::OneShotOperation::AnalyzeImage,
|
||||
content: json!({
|
||||
@@ -6826,6 +6838,7 @@ impl BdsApp {
|
||||
"caption": state.caption,
|
||||
"filename": state.original_name,
|
||||
"mime_type": state.mime_type,
|
||||
"image_data_url": image_data_url,
|
||||
}),
|
||||
};
|
||||
match ai::run_one_shot(db.conn(), self.offline_mode, &request) {
|
||||
@@ -7043,6 +7056,35 @@ fn content_sample(content: &str, max_len: usize) -> String {
|
||||
content.chars().take(max_len).collect()
|
||||
}
|
||||
|
||||
fn build_ai_image_data_url(
|
||||
data_dir: &Path,
|
||||
media_id: &str,
|
||||
file_path: &str,
|
||||
mime_type: &str,
|
||||
) -> Result<String, String> {
|
||||
if !mime_type.starts_with("image/") {
|
||||
return Err("AI image analysis requires an image".to_string());
|
||||
}
|
||||
|
||||
let source_path = data_dir.join(file_path.trim_start_matches('/'));
|
||||
let thumbnail_relative = bds_core::util::thumbnail_path(media_id, "ai", "jpg");
|
||||
let thumbnail_path = data_dir.join(&thumbnail_relative);
|
||||
|
||||
if !thumbnail_path.exists() {
|
||||
bds_core::util::thumbnail::generate_all_thumbnails(
|
||||
&source_path,
|
||||
&data_dir.join("thumbnails"),
|
||||
media_id,
|
||||
)
|
||||
.map_err(|error| format!("failed to generate AI thumbnail: {error}"))?;
|
||||
}
|
||||
|
||||
let bytes = std::fs::read(&thumbnail_path)
|
||||
.map_err(|error| format!("failed to read AI thumbnail: {error}"))?;
|
||||
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
|
||||
Ok(format!("data:image/jpeg;base64,{encoded}"))
|
||||
}
|
||||
|
||||
fn split_csv_values(value: &str) -> Vec<String> {
|
||||
value
|
||||
.split(',')
|
||||
|
||||
@@ -222,6 +222,7 @@ pub fn view<'a>(
|
||||
state: &'a MediaEditorState,
|
||||
locale: UiLocale,
|
||||
data_dir: Option<&Path>,
|
||||
ai_enabled: bool,
|
||||
) -> Element<'a, Message> {
|
||||
let header = inputs::toolbar(
|
||||
vec![
|
||||
@@ -230,18 +231,18 @@ pub fn view<'a>(
|
||||
vec![
|
||||
if state.mime_type.starts_with("image/") {
|
||||
button(text(t(locale, "editor.aiAnalyze")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::AnalyzeWithAi)))
|
||||
.padding([6, 16])
|
||||
.into()
|
||||
} else {
|
||||
Space::new(0, 0).into()
|
||||
},
|
||||
button(text(t(locale, "editor.detectLanguage")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::DetectLanguage))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::DetectLanguage)))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "editor.translate")).size(13))
|
||||
.on_press(Message::MediaEditor(MediaEditorMsg::TranslateMetadata))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::MediaEditor(MediaEditorMsg::TranslateMetadata)))
|
||||
.padding([6, 16])
|
||||
.into(),
|
||||
button(text(t(locale, "common.save")).size(13))
|
||||
|
||||
@@ -372,6 +372,7 @@ pub fn view<'a>(
|
||||
state: &'a PostEditorState,
|
||||
locale: UiLocale,
|
||||
word_wrap: bool,
|
||||
ai_enabled: bool,
|
||||
preview_widget: Option<Element<'a, Message>>,
|
||||
) -> Element<'a, Message> {
|
||||
let on_translation = state.active_language != state.canonical_language;
|
||||
@@ -389,7 +390,7 @@ pub fn view<'a>(
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced),
|
||||
)
|
||||
.on_press(Message::PostEditor(PostEditorMsg::ToggleQuickActions))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::ToggleQuickActions)))
|
||||
.padding([6, 16])
|
||||
.style(status_bar::dropdown_trigger)
|
||||
.into();
|
||||
@@ -479,10 +480,10 @@ pub fn view<'a>(
|
||||
Space::with_width(Length::Fill),
|
||||
container(
|
||||
column![
|
||||
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi),
|
||||
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy),
|
||||
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate),
|
||||
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage),
|
||||
quick_action_item(locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi, ai_enabled),
|
||||
quick_action_item(locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy, ai_enabled),
|
||||
quick_action_item(locale, t(locale, "editor.translate"), PostEditorMsg::Translate, ai_enabled),
|
||||
quick_action_item(locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage, ai_enabled),
|
||||
]
|
||||
.spacing(4)
|
||||
)
|
||||
@@ -574,7 +575,7 @@ pub fn view<'a>(
|
||||
|lang| Message::PostEditor(PostEditorMsg::LanguageChanged(lang)),
|
||||
);
|
||||
let detect_language = button(text(t(locale, "editor.detectLanguage")).size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(PostEditorMsg::DetectLanguage))
|
||||
.on_press_maybe(ai_enabled.then_some(Message::PostEditor(PostEditorMsg::DetectLanguage)))
|
||||
.padding([6, 12]);
|
||||
let template_input = inputs::labeled_input(
|
||||
&t(locale, "editor.templateSlug"),
|
||||
@@ -979,10 +980,10 @@ fn mode_button<'a>(
|
||||
.into()
|
||||
}
|
||||
|
||||
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg) -> Element<'a, Message> {
|
||||
fn quick_action_item<'a>(locale: UiLocale, label: String, msg: PostEditorMsg, enabled: bool) -> Element<'a, Message> {
|
||||
let _ = locale;
|
||||
button(text(label).size(12).shaping(Shaping::Advanced))
|
||||
.on_press(Message::PostEditor(msg))
|
||||
.on_press_maybe(enabled.then_some(Message::PostEditor(msg)))
|
||||
.padding([6, 12])
|
||||
.style(status_bar::dropdown_item)
|
||||
.width(Length::Fixed(220.0))
|
||||
|
||||
@@ -124,6 +124,7 @@ pub fn view<'a>(
|
||||
tabs,
|
||||
active_tab,
|
||||
locale,
|
||||
offline_mode,
|
||||
data_dir,
|
||||
post_preview_widget,
|
||||
post_editors,
|
||||
@@ -347,6 +348,7 @@ fn route_content_area<'a>(
|
||||
tabs: &'a [Tab],
|
||||
active_tab: Option<&'a str>,
|
||||
locale: UiLocale,
|
||||
offline_mode: bool,
|
||||
data_dir: Option<&'a Path>,
|
||||
post_preview_widget: Option<Element<'a, Message>>,
|
||||
post_editors: &'a HashMap<String, PostEditorState>,
|
||||
@@ -376,14 +378,14 @@ fn route_content_area<'a>(
|
||||
ContentRoute::Post(tab_id) => {
|
||||
if let Some(state) = post_editors.get(tab_id) {
|
||||
let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(true);
|
||||
post_editor::view(state, locale, wrap, post_preview_widget)
|
||||
post_editor::view(state, locale, wrap, is_ai_enabled(settings_state, offline_mode), post_preview_widget)
|
||||
} else {
|
||||
loading_view(locale)
|
||||
}
|
||||
}
|
||||
ContentRoute::Media(tab_id) => {
|
||||
if let Some(state) = media_editors.get(tab_id) {
|
||||
media_editor::view(state, locale, data_dir)
|
||||
media_editor::view(state, locale, data_dir, is_ai_enabled(settings_state, offline_mode))
|
||||
} else {
|
||||
loading_view(locale)
|
||||
}
|
||||
@@ -488,6 +490,20 @@ fn route_kind<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_ai_enabled(settings_state: Option<&SettingsViewState>, offline_mode: bool) -> bool {
|
||||
let Some(state) = settings_state else {
|
||||
return false;
|
||||
};
|
||||
|
||||
if offline_mode {
|
||||
!state.airplane_endpoint_url.trim().is_empty() && !state.airplane_endpoint_model.trim().is_empty()
|
||||
} 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())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -597,6 +613,23 @@ mod tests {
|
||||
_ => panic!("expected site validation route"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ai_actions_require_configured_endpoint_for_current_mode() {
|
||||
let mut settings = SettingsViewState::default();
|
||||
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;
|
||||
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();
|
||||
assert!(is_ai_enabled(Some(&settings), true));
|
||||
}
|
||||
}
|
||||
|
||||
fn loading_view<'a>(locale: UiLocale) -> Element<'a, Message> {
|
||||
|
||||
Reference in New Issue
Block a user