Merge pull request #1 from rfc1437/codex/run-one-shot-activities-in-background-thread
Run one-shot AI actions asynchronously
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<modal::AiSuggestionField>),
|
||||
|
||||
OneShotAiFinished {
|
||||
entity_id: String,
|
||||
action: OneShotAiAction,
|
||||
result: Result<ai::OneShotResponse, String>,
|
||||
},
|
||||
|
||||
// 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<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> {
|
||||
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<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 {
|
||||
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<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 {
|
||||
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<Message> {
|
||||
@@ -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<Message> {
|
||||
@@ -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<Message> {
|
||||
@@ -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<Message> {
|
||||
@@ -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<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);
|
||||
} 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()
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ pub struct MediaEditorState {
|
||||
pub post_picker_search: String,
|
||||
pub post_picker_results: Vec<LinkedPostItem>,
|
||||
pub quick_actions_open: bool,
|
||||
pub ai_activity: Option<String>,
|
||||
}
|
||||
|
||||
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<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(
|
||||
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)
|
||||
|
||||
@@ -78,6 +78,7 @@ pub struct PostEditorState {
|
||||
pub categories_input: String,
|
||||
pub available_tags: Vec<String>,
|
||||
pub semantic_tag_suggestions: Vec<String>,
|
||||
pub ai_activity: Option<String>,
|
||||
pub active_language: String,
|
||||
pub canonical_language: String,
|
||||
pub blog_languages: Vec<String>,
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user