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

@@ -6,15 +6,13 @@ namespace AIStudio.Tools.Services;
/// Wire up the clipboard service to copy Markdown to the clipboard.
/// We use our own Rust-based clipboard service for this.
/// </summary>
public sealed class MarkdownClipboardService(RustService rust, ISnackbar snackbar) : IMudMarkdownClipboardService
public sealed class MarkdownClipboardService(RustService rust) : IMudMarkdownClipboardService
{
private ISnackbar Snackbar { get; } = snackbar;
private RustService Rust { get; } = rust;
/// <summary>
/// Gets called when the user wants to copy the Markdown to the clipboard.
/// </summary>
/// <param name="text">The Markdown text to copy.</param>
public async ValueTask CopyToClipboardAsync(string text) => await this.Rust.CopyText2Clipboard(this.Snackbar, text);
public async ValueTask CopyToClipboardAsync(string text) => await this.Rust.CopyText2Clipboard(text);
}
@@ -10,7 +10,11 @@ namespace AIStudio.Tools.Services;
/// <summary>
/// Coordinates serialized visible media imports and independent voice transcriptions.
/// </summary>
public sealed class MediaTranscriptionService(RustService rustService, SettingsManager settingsManager, ILogger<MediaTranscriptionService> logger) : IDisposable
public sealed class MediaTranscriptionService(
RustService rustService,
SettingsManager settingsManager,
IEnumerable<IMediaTranscriptStorage> transcriptStorages,
ILogger<MediaTranscriptionService> logger) : IDisposable
{
private const string NORMALIZED_OUTPUT_EXTENSION = ".webm";
private const string NORMALIZED_OUTPUT_FORMAT = "webm";
@@ -294,12 +298,18 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
continue;
}
var isPersistedChat = ownerChat is not null && WorkspaceBehaviour.IsChatExisting(new LoadChat(ownerChat.WorkspaceId, ownerChat.ChatId));
var attachment = isPersistedChat
? await WorkspaceBehaviour.CreateManagedTranscriptAsync(ownerChat!, mediaPath, result.Text)
: await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text);
var persistentStorage = transcriptStorages.FirstOrDefault(storage => storage.CanStore(target.Owner));
var isPersistedChat = persistentStorage is null &&
ownerChat is not null &&
WorkspaceBehaviour.IsChatExisting(new LoadChat(ownerChat.WorkspaceId, ownerChat.ChatId));
var attachment = persistentStorage is not null
? await persistentStorage.StoreAsync(target, mediaPath, result.Text, batchToken)
: isPersistedChat
? await WorkspaceBehaviour.CreateManagedTranscriptAsync(ownerChat!, mediaPath, result.Text)
: await ManagedTranscriptAttachment.CreateStagedAsync(mediaPath, result.Text);
if (ownerChat is not null && attachment is { } managed
if (persistentStorage is null && ownerChat is not null && attachment is ManagedTranscriptAttachment managed
&& ownerChat.PendingMediaTranscripts.All(existing => existing.FilePath != managed.FilePath))
ownerChat.PendingMediaTranscripts.Add(managed);
@@ -443,6 +453,11 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
var normalizedPath = Path.Combine(Path.GetTempPath(), "mindwork-ai-studio-media", $"{operation.Id:N}.webm");
Directory.CreateDirectory(Path.GetDirectoryName(normalizedPath)!);
// Logged next to the operation ID: users recognize the file they picked, whereas an ID only
// helps when correlating log lines. The name alone is enough and keeps full paths out of
// logs that get shared in bug reports.
var fileName = Path.GetFileName(mediaPath);
try
{
var normalized = await this.NormalizeAsync(mediaPath, normalizedPath, operation, updateImportState);
@@ -454,13 +469,20 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
var uploadContractError = await ValidateNormalizedProviderUploadAsync(normalized.Result, normalizedPath, operation.Cancellation.Token);
if (uploadContractError is not null)
{
logger.LogError("Refusing the transcription provider upload because the normalized media contract validation failed: {Diagnostic}", uploadContractError);
logger.LogError(
"Refusing the transcription provider upload for '{FileName}' (operation {OperationId}) because the normalized media contract validation failed: {Diagnostic}",
fileName,
operation.Id,
uploadContractError);
return MediaTranscriptionResult.Failed(TB("The media pipeline ended without an output file."));
}
if (!normalized.Result.HasAudibleSignal)
{
logger.LogInformation("Skipping transcription for '{MediaPath}' because its maximum audio peak does not exceed the practical-silence threshold.", mediaPath);
logger.LogInformation(
"Skipping media transcription for '{FileName}' (operation {OperationId}) because its maximum audio peak does not exceed the practical-silence threshold.",
fileName,
operation.Id);
return MediaTranscriptionResult.NoAudibleSignal(TB("The audio track contains no audible signal, so there is nothing to transcribe."));
}
@@ -480,10 +502,10 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
var reductionPercent = sourceSize > 0
? (1.0 - (double)normalizedSize / sourceSize) * 100.0
: 0.0;
logger.LogInformation("Transcribing normalized WebM/Opus media '{NormalizedPath}' ({NormalizedSize} bytes; source '{SourcePath}' {SourceSize} bytes; size reduction {ReductionPercent:F1}%) with provider '{Provider}' and model '{Model}'.",
normalizedPath,
logger.LogInformation("Transcribing normalized WebM/Opus media '{FileName}' for operation {OperationId} ({NormalizedSize} bytes; source {SourceSize} bytes; size reduction {ReductionPercent:F1}%) with provider '{Provider}' and model '{Model}'.",
fileName,
operation.Id,
normalizedSize,
mediaPath,
sourceSize,
reductionPercent,
providerSettings.UsedLLMProvider,
@@ -493,7 +515,11 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
operation.Cancellation.Token.ThrowIfCancellationRequested();
if (!providerResult.Success)
{
logger.LogWarning("The transcription provider failed for '{MediaPath}': {Diagnostic}", mediaPath, providerResult.ErrorMessage);
logger.LogWarning(
"The transcription provider failed for '{FileName}' (operation {OperationId}): {Diagnostic}",
fileName,
operation.Id,
providerResult.ErrorMessage);
return MediaTranscriptionResult.Failed(TB("The transcription provider could not transcribe the media file."));
}
@@ -505,7 +531,11 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
}
catch (Exception exception)
{
logger.LogError(exception, "Media transcription failed for '{MediaPath}'.", mediaPath);
logger.LogError(
"Media transcription failed for '{FileName}' (operation {OperationId}). ExceptionType={ExceptionType}",
fileName,
operation.Id,
exception.GetType().Name);
return MediaTranscriptionResult.Failed(TB("The media file could not be transcribed."));
}
finally
@@ -622,7 +652,11 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
case MediaJobPhase.FAILED:
if (mediaEvent.Error is not null)
logger.LogWarning("Rust media normalization failed for '{MediaPath}' with {Code}: {Diagnostic}", mediaPath, mediaEvent.Error.Code, mediaEvent.Error.Message);
logger.LogWarning(
"Rust media normalization failed for operation {OperationId} with {Code}: {Diagnostic}",
operation.Id,
mediaEvent.Error.Code,
mediaEvent.Error.Message);
return (null, mediaEvent.Error);
@@ -785,7 +819,9 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not delete operation-owned temporary media file '{Path}'.", path);
logger.LogWarning(
"Could not delete an operation-owned temporary media file. ExceptionType={ExceptionType}",
exception.GetType().Name);
}
}
@@ -807,12 +843,15 @@ public sealed class MediaTranscriptionService(RustService rustService, SettingsM
foreach (var oldPath in new DirectoryInfo(diagnosticDirectory).EnumerateFiles("*.webm").OrderByDescending(file => file.LastWriteTimeUtc).Skip(10))
oldPath.Delete();
logger.LogInformation("Retained normalized media diagnostic '{DiagnosticPath}'.", diagnosticPath);
logger.LogInformation("Retained normalized media diagnostic for operation {OperationId}.", operationId);
return true;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Could not retain normalized media diagnostic for operation '{OperationId}'.", operationId);
logger.LogWarning(
"Could not retain normalized media diagnostic for operation {OperationId}. ExceptionType={ExceptionType}",
operationId,
exception.GetType().Name);
}
#endif
return false;
@@ -7,13 +7,16 @@ public sealed partial class RustService
/// <summary>
/// Tries to copy the given text to the clipboard.
/// </summary>
/// <param name="snackbar">The snackbar to show the result.</param>
/// <remarks>
/// The outcome is reported through the message bus. Callers used to hand in their snackbar, which
/// was the reason most components injected one at all, and it meant this notification was styled
/// here instead of together with every other notification of the app.
/// </remarks>
/// <param name="text">The text to copy to the clipboard.</param>
public async Task CopyText2Clipboard(ISnackbar snackbar, string text)
public async Task CopyText2Clipboard(string text)
{
var message = TB("Successfully copied the text to your clipboard");
var iconColor = Color.Error;
var severity = Severity.Error;
var succeeded = false;
try
{
var encryptedText = await text.Encrypt(this.encryptor!);
@@ -32,19 +35,16 @@ public sealed partial class RustService
message = TB("Failed to copy the text to your clipboard.");
return;
}
iconColor = Color.Success;
severity = Severity.Success;
succeeded = true;
this.logger!.LogDebug("Successfully copied the text to the clipboard.");
}
finally
{
snackbar.Add(message, severity, config =>
{
config.Icon = Icons.Material.Filled.ContentCopy;
config.IconSize = Size.Large;
config.IconColor = iconColor;
});
if (succeeded)
await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.ContentCopy, message));
else
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.ContentCopy, message));
}
}
}
@@ -0,0 +1,29 @@
using AIStudio.Tools.Rust;
namespace AIStudio.Tools.Services;
public sealed partial class RustService
{
/// <summary>
/// Validates and optionally optimizes a local image in the Rust runtime.
/// </summary>
/// <remarks>
/// The runtime rejects files whose content does not match their extension, so the returned MIME
/// type always describes the actual bytes.
/// </remarks>
/// <param name="path">The absolute path of a PNG, JPEG, or WebP image.</param>
/// <param name="optimize">Whether the maximum-edge policy and re-encoding are applied.</param>
/// <param name="token">The cancellation token.</param>
/// <returns>The prepared image, dimensions, and stable MIME type.</returns>
public async Task<ImagePrepareResponse> PrepareImageAsync(
string path,
bool optimize,
CancellationToken token = default)
{
using var response = await this.http.PostAsJsonAsync("/image/prepare", new { path, optimize }, this.jsonRustSerializerOptions, token);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<ImagePrepareResponse>(this.jsonRustSerializerOptions, token)
?? throw new InvalidDataException("The Rust image preparation returned an empty response.");
}
}
@@ -11,8 +11,7 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(UpdateService).Namespace, nameof(UpdateService));
private static bool IS_INITIALIZED;
private static ISnackbar? SNACKBAR;
private readonly SettingsManager settingsManager;
private readonly MessageBus messageBus;
private readonly RustService rust;
@@ -101,12 +100,7 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
if (notifyUserWhenNoUpdate)
{
SNACKBAR!.Add(TB("Failed to check for updates. Please try again later."), Severity.Error, config =>
{
config.Icon = Icons.Material.Filled.Error;
config.IconSize = Size.Large;
config.IconColor = Color.Error;
});
await this.messageBus.SendError(new(Icons.Material.Filled.Error, TB("Failed to check for updates. Please try again later.")));
}
return;
@@ -133,12 +127,7 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
}
catch (Exception)
{
SNACKBAR!.Add(TB("Failed to install update automatically. Please try again manually."), Severity.Error, config =>
{
config.Icon = Icons.Material.Filled.Error;
config.IconSize = Size.Large;
config.IconColor = Color.Error;
});
await this.messageBus.SendError(new(Icons.Material.Filled.Error, TB("Failed to install update automatically. Please try again manually.")));
}
}
else
@@ -148,12 +137,7 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
{
if (notifyUserWhenNoUpdate)
{
SNACKBAR!.Add(TB("No update found."), Severity.Normal, config =>
{
config.Icon = Icons.Material.Filled.Update;
config.IconSize = Size.Large;
config.IconColor = Color.Primary;
});
await this.messageBus.SendInfo(new(Icons.Material.Filled.Update, TB("No update found.")));
}
}
}
@@ -168,9 +152,8 @@ public sealed class UpdateService : BackgroundService, IMessageBusReceiver
_ => Timeout.InfiniteTimeSpan
};
public static void SetBlazorDependencies(ISnackbar snackbar)
{
SNACKBAR = snackbar;
IS_INITIALIZED = true;
}
/// <summary>
/// Signals that the Blazor UI is ready, so queued update notifications can be shown.
/// </summary>
public static void MarkBlazorReady() => IS_INITIALIZED = true;
}