mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-10-30 14:24:26 +00:00
Added progress messages and quick access to exported files (#1112)
This commit is contained in:
1 parent
7f72da628e
commit
e0d8e18f34
33 files changed
+1130
-23
No files matched your search
@@ -69,16 +69,25 @@
|
||||
|
||||
@if (this.ShowDedicatedProgress && this.IsProcessing)
|
||||
{
|
||||
@if (string.IsNullOrWhiteSpace(this.ProgressText))
|
||||
@if (this.processingStartedAt is null && string.IsNullOrWhiteSpace(this.ProgressText))
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-6" />
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudProgressLinear Color="Color.Primary" Indeterminate="true" Class="mb-2" />
|
||||
<MudText Typo="Typo.body2" Class="mb-6">
|
||||
@this.ProgressText
|
||||
</MudText>
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="3" Class="mb-6">
|
||||
@if (this.processingStartedAt is { } startedAt)
|
||||
{
|
||||
<ElapsedTime Start="@startedAt"/>
|
||||
}
|
||||
@if (!string.IsNullOrWhiteSpace(this.ProgressText))
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
@this.ProgressText
|
||||
</MudText>
|
||||
}
|
||||
</MudStack>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -180,6 +180,14 @@ public abstract partial class AssistantBase<TSettings> : AssistantLowerBase wher
|
||||
private AssistantSessionKey assistantSessionKey;
|
||||
private MediaImportOwner CurrentMediaImportOwner => MediaImportOwner.ForAssistant(this.assistantSessionKey);
|
||||
private Guid? assistantSessionId;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private DateTimeOffset? processingStartedAt;
|
||||
|
||||
private AssistantSessionSnapshot? pendingRenderedAssistantSessionSnapshot;
|
||||
private AssistantSessionSnapshot? lastAttachedAssistantSessionSnapshot;
|
||||
|
||||
@@ -311,6 +319,7 @@ public abstract partial class AssistantBase<TSettings> : 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<TSettings> : 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<TSettings> : 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;
|
||||
|
||||
@@ -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?"
|
||||
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
+54
-3
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes an exported version into the file the user chose, and tells the user when that fails.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="source">The verified version to export.</param>
|
||||
/// <param name="targetPath">The file the user chose.</param>
|
||||
/// <returns>True, when the file was written completely.</returns>
|
||||
private async Task<bool> 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -32,9 +32,13 @@
|
||||
@if (backupResult.Success)
|
||||
{
|
||||
<MudAlert Severity="@(backupResult.IncludesDataSourceIndexes ? Severity.Success : Severity.Warning)" Dense="@true" Class="mt-3">
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Backed up {0} files into: {1}"), backupResult.FileCount, backupResult.ArchivePath)
|
||||
</MudText>
|
||||
@* Opening a backup with the default program would only unpack it, so the file manager is the one way offered. *@
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<MudText Typo="Typo.body2">
|
||||
@string.Format(T("Backed up {0} files into: {1}"), backupResult.FileCount, backupResult.ArchivePath)
|
||||
</MudText>
|
||||
<FileExportActions FilePath="@backupResult.ArchivePath" OffersOpening="@false"/>
|
||||
</MudStack>
|
||||
@if (!backupResult.IncludesDataSourceIndexes)
|
||||
{
|
||||
<MudText Typo="Typo.body2">
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="1">
|
||||
<MudIcon Icon="@Icons.Material.Filled.Timer" Size="Size.Small"/>
|
||||
<MudText Typo="Typo.body2">
|
||||
@Format(DateTimeOffset.Now - this.Start)
|
||||
</MudText>
|
||||
</MudStack>
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Shows how long something has been running, counting up every second.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public partial class ElapsedTime : MSGComponentBase
|
||||
{
|
||||
private static readonly TimeSpan TICK_INTERVAL = TimeSpan.FromSeconds(1);
|
||||
|
||||
/// <summary>
|
||||
/// When it started.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public DateTimeOffset Start { get; set; }
|
||||
|
||||
/// <summary>Stops counting.</summary>
|
||||
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
|
||||
|
||||
/// <summary>
|
||||
/// Writes a time span as minutes and seconds, and with the hours in front from the first hour on.
|
||||
/// </summary>
|
||||
/// <param name="elapsed">The time span; a negative one, e.g., after the clock was set back, counts as none.</param>
|
||||
/// <returns>The time span, such as 4:07 or 1:02:09.</returns>
|
||||
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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="0">
|
||||
<MudTooltip Text="@T("Show in the file manager")" Placement="Placement.Top">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Small" OnClick="@this.ShowInFileManager"/>
|
||||
</MudTooltip>
|
||||
@if (this.OffersOpening)
|
||||
{
|
||||
<MudTooltip Text="@T("Open with the default program")" Placement="Placement.Top">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.OpenInNew" Size="Size.Small" OnClick="@this.OpenWithDefaultProgram"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
</MudStack>
|
||||
@@ -0,0 +1,73 @@
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// The ways from an exported file to the file itself: the file manager, which shows it, and the default
|
||||
/// program, which opens it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public partial class FileExportActions : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The exported file.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the file may be opened with the default program. False for an archive, which that
|
||||
/// program would only unpack, at least on macOS.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool OffersOpening { get; set; }
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<FileExportActions> 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.")));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="3">
|
||||
<MudProgressCircular Size="Size.Small" Indeterminate="@true" Color="Color.Primary"/>
|
||||
<div>
|
||||
@string.Format(T("Saving '{0}'..."), this.FileName)
|
||||
</div>
|
||||
</MudStack>
|
||||
@@ -0,0 +1,15 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// The content of the notification that a file is being exported, see DataFileExportRunningMessage.
|
||||
/// </summary>
|
||||
public partial class FileExportRunningMessage : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The name of the file which is being written.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
<MudStack Row="@true" AlignItems="@AlignItems.Center" Spacing="2">
|
||||
<div>
|
||||
@this.Message
|
||||
</div>
|
||||
<FileExportActions FilePath="@this.FilePath" OffersOpening="@this.OffersOpening"/>
|
||||
</MudStack>
|
||||
@@ -0,0 +1,31 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// The content of the notification that a file was exported, see DataFileExportSuccessMessage.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public partial class FileExportSuccessMessage : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// What was exported.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// The file which was written.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public string FilePath { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the file may be opened with the default program.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public bool OffersOpening { get; set; }
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
<ConfigurationSelect OptionDescription="@T("Color theme")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.PreferredTheme)" Data="@ConfigurationSelectDataFactory.GetThemesData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.PreferredTheme = selectedValue)" OptionHelp="@T("Choose the color theme that best suits for you.")"/>
|
||||
<ConfigurationOption OptionDescription="@T("Save energy?")" LabelOn="@T("Energy saving is enabled")" LabelOff="@T("Energy saving is disabled")" State="@(() => this.SettingsManager.ConfigurationData.App.IsSavingEnergy)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.IsSavingEnergy = updatedState)" OptionHelp="@T("When enabled, streamed content from the AI is updated once every third second. When disabled, streamed content will be updated as soon as it is available.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.IsSavingEnergy, out var meta) && meta.IsLocked"/>
|
||||
<ConfigurationOption OptionDescription="@T("Enable spellchecking?")" LabelOn="@T("Spellchecking is enabled")" LabelOff="@T("Spellchecking is disabled")" State="@(() => this.SettingsManager.ConfigurationData.App.EnableSpellchecking)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.EnableSpellchecking = updatedState)" OptionHelp="@T("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.")"/>
|
||||
<ConfigurationOption OptionDescription="@T("Close messages about exported files automatically?")" LabelOn="@T("The message closes after a few seconds")" LabelOff="@T("The message stays until you close it")" State="@(() => this.SettingsManager.ConfigurationData.App.CloseExportMessagesAutomatically)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.CloseExportMessagesAutomatically = updatedState)" OptionHelp="@T("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.")"/>
|
||||
<ConfigurationOption OptionDescription="@T("Show details when suspicious content was removed?")" LabelOn="@T("A dialog lists what was removed and explains the attack pattern")" LabelOff="@T("Only a short notification is shown")" State="@(() => this.SettingsManager.ConfigurationData.App.ShowPromptInjectionAlert)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.ShowPromptInjectionAlert = updatedState)" OptionHelp="@T("Shows a dialog listing the removed passages, together with an explanation and an external reference.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.ShowPromptInjectionAlert, out var meta) && meta.IsLocked"/>
|
||||
<ConfigurationSlider T="int" OptionDescription="@T("Request timeout")" Min="@ExternalHttpClientTimeout.MIN_HTTP_CLIENT_TIMEOUT_SECONDS" Max="@ExternalHttpClientTimeout.MAX_HTTP_CLIENT_TIMEOUT_SECONDS" Step="60" Unit="@T("seconds")" Value="@(() => this.SettingsManager.ConfigurationData.App.HttpClientTimeoutSeconds)" ValueUpdate="@(updatedValue => this.SettingsManager.ConfigurationData.App.HttpClientTimeoutSeconds = updatedValue)" OptionHelp="@T("How long AI Studio waits for external HTTP requests, such as AI providers, embeddings, transcription, ERI data sources, and enterprise configuration downloads.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.HttpClientTimeoutSeconds, out var meta) && meta.IsLocked"/>
|
||||
<ConfigurationOption OptionDescription="@T("Share feature usage with operators of self-hosted servers?")" LabelOn="@T("Operators of self-hosted servers learn which feature sent a request")" LabelOff="@T("No usage details are sent")" State="@(() => this.SettingsManager.ConfigurationData.App.ShareFeatureUsageWithSelfHostedServerOperators)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.App.ShareFeatureUsageWithSelfHostedServerOperators = updatedState)" OptionHelp="@T("When enabled, requests to self-hosted providers and LiteLLM name the feature that sent them, e.g., the chat, an assistant, or an agent, together with the version of AI Studio and your operating system. This lets the operators of these servers, usually your organization, see which features are used. Cloud providers never receive these details, and AI Studio never sends anything to its developers.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.ShareFeatureUsageWithSelfHostedServerOperators, out var meta) && meta.IsLocked"/>
|
||||
|
||||
@@ -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 () =>
|
||||
{
|
||||
|
||||
@@ -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:
|
||||
|
||||
+42
@@ -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?"
|
||||
|
||||
|
||||
+42
@@ -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?"
|
||||
|
||||
|
||||
@@ -40,6 +40,12 @@ public sealed class DataApp(Expression<Func<Data, DataApp>>? configSelection = n
|
||||
/// </summary>
|
||||
public bool EnableSpellchecking { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public bool CloseExportMessagesAutomatically { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// If and when we should look for updates.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using AIStudio.Components;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// The notification that a file is being exported.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="Key">The key of the export, shared with its success notification, which replaces this one.</param>
|
||||
/// <param name="FilePath">The file the export writes.</param>
|
||||
public readonly record struct DataFileExportRunningMessage(string Key, string FilePath)
|
||||
{
|
||||
public void Show(ISnackbar snackbar)
|
||||
{
|
||||
var parameters = new Dictionary<string, object>
|
||||
{
|
||||
[nameof(FileExportRunningMessage.FileName)] = Path.GetFileName(this.FilePath)
|
||||
};
|
||||
|
||||
snackbar.Add<FileExportRunningMessage>(parameters, Severity.Info, config =>
|
||||
{
|
||||
config.HideIcon = true;
|
||||
config.RequireInteraction = true;
|
||||
config.HideTransitionDuration = 600;
|
||||
}, this.Key);
|
||||
}
|
||||
|
||||
public void Hide(ISnackbar snackbar) => snackbar.RemoveByKey(this.Key);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using AIStudio.Components;
|
||||
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// The notification that a file was exported, with the ways to reach it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="Key">The key of the export. The notification that the export runs has the same one and gets replaced.</param>
|
||||
/// <param name="Icon">The icon of the notification.</param>
|
||||
/// <param name="Message">What was exported.</param>
|
||||
/// <param name="FilePath">The file which was written.</param>
|
||||
/// <param name="OffersOpening">Whether the file may be opened with the default program. False for an
|
||||
/// archive, which that program would only unpack, at least on macOS.</param>
|
||||
public readonly record struct DataFileExportSuccessMessage(string Key, string Icon, string Message, string FilePath, bool OffersOpening)
|
||||
{
|
||||
/// <param name="snackbar">The notifications of the window.</param>
|
||||
/// <param name="closesAutomatically">Whether the notification closes after a few seconds, like other success messages.</param>
|
||||
public void Show(ISnackbar snackbar, bool closesAutomatically)
|
||||
{
|
||||
var parameters = new Dictionary<string, object>
|
||||
{
|
||||
[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<FileExportSuccessMessage>(parameters, Severity.Success, config =>
|
||||
{
|
||||
config.Icon = icon;
|
||||
config.IconSize = Size.Large;
|
||||
config.RequireInteraction = !closesAutomatically;
|
||||
config.VisibleStateDuration = 10_000;
|
||||
config.HideTransitionDuration = 600;
|
||||
}, this.Key);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,22 @@ public enum Event
|
||||
/// </summary>
|
||||
SHOW_INFO,
|
||||
|
||||
/// <summary>
|
||||
/// Requests display of the notification that a file is being exported.
|
||||
/// </summary>
|
||||
SHOW_FILE_EXPORT_RUNNING,
|
||||
|
||||
/// <summary>
|
||||
/// Requests display of the notification that a file was exported, with the ways to reach it.
|
||||
/// It replaces the notification that the export runs.
|
||||
/// </summary>
|
||||
SHOW_FILE_EXPORT_SUCCESS,
|
||||
|
||||
/// <summary>
|
||||
/// Requests removal of the notification that a file is being exported.
|
||||
/// </summary>
|
||||
HIDE_FILE_EXPORT_RUNNING,
|
||||
|
||||
/// <summary>
|
||||
/// Requests display of a prompt-injection alert dialog.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Tells the user that a file is being exported, and afterward where it went.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// <br/><br/>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public sealed class FileExportFeedback : IAsyncDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// How long an export runs before the user hears about it.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan RUNNING_MESSAGE_DELAY = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly Func<Event, object, Task> send;
|
||||
private readonly Func<CancellationToken, Task> 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))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the feedback with what it needs from outside, so that tests can stand in for both.
|
||||
/// </summary>
|
||||
/// <param name="send">Sends a notification, as the message bus does.</param>
|
||||
/// <param name="waitForRunningMessage">Waits until the notification that the export runs is due.</param>
|
||||
internal FileExportFeedback(Func<Event, object, Task> send, Func<CancellationToken, Task> waitForRunningMessage)
|
||||
{
|
||||
this.send = send;
|
||||
this.waitForRunningMessage = waitForRunningMessage;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shows that the given file is being exported, unless the export ends before that is due.
|
||||
/// </summary>
|
||||
/// <param name="filePath">The file the export writes.</param>
|
||||
public void ShowRunning(string filePath)
|
||||
{
|
||||
this.runningMessage = new DataFileExportRunningMessage(this.key, filePath);
|
||||
this.delayedRunningMessage = this.ShowRunningWhenDueAsync(this.runningMessageCancellation.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the export with the notification of its success, which replaces the one that it runs.
|
||||
/// </summary>
|
||||
/// <param name="icon">The icon of the notification.</param>
|
||||
/// <param name="message">What was exported.</param>
|
||||
/// <param name="filePath">The file which was written.</param>
|
||||
/// <param name="offersOpening">Whether the file may be opened with the default program.</param>
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ends the export, with the notification of its success or without any.
|
||||
/// </summary>
|
||||
/// <param name="success">The notification of the success, or null when the export failed.</param>
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the notifications of file exports in one window, and keeps a late notification that an export
|
||||
/// runs from appearing after its end.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// <br/><br/>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="snackbar">The notifications of the window.</param>
|
||||
public sealed class FileExportNotifications(ISnackbar snackbar)
|
||||
{
|
||||
private readonly HashSet<string> finishedExports = [];
|
||||
|
||||
public void ShowRunning(DataFileExportRunningMessage message)
|
||||
{
|
||||
if (this.finishedExports.Contains(message.Key))
|
||||
return;
|
||||
|
||||
message.Show(snackbar);
|
||||
}
|
||||
|
||||
/// <param name="message">The notification of the success.</param>
|
||||
/// <param name="closesAutomatically">Whether the notification closes after a few seconds, as the user chose.</param>
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -42,17 +42,19 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
|
||||
/// Studio restarts.
|
||||
/// </remarks>
|
||||
/// <param name="plugin">The local plugin to archive and share.</param>
|
||||
/// <param name="exportFeedback">Shows that the archive is being written, once the user chose where.
|
||||
/// The caller ends it with the success, which only the caller announces.</param>
|
||||
/// <param name="token">Cancellation token for archive creation.</param>
|
||||
/// <returns>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.</returns>
|
||||
public async Task<PluginShareResult> ShareAsync(IAvailablePlugin plugin, CancellationToken token)
|
||||
public async Task<PluginShareResult> 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.
|
||||
/// </summary>
|
||||
/// <param name="plugin">The local plugin to archive and export.</param>
|
||||
/// <param name="exportFeedback">Shows that the archive is being written, once the user chose where.
|
||||
/// The caller ends it with the success, which only the caller announces.</param>
|
||||
/// <param name="token">Cancellation token for archive creation.</param>
|
||||
/// <returns>The export result, including the chosen archive path when successful.</returns>
|
||||
public async Task<PluginShareResult> ExportAsync(IAvailablePlugin plugin, CancellationToken token)
|
||||
public async Task<PluginShareResult> 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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -109,9 +113,10 @@ public sealed class PluginShareService(NativeShareService nativeShareService, Ru
|
||||
/// </summary>
|
||||
/// <param name="plugin">The local plugin to archive.</param>
|
||||
/// <param name="pluginRoot">The validated plugin root directory.</param>
|
||||
/// <param name="exportFeedback">Shows that the archive is being written.</param>
|
||||
/// <param name="token">Cancellation token for archive creation.</param>
|
||||
/// <returns>The share result, including the chosen archive path when successful.</returns>
|
||||
private async Task<PluginShareResult> ExportArchiveAsync(IAvailablePlugin plugin, string pluginRoot, CancellationToken token)
|
||||
private async Task<PluginShareResult> 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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
using AIStudio.Components;
|
||||
|
||||
namespace AIStudio.Tests.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Checks how the time below the progress bar of an assistant is written.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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"));
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
using AIStudio.Tools;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks which notifications a file export sends, and in which order.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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<IOException>(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 }));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records what a FileExportFeedback sends, in place of the message bus.
|
||||
/// </summary>
|
||||
/// <param name="whileSendingSuccess">Runs while the success is being sent, before it counts as sent.</param>
|
||||
private sealed class SentNotifications(Func<Task>? whileSendingSuccess = null)
|
||||
{
|
||||
private readonly TaskCompletionSource runningShown = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
public List<Event> Events { get; } = [];
|
||||
|
||||
public List<object> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using AIStudio.Tools;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
using MudBlazor;
|
||||
|
||||
namespace AIStudio.Tests.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Checks that the notification that a file export runs never appears in a window after the export ended.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records which notifications are added and removed, by their key, and whether a component
|
||||
/// notification waits for the user.
|
||||
/// </summary>
|
||||
private sealed class RecordingSnackbar : ISnackbar
|
||||
{
|
||||
public List<string> Calls { get; } = [];
|
||||
|
||||
public List<bool> RequiresInteraction { get; } = [];
|
||||
|
||||
public SnackbarConfiguration Configuration { get; } = new();
|
||||
|
||||
public IEnumerable<Snackbar> ShownSnackbars => [];
|
||||
|
||||
public event Action? OnSnackbarsUpdated
|
||||
{
|
||||
add { }
|
||||
remove { }
|
||||
}
|
||||
|
||||
public Snackbar? Add(string message, Severity severity = Severity.Normal, Action<SnackbarOptions>? configure = null, string? key = null) => this.Record("string", key);
|
||||
|
||||
public Snackbar? Add(MarkupString message, Severity severity = Severity.Normal, Action<SnackbarOptions>? configure = null, string? key = null) => this.Record(nameof(MarkupString), key);
|
||||
|
||||
public Snackbar? Add(RenderFragment message, Severity severity = Severity.Normal, Action<SnackbarOptions>? configure = null, string? key = null) => this.Record(nameof(RenderFragment), key);
|
||||
|
||||
public Snackbar? Add<T>(Dictionary<string, object>? componentParameters = null, Severity severity = Severity.Normal, Action<SnackbarOptions>? 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in new issue
Block a user