From e0d8e18f34a4fef7469a737f8c1b345d536621c2 Mon Sep 17 00:00:00 2001 From: Thorsten Sommer Date: Sun, 25 Oct 2026 22:10:38 +0100 Subject: [PATCH] Added progress messages and quick access to exported files (#1112) --- .../Assistants/AssistantBase.razor | 17 +- .../Assistants/AssistantBase.razor.cs | 11 ++ .../Assistants/I18N/allTexts.lua | 42 +++++ .../SlideAssistant.razor.Presentation.cs | 6 +- .../VisualBriefingAssistant.razor.Versions.cs | 57 ++++++- .../Components/DataBackupPanel.razor | 10 +- .../Components/ElapsedTime.razor | 8 + .../Components/ElapsedTime.razor.cs | 75 +++++++++ .../Components/FileExportActions.razor | 13 ++ .../Components/FileExportActions.razor.cs | 73 +++++++++ .../Components/FileExportRunningMessage.razor | 8 + .../FileExportRunningMessage.razor.cs | 15 ++ .../Components/FileExportSuccessMessage.razor | 8 + .../FileExportSuccessMessage.razor.cs | 31 ++++ .../Settings/SettingsPanelApp.razor | 1 + .../Layout/MainLayout.razor.cs | 24 ++- app/MindWork AI Studio/Pages/Plugins.razor.cs | 15 +- .../plugin.lua | 42 +++++ .../plugin.lua | 42 +++++ .../Settings/DataModel/DataApp.cs | 6 + .../Tools/DataFileExportRunningMessage.cs | 32 ++++ .../Tools/DataFileExportSuccessMessage.cs | 44 ++++++ app/MindWork AI Studio/Tools/Event.cs | 16 ++ .../Tools/FileExportFeedback.cs | 134 ++++++++++++++++ .../Tools/FileExportNotifications.cs | 43 +++++ app/MindWork AI Studio/Tools/PandocExport.cs | 8 +- .../Tools/PlainFileExport.cs | 4 +- .../Tools/Services/PluginShareService.cs | 16 +- .../wwwroot/changelog/v26.10.1.md | 4 + app/Tests/Components/ElapsedTimeTests.cs | 28 ++++ app/Tests/Tools/FileExportFeedbackTests.cs | 148 ++++++++++++++++++ .../Tools/FileExportNotificationsTests.cs | 135 ++++++++++++++++ runtime/Cargo.toml | 37 +++++ 33 files changed, 1130 insertions(+), 23 deletions(-) create mode 100644 app/MindWork AI Studio/Components/ElapsedTime.razor create mode 100644 app/MindWork AI Studio/Components/ElapsedTime.razor.cs create mode 100644 app/MindWork AI Studio/Components/FileExportActions.razor create mode 100644 app/MindWork AI Studio/Components/FileExportActions.razor.cs create mode 100644 app/MindWork AI Studio/Components/FileExportRunningMessage.razor create mode 100644 app/MindWork AI Studio/Components/FileExportRunningMessage.razor.cs create mode 100644 app/MindWork AI Studio/Components/FileExportSuccessMessage.razor create mode 100644 app/MindWork AI Studio/Components/FileExportSuccessMessage.razor.cs create mode 100644 app/MindWork AI Studio/Tools/DataFileExportRunningMessage.cs create mode 100644 app/MindWork AI Studio/Tools/DataFileExportSuccessMessage.cs create mode 100644 app/MindWork AI Studio/Tools/FileExportFeedback.cs create mode 100644 app/MindWork AI Studio/Tools/FileExportNotifications.cs create mode 100644 app/Tests/Components/ElapsedTimeTests.cs create mode 100644 app/Tests/Tools/FileExportFeedbackTests.cs create mode 100644 app/Tests/Tools/FileExportNotificationsTests.cs diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor b/app/MindWork AI Studio/Assistants/AssistantBase.razor index 9660d2c29..7acf7e6e2 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor @@ -69,16 +69,25 @@ @if (this.ShowDedicatedProgress && this.IsProcessing) { - @if (string.IsNullOrWhiteSpace(this.ProgressText)) + @if (this.processingStartedAt is null && string.IsNullOrWhiteSpace(this.ProgressText)) { } else { - - @this.ProgressText - + + @if (this.processingStartedAt is { } startedAt) + { + + } + @if (!string.IsNullOrWhiteSpace(this.ProgressText)) + { + + @this.ProgressText + + } + } } diff --git a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs index c285212b5..79a79eec3 100644 --- a/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs +++ b/app/MindWork AI Studio/Assistants/AssistantBase.razor.cs @@ -180,6 +180,14 @@ public abstract partial class AssistantBase : AssistantLowerBase wher private AssistantSessionKey assistantSessionKey; private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(this.assistantSessionKey); private Guid? assistantSessionId; + + /// + /// When the running session started, which the time below the progress bar counts from; null while + /// no session runs. It comes from the session, so an instance which returns to a running session + /// goes on counting where the first one was. + /// + private DateTimeOffset? processingStartedAt; + private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot; private AssistantSessionSnapshot? lastAttachedAssistantSessionSnapshot; @@ -311,6 +319,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher } this.assistantSessionId = startedSession.SessionId; + this.processingStartedAt = startedSession.StartedAt; await this.RefreshAssistantUIAsync(); var sessionStatus = AssistantSessionStatus.COMPLETED; @@ -343,6 +352,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher finally { this.IsProcessing = false; + this.processingStartedAt = null; var sessionCancellationTokenSource = this.CancellationTokenSource; this.CancellationTokenSource = null; if (this.assistantSessionId is { } sessionId) @@ -1060,6 +1070,7 @@ public abstract partial class AssistantBase : AssistantLowerBase wher this.lastAttachedAssistantSessionSnapshot = snapshot; this.ChatThread = snapshot.ChatThread ?? this.ChatThread; this.IsProcessing = snapshot.IsActive; + this.processingStartedAt = snapshot.IsActive ? snapshot.StartedAt : null; if (!snapshot.IsActive) this.CancellationTokenSource = null; diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index 3b45fb54c..e4d139321 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3157,6 +3157,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT:: -- Opened the visual briefing project folder. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T2964042492"] = "Opened the visual briefing project folder." +-- The visual briefing could not be exported. Please check that the target file is not open in another program. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T3136382170"] = "The visual briefing could not be exported. Please check that the target file is not open in another program." + -- Visual assets UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T3226971402"] = "Visual assets" @@ -3286,6 +3289,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT:: -- If you need help, report the problem and include the project ID. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T4292361710"] = "If you need help, report the problem and include the project ID." +-- The visual briefing could not be written completely. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T442972740"] = "The visual briefing could not be written completely." + -- The briefing was recompiled with the current AI Studio version. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T453632597"] = "The briefing was recompiled with the current AI Studio version." @@ -4702,6 +4708,30 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T244391 -- Change what this tile opens UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T4272203100"] = "Change what this tile opens" +-- Could not open the file location. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1118835751"] = "Could not open the file location." + +-- Could not open the file: {0} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1290686840"] = "Could not open the file: {0}" + +-- Open with the default program +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1332010305"] = "Open with the default program" + +-- Could not open the file location: {0} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1455637941"] = "Could not open the file location: {0}" + +-- Show in the file manager +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T3007835369"] = "Show in the file manager" + +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T3461425987"] = "Unknown error" + +-- Could not open the file. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T362546438"] = "Could not open the file." + +-- Saving '{0}'... +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTRUNNINGMESSAGE::T1063135893"] = "Saving '{0}'..." + -- LLMs can make mistakes. Check important information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::HALLUZINATIONREMINDER::T3528806904"] = "LLMs can make mistakes. Check important information." @@ -5266,6 +5296,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1594053136"] -- Color theme UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] = "Color theme" +-- After you export a file, for example, an answer or a presentation, a message offers to show the file in your file manager or to open it. When enabled, this message closes after a few seconds, like other messages. When disabled, it stays until you close it. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T160366478"] = "After you export a file, for example, an answer or a presentation, a message offers to show the file in your file manager or to open it. When enabled, this message closes after a few seconds, like other messages. When disabled, it stays until you close it." + -- Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence." @@ -5317,6 +5350,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] -- Enterprise Administration UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2277116008"] = "Enterprise Administration" +-- Close messages about exported files automatically? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2330147290"] = "Close messages about exported files automatically?" + -- Language behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"] = "Language behavior" @@ -5344,6 +5380,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2700836219"] -- No preview features selected. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2809641588"] = "No preview features selected." +-- The message stays until you close it +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2879147627"] = "The message stays until you close it" + -- This installation does not check for updates itself. Contact the person or organization that installed AI Studio for update information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2918560776"] = "This installation does not check for updates itself. Contact the person or organization that installed AI Studio for update information." @@ -5413,6 +5452,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4004501229"] -- When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4067492921"] = "When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections." +-- The message closes after a few seconds +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4102409342"] = "The message closes after a few seconds" + -- Show details when suspicious content was removed? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4156872850"] = "Show details when suspicious content was removed?" diff --git a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.Presentation.cs b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.Presentation.cs index b9e7631cb..bfe109aa2 100644 --- a/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.Presentation.cs +++ b/app/MindWork AI Studio/Assistants/SlideBuilder/SlideAssistant.razor.Presentation.cs @@ -562,6 +562,10 @@ public partial class SlideAssistant var targetPath = response.SaveFilePath; this.isWritingPresentation = true; this.StateHasChanged(); + + // Pictures make this take seconds, since each of them is prepared anew: + await using var feedback = new FileExportFeedback(); + feedback.ShowRunning(targetPath); try { var options = new PresentationWriteOptions(this.variantLabel, this.footerText.Trim(), this.keepEmptyPicturePlaceholders); @@ -582,7 +586,7 @@ public partial class SlideAssistant await this.MessageBus.SendWarning(new(Icons.Material.Filled.HideImage, string.Format(T("These pictures could not be placed, because PowerPoint cannot show their format or they cannot be read: {0}"), string.Join(", ", names)))); } - await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Slideshow, T("The PowerPoint file was created."))); + await feedback.SucceededAsync(Icons.Material.Filled.Slideshow, T("The PowerPoint file was created."), targetPath, true); } catch (Exception e) { diff --git a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs index 5f3f4a2bf..589f3d385 100644 --- a/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs +++ b/app/MindWork AI Studio/Assistants/VisualBriefing/VisualBriefingAssistant.razor.Versions.cs @@ -134,8 +134,10 @@ public partial class VisualBriefingAssistant // why it gets no notice that AI generated it: any change would make it a version nobody checked. // await using var source = verified.Value.Stream; - await using var destination = new FileStream(response.SaveFilePath, FileMode.Create, FileAccess.Write, FileShare.None, 65_536, true); - await source.CopyToAsync(destination); + await using var feedback = new FileExportFeedback(); + feedback.ShowRunning(response.SaveFilePath); + if (!await this.TryWriteExportAsync(source, response.SaveFilePath)) + return; var exportedVersion = this.selectedBriefing.Versions.First(version => version.RevisionId == this.selectedRevisionId); @@ -150,7 +152,56 @@ public partial class VisualBriefingAssistant exportedVersion.DocumentHash, source.Length); - await this.MessageBus.SendSuccess(new(Icons.Material.Filled.FileDownload, T("The visual briefing was exported."))); + await feedback.SucceededAsync(Icons.Material.Filled.FileDownload, T("The visual briefing was exported."), response.SaveFilePath, true); + } + + /// + /// Writes an exported version into the file the user chose, and tells the user when that fails. + /// + /// + /// The file is written over rather than removed and created anew, which the Flatpak would not allow. + /// A file which cannot even be opened, e.g., because another program holds it, stays as it is. Once + /// writing has begun, a failure removes the file: half a briefing is worse than none. + /// + /// The verified version to export. + /// The file the user chose. + /// True, when the file was written completely. + private async Task TryWriteExportAsync(Stream source, string targetPath) + { + FileStream destination; + try + { + destination = new FileStream(targetPath, FileMode.Create, FileAccess.Write, FileShare.None, 65_536, true); + } + catch (Exception e) + { + this.Logger.LogError(e, "The visual briefing could not be exported, because the file '{FilePath}' cannot be opened for writing.", targetPath); + await this.MessageBus.SendError(new(Icons.Material.Filled.FileDownload, T("The visual briefing could not be exported. Please check that the target file is not open in another program."))); + return false; + } + + try + { + await using (destination) + await source.CopyToAsync(destination); + + return true; + } + catch (Exception e) + { + this.Logger.LogError(e, "Writing the visual briefing into the file '{FilePath}' failed.", targetPath); + try + { + File.Delete(targetPath); + } + catch (Exception deleteFailure) + { + this.Logger.LogWarning(deleteFailure, "The incomplete export '{FilePath}' cannot be deleted.", targetPath); + } + + await this.MessageBus.SendError(new(Icons.Material.Filled.FileDownload, T("The visual briefing could not be written completely."))); + return false; + } } /// diff --git a/app/MindWork AI Studio/Components/DataBackupPanel.razor b/app/MindWork AI Studio/Components/DataBackupPanel.razor index 6942679de..a183d2304 100644 --- a/app/MindWork AI Studio/Components/DataBackupPanel.razor +++ b/app/MindWork AI Studio/Components/DataBackupPanel.razor @@ -32,9 +32,13 @@ @if (backupResult.Success) { - - @string.Format(T("Backed up {0} files into: {1}"), backupResult.FileCount, backupResult.ArchivePath) - + @* Opening a backup with the default program would only unpack it, so the file manager is the one way offered. *@ + + + @string.Format(T("Backed up {0} files into: {1}"), backupResult.FileCount, backupResult.ArchivePath) + + + @if (!backupResult.IncludesDataSourceIndexes) { diff --git a/app/MindWork AI Studio/Components/ElapsedTime.razor b/app/MindWork AI Studio/Components/ElapsedTime.razor new file mode 100644 index 000000000..86240a3e5 --- /dev/null +++ b/app/MindWork AI Studio/Components/ElapsedTime.razor @@ -0,0 +1,8 @@ +@inherits MSGComponentBase + + + + + @Format(DateTimeOffset.Now - this.Start) + + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/ElapsedTime.razor.cs b/app/MindWork AI Studio/Components/ElapsedTime.razor.cs new file mode 100644 index 000000000..d39819993 --- /dev/null +++ b/app/MindWork AI Studio/Components/ElapsedTime.razor.cs @@ -0,0 +1,75 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// Shows how long something has been running, counting up every second. +/// +/// +/// A model which thinks for minutes shows nothing for just as long, and the time is what tells the user +/// that it still works. The component owns its timer and renders only itself once a second, not the +/// page around it: an assistant would otherwise render its entire form every second. +/// +public partial class ElapsedTime : MSGComponentBase +{ + private static readonly TimeSpan TICK_INTERVAL = TimeSpan.FromSeconds(1); + + /// + /// When it started. + /// + [Parameter] + public DateTimeOffset Start { get; set; } + + /// Stops counting. + private readonly CancellationTokenSource tickCancellation = new(); + + #region Overrides of ComponentBase + + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + this.TickAsync(this.tickCancellation.Token).Observe($"{nameof(ElapsedTime)}: counting the elapsed time"); + } + + #endregion + + #region Overrides of MSGComponentBase + + protected override void DisposeResources() + { + this.tickCancellation.Cancel(); + this.tickCancellation.Dispose(); + base.DisposeResources(); + } + + #endregion + + /// + /// Writes a time span as minutes and seconds, and with the hours in front from the first hour on. + /// + /// The time span; a negative one, e.g., after the clock was set back, counts as none. + /// The time span, such as 4:07 or 1:02:09. + internal static string Format(TimeSpan elapsed) + { + if (elapsed < TimeSpan.Zero) + elapsed = TimeSpan.Zero; + + var hours = (int)elapsed.TotalHours; + return hours > 0 + ? $"{hours}:{elapsed.Minutes:00}:{elapsed.Seconds:00}" + : $"{elapsed.Minutes}:{elapsed.Seconds:00}"; + } + + private async Task TickAsync(CancellationToken token) + { + using var timer = new PeriodicTimer(TICK_INTERVAL); + try + { + while (await timer.WaitForNextTickAsync(token)) + await this.InvokeAsync(this.StateHasChanged); + } + catch (OperationCanceledException) when (token.IsCancellationRequested) + { + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/FileExportActions.razor b/app/MindWork AI Studio/Components/FileExportActions.razor new file mode 100644 index 000000000..36de7d33a --- /dev/null +++ b/app/MindWork AI Studio/Components/FileExportActions.razor @@ -0,0 +1,13 @@ +@inherits MSGComponentBase + + + + + + @if (this.OffersOpening) + { + + + + } + \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/FileExportActions.razor.cs b/app/MindWork AI Studio/Components/FileExportActions.razor.cs new file mode 100644 index 000000000..ed811b1bd --- /dev/null +++ b/app/MindWork AI Studio/Components/FileExportActions.razor.cs @@ -0,0 +1,73 @@ +using AIStudio.Tools.Services; + +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// The ways from an exported file to the file itself: the file manager, which shows it, and the default +/// program, which opens it. +/// +/// +/// Whoever chose where a file goes wants to get to it afterward, and a message which merely says that +/// the export succeeded leaves them searching. Both ways go through the runtime, like those of the list +/// of sources: under Linux, the file manager is reached through the desktop portal, and the runtime +/// refuses to start programs and launchers. +/// +public partial class FileExportActions : MSGComponentBase +{ + /// + /// The exported file. + /// + [Parameter] + public string FilePath { get; set; } = string.Empty; + + /// + /// Whether the file may be opened with the default program. False for an archive, which that + /// program would only unpack, at least on macOS. + /// + [Parameter] + public bool OffersOpening { get; set; } + + [Inject] + private RustService RustService { get; init; } = null!; + + [Inject] + private ILogger Logger { get; init; } = null!; + + private async Task ShowInFileManager() + { + try + { + var response = await this.RustService.TryOpenPathInRuntimeFileManager(this.FilePath); + if (response.Success) + return; + + var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue; + await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, string.Format(T("Could not open the file location: {0}"), issue))); + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Could not show an exported file in the file manager."); + await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, T("Could not open the file location."))); + } + } + + private async Task OpenWithDefaultProgram() + { + try + { + var response = await this.RustService.TryOpenDocumentInSystemViewer(this.FilePath, null); + if (response.Success) + return; + + var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue; + await this.MessageBus.SendError(new(Icons.Material.Filled.OpenInNew, string.Format(T("Could not open the file: {0}"), issue))); + } + catch (Exception e) + { + this.Logger.LogWarning(e, "Could not open an exported file with the default program."); + await this.MessageBus.SendError(new(Icons.Material.Filled.OpenInNew, T("Could not open the file."))); + } + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/FileExportRunningMessage.razor b/app/MindWork AI Studio/Components/FileExportRunningMessage.razor new file mode 100644 index 000000000..076e34cb4 --- /dev/null +++ b/app/MindWork AI Studio/Components/FileExportRunningMessage.razor @@ -0,0 +1,8 @@ +@inherits MSGComponentBase + + + +
+ @string.Format(T("Saving '{0}'..."), this.FileName) +
+
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/FileExportRunningMessage.razor.cs b/app/MindWork AI Studio/Components/FileExportRunningMessage.razor.cs new file mode 100644 index 000000000..c94b5554f --- /dev/null +++ b/app/MindWork AI Studio/Components/FileExportRunningMessage.razor.cs @@ -0,0 +1,15 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// The content of the notification that a file is being exported, see DataFileExportRunningMessage. +/// +public partial class FileExportRunningMessage : MSGComponentBase +{ + /// + /// The name of the file which is being written. + /// + [Parameter] + public string FileName { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/FileExportSuccessMessage.razor b/app/MindWork AI Studio/Components/FileExportSuccessMessage.razor new file mode 100644 index 000000000..1b723d946 --- /dev/null +++ b/app/MindWork AI Studio/Components/FileExportSuccessMessage.razor @@ -0,0 +1,8 @@ +@inherits MSGComponentBase + + +
+ @this.Message +
+ +
\ No newline at end of file diff --git a/app/MindWork AI Studio/Components/FileExportSuccessMessage.razor.cs b/app/MindWork AI Studio/Components/FileExportSuccessMessage.razor.cs new file mode 100644 index 000000000..2ebe11838 --- /dev/null +++ b/app/MindWork AI Studio/Components/FileExportSuccessMessage.razor.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Components; + +namespace AIStudio.Components; + +/// +/// The content of the notification that a file was exported, see DataFileExportSuccessMessage. +/// +/// +/// Taking one of the ways to the file leaves the notification open: whoever opened the file often +/// wants to see it in its folder afterward, or the other way round. +/// +public partial class FileExportSuccessMessage : MSGComponentBase +{ + /// + /// What was exported. + /// + [Parameter] + public string Message { get; set; } = string.Empty; + + /// + /// The file which was written. + /// + [Parameter] + public string FilePath { get; set; } = string.Empty; + + /// + /// Whether the file may be opened with the default program. + /// + [Parameter] + public bool OffersOpening { get; set; } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor index acd909b46..b40ee6f65 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelApp.razor @@ -14,6 +14,7 @@ + diff --git a/app/MindWork AI Studio/Layout/MainLayout.razor.cs b/app/MindWork AI Studio/Layout/MainLayout.razor.cs index 6e0c33c2a..7e2334fd0 100644 --- a/app/MindWork AI Studio/Layout/MainLayout.razor.cs +++ b/app/MindWork AI Studio/Layout/MainLayout.razor.cs @@ -68,7 +68,9 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan private IJSRuntime JsRuntime { get; init; } = null!; private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage; - + + private FileExportNotifications FileExportNotifications => this.fileExportNotifications ??= new(this.Snackbar); + private string PaddingLeft => this.navBarOpen ? $"padding-left: {NAVBAR_EXPANDED_WIDTH_INT - NAVBAR_COLLAPSED_WIDTH_INT}em;" : "padding-left: 0em;"; private const int NAVBAR_COLLAPSED_WIDTH_INT = 4; @@ -83,6 +85,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan private bool useDarkMode; private bool startupCompleted; private bool settingsWriteProtectionWarningShown; + private FileExportNotifications? fileExportNotifications; private readonly SemaphoreSlim mandatoryInfoDialogSemaphore = new(1, 1); private readonly SemaphoreSlim promptInjectionDialogSemaphore = new(1, 1); private readonly SemaphoreSlim dataRestorePendingDialogSemaphore = new(1, 1); @@ -133,6 +136,7 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan [ Event.UPDATE_AVAILABLE, Event.CONFIGURATION_CHANGED, Event.COLOR_THEME_CHANGED, Event.SHOW_ERROR, Event.SHOW_WARNING, Event.SHOW_SUCCESS, Event.SHOW_INFO, Event.SHOW_PROMPT_INJECTION_ALERT, Event.STARTUP_PLUGIN_SYSTEM, Event.PLUGINS_RELOADED, + Event.SHOW_FILE_EXPORT_RUNNING, Event.SHOW_FILE_EXPORT_SUCCESS, Event.HIDE_FILE_EXPORT_RUNNING, Event.INSTALL_UPDATE, Event.STARTUP_COMPLETED, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.RAG_EMBEDDING_STATUS_CHANGED,Event.ASSISTANT_SESSION_CHANGED, Event.ASSISTANT_SESSION_FINISHED, @@ -326,6 +330,24 @@ public partial class MainLayout : LayoutComponentBase, IMessageBusReceiver, ILan break; + case Event.SHOW_FILE_EXPORT_RUNNING: + if (data is DataFileExportRunningMessage exportRunning) + this.FileExportNotifications.ShowRunning(exportRunning); + + break; + + case Event.SHOW_FILE_EXPORT_SUCCESS: + if (data is DataFileExportSuccessMessage exportSuccess) + this.FileExportNotifications.ShowSuccess(exportSuccess, this.SettingsManager.ConfigurationData.App.CloseExportMessagesAutomatically); + + break; + + case Event.HIDE_FILE_EXPORT_RUNNING: + if (data is DataFileExportRunningMessage exportFinished) + this.FileExportNotifications.HideRunning(exportFinished); + + break; + case Event.STARTUP_PLUGIN_SYSTEM: Task.Run(async () => { diff --git a/app/MindWork AI Studio/Pages/Plugins.razor.cs b/app/MindWork AI Studio/Pages/Plugins.razor.cs index 81d707398..a7bea9a7b 100644 --- a/app/MindWork AI Studio/Pages/Plugins.razor.cs +++ b/app/MindWork AI Studio/Pages/Plugins.razor.cs @@ -371,9 +371,12 @@ public partial class Plugins : MSGComponentBase try { + // Shows that an export runs, and nothing when the share sheet takes the archive: + await using var exportFeedback = new FileExportFeedback(); + // A failed share sheet switches to the export, so we remember what this click does: var isExport = this.PluginShareService.UsesExport; - var shareResult = await this.PluginShareService.ShareAsync(plugin, CancellationToken.None); + var shareResult = await this.PluginShareService.ShareAsync(plugin, exportFeedback, CancellationToken.None); if (shareResult.Outcome is PluginShareOutcome.SHARE_SHEET_UNAVAILABLE) { // The runtime and NativeShareService already logged why the share sheet did not open: @@ -381,14 +384,18 @@ public partial class Plugins : MSGComponentBase return; isExport = true; - shareResult = await this.PluginShareService.ExportAsync(plugin, CancellationToken.None); + shareResult = await this.PluginShareService.ExportAsync(plugin, exportFeedback, CancellationToken.None); } switch (shareResult.Outcome) { - // On export, the user chose the target location, so we confirm where the archive was stored: + // + // On export, the user chose the target location, so we confirm where the archive was + // stored and offer the way there. Opening it with the default program is left out: + // that would only unpack the archive, at least on macOS. + // case PluginShareOutcome.EXPORTED: - await this.MessageBus.SendSuccess(new(Icons.Material.Filled.FileDownload, string.Format(T("The plugin archive was exported to '{0}'."), shareResult.ArchivePath))); + await exportFeedback.SucceededAsync(Icons.Material.Filled.FileDownload, string.Format(T("The plugin archive was exported to '{0}'."), shareResult.ArchivePath), shareResult.ArchivePath, false); break; case PluginShareOutcome.FAILED: diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index d2153cb3d..d9df2bbbb 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -3159,6 +3159,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT:: -- Opened the visual briefing project folder. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T2964042492"] = "Projektordner für das visuelle Briefing öffnen." +-- The visual briefing could not be exported. Please check that the target file is not open in another program. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T3136382170"] = "Das visuelle Briefing konnte nicht exportiert werden. Bitte überprüfen Sie, ob die Zieldatei nicht in einem anderen Programm geöffnet ist." + -- Visual assets UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T3226971402"] = "Visuelle Elemente" @@ -3288,6 +3291,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT:: -- If you need help, report the problem and include the project ID. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T4292361710"] = "Wenn Sie Hilfe benötigen, melden Sie das Problem und geben Sie die Projekt-ID an." +-- The visual briefing could not be written completely. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T442972740"] = "Das visuelle Briefing konnte nicht vollständig geschrieben werden." + -- The briefing was recompiled with the current AI Studio version. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T453632597"] = "Das Briefing wurde mit der aktuellen AI Studio-Version neu kompiliert." @@ -4704,6 +4710,30 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T244391 -- Change what this tile opens UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T4272203100"] = "Ändern, was diese Kachel öffnet" +-- Could not open the file location. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1118835751"] = "Der Speicherort der Datei konnte nicht geöffnet werden." + +-- Could not open the file: {0} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1290686840"] = "Die Datei konnte nicht geöffnet werden: {0}" + +-- Open with the default program +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1332010305"] = "Mit dem Standardprogramm öffnen" + +-- Could not open the file location: {0} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1455637941"] = "Der Speicherort der Datei konnte nicht geöffnet werden: {0}" + +-- Show in the file manager +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T3007835369"] = "Im Dateimanager anzeigen" + +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T3461425987"] = "Unbekannter Fehler" + +-- Could not open the file. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T362546438"] = "Die Datei konnte nicht geöffnet werden." + +-- Saving '{0}'... +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTRUNNINGMESSAGE::T1063135893"] = "„{0}“ wird gespeichert …" + -- LLMs can make mistakes. Check important information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::HALLUZINATIONREMINDER::T3528806904"] = "LLMs können Fehler machen. Überprüfen Sie wichtige Informationen." @@ -5268,6 +5298,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1594053136"] -- Color theme UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] = "Farbschema" +-- After you export a file, for example, an answer or a presentation, a message offers to show the file in your file manager or to open it. When enabled, this message closes after a few seconds, like other messages. When disabled, it stays until you close it. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T160366478"] = "Nach dem Export einer Datei, zum Beispiel einer Antwort oder Präsentation, erscheint eine Meldung, über die Sie die Datei im Dateimanager anzeigen oder öffnen können. Wenn diese Option aktiviert ist, schließt sich die Meldung wie andere Meldungen nach einigen Sekunden. Wenn sie deaktiviert ist, bleibt die Meldung sichtbar, bis Sie sie schließen." + -- Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Möchten Sie eines Ihrer Profile als Standard für die gesamte App festlegen? Wenn Sie einem Assistenten ein anderes Profil zuweisen, hat dieses immer Vorrang." @@ -5319,6 +5352,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] -- Enterprise Administration UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2277116008"] = "Unternehmensverwaltung" +-- Close messages about exported files automatically? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2330147290"] = "Meldungen zu exportierten Dateien automatisch schließen?" + -- Language behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"] = "Sprachverhalten" @@ -5346,6 +5382,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2700836219"] -- No preview features selected. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2809641588"] = "Keine Vorschau-Funktionen ausgewählt." +-- The message stays until you close it +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2879147627"] = "Die Meldung bleibt sichtbar, bis Sie sie schließen" + -- This installation does not check for updates itself. Contact the person or organization that installed AI Studio for update information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2918560776"] = "Diese Installation sucht nicht selbst nach Updates. Wenden Sie sich an die Person oder Organisation, die AI Studio installiert hat, um Informationen zu Updates zu erhalten." @@ -5415,6 +5454,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4004501229"] -- When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4067492921"] = "Wenn aktiviert, ist die Rechtschreibprüfung in allen Eingabefeldern aktiv. Je nach Betriebssystem werden Fehler möglicherweise nicht visuell hervorgehoben, aber ein Rechtsklick kann dennoch Korrekturvorschläge anzeigen." +-- The message closes after a few seconds +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4102409342"] = "Die Meldung schließt sich nach einigen Sekunden" + -- Show details when suspicious content was removed? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4156872850"] = "Details anzeigen, wenn verdächtige Inhalte entfernt wurden?" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index ada2657ae..68af71801 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -3159,6 +3159,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT:: -- Opened the visual briefing project folder. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T2964042492"] = "Opened the visual briefing project folder." +-- The visual briefing could not be exported. Please check that the target file is not open in another program. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T3136382170"] = "The visual briefing could not be exported. Please check that the target file is not open in another program." + -- Visual assets UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T3226971402"] = "Visual assets" @@ -3288,6 +3291,9 @@ UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT:: -- If you need help, report the problem and include the project ID. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T4292361710"] = "If you need help, report the problem and include the project ID." +-- The visual briefing could not be written completely. +UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T442972740"] = "The visual briefing could not be written completely." + -- The briefing was recompiled with the current AI Studio version. UI_TEXT_CONTENT["AISTUDIO::ASSISTANTS::VISUALBRIEFING::VISUALBRIEFINGASSISTANT::T453632597"] = "The briefing was recompiled with the current AI Studio version." @@ -4704,6 +4710,30 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T244391 -- Change what this tile opens UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DIRECTCHATLAUNCHERSETTINGSACTION::T4272203100"] = "Change what this tile opens" +-- Could not open the file location. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1118835751"] = "Could not open the file location." + +-- Could not open the file: {0} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1290686840"] = "Could not open the file: {0}" + +-- Open with the default program +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1332010305"] = "Open with the default program" + +-- Could not open the file location: {0} +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T1455637941"] = "Could not open the file location: {0}" + +-- Show in the file manager +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T3007835369"] = "Show in the file manager" + +-- Unknown error +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T3461425987"] = "Unknown error" + +-- Could not open the file. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTACTIONS::T362546438"] = "Could not open the file." + +-- Saving '{0}'... +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::FILEEXPORTRUNNINGMESSAGE::T1063135893"] = "Saving '{0}'..." + -- LLMs can make mistakes. Check important information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::HALLUZINATIONREMINDER::T3528806904"] = "LLMs can make mistakes. Check important information." @@ -5268,6 +5298,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1594053136"] -- Color theme UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1599198973"] = "Color theme" +-- After you export a file, for example, an answer or a presentation, a message offers to show the file in your file manager or to open it. When enabled, this message closes after a few seconds, like other messages. When disabled, it stays until you close it. +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T160366478"] = "After you export a file, for example, an answer or a presentation, a message offers to show the file in your file manager or to open it. When enabled, this message closes after a few seconds, like other messages. When disabled, it stays until you close it." + -- Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T1666052109"] = "Would you like to set one of your profiles as the default for the entire app? When you configure a different profile for an assistant, it will always take precedence." @@ -5319,6 +5352,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T215381891"] -- Enterprise Administration UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2277116008"] = "Enterprise Administration" +-- Close messages about exported files automatically? +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2330147290"] = "Close messages about exported files automatically?" + -- Language behavior UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2341504363"] = "Language behavior" @@ -5346,6 +5382,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2700836219"] -- No preview features selected. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2809641588"] = "No preview features selected." +-- The message stays until you close it +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2879147627"] = "The message stays until you close it" + -- This installation does not check for updates itself. Contact the person or organization that installed AI Studio for update information. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T2918560776"] = "This installation does not check for updates itself. Contact the person or organization that installed AI Studio for update information." @@ -5415,6 +5454,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4004501229"] -- When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections. UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4067492921"] = "When enabled, spellchecking will be active in all input fields. Depending on your operating system, errors may not be visually highlighted, but right-clicking may still offer possible corrections." +-- The message closes after a few seconds +UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4102409342"] = "The message closes after a few seconds" + -- Show details when suspicious content was removed? UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELAPP::T4156872850"] = "Show details when suspicious content was removed?" diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index f8abb17a5..d009c36f7 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -40,6 +40,12 @@ public sealed class DataApp(Expression>? configSelection = n ///
public bool EnableSpellchecking { get; set; } + /// + /// Should the message about an exported file close after a few seconds? When false, it stays until + /// the user closes it, so that its buttons to the file remain at hand. + /// + public bool CloseExportMessagesAutomatically { get; set; } + /// /// If and when we should look for updates. /// diff --git a/app/MindWork AI Studio/Tools/DataFileExportRunningMessage.cs b/app/MindWork AI Studio/Tools/DataFileExportRunningMessage.cs new file mode 100644 index 000000000..46d58fe14 --- /dev/null +++ b/app/MindWork AI Studio/Tools/DataFileExportRunningMessage.cs @@ -0,0 +1,32 @@ +using AIStudio.Components; + +namespace AIStudio.Tools; + +/// +/// The notification that a file is being exported. +/// +/// +/// It stays until the export ends, however long that takes, since a timeout would claim an end which +/// has not happened. Closing it is allowed: the export goes on all the same. +/// +/// The key of the export, shared with its success notification, which replaces this one. +/// The file the export writes. +public readonly record struct DataFileExportRunningMessage(string Key, string FilePath) +{ + public void Show(ISnackbar snackbar) + { + var parameters = new Dictionary + { + [nameof(FileExportRunningMessage.FileName)] = Path.GetFileName(this.FilePath) + }; + + snackbar.Add(parameters, Severity.Info, config => + { + config.HideIcon = true; + config.RequireInteraction = true; + config.HideTransitionDuration = 600; + }, this.Key); + } + + public void Hide(ISnackbar snackbar) => snackbar.RemoveByKey(this.Key); +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/DataFileExportSuccessMessage.cs b/app/MindWork AI Studio/Tools/DataFileExportSuccessMessage.cs new file mode 100644 index 000000000..23e3fe730 --- /dev/null +++ b/app/MindWork AI Studio/Tools/DataFileExportSuccessMessage.cs @@ -0,0 +1,44 @@ +using AIStudio.Components; + +namespace AIStudio.Tools; + +/// +/// The notification that a file was exported, with the ways to reach it. +/// +/// +/// By default, it stays until the user closes it, also after they took one of its ways: whoever exports +/// a file wants to get to it, often in both ways, and that may well happen later than a timeout would +/// allow. Users who prefer it otherwise let it close like other messages, see +/// DataApp.CloseExportMessagesAutomatically. +/// +/// The key of the export. The notification that the export runs has the same one and gets replaced. +/// The icon of the notification. +/// What was exported. +/// The file which was written. +/// Whether the file may be opened with the default program. False for an +/// archive, which that program would only unpack, at least on macOS. +public readonly record struct DataFileExportSuccessMessage(string Key, string Icon, string Message, string FilePath, bool OffersOpening) +{ + /// The notifications of the window. + /// Whether the notification closes after a few seconds, like other success messages. + public void Show(ISnackbar snackbar, bool closesAutomatically) + { + var parameters = new Dictionary + { + [nameof(FileExportSuccessMessage.Message)] = this.Message, + [nameof(FileExportSuccessMessage.FilePath)] = this.FilePath, + [nameof(FileExportSuccessMessage.OffersOpening)] = this.OffersOpening + }; + + var icon = this.Icon; + snackbar.RemoveByKey(this.Key); + snackbar.Add(parameters, Severity.Success, config => + { + config.Icon = icon; + config.IconSize = Size.Large; + config.RequireInteraction = !closesAutomatically; + config.VisibleStateDuration = 10_000; + config.HideTransitionDuration = 600; + }, this.Key); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/Event.cs b/app/MindWork AI Studio/Tools/Event.cs index fba32b942..7a1ae1675 100644 --- a/app/MindWork AI Studio/Tools/Event.cs +++ b/app/MindWork AI Studio/Tools/Event.cs @@ -78,6 +78,22 @@ public enum Event /// SHOW_INFO, + /// + /// Requests display of the notification that a file is being exported. + /// + SHOW_FILE_EXPORT_RUNNING, + + /// + /// Requests display of the notification that a file was exported, with the ways to reach it. + /// It replaces the notification that the export runs. + /// + SHOW_FILE_EXPORT_SUCCESS, + + /// + /// Requests removal of the notification that a file is being exported. + /// + HIDE_FILE_EXPORT_RUNNING, + /// /// Requests display of a prompt-injection alert dialog. /// diff --git a/app/MindWork AI Studio/Tools/FileExportFeedback.cs b/app/MindWork AI Studio/Tools/FileExportFeedback.cs new file mode 100644 index 000000000..96ed43a20 --- /dev/null +++ b/app/MindWork AI Studio/Tools/FileExportFeedback.cs @@ -0,0 +1,134 @@ +namespace AIStudio.Tools; + +/// +/// Tells the user that a file is being exported, and afterward where it went. +/// +/// +/// Start it once the user chose the file, and dispose of it when the export is over, also after an error: +/// disposing removes the notification that the export runs, in case it still stands. That notification +/// appears only after a moment, so that a fast export shows its success right away instead of two +/// notifications which flicker one after the other. +///

+/// The notification that the export runs must never appear after its end, where it would stay until the +/// user closes it. The delayed notification and the end of the export therefore take the same lock, and +/// whichever comes second sees what the first one did. That settles the order in which they are sent; +/// FileExportNotifications settles the order in which they take effect. +///
+public sealed class FileExportFeedback : IAsyncDisposable +{ + /// + /// How long an export runs before the user hears about it. + /// + private static readonly TimeSpan RUNNING_MESSAGE_DELAY = TimeSpan.FromMilliseconds(500); + + private readonly Func send; + private readonly Func waitForRunningMessage; + private readonly string key = Guid.NewGuid().ToString("N"); + private readonly SemaphoreSlim sendLock = new(1, 1); + private readonly CancellationTokenSource runningMessageCancellation = new(); + + private DataFileExportRunningMessage runningMessage; + private Task delayedRunningMessage = Task.CompletedTask; + private bool isRunningMessageShown; + private bool isFinished; + + public FileExportFeedback() : this((triggeredEvent, data) => MessageBus.INSTANCE.SendMessage(null, triggeredEvent, data), token => Task.Delay(RUNNING_MESSAGE_DELAY, token)) + { + } + + /// + /// Creates the feedback with what it needs from outside, so that tests can stand in for both. + /// + /// Sends a notification, as the message bus does. + /// Waits until the notification that the export runs is due. + internal FileExportFeedback(Func send, Func waitForRunningMessage) + { + this.send = send; + this.waitForRunningMessage = waitForRunningMessage; + } + + /// + /// Shows that the given file is being exported, unless the export ends before that is due. + /// + /// The file the export writes. + public void ShowRunning(string filePath) + { + this.runningMessage = new DataFileExportRunningMessage(this.key, filePath); + this.delayedRunningMessage = this.ShowRunningWhenDueAsync(this.runningMessageCancellation.Token); + } + + /// + /// Ends the export with the notification of its success, which replaces the one that it runs. + /// + /// The icon of the notification. + /// What was exported. + /// The file which was written. + /// Whether the file may be opened with the default program. + public Task SucceededAsync(string icon, string message, string filePath, bool offersOpening) => this.FinishAsync(new DataFileExportSuccessMessage(this.key, icon, message, filePath, offersOpening)); + + private async Task ShowRunningWhenDueAsync(CancellationToken token) + { + try + { + await this.waitForRunningMessage(token); + } + catch (OperationCanceledException) + { + return; + } + + await this.sendLock.WaitAsync(); + try + { + if (this.isFinished) + return; + + await this.send(Event.SHOW_FILE_EXPORT_RUNNING, this.runningMessage); + this.isRunningMessageShown = true; + } + finally + { + this.sendLock.Release(); + } + } + + /// + /// Ends the export, with the notification of its success or without any. + /// + /// The notification of the success, or null when the export failed. + private async Task FinishAsync(DataFileExportSuccessMessage? success) + { + await this.runningMessageCancellation.CancelAsync(); + await this.sendLock.WaitAsync(); + try + { + if (this.isFinished) + return; + + this.isFinished = true; + if (success is { } successMessage) + await this.send(Event.SHOW_FILE_EXPORT_SUCCESS, successMessage); + else if (this.isRunningMessageShown) + await this.send(Event.HIDE_FILE_EXPORT_RUNNING, this.runningMessage); + } + finally + { + this.sendLock.Release(); + } + } + + #region Implementation of IAsyncDisposable + + public async ValueTask DisposeAsync() + { + await this.FinishAsync(null); + + // The delayed notification may still wait for the lock; it sees the end and gives up: + await this.delayedRunningMessage; + + this.runningMessageCancellation.Dispose(); + this.sendLock.Dispose(); + } + + #endregion +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/FileExportNotifications.cs b/app/MindWork AI Studio/Tools/FileExportNotifications.cs new file mode 100644 index 000000000..e9ce827f5 --- /dev/null +++ b/app/MindWork AI Studio/Tools/FileExportNotifications.cs @@ -0,0 +1,43 @@ +namespace AIStudio.Tools; + +/// +/// Shows the notifications of file exports in one window, and keeps a late notification that an export +/// runs from appearing after its end. +/// +/// +/// FileExportFeedback sends its notifications in the right order, but they may take effect in another +/// one. The notification that an export runs comes late, from a timer, and waits in the queue of the +/// renderer. The end of an export usually comes from a component, on the thread of the renderer, and +/// takes effect right away: it overtakes the waiting notification, which would then appear after the +/// end and stay until the user closes it. So we remember which exports ended and drop what comes late. +///

+/// One key stays per export which ended, for as long as the window lives. That is a few bytes per +/// file the user saved, which is not worth a cleanup that could drop a key too early. +///
+/// The notifications of the window. +public sealed class FileExportNotifications(ISnackbar snackbar) +{ + private readonly HashSet finishedExports = []; + + public void ShowRunning(DataFileExportRunningMessage message) + { + if (this.finishedExports.Contains(message.Key)) + return; + + message.Show(snackbar); + } + + /// The notification of the success. + /// Whether the notification closes after a few seconds, as the user chose. + public void ShowSuccess(DataFileExportSuccessMessage message, bool closesAutomatically) + { + this.finishedExports.Add(message.Key); + message.Show(snackbar, closesAutomatically); + } + + public void HideRunning(DataFileExportRunningMessage message) + { + this.finishedExports.Add(message.Key); + message.Hide(snackbar); + } +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PandocExport.cs b/app/MindWork AI Studio/Tools/PandocExport.cs index e83275a03..d67dd2df6 100644 --- a/app/MindWork AI Studio/Tools/PandocExport.cs +++ b/app/MindWork AI Studio/Tools/PandocExport.cs @@ -157,13 +157,19 @@ public static class PandocExport if (!pandocState.IsAvailable) return false; + // + // The export runs from here on. Before, the user may still be looking at the dialog which + // offers to install Pandoc, and a notification that we save would be wrong there: + // + await using var feedback = new FileExportFeedback(); + feedback.ShowRunning(response.SaveFilePath); if (!await ConvertAsync(rustService, markdownText, response.SaveFilePath, format)) { await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("The export failed."))); return false; } - await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, TB("The export succeeded."))); + await feedback.SucceededAsync(Icons.Material.Filled.CheckCircle, TB("The export succeeded."), response.SaveFilePath, true); return true; } } diff --git a/app/MindWork AI Studio/Tools/PlainFileExport.cs b/app/MindWork AI Studio/Tools/PlainFileExport.cs index 9ff96576b..def245765 100644 --- a/app/MindWork AI Studio/Tools/PlainFileExport.cs +++ b/app/MindWork AI Studio/Tools/PlainFileExport.cs @@ -209,10 +209,12 @@ public static class PlainFileExport LOGGER.LogInformation("The user chose the path '{SaveFilePath}' for the {ExportFormat} export.", response.SaveFilePath, format); + await using var feedback = new FileExportFeedback(); + feedback.ShowRunning(response.SaveFilePath); try { await File.WriteAllTextAsync(response.SaveFilePath, fileContent, format.ToFileEncoding()); - await MessageBus.INSTANCE.SendSuccess(new(Icons.Material.Filled.CheckCircle, TB("The export succeeded."))); + await feedback.SucceededAsync(Icons.Material.Filled.CheckCircle, TB("The export succeeded."), response.SaveFilePath, true); return true; } diff --git a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs index cd0025de8..77dde1697 100644 --- a/app/MindWork AI Studio/Tools/Services/PluginShareService.cs +++ b/app/MindWork AI Studio/Tools/Services/PluginShareService.cs @@ -42,17 +42,19 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru /// Studio restarts. /// /// The local plugin to archive and share. + /// Shows that the archive is being written, once the user chose where. + /// The caller ends it with the success, which only the caller announces. /// Cancellation token for archive creation. /// The share result, including the archive path when successful. When the share sheet /// failed or did not respond, the outcome is SHARE_SHEET_UNAVAILABLE, and ExportAsync can still /// export the plugin. - public async Task ShareAsync(IAvailablePlugin plugin, CancellationToken token) + public async Task ShareAsync(IAvailablePlugin plugin, FileExportFeedback exportFeedback, CancellationToken token) { if (!this.TryGetShareablePluginRoot(plugin, out var pluginRoot, out var issue)) return ShareError(plugin, issue); if (this.UsesExport) - return await this.ExportArchiveAsync(plugin, pluginRoot, token); + return await this.ExportArchiveAsync(plugin, pluginRoot, exportFeedback, token); return await this.ShareViaNativeSheetAsync(plugin, pluginRoot, token); } @@ -62,14 +64,16 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru /// be available. This is the way out after the share sheet failed or did not respond. /// /// The local plugin to archive and export. + /// Shows that the archive is being written, once the user chose where. + /// The caller ends it with the success, which only the caller announces. /// Cancellation token for archive creation. /// The export result, including the chosen archive path when successful. - public async Task ExportAsync(IAvailablePlugin plugin, CancellationToken token) + public async Task ExportAsync(IAvailablePlugin plugin, FileExportFeedback exportFeedback, CancellationToken token) { if (!this.TryGetShareablePluginRoot(plugin, out var pluginRoot, out var issue)) return ShareError(plugin, issue); - return await this.ExportArchiveAsync(plugin, pluginRoot, token); + return await this.ExportArchiveAsync(plugin, pluginRoot, exportFeedback, token); } /// @@ -109,9 +113,10 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru /// /// The local plugin to archive. /// The validated plugin root directory. + /// Shows that the archive is being written. /// Cancellation token for archive creation. /// The share result, including the chosen archive path when successful. - private async Task ExportArchiveAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token) + private async Task ExportArchiveAsync(IAvailablePlugin plugin, string pluginRoot, FileExportFeedback exportFeedback, CancellationToken token) { var suggestedFileName = $"{CreateSafeFileNamePrefix(plugin.Name)}{PluginArchive.PLUGIN_FILE_EXTENSION}"; var saveResponse = await rustService.SaveFile(TB("Export plugin archive"), [FileTypes.PLUGIN_ARCHIVE], suggestedFileName); @@ -119,6 +124,7 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru return new(PluginShareOutcome.CANCELLED, plugin.Name, string.Empty, string.Empty); var archivePath = saveResponse.SaveFilePath; + exportFeedback.ShowRunning(archivePath); try { token.ThrowIfCancellationRequested(); diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md index 787c37521..3275bd568 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.10.1.md @@ -60,6 +60,9 @@ - Improved attaching images to chats and assistants. AI Studio now converts BMP and TIFF images into a format the AI understands, and it tells you right away when it cannot give an image to the AI, for example, in HEIC or SVG format. - Improved the jump to the right page for the sources of your own documents (RAG): when you open PDFs with PdfCraft, the document now opens directly at the page where the passage was found, on Windows, macOS, and Linux. Each source opens in a PdfCraft window of its own. - Improved assistants which write long answers, such as the Slide Assistant. While the AI writes, AI Studio now spends less time updating the screen. The Slide Assistant also tells you what the AI is doing while it plans, for example, which slide it is writing. +- Improved assistants which take a while, such as the Slide Assistant. Below their progress bar, AI Studio now shows how long the assistant has been working. +- Improved saving files, for example, when you export an answer, save the presentation of the Slide Assistant, or export a plugin. While a large file is being saved, AI Studio now tells you so instead of seeming to do nothing. +- Improved the message that a file was saved, for example, an exported answer or the presentation of the Slide Assistant. It now offers buttons to show the file in your file manager or to open it with its default program, and it stays until you close it, unless you let it close after a few seconds in the app settings. When you back up your data, you can show the backup in your file manager as well. - Changed the banner on the start page to a fresh design that shows what AI Studio can do today. - Changed when a chat you add to a workspace in the workspace list appears there. Like any other new chat, it now appears once there is something to keep, for example, your first message, instead of right away. - Changed the name of the Slide Planner Assistant to Slide Assistant. With a PowerPoint template, it now creates the slides themselves, not just a plan for them. @@ -131,6 +134,7 @@ - Fixed AI Studio sometimes discarding the answer of the AI when it chose your data sources, checked the passages it found, or checked an assistant plugin for security issues, because the AI wrote its answer in a slightly different form than expected. - Fixed assistants doing nothing at all when you started them without selecting a provider. They now ask you to select one, together with anything else that is still missing. - Fixed an assistant undoing your changes every few seconds when you left it during a run and came back before it finished. Your changes now stay, and the result still appears when the assistant is done. In the Slide Assistant, this also ends short freezes while it plans. +- Fixed exporting a briefing of the Visual Briefing assistant into a file which another program keeps open or which cannot be written. AI Studio now tells you that the export failed. - Updated the code contributions on the supporters page, which now thank everyone who has contributed code to AI Studio so far. - Upgraded several libraries to improve security. - Upgraded to Rust v1.99.1 diff --git a/app/Tests/Components/ElapsedTimeTests.cs b/app/Tests/Components/ElapsedTimeTests.cs new file mode 100644 index 000000000..867e1f2fa --- /dev/null +++ b/app/Tests/Components/ElapsedTimeTests.cs @@ -0,0 +1,28 @@ +using AIStudio.Components; + +namespace AIStudio.Tests.Components; + +/// +/// Checks how the time below the progress bar of an assistant is written. +/// +/// +/// Minutes and seconds suffice for almost every run; the hours come in front only once there are any, +/// and they keep counting past a day instead of starting over. +/// +[TestFixture] +public sealed class ElapsedTimeTests +{ + [TestCase(0, ExpectedResult = "0:00")] + [TestCase(9, ExpectedResult = "0:09")] + [TestCase(61, ExpectedResult = "1:01")] + [TestCase(59 * 60 + 59, ExpectedResult = "59:59")] + [TestCase(60 * 60, ExpectedResult = "1:00:00")] + [TestCase(25 * 60 * 60 + 3 * 60 + 4, ExpectedResult = "25:03:04")] + public string ATimeSpanIsWrittenAsMinutesAndSecondsWithHoursWhereThereAreAny(int seconds) => ElapsedTime.Format(TimeSpan.FromSeconds(seconds)); + + [Test] + public void ANegativeTimeSpanCountsAsNone() => Assert.That(ElapsedTime.Format(TimeSpan.FromSeconds(-5)), Is.EqualTo("0:00"), "A clock which was set back must not show a negative time."); + + [Test] + public void FractionsOfASecondAreCutOff() => Assert.That(ElapsedTime.Format(TimeSpan.FromMilliseconds(1_999)), Is.EqualTo("0:01")); +} \ No newline at end of file diff --git a/app/Tests/Tools/FileExportFeedbackTests.cs b/app/Tests/Tools/FileExportFeedbackTests.cs new file mode 100644 index 000000000..b17501f6d --- /dev/null +++ b/app/Tests/Tools/FileExportFeedbackTests.cs @@ -0,0 +1,148 @@ +using AIStudio.Tools; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks which notifications a file export sends, and in which order. +/// +/// +/// A fast export shows its success right away; a slow one first shows that it runs. That notification +/// waits for the user and must therefore never come after the end of the export: it would stay until +/// the user closes it, claiming an export which is long over. +/// +[TestFixture] +public sealed class FileExportFeedbackTests +{ + private const string FILE_PATH = "/home/user/Documents/report.docx"; + private const string ICON = "icon"; + private const string MESSAGE = "The export succeeded."; + + [Test] + public async Task AFastExportShowsNoRunningNotification() + { + var sent = new SentNotifications(); + await using (var feedback = new FileExportFeedback(sent.Send, token => Task.Delay(Timeout.Infinite, token))) + { + feedback.ShowRunning(FILE_PATH); + await feedback.SucceededAsync(ICON, MESSAGE, FILE_PATH, true); + } + + Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_SUCCESS })); + } + + [Test] + public async Task ASlowExportShowsThatItRunsAndThenItsSuccessUnderTheSameKey() + { + var sent = new SentNotifications(); + await using (var feedback = new FileExportFeedback(sent.Send, _ => Task.CompletedTask)) + { + feedback.ShowRunning(FILE_PATH); + await sent.RunningShown; + await feedback.SucceededAsync(ICON, MESSAGE, FILE_PATH, true); + } + + Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_RUNNING, Event.SHOW_FILE_EXPORT_SUCCESS })); + var running = (DataFileExportRunningMessage)sent.Data[0]; + var success = (DataFileExportSuccessMessage)sent.Data[1]; + Assert.That(success.Key, Is.EqualTo(running.Key), "The success has to replace the notification that the export runs."); + } + + [Test] + public async Task AFailedExportRemovesItsRunningNotification() + { + var sent = new SentNotifications(); + await using (var feedback = new FileExportFeedback(sent.Send, _ => Task.CompletedTask)) + { + feedback.ShowRunning(FILE_PATH); + await sent.RunningShown; + } + + Assert.Multiple(() => + { + Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_RUNNING, Event.HIDE_FILE_EXPORT_RUNNING })); + Assert.That(((DataFileExportRunningMessage)sent.Data[1]).Key, Is.EqualTo(((DataFileExportRunningMessage)sent.Data[0]).Key)); + }); + } + + [Test] + public void AnExceptionRemovesTheRunningNotification() + { + var sent = new SentNotifications(); + Assert.ThrowsAsync(async () => + { + await using var feedback = new FileExportFeedback(sent.Send, _ => Task.CompletedTask); + feedback.ShowRunning(FILE_PATH); + await sent.RunningShown; + throw new IOException("The target file is locked."); + }); + + Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_RUNNING, Event.HIDE_FILE_EXPORT_RUNNING })); + } + + [Test] + public async Task AFastFailedExportSendsNothing() + { + var sent = new SentNotifications(); + await using (var feedback = new FileExportFeedback(sent.Send, token => Task.Delay(Timeout.Infinite, token))) + feedback.ShowRunning(FILE_PATH); + + Assert.That(sent.Events, Is.Empty, "The error message of the caller says everything."); + } + + [Test] + public async Task TheRunningNotificationNeverComesAfterTheEnd() + { + // + // The notification that the export runs becomes due while the end of the export is being sent. + // The wait ignores its cancellation, as a timer would which fires at that very moment: + // + var due = new TaskCompletionSource(); + var sendingSuccess = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseSuccess = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var sent = new SentNotifications(async () => + { + sendingSuccess.SetResult(); + await releaseSuccess.Task; + }); + + await using (var feedback = new FileExportFeedback(sent.Send, _ => due.Task)) + { + feedback.ShowRunning(FILE_PATH); + var ending = feedback.SucceededAsync(ICON, MESSAGE, FILE_PATH, true); + + await sendingSuccess.Task; + due.SetResult(); + releaseSuccess.SetResult(); + await ending; + } + + // Disposing waits for the delayed notification, so whatever it would send is in by now: + Assert.That(sent.Events, Is.EqualTo(new[] { Event.SHOW_FILE_EXPORT_SUCCESS })); + } + + /// + /// Records what a FileExportFeedback sends, in place of the message bus. + /// + /// Runs while the success is being sent, before it counts as sent. + private sealed class SentNotifications(Func? whileSendingSuccess = null) + { + private readonly TaskCompletionSource runningShown = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public List Events { get; } = []; + + public List Data { get; } = []; + + public Task RunningShown => this.runningShown.Task; + + public async Task Send(Event triggeredEvent, object data) + { + if (triggeredEvent is Event.SHOW_FILE_EXPORT_SUCCESS && whileSendingSuccess is not null) + await whileSendingSuccess(); + + this.Events.Add(triggeredEvent); + this.Data.Add(data); + if (triggeredEvent is Event.SHOW_FILE_EXPORT_RUNNING) + this.runningShown.TrySetResult(); + } + } +} \ No newline at end of file diff --git a/app/Tests/Tools/FileExportNotificationsTests.cs b/app/Tests/Tools/FileExportNotificationsTests.cs new file mode 100644 index 000000000..c2799a8da --- /dev/null +++ b/app/Tests/Tools/FileExportNotificationsTests.cs @@ -0,0 +1,135 @@ +using AIStudio.Tools; + +using Microsoft.AspNetCore.Components; + +using MudBlazor; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks that the notification that a file export runs never appears in a window after the export ended. +/// +/// +/// The notifications of an export are sent in order, but they may take effect out of it: the one that +/// the export runs comes from a timer and waits in the queue of the renderer, while the end usually +/// comes from the renderer itself and takes effect at once. +/// +[TestFixture] +public sealed class FileExportNotificationsTests +{ + private const string KEY = "export"; + private const string FILE_PATH = "/home/user/Documents/report.docx"; + + private static readonly DataFileExportRunningMessage RUNNING = new(KEY, FILE_PATH); + private static readonly DataFileExportSuccessMessage SUCCESS = new(KEY, "icon", "The export succeeded.", FILE_PATH, true); + + [Test] + public void TheSuccessReplacesTheRunningNotification() + { + var snackbar = new RecordingSnackbar(); + var notifications = new FileExportNotifications(snackbar); + + notifications.ShowRunning(RUNNING); + notifications.ShowSuccess(SUCCESS, false); + + Assert.That(snackbar.Calls, Is.EqualTo(new[] { $"add FileExportRunningMessage {KEY}", $"remove {KEY}", $"add FileExportSuccessMessage {KEY}" })); + } + + [Test] + public void ARunningNotificationWhichComesAfterTheSuccessIsDropped() + { + var snackbar = new RecordingSnackbar(); + var notifications = new FileExportNotifications(snackbar); + + notifications.ShowSuccess(SUCCESS, false); + notifications.ShowRunning(RUNNING); + + Assert.That(snackbar.Calls, Is.EqualTo(new[] { $"remove {KEY}", $"add FileExportSuccessMessage {KEY}" })); + } + + [Test] + public void ARunningNotificationWhichComesAfterItsRemovalIsDropped() + { + var snackbar = new RecordingSnackbar(); + var notifications = new FileExportNotifications(snackbar); + + notifications.HideRunning(RUNNING); + notifications.ShowRunning(RUNNING); + + Assert.That(snackbar.Calls, Is.EqualTo(new[] { $"remove {KEY}" })); + } + + [Test] + public void TheEndOfOneExportDoesNotSilenceAnother() + { + var snackbar = new RecordingSnackbar(); + var notifications = new FileExportNotifications(snackbar); + + notifications.ShowSuccess(SUCCESS, false); + notifications.ShowRunning(RUNNING with { Key = "another export" }); + + Assert.That(snackbar.Calls, Does.Contain("add FileExportRunningMessage another export")); + } + + [TestCase(false, ExpectedResult = true)] + [TestCase(true, ExpectedResult = false)] + public bool TheSuccessWaitsForTheUserUnlessTheyChoseOtherwise(bool closesAutomatically) + { + var snackbar = new RecordingSnackbar(); + new FileExportNotifications(snackbar).ShowSuccess(SUCCESS, closesAutomatically); + + return snackbar.RequiresInteraction.Single(); + } + + /// + /// Records which notifications are added and removed, by their key, and whether a component + /// notification waits for the user. + /// + private sealed class RecordingSnackbar : ISnackbar + { + public List Calls { get; } = []; + + public List RequiresInteraction { get; } = []; + + public SnackbarConfiguration Configuration { get; } = new(); + + public IEnumerable ShownSnackbars => []; + + public event Action? OnSnackbarsUpdated + { + add { } + remove { } + } + + public Snackbar? Add(string message, Severity severity = Severity.Normal, Action? configure = null, string? key = null) => this.Record("string", key); + + public Snackbar? Add(MarkupString message, Severity severity = Severity.Normal, Action? configure = null, string? key = null) => this.Record(nameof(MarkupString), key); + + public Snackbar? Add(RenderFragment message, Severity severity = Severity.Normal, Action? configure = null, string? key = null) => this.Record(nameof(RenderFragment), key); + + public Snackbar? Add(Dictionary? componentParameters = null, Severity severity = Severity.Normal, Action? configure = null, string? key = null) where T : IComponent + { + var options = new SnackbarOptions(severity, this.Configuration); + configure?.Invoke(options); + this.RequiresInteraction.Add(options.RequireInteraction); + + return this.Record(typeof(T).Name, key); + } + + public void Clear() => this.Calls.Add("clear"); + + public void Remove(Snackbar snackbar) => this.Calls.Add("remove one"); + + public void RemoveByKey(string key) => this.Calls.Add($"remove {key}"); + + public void Dispose() + { + } + + private Snackbar? Record(string content, string? key) + { + this.Calls.Add($"add {content} {key}"); + return null; + } + } +} \ No newline at end of file diff --git a/runtime/Cargo.toml b/runtime/Cargo.toml index ad727a07b..7c69c9c74 100644 --- a/runtime/Cargo.toml +++ b/runtime/Cargo.toml @@ -153,3 +153,40 @@ opt-level = 3 [profile.dev.package.memchr] opt-level = 3 + +# Preparing images is CPU-heavy as well: AI Studio decodes a picture, scales it down to 2560 pixels, +# and encodes it again, both for the AI and for the slides of the Slide Assistant. A photo of +# 2912 × 1632 pixels saved as PNG takes about 0.12 seconds in a release build, 6.6 seconds in a +# development build without this, and about 0.5 seconds with it; almost all of that is scaling and +# encoding. `image` does the scaling; `png`, `fdeflate`, `flate2`, `miniz_oxide`, `crc32fast`, +# and `simd-adler32` read and write PNG files; `zune-jpeg` and `zune-core` read JPEG files; and +# `image-webp` reads and writes WebP files. +[profile.dev.package.image] +opt-level = 3 + +[profile.dev.package.png] +opt-level = 3 + +[profile.dev.package.fdeflate] +opt-level = 3 + +[profile.dev.package.flate2] +opt-level = 3 + +[profile.dev.package.miniz_oxide] +opt-level = 3 + +[profile.dev.package.crc32fast] +opt-level = 3 + +[profile.dev.package.simd-adler32] +opt-level = 3 + +[profile.dev.package.zune-jpeg] +opt-level = 3 + +[profile.dev.package.zune-core] +opt-level = 3 + +[profile.dev.package.image-webp] +opt-level = 3