Added progress messages and quick access to exported files (#1112)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-25 22:10:38 +01:00
1 parent 7f72da628e
commit e0d8e18f34
33 files changed
+1130 -23

No files matched your search

@@ -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"/>