Added the visual briefing assistant (#893)
Build and Release / Determine run mode (push) Has been cancelled
Build and Release / Read metadata (push) Has been cancelled
Build and Release / Sync Flatpak repo (push) Has been cancelled
Build and Release / Collect Flatpak artifacts (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Has been cancelled
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Has been cancelled
Build and Release / Prepare & create release (push) Has been cancelled
Build and Release / Publish release (push) Has been cancelled

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-08-02 21:41:12 +02:00
1 parent df4663fff4
commit 58cc811a58
214 files changed
+18943 -353

No files matched your search

+1
View File
@@ -4258,6 +4258,7 @@ dependencies = [
"flexi_logger",
"futures",
"hmac 0.13.0",
"image",
"keyring-core",
"log",
"once_cell",
+1
View File
@@ -55,6 +55,7 @@ strum_macros = "0.28.0"
sysinfo = "0.39.6"
bytes = "1.12.1"
qdrant-edge = "0.7.2"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp"] }
[patch.crates-io]
# Issue: It was not possible to build qdrant-edge for macOS. See PR 9312: https://github.com/qdrant/qdrant/pull/9312
@@ -149,6 +149,28 @@ The above copyright notice and this permission notice shall be included in all c
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
## Apache ECharts 6.1.0 common
Copyright 2017-2026 The Apache Software Foundation
Apache ECharts is licensed under the Apache License, Version 2.0:
https://www.apache.org/licenses/LICENSE-2.0
The bundled distribution includes zrender and other BSD-licensed subcomponents.
Their copyright and license notices are preserved in the bundled
`echarts.common.min.js` file and in the Apache ECharts distribution:
https://github.com/apache/echarts/tree/6.1.0/licenses
## image 0.25.10
Copyright image-rs developers
Licensed, at your option, under either the Apache License, Version 2.0 or the
MIT License:
- https://www.apache.org/licenses/LICENSE-2.0
- https://github.com/image-rs/image/blob/v0.25.10/LICENSE-MIT
## webm-iterable 0.6.4
MIT License
+59 -6
View File
@@ -46,7 +46,7 @@ pub struct SelectFileOptions {
#[derive(Clone, Deserialize)]
pub struct SaveFileOptions {
title: String,
name_file: Option<PreviousFile>,
previous_file: Option<PreviousFile>,
filter: Option<FileTypeFilter>,
}
@@ -275,10 +275,15 @@ pub async fn save_file(_token: APIToken, payload: Json<SaveFileOptions>) -> Json
// Set the file type filter if provided:
file_dialog = apply_filter(file_dialog, &payload.filter);
// Set the previous file path if provided:
if let Some(previous) = &payload.name_file {
let previous_path = previous.file_path.as_str();
file_dialog = file_dialog.set_directory(previous_path);
// Set the initial directory and file name if provided:
if let Some(previous) = &payload.previous_file {
let (directory, file_name) = split_save_file_path(&previous.file_path);
if let Some(directory) = directory {
file_dialog = file_dialog.set_directory(directory);
}
if let Some(file_name) = file_name {
file_dialog = file_dialog.set_file_name(file_name);
}
}
// Displays the file dialogue box and select the file:
@@ -396,6 +401,21 @@ fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &O
}
}
fn split_save_file_path(file_path: &str) -> (Option<PathBuf>, Option<String>) {
let path = Path::new(file_path);
let directory = path
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.map(Path::to_path_buf);
let file_name = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.filter(|name| !name.is_empty());
(directory, file_name)
}
#[derive(Debug, PartialEq, Eq)]
struct FileManagerTarget {
path: PathBuf,
@@ -529,6 +549,39 @@ mod tests {
use super::*;
use std::fs;
#[test]
fn save_file_options_accept_the_previous_file_contract() {
let options: SaveFileOptions = serde_json::from_str(
r#"{"title":"Export visual briefing","previous_file":{"file_path":"Quarterly briefing.html"}}"#,
)
.unwrap();
assert_eq!(options.title, "Export visual briefing");
assert_eq!(
options.previous_file.unwrap().file_path,
"Quarterly briefing.html",
);
}
#[test]
fn save_file_name_without_directory_is_preserved() {
let (directory, file_name) = split_save_file_path("Quarterly briefing.html");
assert_eq!(directory, None);
assert_eq!(file_name.as_deref(), Some("Quarterly briefing.html"));
}
#[test]
fn save_file_path_is_split_into_directory_and_name() {
let temp_dir = tempfile::tempdir().unwrap();
let initial_path = temp_dir.path().join("Quarterly briefing.html");
let (directory, file_name) = split_save_file_path(initial_path.to_str().unwrap());
assert_eq!(directory.as_deref(), Some(temp_dir.path()));
assert_eq!(file_name.as_deref(), Some("Quarterly briefing.html"));
}
#[test]
fn existing_file_is_revealed_and_falls_back_to_its_parent() {
let temp_dir = tempfile::tempdir().unwrap();
@@ -575,4 +628,4 @@ mod tests {
assert!(resolve_file_manager_target(&invalid_path).is_none());
}
}
}
+385
View File
@@ -0,0 +1,385 @@
//! Local image preparation: decode a file, apply the size policy, and return it as a Data URL.
//!
//! This module is deliberately free of any feature-specific behavior so that every part of
//! AI Studio that needs an embeddable image can use it. The size policy is a single maximum edge
//! length; callers that want the original bytes pass `optimize = false`.
use std::io::Cursor;
use std::path::Path;
use axum::Json;
use axum::http::StatusCode;
use base64::{Engine as _, engine::general_purpose};
use image::codecs::jpeg::JpegEncoder;
use image::imageops::FilterType;
use image::{DynamicImage, ImageFormat, ImageReader};
use serde::{Deserialize, Serialize};
/// The longest edge an optimized image may have. Larger images are scaled down proportionally.
const MAX_EDGE_PIXELS: u32 = 2_560;
/// The quality used when re-encoding JPEG images. Pinned so that repeated runs are byte-identical.
const JPEG_QUALITY: u8 = 85;
/// The request to prepare one local image file.
#[derive(Debug, Deserialize)]
pub struct PrepareImageRequest {
/// The absolute path of the image file to read.
path: String,
/// Whether the size policy and re-encoding are applied. When false, the original bytes are used.
optimize: bool,
}
/// The prepared image together with the dimensions the caller can lay out against.
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case")]
pub struct PrepareImageResponse {
/// The complete `data:` URL, ready to embed.
data_url: String,
/// The MIME type matching the source format.
mime_type: String,
/// The width of the prepared image in pixels.
width: u32,
/// The height of the prepared image in pixels.
height: u32,
/// Whether the size policy actually scaled the image down.
was_resized: bool,
}
/// Decodes one supported image, applies the size policy, and returns a Data URL.
///
/// Decoding runs on a blocking worker because it is CPU-bound and would otherwise stall the
/// async runtime for large images.
pub async fn prepare_image(
Json(request): Json<PrepareImageRequest>,
) -> Result<Json<PrepareImageResponse>, (StatusCode, String)> {
tokio::task::spawn_blocking(move || prepare_image_sync(&request))
.await
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("The image worker failed: {error}"),
)
})?
.map(Json)
}
/// Performs the blocking part of [`prepare_image`].
///
/// Only absolute paths to existing files are accepted, and the decoded format has to match the
/// file extension. Rejecting a mismatch keeps a file that merely claims to be an image from being
/// embedded under a MIME type derived from its name.
fn prepare_image_sync(
request: &PrepareImageRequest,
) -> Result<PrepareImageResponse, (StatusCode, String)> {
let path = Path::new(&request.path);
if !path.is_absolute() || !path.is_file() {
return Err((
StatusCode::BAD_REQUEST,
"The image path is not an accessible absolute file path.".to_string(),
));
}
let format = supported_format(path)?;
let reader = ImageReader::open(path)
.and_then(|reader| reader.with_guessed_format())
.map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("The image could not be opened: {error}"),
)
})?;
if reader.format() != Some(format) {
return Err((
StatusCode::BAD_REQUEST,
"The image content does not match its file extension.".to_string(),
));
}
let decoded = reader.decode().map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("The image could not be decoded: {error}"),
)
})?;
let original_width = decoded.width();
let original_height = decoded.height();
let should_resize = request.optimize && original_width.max(original_height) > MAX_EDGE_PIXELS;
let prepared = if should_resize {
resize_to_max_edge(decoded)
} else {
decoded
};
let width = prepared.width();
let height = prepared.height();
let bytes = if request.optimize {
encode(&prepared, format)?
} else {
std::fs::read(path).map_err(|error| {
(
StatusCode::BAD_REQUEST,
format!("The image could not be read: {error}"),
)
})?
};
let mime_type = match format {
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Png => "image/png",
ImageFormat::WebP => "image/webp",
_ => unreachable!(),
}
.to_string();
Ok(PrepareImageResponse {
data_url: format!(
"data:{mime_type};base64,{}",
general_purpose::STANDARD.encode(bytes)
),
mime_type,
width,
height,
was_resized: should_resize,
})
}
/// Maps a file extension to the one image format AI Studio embeds.
///
/// The result is only the expected format; [`prepare_image_sync`] still verifies it against the
/// actual file content.
fn supported_format(path: &Path) -> Result<ImageFormat, (StatusCode, String)> {
match path
.extension()
.and_then(|extension| extension.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("jpg" | "jpeg") => Ok(ImageFormat::Jpeg),
Some("png") => Ok(ImageFormat::Png),
Some("webp") => Ok(ImageFormat::WebP),
_ => Err((
StatusCode::BAD_REQUEST,
"Images must be PNG, JPEG, or WebP files.".to_string(),
)),
}
}
/// Scales an image down so that its longest edge equals [`MAX_EDGE_PIXELS`].
///
/// The aspect ratio is preserved, and both edges stay at least one pixel wide.
fn resize_to_max_edge(image: DynamicImage) -> DynamicImage {
let width = image.width();
let height = image.height();
let scale = MAX_EDGE_PIXELS as f64 / width.max(height) as f64;
let target_width = (width as f64 * scale).round().max(1.0) as u32;
let target_height = (height as f64 * scale).round().max(1.0) as u32;
image.resize_exact(target_width, target_height, FilterType::Lanczos3)
}
/// Encodes a prepared image back into its source format.
///
/// JPEG uses the pinned [`JPEG_QUALITY`] so that the same input always produces the same bytes,
/// which keeps artifact hashes stable across runs.
fn encode(image: &DynamicImage, format: ImageFormat) -> Result<Vec<u8>, (StatusCode, String)> {
let mut bytes = Vec::new();
match format {
ImageFormat::Jpeg => JpegEncoder::new_with_quality(&mut bytes, JPEG_QUALITY)
.encode_image(image)
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("The JPEG image could not be encoded: {error}"),
)
})?,
ImageFormat::Png | ImageFormat::WebP => image
.write_to(&mut Cursor::new(&mut bytes), format)
.map_err(|error| {
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("The image could not be encoded: {error}"),
)
})?,
_ => unreachable!(),
}
Ok(bytes)
}
#[cfg(test)]
mod tests {
use super::*;
fn temporary_image_path(extension: &str) -> std::path::PathBuf {
let unique = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
std::env::temp_dir().join(format!("mwai-visual-briefing-test-{unique}.{extension}"))
}
#[test]
fn rejects_unsupported_visual_asset_extension() {
let issue = supported_format(Path::new("/tmp/asset.gif")).unwrap_err();
assert_eq!(issue.0, StatusCode::BAD_REQUEST);
}
#[test]
fn keeps_supported_formats_stable() {
assert_eq!(
supported_format(Path::new("/tmp/asset.jpeg")).unwrap(),
ImageFormat::Jpeg
);
assert_eq!(
supported_format(Path::new("/tmp/asset.png")).unwrap(),
ImageFormat::Png
);
assert_eq!(
supported_format(Path::new("/tmp/asset.webp")).unwrap(),
ImageFormat::WebP
);
}
#[test]
fn serializes_response_in_snake_case_for_the_rust_service_contract() {
let response = PrepareImageResponse {
data_url: "data:image/jpeg;base64,/9j/".to_string(),
mime_type: "image/jpeg".to_string(),
width: 17,
height: 11,
was_resized: false,
};
let json = serde_json::to_value(response).unwrap();
assert_eq!(json["data_url"], "data:image/jpeg;base64,/9j/");
assert_eq!(json["mime_type"], "image/jpeg");
assert_eq!(json["width"], 17);
assert_eq!(json["height"], 11);
assert_eq!(json["was_resized"], false);
assert!(json.get("dataUrl").is_none());
assert!(json.get("mimeType").is_none());
assert!(json.get("wasResized").is_none());
}
#[test]
fn disabled_optimization_preserves_original_bytes() {
let path = temporary_image_path("png");
DynamicImage::new_rgb8(4, 3)
.save_with_format(&path, ImageFormat::Png)
.unwrap();
let original = std::fs::read(&path).unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: false,
})
.unwrap();
let encoded = response.data_url.split_once(',').unwrap().1;
assert_eq!(general_purpose::STANDARD.decode(encoded).unwrap(), original);
assert!(!response.was_resized);
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimization_resizes_only_images_over_the_maximum_edge() {
let path = temporary_image_path("png");
DynamicImage::new_rgb8(MAX_EDGE_PIXELS + 1, 1)
.save_with_format(&path, ImageFormat::Png)
.unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
assert_eq!(response.width, MAX_EDGE_PIXELS);
assert_eq!(response.height, 1);
assert!(response.was_resized);
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimization_keeps_images_at_the_maximum_edge_unchanged() {
let path = temporary_image_path("png");
DynamicImage::new_rgb8(MAX_EDGE_PIXELS, 2)
.save_with_format(&path, ImageFormat::Png)
.unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
assert_eq!(response.width, MAX_EDGE_PIXELS);
assert_eq!(response.height, 2);
assert!(!response.was_resized);
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimized_jpeg_uses_the_pinned_quality_encoder() {
let path = temporary_image_path("jpg");
let image = DynamicImage::new_rgb8(17, 11);
image.save_with_format(&path, ImageFormat::Jpeg).unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
let actual = general_purpose::STANDARD
.decode(response.data_url.split_once(',').unwrap().1)
.unwrap();
let mut expected = Vec::new();
JpegEncoder::new_with_quality(&mut expected, JPEG_QUALITY)
.encode_image(&image)
.unwrap();
assert_eq!(actual, expected);
assert_eq!(response.mime_type, "image/jpeg");
std::fs::remove_file(path).unwrap();
}
#[test]
fn optimization_keeps_png_and_webp_formats_stable() {
for (extension, format, expected_mime) in [
("png", ImageFormat::Png, "image/png"),
("webp", ImageFormat::WebP, "image/webp"),
] {
let path = temporary_image_path(extension);
DynamicImage::new_rgba8(9, 7)
.save_with_format(&path, format)
.unwrap();
let response = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap();
let bytes = general_purpose::STANDARD
.decode(response.data_url.split_once(',').unwrap().1)
.unwrap();
assert_eq!(image::guess_format(&bytes).unwrap(), format);
assert_eq!(response.mime_type, expected_mime);
std::fs::remove_file(path).unwrap();
}
}
#[test]
fn rejects_file_contents_that_do_not_match_the_extension() {
let path = temporary_image_path("png");
std::fs::write(&path, b"not an image").unwrap();
let issue = prepare_image_sync(&PrepareImageRequest {
path: path.to_string_lossy().into_owned(),
optimize: true,
})
.unwrap_err();
assert_eq!(issue.0, StatusCode::BAD_REQUEST);
std::fs::remove_file(path).unwrap();
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod runtime_certificate;
pub mod file_data;
pub mod metadata;
pub mod media;
pub mod image;
pub mod pdfium;
pub mod pandoc;
pub mod qdrant_edge_database;
+1
View File
@@ -63,6 +63,7 @@ pub fn start_runtime_api() {
.route("/media/jobs", post(crate::media::create_job))
.route("/media/jobs/{id}/events", get(crate::media::get_job_events))
.route("/media/jobs/{id}", delete(crate::media::cancel_job))
.route("/image/prepare", post(crate::image::prepare_image))
.route("/log/paths", get(crate::log::get_log_paths))
.route("/log/event", post(crate::log::log_event))
.route("/shortcuts/register", post(crate::app_window::register_shortcut))