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:
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user