From fdd9ddde4a89faf1c23bba0402f89d8cf2db7b9d Mon Sep 17 00:00:00 2001 From: Georg Bauer Date: Tue, 21 Jul 2026 10:09:07 +0200 Subject: [PATCH] Run one-shot AI actions asynchronously --- README.md | 2 +- crates/bds-ui/src/app.rs | 467 ++++++++++++++---------- crates/bds-ui/src/views/media_editor.rs | 19 +- crates/bds-ui/src/views/post_editor.rs | 27 +- 4 files changed, 304 insertions(+), 211 deletions(-) diff --git a/README.md b/README.md index 56e4f45..fae5816 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ The project is under active development. Core blogging workflows are broadly ava - `bds-cli server` hosting the shared application engines over a loopback-by-default, public-key-only SSH service, with restrictive private key material, live authorization updates, terminal-session transport, CLI-change synchronization, ordered domain/task events, and native desktop remote-project selection. - 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. -- 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. - SSH-agent-based SCP or rsync publishing. - Integrated Git workflow with repository initialization, Git LFS image tracking, status and diffs, branch/file history, commits, remotes, cancellable fetch/pull/push, and post-pull filesystem reconciliation; network actions respect airplane mode. diff --git a/crates/bds-ui/src/app.rs b/crates/bds-ui/src/app.rs index 69140c5..89153ba 100644 --- a/crates/bds-ui/src/app.rs +++ b/crates/bds-ui/src/app.rs @@ -69,6 +69,16 @@ mod tasks; // ─────────────────────────────────────────────────────────── #[derive(Debug, Clone)] +pub enum OneShotAiAction { + PostAnalysis, + PostTaxonomy, + PostLanguage, + PostTranslation { target_language: String }, + MediaAnalysis, + MediaLanguage, + MediaTranslation { target_language: String }, +} + pub enum Message { // Menu MenuEvent(muda::MenuId), @@ -230,6 +240,12 @@ pub enum Message { ToggleAiSuggestionField(usize, bool), ApplyAiSuggestions(modal::AiEntityTarget, Vec), + OneShotAiFinished { + entity_id: String, + action: OneShotAiAction, + result: Result, + }, + // Blog actions (dispatched to engine) RebuildDatabase, ReindexText, @@ -3063,6 +3079,11 @@ impl BdsApp { self.active_modal = None; self.apply_ai_suggestions(target, &fields) } + Message::OneShotAiFinished { + entity_id, + action, + result, + } => self.finish_one_shot_ai(&entity_id, action, result), // ── Editor view messages ── Message::PostEditor(msg) => self.handle_post_editor_msg(msg), @@ -8972,14 +8993,34 @@ impl BdsApp { Task::none() } + fn start_one_shot_ai( + &self, + entity_id: String, + action: OneShotAiAction, + request: ai::OneShotRequest, + ) -> Task { + 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 { - 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 { return Task::none(); }; @@ -8991,52 +9032,13 @@ impl BdsApp { "content": content_sample(&state.content, 2000), }), }; - match ai::run_one_shot(db.conn(), self.offline_mode, &request) { - Ok((ai::OneShotResponse::PostAnalysis(result), _usage)) => { - 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()), + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.ai_activity = Some(t(self.ui_locale, "editor.aiAnalyze")); } - 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 { - 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 { return Task::none(); }; @@ -9050,44 +9052,13 @@ impl BdsApp { "categories": state.categories, }), }; - match ai::run_one_shot(db.conn(), self.offline_mode, &request) { - Ok((ai::OneShotResponse::Taxonomy(result), _usage)) => { - 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()), + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.ai_activity = Some(t(self.ui_locale, "editor.suggestTaxonomy")); } - Task::none() + self.start_one_shot_ai(post_id.to_string(), OneShotAiAction::PostTaxonomy, request) } fn detect_post_language(&mut self, post_id: &str) -> Task { - 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 { return Task::none(); }; @@ -9097,22 +9068,10 @@ impl BdsApp { "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) { - Ok((ai::OneShotResponse::LanguageDetection(result), _usage)) => { - 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()), + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.ai_activity = Some(t(self.ui_locale, "editor.detectLanguage")); } - 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 { @@ -9207,27 +9166,16 @@ impl BdsApp { "content": state.content, }), }; - match ai::run_one_shot(db.conn(), self.offline_mode, &request) { - Ok((ai::OneShotResponse::Translation(result), _usage)) => { - 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()), + if let Some(editor) = self.post_editors.get_mut(post_id) { + editor.ai_activity = Some(t(self.ui_locale, "editor.translate")); } - 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 { @@ -9271,42 +9219,14 @@ impl BdsApp { "image_data_url": image_data_url, }), }; - match ai::run_one_shot(db.conn(), self.offline_mode, &request) { - Ok((ai::OneShotResponse::ImageAnalysis(result), _usage)) => { - 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()), + if let Some(editor) = self.media_editors.get_mut(media_id) { + editor.ai_activity = Some(t(self.ui_locale, "editor.aiAnalyze")); } - Task::none() + self.start_one_shot_ai( + media_id.to_string(), + OneShotAiAction::MediaAnalysis, + request, + ) } fn detect_media_language(&mut self, media_id: &str) -> Task { @@ -9326,22 +9246,14 @@ impl BdsApp { "text": format!("{}\n{}\n{}", state.title, state.alt, state.caption), }), }; - match ai::run_one_shot(db.conn(), self.offline_mode, &request) { - Ok((ai::OneShotResponse::LanguageDetection(result), _usage)) => { - 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()), + if let Some(editor) = self.media_editors.get_mut(media_id) { + editor.ai_activity = Some(t(self.ui_locale, "editor.detectLanguage")); } - 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 { @@ -9385,31 +9297,10 @@ impl BdsApp { let Some(state) = self.media_editors.get(media_id).cloned() else { 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) && editor.language.is_empty() { - editor.language = source_language; + editor.language = state.canonical_language.clone(); } let request = ai::OneShotRequest { operation: ai::OneShotOperation::TranslateMedia { @@ -9421,23 +9312,197 @@ impl BdsApp { "caption": state.caption, }), }; - match ai::run_one_shot(db.conn(), self.offline_mode, &request) { - Ok((ai::OneShotResponse::MediaTranslation(result), _usage)) => { - if let Some(editor) = self.media_editors.get_mut(media_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 Some(editor) = self.media_editors.get_mut(media_id) { + editor.ai_activity = Some(t(self.ui_locale, "editor.translate")); + } + self.start_one_shot_ai( + media_id.to_string(), + OneShotAiAction::MediaTranslation { + target_language: target_language.to_string(), + }, + request, + ) + } + + fn finish_one_shot_ai( + &mut self, + entity_id: &str, + action: OneShotAiAction, + result: Result, + ) -> Task { + 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); } else { 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() } diff --git a/crates/bds-ui/src/views/media_editor.rs b/crates/bds-ui/src/views/media_editor.rs index ecf8469..4553b24 100644 --- a/crates/bds-ui/src/views/media_editor.rs +++ b/crates/bds-ui/src/views/media_editor.rs @@ -61,6 +61,7 @@ pub struct MediaEditorState { pub post_picker_search: String, pub post_picker_results: Vec, pub quick_actions_open: bool, + pub ai_activity: Option, } impl MediaEditorState { @@ -114,6 +115,7 @@ impl MediaEditorState { post_picker_search: String::new(), post_picker_results: Vec::new(), quick_actions_open: false, + ai_activity: None, } } @@ -236,7 +238,18 @@ pub fn view<'a>( ai_enabled: bool, ) -> Element<'a, Message> { let mut quick_action_items: Vec> = 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( t(locale, "editor.aiAnalyze"), MediaEditorMsg::AnalyzeWithAi, @@ -246,12 +259,12 @@ pub fn view<'a>( quick_action_items.push(quick_action_item( t(locale, "editor.detectLanguage"), MediaEditorMsg::DetectLanguage, - ai_enabled, + ai_enabled && state.ai_activity.is_none(), )); quick_action_items.push(quick_action_item( t(locale, "editor.translate"), 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)) .padding(8) diff --git a/crates/bds-ui/src/views/post_editor.rs b/crates/bds-ui/src/views/post_editor.rs index c7a90e6..9e95ad4 100644 --- a/crates/bds-ui/src/views/post_editor.rs +++ b/crates/bds-ui/src/views/post_editor.rs @@ -78,6 +78,7 @@ pub struct PostEditorState { pub categories_input: String, pub available_tags: Vec, pub semantic_tag_suggestions: Vec, + pub ai_activity: Option, pub active_language: String, pub canonical_language: String, pub blog_languages: Vec, @@ -127,6 +128,7 @@ impl Clone for PostEditorState { categories_input: self.categories_input.clone(), available_tags: self.available_tags.clone(), semantic_tag_suggestions: self.semantic_tag_suggestions.clone(), + ai_activity: self.ai_activity.clone(), active_language: self.active_language.clone(), canonical_language: self.canonical_language.clone(), blog_languages: self.blog_languages.clone(), @@ -194,6 +196,7 @@ impl PostEditorState { categories_input: String::new(), available_tags: Vec::new(), semantic_tag_suggestions: Vec::new(), + ai_activity: None, active_language: canonical_lang.clone(), canonical_language: canonical_lang, blog_languages: blog_languages.to_vec(), @@ -220,6 +223,7 @@ impl PostEditorState { self.categories_input.clone_from(&previous.categories_input); self.semantic_tag_suggestions .clone_from(&previous.semantic_tag_suggestions); + self.ai_activity.clone_from(&previous.ai_activity); self.switch_language(&previous.active_language); } @@ -424,31 +428,42 @@ pub fn view<'a>( .style(inputs::secondary_button) .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( column![ - quick_action_item( + quick_actions_busy.unwrap_or_else(|| quick_action_item( locale, t(locale, "editor.aiAnalyze"), PostEditorMsg::AnalyzeWithAi, - ai_enabled - ), + ai_enabled && state.ai_activity.is_none() + )), quick_action_item( locale, t(locale, "editor.suggestTaxonomy"), PostEditorMsg::AnalyzeTaxonomy, - ai_enabled + ai_enabled && state.ai_activity.is_none() ), quick_action_item( locale, t(locale, "editor.translate"), PostEditorMsg::Translate, - ai_enabled + ai_enabled && state.ai_activity.is_none() ), quick_action_item( locale, t(locale, "editor.detectLanguage"), PostEditorMsg::DetectLanguage, - ai_enabled + ai_enabled && state.ai_activity.is_none() ), quick_action_item( locale,