Merge pull request 'main' (#98) from main into master

Reviewed-on: #98
This commit was merged in pull request #98.
This commit is contained in:
2026-07-21 08:13:16 +00:00
4 changed files with 304 additions and 211 deletions

View File

@@ -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. - `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.
- bDS2-compatible Markdown/Liquid rendering with native macros, canonical multilingual and flat page routes, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation through cancellable section task groups. - bDS2-compatible Markdown/Liquid rendering with native macros, canonical multilingual and flat page routes, recursive menus, calendar archives, feeds, a root hreflang sitemap, Pagefind, and incremental site generation through cancellable section task groups.
- Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content. - Navigable generated-route preview in the app or system browser, with draft database overlays and published filesystem content.
- 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, persistently discovered chat/title/image model selections, explicit tool/vision overrides, chat testing, and status-bar airplane-mode routing. - Optional one-shot AI translation, description, analysis, taxonomy, and language-detection operations run in background tasks with editor-level waiting indicators, using independent online and local OpenAI-compatible profiles. Each profile has secure credentials, persistently 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. - 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. - 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. - 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.

View File

@@ -69,6 +69,16 @@ mod tasks;
// ─────────────────────────────────────────────────────────── // ───────────────────────────────────────────────────────────
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum OneShotAiAction {
PostAnalysis,
PostTaxonomy,
PostLanguage,
PostTranslation { target_language: String },
MediaAnalysis,
MediaLanguage,
MediaTranslation { target_language: String },
}
pub enum Message { pub enum Message {
// Menu // Menu
MenuEvent(muda::MenuId), MenuEvent(muda::MenuId),
@@ -230,6 +240,12 @@ pub enum Message {
ToggleAiSuggestionField(usize, bool), ToggleAiSuggestionField(usize, bool),
ApplyAiSuggestions(modal::AiEntityTarget, Vec<modal::AiSuggestionField>), ApplyAiSuggestions(modal::AiEntityTarget, Vec<modal::AiSuggestionField>),
OneShotAiFinished {
entity_id: String,
action: OneShotAiAction,
result: Result<ai::OneShotResponse, String>,
},
// Blog actions (dispatched to engine) // Blog actions (dispatched to engine)
RebuildDatabase, RebuildDatabase,
ReindexText, ReindexText,
@@ -3063,6 +3079,11 @@ impl BdsApp {
self.active_modal = None; self.active_modal = None;
self.apply_ai_suggestions(target, &fields) self.apply_ai_suggestions(target, &fields)
} }
Message::OneShotAiFinished {
entity_id,
action,
result,
} => self.finish_one_shot_ai(&entity_id, action, result),
// ── Editor view messages ── // ── Editor view messages ──
Message::PostEditor(msg) => self.handle_post_editor_msg(msg), Message::PostEditor(msg) => self.handle_post_editor_msg(msg),
@@ -8972,14 +8993,34 @@ impl BdsApp {
Task::none() Task::none()
} }
fn start_one_shot_ai(
&self,
entity_id: String,
action: OneShotAiAction,
request: ai::OneShotRequest,
) -> Task<Message> {
let db_path = self.db_path.clone();
let offline_mode = self.offline_mode;
Task::perform(
async move {
tokio::task::spawn_blocking(move || {
let db = Database::open(&db_path).map_err(|error| error.to_string())?;
ai::run_one_shot(db.conn(), offline_mode, &request)
.map(|(response, _usage)| response)
.map_err(|error| error.to_string())
})
.await
.unwrap_or_else(|error| Err(format!("task panicked: {error}")))
},
move |result| Message::OneShotAiFinished {
entity_id: entity_id.clone(),
action: action.clone(),
result,
},
)
}
fn run_post_ai_analysis(&mut self, post_id: &str) -> Task<Message> { fn run_post_ai_analysis(&mut self, post_id: &str) -> Task<Message> {
let Some(db) = &self.db else {
self.notify(
ToastLevel::Error,
&t(self.ui_locale, "common.databaseUnavailable"),
);
return Task::none();
};
let Some(state) = self.post_editors.get(post_id).cloned() else { let Some(state) = self.post_editors.get(post_id).cloned() else {
return Task::none(); return Task::none();
}; };
@@ -8991,52 +9032,13 @@ impl BdsApp {
"content": content_sample(&state.content, 2000), "content": content_sample(&state.content, 2000),
}), }),
}; };
match ai::run_one_shot(db.conn(), self.offline_mode, &request) { if let Some(editor) = self.post_editors.get_mut(post_id) {
Ok((ai::OneShotResponse::PostAnalysis(result), _usage)) => { editor.ai_activity = Some(t(self.ui_locale, "editor.aiAnalyze"));
self.active_modal = Some(modal::ModalState::AISuggestions {
target: modal::AiEntityTarget::Post(post_id.to_string()),
fields: vec![
modal::AiSuggestionField {
key: "title".to_string(),
label: t(self.ui_locale, "editor.title"),
current_value: state.title,
suggested_value: result.title,
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "excerpt".to_string(),
label: t(self.ui_locale, "editor.excerpt"),
current_value: state.excerpt,
suggested_value: result.excerpt,
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "slug".to_string(),
label: t(self.ui_locale, "editor.slug"),
current_value: state.slug,
suggested_value: result.slug,
accepted: state.published_at.is_none(),
locked: state.published_at.is_some(),
},
],
});
}
Ok(_) => {}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
} }
Task::none() self.start_one_shot_ai(post_id.to_string(), OneShotAiAction::PostAnalysis, request)
} }
fn run_post_taxonomy_analysis(&mut self, post_id: &str) -> Task<Message> { fn run_post_taxonomy_analysis(&mut self, post_id: &str) -> Task<Message> {
let Some(db) = &self.db else {
self.notify(
ToastLevel::Error,
&t(self.ui_locale, "common.databaseUnavailable"),
);
return Task::none();
};
let Some(state) = self.post_editors.get(post_id).cloned() else { let Some(state) = self.post_editors.get(post_id).cloned() else {
return Task::none(); return Task::none();
}; };
@@ -9050,44 +9052,13 @@ impl BdsApp {
"categories": state.categories, "categories": state.categories,
}), }),
}; };
match ai::run_one_shot(db.conn(), self.offline_mode, &request) { if let Some(editor) = self.post_editors.get_mut(post_id) {
Ok((ai::OneShotResponse::Taxonomy(result), _usage)) => { editor.ai_activity = Some(t(self.ui_locale, "editor.suggestTaxonomy"));
self.active_modal = Some(modal::ModalState::AISuggestions {
target: modal::AiEntityTarget::Post(post_id.to_string()),
fields: vec![
modal::AiSuggestionField {
key: "tags".to_string(),
label: t(self.ui_locale, "sidebar.filter.tags"),
current_value: state.tags.join(", "),
suggested_value: result.tags.join(", "),
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "categories".to_string(),
label: t(self.ui_locale, "sidebar.filter.categories"),
current_value: state.categories.join(", "),
suggested_value: result.categories.join(", "),
accepted: true,
locked: false,
},
],
});
}
Ok(_) => {}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
} }
Task::none() self.start_one_shot_ai(post_id.to_string(), OneShotAiAction::PostTaxonomy, request)
} }
fn detect_post_language(&mut self, post_id: &str) -> Task<Message> { fn detect_post_language(&mut self, post_id: &str) -> Task<Message> {
let Some(db) = &self.db else {
self.notify(
ToastLevel::Error,
&t(self.ui_locale, "common.databaseUnavailable"),
);
return Task::none();
};
let Some(state) = self.post_editors.get(post_id).cloned() else { let Some(state) = self.post_editors.get(post_id).cloned() else {
return Task::none(); return Task::none();
}; };
@@ -9097,22 +9068,10 @@ impl BdsApp {
"text": format!("{}\n\n{}\n\n{}", state.title, state.excerpt, content_sample(&state.content, 2000)), "text": format!("{}\n\n{}\n\n{}", state.title, state.excerpt, content_sample(&state.content, 2000)),
}), }),
}; };
match ai::run_one_shot(db.conn(), self.offline_mode, &request) { if let Some(editor) = self.post_editors.get_mut(post_id) {
Ok((ai::OneShotResponse::LanguageDetection(result), _usage)) => { editor.ai_activity = Some(t(self.ui_locale, "editor.detectLanguage"));
if let Some(editor) = self.post_editors.get_mut(post_id) {
editor.language = result.language_code;
editor.mark_dirty();
}
if let Err(error) = self.persist_post_editor_state(post_id) {
self.notify(ToastLevel::Error, &error);
} else {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
}
Ok(_) => {}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
} }
Task::none() self.start_one_shot_ai(post_id.to_string(), OneShotAiAction::PostLanguage, request)
} }
fn open_post_translation_modal(&mut self, post_id: &str) -> Task<Message> { fn open_post_translation_modal(&mut self, post_id: &str) -> Task<Message> {
@@ -9207,27 +9166,16 @@ impl BdsApp {
"content": state.content, "content": state.content,
}), }),
}; };
match ai::run_one_shot(db.conn(), self.offline_mode, &request) { if let Some(editor) = self.post_editors.get_mut(post_id) {
Ok((ai::OneShotResponse::Translation(result), _usage)) => { editor.ai_activity = Some(t(self.ui_locale, "editor.translate"));
if let Some(editor) = self.post_editors.get_mut(post_id) {
editor.switch_language(target_language);
editor.title = result.title.clone();
editor.excerpt = result.excerpt.clone();
editor.content = result.content.clone();
editor.editor_buffer =
std::cell::RefCell::new(bds_editor::EditorBuffer::new(&result.content));
editor.mark_dirty();
}
if let Err(error) = self.persist_post_editor_state(post_id) {
self.notify(ToastLevel::Error, &error);
} else {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
}
Ok(_) => {}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
} }
Task::none() self.start_one_shot_ai(
post_id.to_string(),
OneShotAiAction::PostTranslation {
target_language: target_language.to_string(),
},
request,
)
} }
fn run_media_ai_analysis(&mut self, media_id: &str) -> Task<Message> { fn run_media_ai_analysis(&mut self, media_id: &str) -> Task<Message> {
@@ -9271,42 +9219,14 @@ impl BdsApp {
"image_data_url": image_data_url, "image_data_url": image_data_url,
}), }),
}; };
match ai::run_one_shot(db.conn(), self.offline_mode, &request) { if let Some(editor) = self.media_editors.get_mut(media_id) {
Ok((ai::OneShotResponse::ImageAnalysis(result), _usage)) => { editor.ai_activity = Some(t(self.ui_locale, "editor.aiAnalyze"));
self.active_modal = Some(modal::ModalState::AISuggestions {
target: modal::AiEntityTarget::Media(media_id.to_string()),
fields: vec![
modal::AiSuggestionField {
key: "title".to_string(),
label: t(self.ui_locale, "editor.title"),
current_value: state.title,
suggested_value: result.title,
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "alt".to_string(),
label: t(self.ui_locale, "editor.alt"),
current_value: state.alt,
suggested_value: result.alt,
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "caption".to_string(),
label: t(self.ui_locale, "editor.caption"),
current_value: state.caption,
suggested_value: result.caption,
accepted: true,
locked: false,
},
],
});
}
Ok(_) => {}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
} }
Task::none() self.start_one_shot_ai(
media_id.to_string(),
OneShotAiAction::MediaAnalysis,
request,
)
} }
fn detect_media_language(&mut self, media_id: &str) -> Task<Message> { fn detect_media_language(&mut self, media_id: &str) -> Task<Message> {
@@ -9326,22 +9246,14 @@ impl BdsApp {
"text": format!("{}\n{}\n{}", state.title, state.alt, state.caption), "text": format!("{}\n{}\n{}", state.title, state.alt, state.caption),
}), }),
}; };
match ai::run_one_shot(db.conn(), self.offline_mode, &request) { if let Some(editor) = self.media_editors.get_mut(media_id) {
Ok((ai::OneShotResponse::LanguageDetection(result), _usage)) => { editor.ai_activity = Some(t(self.ui_locale, "editor.detectLanguage"));
if let Some(editor) = self.media_editors.get_mut(media_id) {
editor.language = result.language_code;
editor.is_dirty = true;
}
if let Err(error) = self.persist_media_editor_state(media_id) {
self.notify(ToastLevel::Error, &error);
} else {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
}
Ok(_) => {}
Err(error) => self.notify(ToastLevel::Error, &error.to_string()),
} }
Task::none() self.start_one_shot_ai(
media_id.to_string(),
OneShotAiAction::MediaLanguage,
request,
)
} }
fn open_media_translation_modal(&mut self, media_id: &str) -> Task<Message> { fn open_media_translation_modal(&mut self, media_id: &str) -> Task<Message> {
@@ -9385,31 +9297,10 @@ impl BdsApp {
let Some(state) = self.media_editors.get(media_id).cloned() else { let Some(state) = self.media_editors.get(media_id).cloned() else {
return Task::none(); return Task::none();
}; };
let source_language = if state.language.is_empty() {
match ai::run_one_shot(
db.conn(),
self.offline_mode,
&ai::OneShotRequest {
operation: ai::OneShotOperation::DetectLanguage,
content: json!({ "text": format!("{}\n{}\n{}", state.title, state.alt, state.caption) }),
},
) {
Ok((ai::OneShotResponse::LanguageDetection(result), _usage)) => {
result.language_code
}
Ok(_) => String::new(),
Err(error) => {
self.notify(ToastLevel::Error, &error.to_string());
return Task::none();
}
}
} else {
state.language.clone()
};
if let Some(editor) = self.media_editors.get_mut(media_id) if let Some(editor) = self.media_editors.get_mut(media_id)
&& editor.language.is_empty() && editor.language.is_empty()
{ {
editor.language = source_language; editor.language = state.canonical_language.clone();
} }
let request = ai::OneShotRequest { let request = ai::OneShotRequest {
operation: ai::OneShotOperation::TranslateMedia { operation: ai::OneShotOperation::TranslateMedia {
@@ -9421,23 +9312,197 @@ impl BdsApp {
"caption": state.caption, "caption": state.caption,
}), }),
}; };
match ai::run_one_shot(db.conn(), self.offline_mode, &request) { if let Some(editor) = self.media_editors.get_mut(media_id) {
Ok((ai::OneShotResponse::MediaTranslation(result), _usage)) => { editor.ai_activity = Some(t(self.ui_locale, "editor.translate"));
if let Some(editor) = self.media_editors.get_mut(media_id) { }
editor.switch_language(target_language); self.start_one_shot_ai(
editor.title = result.title.clone(); media_id.to_string(),
editor.alt = result.alt.clone(); OneShotAiAction::MediaTranslation {
editor.caption = result.caption.clone(); target_language: target_language.to_string(),
editor.is_dirty = true; },
request,
)
}
fn finish_one_shot_ai(
&mut self,
entity_id: &str,
action: OneShotAiAction,
result: Result<ai::OneShotResponse, String>,
) -> Task<Message> {
if matches!(
action,
OneShotAiAction::PostAnalysis
| OneShotAiAction::PostTaxonomy
| OneShotAiAction::PostLanguage
| OneShotAiAction::PostTranslation { .. }
) {
if let Some(editor) = self.post_editors.get_mut(entity_id) {
editor.ai_activity = None;
}
} else if let Some(editor) = self.media_editors.get_mut(entity_id) {
editor.ai_activity = None;
}
let response = match result {
Ok(response) => response,
Err(error) => {
self.notify(ToastLevel::Error, &error);
return Task::none();
}
};
match (action, response) {
(OneShotAiAction::PostAnalysis, ai::OneShotResponse::PostAnalysis(result)) => {
if let Some(state) = self.post_editors.get(entity_id).cloned() {
self.active_modal = Some(modal::ModalState::AISuggestions {
target: modal::AiEntityTarget::Post(entity_id.to_string()),
fields: vec![
modal::AiSuggestionField {
key: "title".to_string(),
label: t(self.ui_locale, "editor.title"),
current_value: state.title,
suggested_value: result.title,
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "excerpt".to_string(),
label: t(self.ui_locale, "editor.excerpt"),
current_value: state.excerpt,
suggested_value: result.excerpt,
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "slug".to_string(),
label: t(self.ui_locale, "editor.slug"),
current_value: state.slug,
suggested_value: result.slug,
accepted: state.published_at.is_none(),
locked: state.published_at.is_some(),
},
],
});
} }
if let Err(error) = self.persist_media_editor_state(media_id) { }
(OneShotAiAction::PostTaxonomy, ai::OneShotResponse::Taxonomy(result)) => {
if let Some(state) = self.post_editors.get(entity_id).cloned() {
self.active_modal = Some(modal::ModalState::AISuggestions {
target: modal::AiEntityTarget::Post(entity_id.to_string()),
fields: vec![
modal::AiSuggestionField {
key: "tags".to_string(),
label: t(self.ui_locale, "sidebar.filter.tags"),
current_value: state.tags.join(", "),
suggested_value: result.tags.join(", "),
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "categories".to_string(),
label: t(self.ui_locale, "sidebar.filter.categories"),
current_value: state.categories.join(", "),
suggested_value: result.categories.join(", "),
accepted: true,
locked: false,
},
],
});
}
}
(OneShotAiAction::PostLanguage, ai::OneShotResponse::LanguageDetection(result)) => {
if let Some(editor) = self.post_editors.get_mut(entity_id) {
editor.language = result.language_code;
editor.mark_dirty();
}
if let Err(error) = self.persist_post_editor_state(entity_id) {
self.notify(ToastLevel::Error, &error); self.notify(ToastLevel::Error, &error);
} else { } else {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved")); self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
} }
} }
Ok(_) => {} (
Err(error) => self.notify(ToastLevel::Error, &error.to_string()), OneShotAiAction::PostTranslation { target_language },
ai::OneShotResponse::Translation(result),
) => {
if let Some(editor) = self.post_editors.get_mut(entity_id) {
editor.switch_language(&target_language);
editor.title = result.title.clone();
editor.excerpt = result.excerpt.clone();
editor.content = result.content.clone();
editor.editor_buffer =
std::cell::RefCell::new(bds_editor::EditorBuffer::new(&result.content));
editor.mark_dirty();
}
if let Err(error) = self.persist_post_editor_state(entity_id) {
self.notify(ToastLevel::Error, &error);
} else {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
}
(OneShotAiAction::MediaAnalysis, ai::OneShotResponse::ImageAnalysis(result)) => {
if let Some(state) = self.media_editors.get(entity_id).cloned() {
self.active_modal = Some(modal::ModalState::AISuggestions {
target: modal::AiEntityTarget::Media(entity_id.to_string()),
fields: vec![
modal::AiSuggestionField {
key: "title".to_string(),
label: t(self.ui_locale, "editor.title"),
current_value: state.title,
suggested_value: result.title,
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "alt".to_string(),
label: t(self.ui_locale, "editor.alt"),
current_value: state.alt,
suggested_value: result.alt,
accepted: true,
locked: false,
},
modal::AiSuggestionField {
key: "caption".to_string(),
label: t(self.ui_locale, "editor.caption"),
current_value: state.caption,
suggested_value: result.caption,
accepted: true,
locked: false,
},
],
});
}
}
(OneShotAiAction::MediaLanguage, ai::OneShotResponse::LanguageDetection(result)) => {
if let Some(editor) = self.media_editors.get_mut(entity_id) {
editor.language = result.language_code;
editor.is_dirty = true;
}
if let Err(error) = self.persist_media_editor_state(entity_id) {
self.notify(ToastLevel::Error, &error);
} else {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
}
(
OneShotAiAction::MediaTranslation { target_language },
ai::OneShotResponse::MediaTranslation(result),
) => {
if let Some(editor) = self.media_editors.get_mut(entity_id) {
editor.switch_language(&target_language);
editor.title = result.title.clone();
editor.alt = result.alt.clone();
editor.caption = result.caption.clone();
editor.is_dirty = true;
}
if let Err(error) = self.persist_media_editor_state(entity_id) {
self.notify(ToastLevel::Error, &error);
} else {
self.notify(ToastLevel::Success, &t(self.ui_locale, "editor.saved"));
}
}
_ => {}
} }
Task::none() Task::none()
} }

View File

@@ -61,6 +61,7 @@ pub struct MediaEditorState {
pub post_picker_search: String, pub post_picker_search: String,
pub post_picker_results: Vec<LinkedPostItem>, pub post_picker_results: Vec<LinkedPostItem>,
pub quick_actions_open: bool, pub quick_actions_open: bool,
pub ai_activity: Option<String>,
} }
impl MediaEditorState { impl MediaEditorState {
@@ -114,6 +115,7 @@ impl MediaEditorState {
post_picker_search: String::new(), post_picker_search: String::new(),
post_picker_results: Vec::new(), post_picker_results: Vec::new(),
quick_actions_open: false, quick_actions_open: false,
ai_activity: None,
} }
} }
@@ -236,7 +238,18 @@ pub fn view<'a>(
ai_enabled: bool, ai_enabled: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
let mut quick_action_items: Vec<Element<'a, Message>> = Vec::new(); let mut quick_action_items: Vec<Element<'a, Message>> = Vec::new();
if state.mime_type.starts_with("image/") { if let Some(label) = &state.ai_activity {
quick_action_items.push(
container(
text(format!("{label}"))
.size(12)
.shaping(Shaping::Advanced),
)
.padding([6, 12])
.width(Length::Fixed(220.0))
.into(),
);
} else if state.mime_type.starts_with("image/") {
quick_action_items.push(quick_action_item( quick_action_items.push(quick_action_item(
t(locale, "editor.aiAnalyze"), t(locale, "editor.aiAnalyze"),
MediaEditorMsg::AnalyzeWithAi, MediaEditorMsg::AnalyzeWithAi,
@@ -246,12 +259,12 @@ pub fn view<'a>(
quick_action_items.push(quick_action_item( quick_action_items.push(quick_action_item(
t(locale, "editor.detectLanguage"), t(locale, "editor.detectLanguage"),
MediaEditorMsg::DetectLanguage, MediaEditorMsg::DetectLanguage,
ai_enabled, ai_enabled && state.ai_activity.is_none(),
)); ));
quick_action_items.push(quick_action_item( quick_action_items.push(quick_action_item(
t(locale, "editor.translate"), t(locale, "editor.translate"),
MediaEditorMsg::TranslateMetadata, MediaEditorMsg::TranslateMetadata,
ai_enabled, ai_enabled && state.ai_activity.is_none(),
)); ));
let quick_actions_menu: Element<'a, Message> = container(column(quick_action_items).spacing(4)) let quick_actions_menu: Element<'a, Message> = container(column(quick_action_items).spacing(4))
.padding(8) .padding(8)

View File

@@ -78,6 +78,7 @@ pub struct PostEditorState {
pub categories_input: String, pub categories_input: String,
pub available_tags: Vec<String>, pub available_tags: Vec<String>,
pub semantic_tag_suggestions: Vec<String>, pub semantic_tag_suggestions: Vec<String>,
pub ai_activity: Option<String>,
pub active_language: String, pub active_language: String,
pub canonical_language: String, pub canonical_language: String,
pub blog_languages: Vec<String>, pub blog_languages: Vec<String>,
@@ -127,6 +128,7 @@ impl Clone for PostEditorState {
categories_input: self.categories_input.clone(), categories_input: self.categories_input.clone(),
available_tags: self.available_tags.clone(), available_tags: self.available_tags.clone(),
semantic_tag_suggestions: self.semantic_tag_suggestions.clone(), semantic_tag_suggestions: self.semantic_tag_suggestions.clone(),
ai_activity: self.ai_activity.clone(),
active_language: self.active_language.clone(), active_language: self.active_language.clone(),
canonical_language: self.canonical_language.clone(), canonical_language: self.canonical_language.clone(),
blog_languages: self.blog_languages.clone(), blog_languages: self.blog_languages.clone(),
@@ -194,6 +196,7 @@ impl PostEditorState {
categories_input: String::new(), categories_input: String::new(),
available_tags: Vec::new(), available_tags: Vec::new(),
semantic_tag_suggestions: Vec::new(), semantic_tag_suggestions: Vec::new(),
ai_activity: None,
active_language: canonical_lang.clone(), active_language: canonical_lang.clone(),
canonical_language: canonical_lang, canonical_language: canonical_lang,
blog_languages: blog_languages.to_vec(), blog_languages: blog_languages.to_vec(),
@@ -220,6 +223,7 @@ impl PostEditorState {
self.categories_input.clone_from(&previous.categories_input); self.categories_input.clone_from(&previous.categories_input);
self.semantic_tag_suggestions self.semantic_tag_suggestions
.clone_from(&previous.semantic_tag_suggestions); .clone_from(&previous.semantic_tag_suggestions);
self.ai_activity.clone_from(&previous.ai_activity);
self.switch_language(&previous.active_language); self.switch_language(&previous.active_language);
} }
@@ -424,31 +428,42 @@ pub fn view<'a>(
.style(inputs::secondary_button) .style(inputs::secondary_button)
.into(); .into();
let quick_actions_busy = state.ai_activity.as_ref().map(|label| {
container(
text(format!("{label}"))
.size(12)
.shaping(Shaping::Advanced),
)
.padding([6, 12])
.width(Length::Fixed(220.0))
.into()
});
let quick_actions_menu: Element<'a, Message> = container( let quick_actions_menu: Element<'a, Message> = container(
column![ column![
quick_action_item( quick_actions_busy.unwrap_or_else(|| quick_action_item(
locale, locale,
t(locale, "editor.aiAnalyze"), t(locale, "editor.aiAnalyze"),
PostEditorMsg::AnalyzeWithAi, PostEditorMsg::AnalyzeWithAi,
ai_enabled ai_enabled && state.ai_activity.is_none()
), )),
quick_action_item( quick_action_item(
locale, locale,
t(locale, "editor.suggestTaxonomy"), t(locale, "editor.suggestTaxonomy"),
PostEditorMsg::AnalyzeTaxonomy, PostEditorMsg::AnalyzeTaxonomy,
ai_enabled ai_enabled && state.ai_activity.is_none()
), ),
quick_action_item( quick_action_item(
locale, locale,
t(locale, "editor.translate"), t(locale, "editor.translate"),
PostEditorMsg::Translate, PostEditorMsg::Translate,
ai_enabled ai_enabled && state.ai_activity.is_none()
), ),
quick_action_item( quick_action_item(
locale, locale,
t(locale, "editor.detectLanguage"), t(locale, "editor.detectLanguage"),
PostEditorMsg::DetectLanguage, PostEditorMsg::DetectLanguage,
ai_enabled ai_enabled && state.ai_activity.is_none()
), ),
quick_action_item( quick_action_item(
locale, locale,