Fix automatic translation request handling

This commit is contained in:
2026-08-01 19:18:01 +02:00
parent bda3c400ef
commit 932b73cd97
5 changed files with 101 additions and 50 deletions

View File

@@ -2,7 +2,7 @@
RuDS is a native Rust blogging desktop application and the successor to bDS2. It manages local projects from authoring through preview, static-site generation, integrity checks, and publishing while preserving the existing bDS filesystem and SQLite formats. RuDS is a native Rust blogging desktop application and the successor to bDS2. It manages local projects from authoring through preview, static-site generation, integrity checks, and publishing while preserving the existing bDS filesystem and SQLite formats.
The desktop is a single-window application: closing its window persists UI state and exits RuDS. Background notifications remain unobtrusive and preserve keyboard focus while editing. The desktop is a single-window application: closing its window persists UI state and exits RuDS. Background notifications remain unobtrusive and preserve keyboard focus while editing; errors stay visible until dismissed.
The project is under active development. Core blogging workflows are broadly available; remaining core work and optional extensions are tracked separately. The project is under active development. Core blogging workflows are broadly available; remaining core work and optional extensions are tracked separately.

View File

@@ -121,6 +121,42 @@ pub struct OneShotRequest {
pub content: Value, pub content: Value,
} }
pub fn post_translation_request(
title: &str,
excerpt: Option<&str>,
content: &str,
target_language: &str,
) -> OneShotRequest {
OneShotRequest {
operation: OneShotOperation::TranslatePost {
target_language: target_language.to_string(),
},
content: json!({
"title": title,
"excerpt": excerpt.unwrap_or_default(),
"content": content,
}),
}
}
pub fn media_translation_request(
title: Option<&str>,
alt: Option<&str>,
caption: Option<&str>,
target_language: &str,
) -> OneShotRequest {
OneShotRequest {
operation: OneShotOperation::TranslateMedia {
target_language: target_language.to_string(),
},
content: json!({
"title": title.unwrap_or_default(),
"alt": alt.unwrap_or_default(),
"caption": caption.unwrap_or_default(),
}),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TaxonomySuggestion { pub struct TaxonomySuggestion {
pub tags: Vec<String>, pub tags: Vec<String>,
@@ -1477,6 +1513,33 @@ mod tests {
); );
} }
#[test]
fn translation_requests_normalize_missing_fields() {
let post = post_translation_request("Hello", None, "Body", "de");
assert_eq!(
post.operation,
OneShotOperation::TranslatePost {
target_language: "de".to_string()
}
);
assert_eq!(
post.content,
json!({"title": "Hello", "excerpt": "", "content": "Body"})
);
let media = media_translation_request(None, None, None, "fr");
assert_eq!(
media.operation,
OneShotOperation::TranslateMedia {
target_language: "fr".to_string()
}
);
assert_eq!(
media.content,
json!({"title": "", "alt": "", "caption": ""})
);
}
#[test] #[test]
fn run_one_shot_supports_image_analysis_via_airplane_endpoint() { fn run_one_shot_supports_image_analysis_via_airplane_endpoint() {
let response = run_airplane_one_shot( let response = run_airplane_one_shot(

View File

@@ -2,16 +2,11 @@ use std::collections::HashSet;
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use serde_json::json;
use crate::db::DbConnection as Connection; use crate::db::DbConnection as Connection;
use crate::db::queries::{ use crate::db::queries::{
media as qm, media_translation as qmt, post as qp, post_media, post_translation, media as qm, media_translation as qmt, post as qp, post_media, post_translation,
}; };
use crate::engine::ai::{ use crate::engine::ai::{self, MediaTranslationResult, OneShotResponse, TranslationResult};
self, MediaTranslationResult, OneShotOperation, OneShotRequest, OneShotResponse,
TranslationResult,
};
use crate::engine::{EngineError, EngineResult}; use crate::engine::{EngineError, EngineResult};
use crate::i18n::{UiLocale, translate, translate_with}; use crate::i18n::{UiLocale, translate, translate_with};
use crate::model::{Media, Post, PostStatus}; use crate::model::{Media, Post, PostStatus};
@@ -445,16 +440,12 @@ fn translate_post_ai(
match ai::run_one_shot( match ai::run_one_shot(
conn, conn,
offline_mode, offline_mode,
&OneShotRequest { &ai::post_translation_request(
operation: OneShotOperation::TranslatePost { &post.title,
target_language: language.to_string(), post.excerpt.as_deref(),
}, post.content.as_deref().unwrap_or_default(),
content: json!({ language,
"title": post.title, ),
"excerpt": post.excerpt,
"content": post.content,
}),
},
)? { )? {
(OneShotResponse::Translation(result), _usage) => Ok(result), (OneShotResponse::Translation(result), _usage) => Ok(result),
_ => Err(EngineError::Parse( _ => Err(EngineError::Parse(
@@ -472,16 +463,12 @@ fn translate_media_ai(
match ai::run_one_shot( match ai::run_one_shot(
conn, conn,
offline_mode, offline_mode,
&OneShotRequest { &ai::media_translation_request(
operation: OneShotOperation::TranslateMedia { media.title.as_deref(),
target_language: language.to_string(), media.alt.as_deref(),
}, media.caption.as_deref(),
content: json!({ language,
"title": media.title, ),
"alt": media.alt,
"caption": media.caption,
}),
},
)? { )? {
(OneShotResponse::MediaTranslation(result), _usage) => Ok(result), (OneShotResponse::MediaTranslation(result), _usage) => Ok(result),
_ => Err(EngineError::Parse( _ => Err(EngineError::Parse(

View File

@@ -10367,16 +10367,12 @@ impl BdsApp {
} }
} }
let request = ai::OneShotRequest { let request = ai::post_translation_request(
operation: ai::OneShotOperation::TranslatePost { &state.title,
target_language: target_language.to_string(), Some(&state.excerpt),
}, &state.content,
content: json!({ target_language,
"title": state.title, );
"excerpt": state.excerpt,
"content": state.content,
}),
};
if let Some(editor) = self.post_editors.get_mut(post_id) { if let Some(editor) = self.post_editors.get_mut(post_id) {
editor.ai_activity = Some(t(self.ui_locale, "editor.translate")); editor.ai_activity = Some(t(self.ui_locale, "editor.translate"));
} }
@@ -10513,16 +10509,12 @@ impl BdsApp {
{ {
editor.language = state.canonical_language.clone(); editor.language = state.canonical_language.clone();
} }
let request = ai::OneShotRequest { let request = ai::media_translation_request(
operation: ai::OneShotOperation::TranslateMedia { Some(&state.title),
target_language: target_language.to_string(), Some(&state.alt),
}, Some(&state.caption),
content: json!({ target_language,
"title": state.title, );
"alt": state.alt,
"caption": state.caption,
}),
};
if let Some(editor) = self.media_editors.get_mut(media_id) { if let Some(editor) = self.media_editors.get_mut(media_id) {
editor.ai_activity = Some(t(self.ui_locale, "editor.translate")); editor.ai_activity = Some(t(self.ui_locale, "editor.translate"));
} }

View File

@@ -1,8 +1,7 @@
/// Toast notification state. /// Toast notification state.
/// ///
/// Toasts are ephemeral, auto-dismissing messages shown at the top of /// Toasts are messages shown at the top of the workspace. Errors remain until
/// the workspace. Each toast has a severity level, a message, and a /// dismissed; other levels expire automatically.
/// monotonically increasing id used for targeted dismissal.
use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::atomic::{AtomicU64, Ordering};
static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1); static NEXT_TOAST_ID: AtomicU64 = AtomicU64::new(1);
@@ -45,6 +44,9 @@ impl Toast {
/// Whether this toast has exceeded its display duration. /// Whether this toast has exceeded its display duration.
pub fn is_expired(&self) -> bool { pub fn is_expired(&self) -> bool {
if self.level == ToastLevel::Error {
return false;
}
let now = std::time::SystemTime::now() let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
@@ -69,4 +71,11 @@ mod tests {
let t = Toast::new(ToastLevel::Info, "test".into()); let t = Toast::new(ToastLevel::Info, "test".into());
assert!(!t.is_expired()); assert!(!t.is_expired());
} }
#[test]
fn error_toast_never_expires_automatically() {
let mut toast = Toast::new(ToastLevel::Error, "important".into());
toast.created_at = 0;
assert!(!toast.is_expired());
}
} }