Merge branch 'main' into chunk-data

Resolved 29 conflicting files. The notable decisions:

Confidence: main's tool-calling gate (RequiredProviderConfidence) and this
branch's local-RAG gate (DataConfidenceLevel) turned out to be the same rule
on the same axis, so they are now one field. Both tool results and data
sources raise it through RequireProviderConfidence(). The gate checks the
level strictly and no longer exempts providers trusted by configuration:
TrustedProviderIds is documented as applying to data-source security checks
only, and organizations set confidence through DataConfidence
.CustomConfidenceScheme instead. The security axis (DataSecurity, ERI,
IsTrustedForDataSourceSecurityChecks) is unchanged.

Provider creation: main's CreateProvider signature won (hfEndpointKind,
capabilityOverrides, no model parameter); tokenizerPath was added to it and
is set for every provider, including the new Hetzner, IONOS and LiteLLM.
Provider and EmbeddingProvider combine the record parameters, Lua parsing and
Lua serialization of both sides.

File types: main's hierarchy (ODT leaf, WORD parent, PowerPoint without the
legacy .ppt, TABULAR instead of DELIMITED_TABLE) plus this branch's
SPREADSHEET parent with ODS and the xlsm/xlsb/xla/xlam extensions, which the
runtime already reads. Both sides had added a conflicting HTML filter; the
reading family keeps the name, and the export path uses a narrow
HTML_DOCUMENT, following the existing LATEX/TEX split.

Runtime: main's file_data.rs is the base, including the prompt-injection
sanitizer and the extraction routes. Token counting and chunk segmentation
moved into take_released, so they act on the text the filter has released
rather than on text it is still holding. A failed count is logged and left
out instead of ending the extraction, because the app counts such a segment
itself.

Data sources: the participating-provider checks of this branch are kept, and
main's GetAllowedDataSources overload now builds on them. DirectChatService
resolves the launched chat's data source options before the check, so filter
and chat see the same options.

.NET and Rust both build clean; I18N regenerated to 4060 keys.
This commit is contained in:
Thorsten Sommer committed 2026-09-05 21:20:33 +02:00
commit fe35630eff
640 files changed
+45059 -4952

No files matched your search

@@ -0,0 +1,8 @@
@inherits MSGComponentBase
@if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
{
<MudTooltip Text="@T("Export configuration")">
<MudIconButton Variant="@this.Variant" Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@this.Export" aria-label="@T("Export configuration")" />
</MudTooltip>
}
@@ -0,0 +1,35 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// The common admin-only configuration export action. Callers decide what is exported.
/// </summary>
public partial class AdminExportButton : MSGComponentBase
{
[Parameter]
public EventCallback OnClick { get; set; }
[Parameter]
public Variant Variant { get; set; } = Variant.Text;
protected override async Task OnInitializedAsync()
{
await base.OnInitializedAsync();
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
}
private async Task Export()
{
if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
await this.OnClick.InvokeAsync();
}
protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
if (triggeredEvent is Event.CONFIGURATION_CHANGED)
this.StateHasChanged();
return Task.CompletedTask;
}
}
@@ -9,7 +9,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components;
public partial class AssistantBlock<TSettings> : MSGComponentBase where TSettings : IComponent
public partial class AssistantBlock<TSettings> : MSGComponentBase, IAssistantCategoryMember where TSettings : IComponent
{
/// <summary>
/// Describes the assistant session indicator shown on top of the assistant icon.
@@ -58,6 +58,12 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
[Parameter]
public PreviewFeatures RequiredPreviewFeature { get; set; } = PreviewFeatures.NONE;
/// <summary>
/// Gets or sets the assistant category this block belongs to, if any.
/// </summary>
[CascadingParameter]
public AssistantCategoryBlock? Category { get; set; }
[Inject]
private MudTheme ColorTheme { get; init; } = null!;
@@ -88,7 +94,8 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
private string BlockStyle => $"border-width: 3px; border-color: {this.BorderColor}; border-radius: 12px; border-style: solid; max-width: 20em;";
private bool IsVisible => this.SettingsManager.IsAssistantVisible(this.Component, assistantName: this.Name, requiredPreviewFeature: this.RequiredPreviewFeature);
/// <inheritdoc />
public bool IsVisible => this.SettingsManager.IsAssistantVisible(this.Component, assistantName: this.Name, requiredPreviewFeature: this.RequiredPreviewFeature);
private bool HasSettingsPanel => typeof(TSettings) != typeof(NoSettingsPanel);
@@ -153,18 +160,20 @@ public partial class AssistantBlock<TSettings> : MSGComponentBase where TSetting
protected override async Task OnInitializedAsync()
{
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
this.Category?.RegisterAssistant(this);
await base.OnInitializedAsync();
}
private void OnMediaImportStateChanged(MediaImportOwner owner)
{
if (this.OwnedByThisBlock(owner))
_ = this.InvokeAsync(this.StateHasChanged);
this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(AssistantBlock<TSettings>)}: rendering a media import change");
}
protected override void DisposeResources()
{
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
this.Category?.UnregisterAssistant(this);
base.DisposeResources();
}
@@ -0,0 +1,11 @@
@if (this.HasVisibleAssistant)
{
<MudText Typo="Typo.h4" Class="@this.HeaderClass">
@this.Title
</MudText>
}
<CascadingValue Value="this" IsFixed="@true">
<MudStack Row="@true" Wrap="@Wrap.Wrap" Class="@this.StackClass">
@this.ChildContent
</MudStack>
</CascadingValue>
@@ -0,0 +1,70 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Renders one category of assistants together with its heading.
/// </summary>
/// <remarks>
/// The heading is derived from the assistant blocks inside this category: it is rendered only when
/// at least one of them is visible. Thus, hiding assistants by configuration can never leave an
/// empty category heading behind.
/// </remarks>
public partial class AssistantCategoryBlock : ComponentBase
{
private readonly HashSet<IAssistantCategoryMember> members = [];
/// <summary>
/// The heading of this category.
/// </summary>
[Parameter]
public string Title { get; set; } = string.Empty;
/// <summary>
/// The CSS classes used for the heading.
/// </summary>
[Parameter]
public string HeaderClass { get; set; } = "mb-2 mr-3 mt-6";
[Parameter]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Adds an assistant block to this category.
/// </summary>
/// <remarks>
/// Assistant blocks call this while they initialize, i.e. after this category was rendered for
/// the first time. Hence, we have to render again to show the heading.
/// </remarks>
/// <param name="member">The assistant block which belongs to this category.</param>
internal void RegisterAssistant(IAssistantCategoryMember member)
{
if (this.members.Add(member))
this.StateHasChanged();
}
/// <summary>
/// Removes an assistant block from this category.
/// </summary>
/// <param name="member">The assistant block which no longer belongs to this category.</param>
internal void UnregisterAssistant(IAssistantCategoryMember member) => this.members.Remove(member);
/// <summary>
/// Gets whether at least one assistant of this category is visible right now.
/// </summary>
/// <remarks>
/// We evaluate this live instead of caching it. That way, changes to the configuration take
/// effect as soon as the assistants page renders again.
/// </remarks>
private bool HasVisibleAssistant => this.members.Any(member => member.IsVisible);
/// <summary>
/// Gets the CSS classes used for the assistant stack.
/// </summary>
/// <remarks>
/// The stack must be rendered even when no assistant is visible, because the assistant blocks
/// register themselves while rendering. Without any visible assistant, we drop the margin so
/// that a hidden category leaves no gap behind.
/// </remarks>
private string StackClass => this.HasVisibleAssistant ? "mb-3" : string.Empty;
}
@@ -1,90 +0,0 @@
using AIStudio.Dialogs;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components;
public partial class AssistantPluginDeleteAction : MSGComponentBase
{
[Parameter, EditorRequired]
public IAvailablePlugin Plugin { get; set; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private AssistantPluginInstallService AssistantPluginInstallService { get; init; } = null!;
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
[Inject]
private ILogger<AssistantPluginDeleteAction> Logger { get; init; } = null!;
private bool CanDelete => AssistantPluginInstallService.CanDeleteInstalledAssistant(this.Plugin);
private bool IsBlockedByActiveWork => this.AssistantPluginInstallService.HasActiveAssistantWork(this.Plugin.Id);
private string Tooltip => this.IsBlockedByActiveWork
? this.T("The assistant cannot be deleted while background work is still running.")
: this.T("Delete assistant plugin");
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.ASSISTANT_SESSION_CHANGED, Event.ASSISTANT_SESSION_FINISHED ]);
this.MediaTranscriptionService.StateChanged += this.OnMediaTranscriptionStateChanged;
await base.OnInitializedAsync();
}
private async Task DeleteAssistantPluginAsync()
{
if (!this.CanDelete || this.IsBlockedByActiveWork)
return;
var dialogParameters = new DialogParameters<ConfirmDialog>
{
{
x => x.Message,
string.Format(this.T("Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."), this.Plugin.Name)
},
};
var dialogReference = await this.DialogService.ShowAsync<ConfirmDialog>(this.T("Delete Assistant Plugin"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
var result = await this.AssistantPluginInstallService.DeleteInstalledAssistantAsync(this.Plugin, CancellationToken.None);
if (!result.Success)
{
this.Logger.LogError("Failed to delete assistant plugin '{PluginName}' ({PluginId}) from '{PluginDirectory}' with issue '{Issue}'.", result.PluginName, result.PluginId, result.PluginDirectory, result.Issue);
await this.MessageBus.SendError(new(Icons.Material.Filled.DeleteForever, string.Format(this.T("The assistant plugin '{0}' could not be deleted: {1}"), this.Plugin.Name, result.Issue)));
return;
}
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Check, string.Format(this.T("The '{0}' assistant plugin has been successfully removed."), result.PluginName)));
}
private void OnMediaTranscriptionStateChanged(MediaImportOwner owner)
{
if (owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.EndsWith($":{this.Plugin.Id}", StringComparison.Ordinal))
_ = this.InvokeAsync(this.StateHasChanged);
}
protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
if (triggeredEvent is Event.ASSISTANT_SESSION_CHANGED or Event.ASSISTANT_SESSION_FINISHED)
this.StateHasChanged();
return base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
}
protected override void DisposeResources()
{
this.MediaTranscriptionService.StateChanged -= this.OnMediaTranscriptionStateChanged;
base.DisposeResources();
}
}
@@ -33,18 +33,27 @@
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="@state.AuditColor">
@state.AuditLabel
</MudChip>
@if (!string.IsNullOrWhiteSpace(state.SourceLabel))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="@state.SourceColor" Icon="@state.SourceIcon">
@state.SourceLabel
</MudChip>
}
@if (!string.IsNullOrWhiteSpace(state.AvailabilityLabel))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@state.AvailabilityColor" Icon="@state.AvailabilityIcon">
@state.AvailabilityLabel
</MudChip>
}
@if (this.PluginToolIds.Count > 0)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info" Icon="@Icons.Material.Filled.Handyman">
@this.GetToolCountLabel()
</MudChip>
}
</div>
<MudText Typo="Typo.body2" Class="mud-text-secondary">
@state.Headline
@@ -65,6 +74,15 @@
<MudIcon Icon="@Icons.Material.Filled.Business" Size="Size.Small" Color="@state.SourceColor" />
<MudText Typo="Typo.body2">@T("Enterprise approval is active")</MudText>
</MudStack>
@if (state.IsActivationEnforcedByOrganization)
{
<MudDivider Vertical="@true" FlexItem="@true" />
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudIcon Icon="@Icons.Material.Filled.Lock" Size="Size.Small" Color="Color.Success" />
<MudText Typo="Typo.body2">@T("Your organization requires this assistant to stay enabled")</MudText>
</MudStack>
}
}
else
{
@@ -126,6 +144,15 @@
</td>
<td><MudText Typo="Typo.body2">@state.SourceLabel</MudText></td>
</tr>
@if (this.PluginToolIds.Count > 0)
{
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Tools")</b></MudText>
</td>
<td><code style="font-size: 0.8rem;">@string.Join(", ", this.PluginToolIds)</code></td>
</tr>
}
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Current hash")</b></MudText>
@@ -176,6 +203,21 @@
<td><MudText Typo="Typo.body2">@state.EnterpriseApproval.Comment</MudText></td>
</tr>
}
@if (state.IsActivationEnforcedByOrganization || state.IsActivatedByOrganizationDefault)
{
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Activation")</b></MudText>
</td>
<td>
<MudText Typo="Typo.body2">
@(state.IsActivationEnforcedByOrganization
? T("Required by your organization")
: T("Enabled by your organization, you may switch it off"))
</MudText>
</td>
</tr>
}
}
@if (state.Audit is not null)
{
@@ -21,6 +21,17 @@ public partial class AssistantPluginSecurityCard : MSGComponentBase
? new PluginAssistantSecurityState()
: PluginAssistantSecurityResolver.Resolve(this.SettingsManager, this.Plugin);
/// <summary>
/// The tools this plugin runs with, either in its assistant or in the chat it launches.
/// </summary>
/// <remarks>
/// Tools are a capability, not a detail: an assistant allowed to search the web or read a page
/// can carry what a user typed out of the app. Whoever decides whether to enable this plugin
/// should see that beforehand, which is why the count sits in the header next to the audit
/// level and the tools themselves are named in the details.
/// </remarks>
private IReadOnlyList<string> PluginToolIds => this.Plugin?.AssistantToolIds ?? this.Plugin?.ChatLaunchConfiguration?.ToolIds ?? [];
private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture;
private bool showSecurityCard;
private bool showDetails;
@@ -126,6 +137,10 @@ public partial class AssistantPluginSecurityCard : MSGComponentBase
: this.FormatFileTimestamp(auditedAt.Value.ToLocalTime().DateTime);
}
private string GetToolCountLabel() => this.PluginToolIds.Count is 1
? this.T("Uses 1 tool")
: string.Format(this.T("Uses {0} tools"), this.PluginToolIds.Count);
private string GetAuditProviderLabel()
{
var providerName = this.SecurityState.Audit?.AuditProviderName;
@@ -140,12 +140,12 @@ public partial class AttachDocuments : MSGComponentBase
private void OnMediaImportStateChanged(MediaImportOwner owner)
{
if (owner == this.EffectiveImportOwner)
_ = this.InvokeAsync(async () =>
this.InvokeAsync(async () =>
{
await this.SyncCompletedMediaAttachmentsAsync();
await this.ConsumeStandaloneMediaOutcomeAsync();
this.StateHasChanged();
});
}).Observe($"{nameof(AttachDocuments)}: syncing media attachments");
}
/// <summary>Consumes outcomes for dialog-local controls that have no chat or assistant owner surface.</summary>
@@ -222,6 +222,11 @@ public partial class AttachDocuments : MSGComponentBase
protected override void DisposeResources()
{
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
// Release the drop area. Without this, drop areas below this one would count this component
// forever and would stop catching dropped files:
this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer).Observe($"{nameof(AttachDocuments)}: releasing the drop area");
base.DisposeResources();
}
@@ -438,23 +443,31 @@ public partial class AttachDocuments : MSGComponentBase
var mediaPaths = existingPaths.Where(IsTranscribableMedia).ToList();
var regularPaths = existingPaths.Except(mediaPaths).ToList();
var canAddRegularFiles = true;
if (regularPaths.Count > 0)
//
// Only the formats we convert with Pandoc depend on a Pandoc installation. Everything
// else, PDFs in particular, is read by the Rust runtime itself, so those files must stay
// attachable without Pandoc.
//
var canAddPandocFiles = true;
if (regularPaths.Any(FileTypes.RequiresPandoc))
{
var pandocState = await this.PandocAvailabilityService.EnsureAvailabilityAsync(
showSuccessMessage: false,
showDialog: true);
canAddRegularFiles = pandocState.IsAvailable;
canAddPandocFiles = pandocState.IsAvailable;
}
foreach (var path in regularPaths)
{
if (!canAddRegularFiles)
break;
if (!canAddPandocFiles && FileTypes.RequiresPandoc(path))
{
this.Logger.LogWarning("The file '{Path}' needs Pandoc and was not attached.", path);
continue;
}
if (!await FileExtensionValidation.IsExtensionValidWithNotifyAsync(FileExtensionValidation.UseCase.ATTACHING_CONTENT, path, this.ValidateMediaFileTypes, this.Provider))
continue;
this.DocumentPaths.Add(FileAttachment.FromPath(path));
}
@@ -13,6 +13,8 @@ public partial class Changelog
public static readonly Log[] LOGS =
[
new (255, "v26.8.2, build 255 (2026-08-31 07:45 UTC)", "v26.8.2.md"),
new (254, "v26.8.1, build 254 (2026-08-19 09:35 UTC)", "v26.8.1.md"),
new (250, "v26.7.3, build 250 (2026-07-21 12:45 UTC)", "v26.7.3.md"),
new (244, "v26.7.2, build 244 (2026-07-06 18:35 UTC)", "v26.7.2.md"),
new (243, "v26.7.1, build 243 (2026-07-05 16:39 UTC)", "v26.7.1.md"),
@@ -127,6 +127,11 @@
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
<ProfileSelection MarginLeft="" CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged" Disabled="@(!this.currentChatTemplate.AllowProfileUsage)" DisabledText="@T("Profile usage is disabled according to your chat template settings.")"/>
@if (this.SettingsManager.AreToolsEnabled())
{
<ToolSelection Component="Components.CHAT" LLMProvider="@this.Provider" SelectedToolIds="@this.selectedToolIds" SelectedToolIdsChanged="@this.SelectedToolIdsChanged" Disabled="@this.IsCurrentChatStreaming" />
}
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
@@ -3,6 +3,7 @@ using AIStudio.Dialogs;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.AIJobs;
using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
@@ -14,7 +15,7 @@ using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components;
public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
public partial class ChatComponent : MSGComponentBase
{
private readonly Guid draftMediaOwnerId = Guid.NewGuid();
private const string CHAT_INPUT_ID = "chat-user-input";
@@ -48,6 +49,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
[Inject]
private ILogger<ChatComponent> Logger { get; set; } = null!;
[Inject]
private ToolRegistry ToolRegistry { get; set; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
@@ -78,6 +82,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private bool mustLoadChat;
private LoadChat loadChat;
private bool autoSaveEnabled;
private HashSet<string> selectedToolIds = [];
private bool previousInputForbidden = true;
private Guid lastSeenChatId = Guid.Empty;
private AIStudio.Settings.Provider lastSeenProvider = AIStudio.Settings.Provider.NONE;
@@ -121,7 +126,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
protected override async Task OnInitializedAsync()
{
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
// Apply the filters for the message bus:
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]);
@@ -136,10 +141,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT);
if (!this.ComposerState.HasUserDraft && !this.ComposerState.HasComposerContent)
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
this.lastAppliedStandardDataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy();
var deferredInput = MessageBus.INSTANCE.CheckDeferredMessages<string>(Event.SEND_TO_CHAT_INPUT).FirstOrDefault();
var deferredInput = MessageBus.INSTANCE.TakeDeferredMessages<string>(Event.SEND_TO_CHAT_INPUT).LastOrDefault();
if (!string.IsNullOrWhiteSpace(deferredInput))
this.ComposerState.SetUserInput(deferredInput);
@@ -147,16 +153,25 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// Check for deferred messages of the kind 'SEND_TO_CHAT',
// aka the user sends an assistant result to the chat:
//
var deferredContent = MessageBus.INSTANCE.CheckDeferredMessages<ChatThread>(Event.SEND_TO_CHAT).FirstOrDefault();
if (deferredContent is not null)
var deferredRequest = MessageBus.INSTANCE.TakeDeferredMessages<ChatStartRequest>(Event.SEND_TO_CHAT).LastOrDefault();
if (deferredRequest is not null)
{
//
// Yes, the user sent an assistant result to the chat.
//
// Use chat thread sent by the user:
this.ChatThread = deferredContent;
this.ChatThread = deferredRequest.ChatThread;
this.ChatThread.IncludeDateTime = true;
this.ApplyToolSelectionOfLoadedChat();
//
// Apply the chat template of the incoming chat to the composer. Like everywhere else,
// a draft the user typed themselves wins: we must not discard it just because someone
// started a preconfigured chat in the meantime.
//
if (deferredRequest.ApplySelectedChatTemplateToComposer && !this.ComposerState.HasUserDraft)
this.ComposerState.ApplyTemplate(this.SettingsManager.GetChatTemplateById(this.ChatThread.SelectedChatTemplate));
this.Logger.LogInformation($"The chat '{this.ChatThread.ChatId}' with {this.ChatThread.Blocks.Count} messages was deferred and will be rendered now.");
this.MarkCurrentChatAsLoadedParameter();
@@ -187,7 +202,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
//
// Check if the user wants to apply the standard chat data source options:
//
if (this.SettingsManager.ConfigurationData.Chat.SendToChatDataSourceBehavior is SendToChatDataSourceBehavior.APPLY_STANDARD_CHAT_DATA_SOURCE_OPTIONS)
if (!deferredRequest.PreserveDataSourceOptions &&
this.SettingsManager.ConfigurationData.Chat.SendToChatDataSourceBehavior is SendToChatDataSourceBehavior.APPLY_STANDARD_CHAT_DATA_SOURCE_OPTIONS)
this.ChatThread.DataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy();
//
@@ -242,7 +258,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
// component sends a message to the chat component to load
// the chat with the bias:
//
var deferredLoading = MessageBus.INSTANCE.CheckDeferredMessages<LoadChat>(Event.LOAD_CHAT).FirstOrDefault();
var deferredLoading = MessageBus.INSTANCE.TakeDeferredMessages<LoadChat>(Event.LOAD_CHAT).LastOrDefault();
if (deferredLoading != default)
{
this.loadChat = deferredLoading;
@@ -269,11 +285,11 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
private void OnMediaImportStateChanged(MediaImportOwner owner)
{
if (owner == this.CurrentMediaImportOwner)
_ = this.InvokeAsync(async () =>
this.InvokeAsync(async () =>
{
await this.ConsumeMediaOutcomeAsync();
this.StateHasChanged();
});
}).Observe($"{nameof(ChatComponent)}: consuming a media import outcome");
}
/// <summary>Consumes a terminal media notification when its chat is visible.</summary>
@@ -331,6 +347,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
this.Logger.LogInformation($"The chat '{this.ChatThread!.ChatId}' with title '{this.ChatThread.Name}' ({this.ChatThread.Blocks.Count} messages) was loaded successfully.");
this.ApplyToolSelectionOfLoadedChat();
await this.SyncWorkspaceHeaderWithChatThreadAsync();
await this.SelectProviderWhenLoadingChat();
}
@@ -623,9 +640,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
{
var previousProvider = this.Provider;
var previousChatTemplate = this.currentChatTemplate;
var chatProviderId = this.ChatThread?.SelectedProvider;
this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(chatProviderId);
this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(this.Provider.Id);
if (this.Provider != previousProvider)
await this.ProviderChanged.InvokeAsync(this.Provider);
@@ -765,6 +781,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
@@ -787,6 +804,8 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
return;
await this.RefreshProviderSelectionFromConfigurationAsync();
if (!this.IsProviderSelected)
return;
@@ -807,6 +826,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
@@ -909,15 +929,18 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
}
this.Logger.LogDebug($"Start processing user input using provider '{this.Provider.InstanceName}' with model '{this.Provider.Model}'.");
this.StateHasChanged();
this.ChatThread!.RuntimeComponent = Tools.Components.CHAT;
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.ChatThread.RuntimeSelectedToolIds = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds);
await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
{
ChatThread = this.ChatThread!,
ChatThread = this.ChatThread,
AIText = aiText,
LastUserPrompt = lastUserPrompt,
ProviderSettings = this.Provider,
IsForeground = true,
});
await this.SyncForegroundChatAsync();
this.StateHasChanged();
}
@@ -927,6 +950,37 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
if (this.ChatThread is not null)
await this.AIJobService.CancelChatGenerationAsync(this.ChatThread.ChatId);
}
/// <summary>
/// Takes over the tool selection of the chat that was just loaded or handed to this component.
/// </summary>
/// <remarks>
/// A thread without a selection means the chat defaults: that is a chat saved before tools
/// existed, as well as one a launcher opened without naming any. Both want what the settings
/// preselect. Every path that puts a thread into this component has to come through here, or
/// the footer would keep showing the tools of the chat before it.
/// </remarks>
private void ApplyToolSelectionOfLoadedChat() =>
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
private Task SelectedToolIdsChanged(HashSet<string> updatedToolIds)
{
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
//
// The thread keeps the selection so that reopening the chat tomorrow brings the same tools
// back. What is stored is what the user chose, not what the current provider is allowed to
// run: filtering here would quietly drop a tool for good the moment the user switches to a
// provider with less confidence.
//
if (this.ChatThread is not null)
{
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.hasUnsavedChanges = true;
}
return Task.CompletedTask;
}
private async Task SaveThread()
{
@@ -990,6 +1044,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
//
this.hasUnsavedChanges = false;
this.ComposerState.Clear();
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
this.RefreshCurrentProfileAndChatTemplate();
//
@@ -1039,6 +1094,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
@@ -1114,6 +1170,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
await this.SyncWorkspaceHeaderWithChatThreadAsync();
await this.SyncForegroundChatAsync();
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
this.ApplyToolSelectionOfLoadedChat();
}
else
{
@@ -1133,6 +1190,19 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.StateHasChanged();
}
private async Task RefreshProviderSelectionFromConfigurationAsync()
{
var updatedProvider = this.SettingsManager.GetPreselectedProvider(Tools.Components.CHAT, this.Provider.Id);
var providerChanged = updatedProvider != this.Provider;
if (providerChanged)
this.Provider = updatedProvider;
if (!providerChanged)
return;
await this.ProviderChanged.InvokeAsync(this.Provider);
}
private async Task ResetState()
{
@@ -1316,6 +1386,7 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
this.StateHasChanged();
}
break;
}
}
@@ -1338,9 +1409,9 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
#endregion
#region Implementation of IAsyncDisposable
#region Overrides of MSGComponentBase
public async ValueTask DisposeAsync()
protected override async ValueTask DisposeResourcesAsync()
{
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
if(this.SettingsManager.ConfigurationData.Workspace.StorageBehavior is WorkspaceStorageBehavior.STORE_CHATS_AUTOMATICALLY)
@@ -1350,7 +1421,6 @@ public partial class ChatComponent : MSGComponentBase, IAsyncDisposable
}
await this.AIJobService.SetForegroundAsync(AIJobKind.CHAT_GENERATION, this.foregroundChatId, false);
this.Dispose();
}
#endregion
@@ -80,9 +80,10 @@ public partial class CodeEditor : ComponentBase, IAsyncDisposable
if (this.module is null)
return;
await this.module.TryInvokeVoidAsync("destroy", this.editorId);
try
{
await this.module.InvokeVoidAsync("destroy", this.editorId);
await this.module.DisposeAsync();
}
catch (JSDisconnectedException)
@@ -5,11 +5,11 @@
<MudTooltip Text="@T("Shows and hides the confidence card with information about the selected LLM provider.")" Placement="Placement.Top">
@if (this.Mode is PopoverTriggerMode.ICON)
{
<MudIconButton Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.LLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager)" OnClick="@(() => this.ToggleConfidence())"/>
<MudIconButton Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.LLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager)" OnClick="@this.ToggleConfidence"/>
}
else
{
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.Security" IconClass="confidence-icon" Style="@this.LLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager)" OnClick="@(() => this.ToggleConfidence())">
<MudButton Variant="Variant.Filled" StartIcon="@Icons.Material.Filled.Security" IconClass="confidence-icon" Style="@this.LLMProvider.GetConfidence(this.SettingsManager).SetColorStyle(this.SettingsManager)" OnClick="@this.ToggleConfidence">
@T("Confidence")
</MudButton>
}
@@ -28,7 +28,7 @@
<MudText Typo="Typo.h6">
@T("Description")
</MudText>
<MudMarkdown Value="@this.currentConfidence.Description" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE"/>
<MudJustifiedMarkdown Value="@this.currentConfidence.Description" />
@if (this.currentConfidence.Sources.Count > 0)
{
@@ -61,7 +61,7 @@
</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" OnClick="@(() => this.HideConfidence())">
<MudButton Variant="Variant.Filled" OnClick="@this.HideConfidence">
Close
</MudButton>
</MudCardActions>
@@ -56,7 +56,13 @@ public abstract partial class ConfigurationBase : MSGComponentBase
protected bool IsDisabled => this.Disabled() || this.IsLocked();
private string Classes => $"{this.GetClassForBase} {JUSTIFIED_HELP_CLASS} {MARGIN_CLASS}";
private string Classes => $"{this.GetClassForBase} {JUSTIFIED_HELP_CLASS} {this.MarginClass}";
/// <summary>
/// The bottom margin of the option. Options inside settings panels need the default
/// spacing; standalone usages like toolbar buttons can remove it.
/// </summary>
protected virtual string MarginClass => MARGIN_CLASS;
private protected virtual RenderFragment? Body => null;
@@ -0,0 +1,27 @@
@inherits ConfigurationBaseCore
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Immediate="@true"
Underline="false"
Class="flex-grow-1"
/>
<MudButton StartIcon="@Icons.Material.Filled.FolderOpen"
Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Small"
Disabled="@(this.IsDisabled || this.isDirectoryDialogOpen)"
Class="mb-1"
OnClick="@this.OpenDirectoryDialog">
@T("Choose Directory")
</MudButton>
</MudStack>
@@ -0,0 +1,133 @@
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using Timer = System.Timers.Timer;
namespace AIStudio.Components;
public partial class ConfigurationDirectory : ConfigurationBaseCore
{
/// <summary>
/// The text used for the textfield.
/// </summary>
[Parameter]
public Func<string> Text { get; set; } = () => string.Empty;
/// <summary>
/// An action which is called when the text was changed.
/// </summary>
[Parameter]
public Action<string> TextUpdate { get; set; } = _ => { };
/// <summary>
/// The icon to display next to the textfield.
/// </summary>
[Parameter]
public string Icon { get; set; } = Icons.Material.Filled.Folder;
/// <summary>
/// The color of the icon to use.
/// </summary>
[Parameter]
public Color IconColor { get; set; } = Color.Default;
/// <summary>
/// The title of the directory selection dialog.
/// </summary>
[Parameter]
public string DirectoryDialogTitle { get; set; } = "Select Directory";
[Inject]
private RustService RustService { get; init; } = null!;
private string internalText = string.Empty;
private bool isDirectoryDialogOpen;
private readonly Timer timer = new(TimeSpan.FromMilliseconds(500))
{
AutoReset = false
};
#region Overrides of ConfigurationBase
/// <inheritdoc />
protected override bool Stretch => true;
protected override Variant Variant => Variant.Outlined;
protected override string Label => this.OptionDescription;
#endregion
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
this.timer.Elapsed += (_, _) => this.InvokeAsync(async () => await this.OptionChanged(this.internalText)).Observe($"{nameof(ConfigurationDirectory)}: applying the changed directory");
await base.OnInitializedAsync();
}
protected override async Task OnParametersSetAsync()
{
this.internalText = this.Text();
await base.OnParametersSetAsync();
}
#endregion
private void InternalUpdate(string text)
{
this.timer.Stop();
this.internalText = text;
this.timer.Start();
}
private async Task OpenDirectoryDialog()
{
if (this.isDirectoryDialogOpen)
return;
this.isDirectoryDialogOpen = true;
try
{
var response = await this.RustService.SelectDirectory(this.DirectoryDialogTitle, string.IsNullOrWhiteSpace(this.internalText) ? null : this.internalText);
if (response.UserCancelled)
return;
this.timer.Stop();
this.internalText = response.SelectedDirectory;
await this.OptionChanged(response.SelectedDirectory);
}
finally
{
this.isDirectoryDialogOpen = false;
}
}
private async Task OptionChanged(string updatedText)
{
this.TextUpdate(updatedText);
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
#region Overrides of MSGComponentBase
protected override void DisposeResources()
{
try
{
this.timer.Stop();
this.timer.Dispose();
}
catch
{
// ignore
}
base.DisposeResources();
}
#endregion
}
@@ -70,7 +70,7 @@ public partial class ConfigurationFile : ConfigurationBaseCore
protected override async Task OnInitializedAsync()
{
this.timer.Elapsed += async (_, _) => await this.InvokeAsync(async () => await this.OptionChanged(this.internalText));
this.timer.Elapsed += (_, _) => this.InvokeAsync(async () => await this.OptionChanged(this.internalText)).Observe($"{nameof(ConfigurationFile)}: applying the changed file");
await base.OnInitializedAsync();
}
@@ -28,11 +28,26 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBaseCore
[Parameter]
public Action<HashSet<TData>> SelectionUpdate { get; set; } = _ => { };
/// <summary>
/// An asynchronous action that is called when the selection changes.
/// </summary>
[Parameter]
public Func<HashSet<TData>, Task> SelectionUpdateAsync { get; set; } = _ => Task.CompletedTask;
/// <summary>
/// Determines whether a specific item is locked by a configuration plugin.
/// </summary>
[Parameter]
public Func<TData, bool> IsItemLocked { get; set; } = _ => false;
[Parameter]
public string? EmptySelectionText { get; set; }
[Parameter]
public string? SingleSelectionText { get; set; }
[Parameter]
public string? MultipleSelectionText { get; set; }
#region Overrides of ConfigurationBase
@@ -49,11 +64,12 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBaseCore
private async Task OptionChanged(IEnumerable<TData?>? updatedValues)
{
if(updatedValues is null)
this.SelectionUpdate([]);
else
this.SelectionUpdate(updatedValues.Where(n => n is not null).ToHashSet()!);
// OfType drops the nulls and gives back the non-nullable element type in one step, which
// Where cannot: it keeps the nullable type no matter what the predicate proves.
var selection = updatedValues is null ? [] : updatedValues.OfType<TData>().ToHashSet();
this.SelectionUpdate(selection);
await this.SelectionUpdateAsync(selection);
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
@@ -61,12 +77,12 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBaseCore
private string GetMultiSelectionText(List<TData?>? selectedValues)
{
if(selectedValues is null || selectedValues.Count == 0)
return T("No preview features selected.");
return this.EmptySelectionText ?? T("No items selected.");
if(selectedValues.Count == 1)
return T("You have selected 1 preview feature.");
return this.SingleSelectionText ?? T("You have selected 1 item.");
return string.Format(T("You have selected {0} preview features."), selectedValues.Count);
return string.Format(this.MultipleSelectionText ?? T("You have selected {0} items."), selectedValues.Count);
}
private bool IsLockedValue(TData value) => this.IsItemLocked(value);
@@ -76,4 +92,4 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBaseCore
"This feature is managed by your organization and has therefore been disabled.",
typeof(ConfigurationBase).Namespace,
nameof(ConfigurationBase));
}
}
@@ -1,2 +1,13 @@
@inherits MSGComponentBase
<ConfigurationSelect IsLocked="@this.IsLocked" OptionDescription="@T("Preselected provider")" Disabled="@(() => this.Disabled())" OptionHelp="@this.HelpText()" Data="@this.FilteredData()" SelectedValue="@this.SelectedValue" SelectionUpdate="@this.SelectionUpdate"/>
<ConfigurationSelect IsLocked="@this.IsLocked" OptionDescription="@T("Preselected provider")" Disabled="@(() => this.Disabled())" OptionHelp="@this.HelpText()" Data="@this.FilteredData()" SelectedValue="@this.SelectedValue" SelectionUpdate="@this.SelectionUpdate">
<ItemTemplate Context="providerData">
@if (this.GetProvider(providerData.Value) is { } provider)
{
<ProviderLabel ProviderSettings="@provider" Text="@providerData.Name" />
}
else
{
@providerData.Name
}
</ItemTemplate>
</ConfigurationSelect>
@@ -1,5 +1,3 @@
using System.Diagnostics.CodeAnalysis;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
@@ -34,32 +32,34 @@ public partial class ConfigurationProviderSelection : MSGComponentBase
[Parameter]
public Func<bool> IsLocked { get; set; } = () => false;
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
private IEnumerable<ConfigurationSelectData<string>> FilteredData()
{
if(this.Component is not Tools.Components.NONE and not Tools.Components.APP_SETTINGS)
yield return new(T("Use app default"), string.Empty);
// Get the minimum confidence level for this component, and/or the enforced global minimum confidence level:
var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(this.Component);
// Apply the explicit minimum confidence level if set and higher than the current minimum level:
if (this.ExplicitMinimumConfidence is not ConfidenceLevel.UNKNOWN && this.ExplicitMinimumConfidence > minimumLevel)
minimumLevel = this.ExplicitMinimumConfidence;
// Filter the providers based on the minimum confidence level:
//
// Filter the providers based on the minimum confidence level of this component, the enforced
// global minimum, and the explicit minimum level when it is higher. Providers which no longer
// exist resolve to `Provider.NONE` and are dropped by the confidence check as well:
//
foreach (var providerId in this.Data)
{
var provider = this.SettingsManager.ConfigurationData.Providers.FirstOrDefault(x => x.Id == providerId.Value);
if (provider is null)
continue;
if (provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel)
var provider = this.SettingsManager.GetProviderById(providerId.Value);
if (this.SettingsManager.IsProviderConfident(provider, this.Component, this.ExplicitMinimumConfidence))
yield return providerId;
}
}
private AIStudio.Settings.Provider? GetProvider(string providerId)
{
if (string.IsNullOrWhiteSpace(providerId))
return null;
var provider = this.SettingsManager.GetProviderById(providerId);
return provider == AIStudio.Settings.Provider.NONE ? null : provider;
}
#region Overrides of MSGComponentBase
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
@@ -5,7 +5,14 @@
@foreach (var data in this.Data)
{
<MudSelectItem Value="@data.Value">
@data.Name
@if (this.ItemTemplate is null)
{
@data.Name
}
else
{
@this.ItemTemplate(data)
}
</MudSelectItem>
}
</MudSelect>
</MudSelect>
@@ -33,7 +33,13 @@ public partial class ConfigurationSelect<TConfig> : ConfigurationBaseCore
/// </summary>
[Parameter]
public Func<TConfig, Task> SelectionUpdateAsync { get; set; } = _ => Task.CompletedTask;
/// <summary>
/// Optional template used to render an item in the list.
/// </summary>
[Parameter]
public RenderFragment<ConfigurationSelectData<TConfig>>? ItemTemplate { get; set; }
#region Overrides of ConfigurationBase
/// <inheritdoc />
@@ -54,4 +60,4 @@ public partial class ConfigurationSelect<TConfig> : ConfigurationBaseCore
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
}
}
@@ -1,17 +1,46 @@
@inherits ConfigurationBaseCore
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Lines="@this.NumLines"
AutoGrow="@this.AutoGrow"
MaxLines="@this.GetMaxLines"
Immediate="@true"
Underline="false"
/>
@if (this.ResetValue is null)
{
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Lines="@this.NumLines"
AutoGrow="@this.AutoGrow"
MaxLines="@this.GetMaxLines"
Immediate="@true"
Validation="@this.Validation"
Underline="false"
/>
}
else
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudTextField
T="string"
Text="@this.Text()"
TextChanged="@this.InternalUpdate"
Disabled="@this.IsDisabled"
Adornment="Adornment.Start"
AdornmentIcon="@this.Icon"
AdornmentColor="@this.IconColor"
UserAttributes="@SPELLCHECK_ATTRIBUTES"
Lines="@this.NumLines"
AutoGrow="@this.AutoGrow"
MaxLines="@this.GetMaxLines"
Immediate="@true"
Validation="@this.Validation"
Underline="false"
Class="flex-grow-1"
/>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Restore" Disabled="@this.IsDisabled" OnClick="@this.ResetTextAsync" Class="mb-1">
@this.ResetButtonText
</MudButton>
</MudStack>
}
@@ -41,6 +41,24 @@ public partial class ConfigurationText : ConfigurationBaseCore
/// </summary>
[Parameter]
public int MaxLines { get; set; } = 12;
/// <summary>
/// When configured, displays a button which restores this value.
/// </summary>
[Parameter]
public Func<string>? ResetValue { get; set; }
/// <summary>
/// The text displayed on the optional reset button.
/// </summary>
[Parameter]
public string ResetButtonText { get; set; } = string.Empty;
/// <summary>
/// Validates the configured text before it is stored.
/// </summary>
[Parameter]
public Func<string, string?>? Validation { get; set; }
private string internalText = string.Empty;
private readonly Timer timer = new(TimeSpan.FromMilliseconds(500))
@@ -57,13 +75,9 @@ public partial class ConfigurationText : ConfigurationBaseCore
protected override string Label => this.OptionDescription;
#endregion
#region Overrides of ConfigurationBase
protected override async Task OnInitializedAsync()
{
this.timer.Elapsed += async (_, _) => await this.InvokeAsync(async () => await this.OptionChanged(this.internalText));
this.timer.Elapsed += (_, _) => this.InvokeAsync(async () => await this.OptionChanged(this.internalText)).Observe($"{nameof(ConfigurationText)}: applying the changed text");
await base.OnInitializedAsync();
}
@@ -85,9 +99,22 @@ public partial class ConfigurationText : ConfigurationBaseCore
this.internalText = text;
this.timer.Start();
}
private async Task ResetTextAsync()
{
if (this.ResetValue is null || this.IsDisabled)
return;
this.timer.Stop();
this.internalText = this.ResetValue();
await this.OptionChanged(this.internalText);
}
private async Task OptionChanged(string updatedText)
{
if (this.Validation?.Invoke(updatedText) is not null)
return;
this.TextUpdate(updatedText);
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
@@ -64,9 +64,9 @@ public partial class DebouncedTextField : MudComponentBase, IDisposable
this.debounceTimer.Elapsed += (_, _) =>
{
this.debounceTimer.Stop();
this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text));
this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text));
this.InvokeAsync(() => this.WhenTextCanged(this.text));
this.InvokeAsync(async () => await this.TextChanged.InvokeAsync(this.text)).Observe($"{nameof(DebouncedTextField)}: notifying about changed text");
this.InvokeAsync(async () => await this.WhenTextChangedAsync(this.text)).Observe($"{nameof(DebouncedTextField)}: handling changed text asynchronously");
this.InvokeAsync(() => this.WhenTextCanged(this.text)).Observe($"{nameof(DebouncedTextField)}: handling changed text");
};
this.isInitialized = true;
@@ -0,0 +1,46 @@
@inherits MSGComponentBase
@if (this.availableWorkspaces.Count > 0)
{
<MudSelect T="string" Value="@this.WorkspaceName" ValueChanged="@this.SelectExistingWorkspace" Strict="@false" Label="@T("Existing workspace (Optional)")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Folder">
@foreach (var workspace in this.availableWorkspaces)
{
<MudSelectItem T="string" Value="@workspace.Name">@workspace.Name</MudSelectItem>
}
</MudSelect>
}
<MudTextField T="string" Text="@this.WorkspaceName" TextChanged="@this.SetWorkspaceName" Validation="@this.ValidateWorkspaceName" AdornmentIcon="@Icons.Material.Filled.CreateNewFolder" Adornment="Adornment.Start" IconSize="Size.Small" Label="@T("Workspace name")" HelperText="@T("Choose an existing workspace or enter a name that should be created when the launcher is opened.")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3" UserAttributes="@USER_INPUT_ATTRIBUTES"/>
<MudSelect T="string" Value="@this.ProviderId" ValueChanged="@this.SetProviderId" Label="@T("Chat provider")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.SmartToy">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
@foreach (var provider in this.SettingsManager.GetConfidentProviders(Components.CHAT))
{
<MudSelectItem T="string" Value="@provider.Id">
<ProviderLabel ProviderSettings="@provider" Text="@provider.InstanceName"/>
</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Value="@this.ProfileId" ValueChanged="@this.SetProfileId" Label="@T("Chat profile")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Person">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
<MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no profile")</MudSelectItem>
@foreach (var profile in this.SettingsManager.ConfigurationData.Profiles)
{
<MudSelectItem T="string" Value="@profile.Id">@profile.GetSafeName()</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Value="@this.ChatTemplateId" ValueChanged="@this.SetChatTemplateId" Label="@T("Chat template")" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Article">
<MudSelectItem T="string" Value="@string.Empty">@T("Use chat default")</MudSelectItem>
<MudSelectItem T="string" Value="@Guid.Empty.ToString()">@T("Use no chat template")</MudSelectItem>
@foreach (var chatTemplate in this.SettingsManager.ConfigurationData.ChatTemplates)
{
<MudSelectItem T="string" Value="@chatTemplate.Id">@chatTemplate.GetSafeName()</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Source">
@foreach (var dataSource in this.SettingsManager.ConfigurationData.DataSources)
{
<MudSelectItem T="string" Value="@dataSource.Id">@dataSource.Name</MudSelectItem>
}
</MudSelect>
<ToolSelectionField Component="Components.CHAT" SelectedToolIds="@this.ToolIds" SelectedToolIdsChanged="@this.SetToolIds" Label="@T("Tools (Optional)")" Help="@T("These tools are preselected when the chat opens. Users can change the selection in the chat, and every tool has to meet the confidence requirements of the provider in use.")"/>
@@ -0,0 +1,164 @@
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// The selection a direct chat launcher needs: the workspace its chat is created in, and the
/// provider, profile, chat template, and data sources that chat starts with.
/// </summary>
/// <remarks>
/// The Assistant Builder uses this form to describe a launcher it is about to generate, while the
/// launcher settings dialog uses it to change an installed launcher. Both keep their own state, so
/// every field is a two-way bound parameter here.
/// </remarks>
public partial class DirectChatLauncherForm : MSGComponentBase
{
/// <summary>
/// The name of the workspace the launcher opens its chat in. The workspace is created when it
/// does not exist yet, hence this is a free-text field and not a workspace ID.
/// </summary>
[Parameter]
public string WorkspaceName { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> WorkspaceNameChanged { get; set; }
/// <summary>
/// The provider ID for the chat, or an empty string to use the chat default.
/// </summary>
[Parameter]
public string ProviderId { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> ProviderIdChanged { get; set; }
/// <summary>
/// The profile ID for the chat, an empty GUID for explicitly no profile, or an empty string to
/// use the chat default.
/// </summary>
[Parameter]
public string ProfileId { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> ProfileIdChanged { get; set; }
/// <summary>
/// The chat template ID, an empty GUID for explicitly no template, or an empty string to use
/// the chat default.
/// </summary>
[Parameter]
public string ChatTemplateId { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> ChatTemplateIdChanged { get; set; }
/// <summary>
/// The data sources the chat starts with. An empty selection keeps the normal chat defaults.
/// </summary>
[Parameter]
public IEnumerable<string> DataSourceIds { get; set; } = [];
[Parameter]
public EventCallback<IEnumerable<string>> DataSourceIdsChanged { get; set; }
/// <summary>
/// The tools preselected for the chat. An empty selection keeps the normal chat defaults.
/// </summary>
/// <remarks>
/// A preselection, not a limit: the user can switch tools in the chat as usual. What a tool
/// may actually do is decided there, by the confidence of the provider in use.
/// </remarks>
[Parameter]
public HashSet<string> ToolIds { get; set; } = [];
[Parameter]
public EventCallback<HashSet<string>> ToolIdsChanged { get; set; }
/// <summary>
/// Validates the workspace name. The hosts differ here: the Builder requires a name only while
/// its launcher switch is on, whereas the settings dialog always requires one.
/// </summary>
[Parameter]
public Func<string, string?>? ValidateWorkspaceName { get; set; }
private IReadOnlyList<WorkspaceTreeWorkspace> availableWorkspaces = [];
private static readonly Dictionary<string, object?> USER_INPUT_ATTRIBUTES = new();
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
// Configure the spellchecking for the workspace name input:
this.SettingsManager.InjectSpellchecking(USER_INPUT_ATTRIBUTES);
await base.OnInitializedAsync();
var workspaceSnapshot = await WorkspaceBehaviour.GetOrLoadWorkspaceTreeShellAsync();
this.availableWorkspaces = workspaceSnapshot.Workspaces;
}
#endregion
//
// Picking an existing workspace fills the name field. Clearing the select must not wipe a name
// the user typed, though, so an empty selection is ignored:
//
private async Task SelectExistingWorkspace(string workspaceName)
{
if (string.IsNullOrWhiteSpace(workspaceName))
return;
await this.SetWorkspaceName(workspaceName);
}
private async Task SetWorkspaceName(string workspaceName)
{
this.WorkspaceName = workspaceName;
await this.WorkspaceNameChanged.InvokeAsync(workspaceName);
}
private async Task SetProviderId(string providerId)
{
this.ProviderId = providerId;
await this.ProviderIdChanged.InvokeAsync(providerId);
}
private async Task SetProfileId(string profileId)
{
this.ProfileId = profileId;
await this.ProfileIdChanged.InvokeAsync(profileId);
}
private async Task SetChatTemplateId(string chatTemplateId)
{
this.ChatTemplateId = chatTemplateId;
await this.ChatTemplateIdChanged.InvokeAsync(chatTemplateId);
}
//
// MudSelect hands out its selection as a lazy sequence of nullable strings. We materialize it
// once and drop empty entries, so the host always receives a stable list of usable IDs:
//
private async Task SetDataSourceIds(IEnumerable<string?>? dataSourceIds)
{
var selectedDataSourceIds = dataSourceIds is null ? [] : dataSourceIds.Where(id => !string.IsNullOrWhiteSpace(id)).Select(id => id!).ToArray();
this.DataSourceIds = selectedDataSourceIds;
await this.DataSourceIdsChanged.InvokeAsync(selectedDataSourceIds);
}
private async Task SetToolIds(HashSet<string> toolIds)
{
this.ToolIds = toolIds;
await this.ToolIdsChanged.InvokeAsync(toolIds);
}
private string GetSelectedDataSourceText(List<string?>? selectedValues)
{
if (selectedValues is null || selectedValues.Count == 0)
return T("Use the normal chat data source defaults");
return string.Format(T("{0} data source(s) selected"), selectedValues.Count);
}
}
@@ -0,0 +1,13 @@
@inherits MSGComponentBase
@if (this.CanEditSettings)
{
<MudTooltip Text="@T("Change what this tile opens")">
<MudIconButton Icon="@Icons.Material.Filled.Tune"
Color="Color.Default"
Variant="Variant.Text"
Size="Size.Medium"
Disabled="@this.isEditing"
OnClick="@this.OpenSettingsDialogAsync"/>
</MudTooltip>
}
@@ -0,0 +1,71 @@
using AIStudio.Dialogs;
using AIStudio.Tools.PluginSystem.Assistants;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components;
/// <summary>
/// Lets users change the chat a direct chat launcher opens, right from its tile.
/// </summary>
/// <remarks>
/// A launcher tile has no assistant page: opening it goes straight to the chat, so the revise
/// action on the dynamic assistant page can never be reached for one. Its tile is therefore the
/// place where users look for its settings.
/// </remarks>
public partial class DirectChatLauncherSettingsAction : MSGComponentBase
{
[Parameter, EditorRequired]
public PluginAssistants Plugin { get; set; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private ILogger<DirectChatLauncherSettingsAction> Logger { get; init; } = null!;
private bool isEditing;
//
// This check reads no files on purpose: it runs on every render of the assistants page. Whether
// the plugin file itself can be rewritten is decided by the dialog, which reads it anyway:
//
private bool CanEditSettings => DirectChatLauncherLuaWriter.CanRewrite(this.Plugin);
private async Task OpenSettingsDialogAsync()
{
if (!this.CanEditSettings || this.isEditing)
return;
this.isEditing = true;
await this.InvokeAsync(this.StateHasChanged);
try
{
var parameters = new DialogParameters<DirectChatLauncherSettingsDialog>
{
{ x => x.PluginId, this.Plugin.Id },
{ x => x.PluginLocalPath, this.Plugin.PluginPath },
};
var dialogReference = await this.DialogService.ShowAsync<DirectChatLauncherSettingsDialog>(this.T("Tile Settings"), parameters, DialogOptions.BLOCKING_FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled || dialogResult.Data is not DirectChatLauncherSettingsDialogResult result)
return;
this.Logger.LogInformation("The chat launcher '{PluginName}' ({PluginId}) has been updated from its tile.", result.PluginName, result.PluginId);
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Save, string.Format(this.T("The tile '{0}' has been updated."), result.PluginName)));
// Saving already ran LoadAll, which announced PLUGINS_RELOADED. We still announce the
// configuration change: with automatic audits enabled, the dialog stored an audit result:
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
finally
{
this.isEditing = false;
await this.InvokeAsync(this.StateHasChanged);
}
}
}
@@ -0,0 +1,16 @@
namespace AIStudio.Components;
/// <summary>
/// Represents an assistant block which belongs to an assistant category.
/// </summary>
/// <remarks>
/// Assistant blocks are generic over their settings dialog. This interface gives the category block
/// access to their visibility without the need to know that type parameter.
/// </remarks>
public interface IAssistantCategoryMember
{
/// <summary>
/// Gets whether the assistant is visible right now.
/// </summary>
bool IsVisible { get; }
}
@@ -0,0 +1,24 @@
<MudTreeView T="JsonTreeNode"
Items="@this.items"
ReadOnly="true"
Hover="true"
Dense="true"
ExpandOnClick="true">
<ItemTemplate Context="item">
@if (item.Value is { } node)
{
<MudTreeViewItem T="JsonTreeNode"
Icon="@node.Icon"
Value="@item.Value"
Expanded="@item.Expanded"
CanExpand="@node.Expandable"
Items="@item.Children">
<BodyContent>
<MudText Typo="Typo.body2" Style="font-family: monospace; white-space: pre-wrap; overflow-wrap: anywhere;">
@node.Text
</MudText>
</BodyContent>
</MudTreeViewItem>
}
</ItemTemplate>
</MudTreeView>
@@ -0,0 +1,73 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class JsonTreeView : ComponentBase
{
[Parameter]
public JsonNode? Value { get; set; }
private IReadOnlyCollection<TreeItemData<JsonTreeNode>> items = [];
protected override void OnParametersSet()
{
this.items = [CreateTreeItem("$", this.Value)];
}
private static TreeItemData<JsonTreeNode> CreateTreeItem(string label, JsonNode? value)
{
var children = CreateChildren(value);
return new TreeItemData<JsonTreeNode>
{
Expanded = false,
Expandable = children.Count > 0,
Value = new JsonTreeNode
{
Text = $"{label}: {FormatValue(value)}",
Icon = GetIcon(value),
Expandable = children.Count > 0,
},
Children = children,
};
}
private static List<TreeItemData<JsonTreeNode>> CreateChildren(JsonNode? value) => value switch
{
JsonObject jsonObject => jsonObject
.Select(property => CreateTreeItem(JsonSerializer.Serialize(property.Key), property.Value))
.ToList(),
JsonArray jsonArray => jsonArray
.Select((item, index) => CreateTreeItem($"[{index}]", item))
.ToList(),
_ => [],
};
private static string FormatValue(JsonNode? value) => value switch
{
JsonObject jsonObject when jsonObject.Count == 0 => "{}",
JsonObject => "{...}",
JsonArray jsonArray when jsonArray.Count == 0 => "[]",
JsonArray => "[...]",
null => "null",
_ => value.ToJsonString(),
};
private static string GetIcon(JsonNode? value) => value switch
{
JsonObject => Icons.Material.Filled.DataObject,
JsonArray => Icons.Material.Filled.DataArray,
_ => Icons.Material.Filled.Code,
};
private sealed class JsonTreeNode
{
public string Text { get; init; } = string.Empty;
public string Icon { get; init; } = string.Empty;
public bool Expandable { get; init; }
}
}
@@ -1,5 +1,8 @@
@inherits ConfigurationBaseCore
<MudButton Variant="Variant.Filled" Color="@Color.Primary" StartIcon="@this.Icon" Disabled="@this.IsDisabled" OnClick="@(async () => await this.ClickAsync())">
@this.Text
</MudButton>
@* The tooltip is suppressed while the button is locked, so that the lock icon's tooltip is the only one shown: *@
<MudTooltip Text="@this.Tooltip" Disabled="@(this.IsLocked() || string.IsNullOrWhiteSpace(this.Tooltip))" RootStyle="display:inline-flex;">
<MudButton Variant="@this.ButtonVariant" Color="@this.ButtonColor" StartIcon="@this.Icon" Disabled="@this.IsDisabled" OnClick="@(async () => await this.ClickAsync())">
@this.Text
</MudButton>
</MudTooltip>
@@ -18,7 +18,33 @@ public partial class LockableButton : ConfigurationBaseCore
[Parameter]
public string Class { get; set; } = string.Empty;
/// <summary>
/// An optional tooltip for the button. It is not shown while the button is locked,
/// because the lock icon explains the situation in that case.
/// </summary>
[Parameter]
public string Tooltip { get; set; } = string.Empty;
/// <summary>
/// The visual variant of the button.
/// </summary>
[Parameter]
public Variant ButtonVariant { get; set; } = Variant.Filled;
/// <summary>
/// The color of the button.
/// </summary>
[Parameter]
public Color ButtonColor { get; set; } = Color.Primary;
/// <summary>
/// Should the default bottom margin be removed? Useful when the button is placed in a
/// toolbar instead of a settings panel.
/// </summary>
[Parameter]
public bool NoMargin { get; set; }
#region Overrides of ConfigurationBase
/// <inheritdoc />
@@ -26,6 +52,8 @@ public partial class LockableButton : ConfigurationBaseCore
protected override string GetClassForBase => this.Class;
protected override string MarginClass => this.NoMargin ? string.Empty : base.MarginClass;
#endregion
private async Task ClickAsync()
@@ -1,11 +1,12 @@
using AIStudio.Settings;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBusReceiver, ILang
public abstract class MSGComponentBase : ComponentBase, IDisposable, IAsyncDisposable, IMessageBusReceiver, ILang
{
[Inject]
protected SettingsManager SettingsManager { get; init; } = null!;
@@ -13,6 +14,13 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
[Inject]
protected MessageBus MessageBus { get; init; } = null!;
/// <summary>
/// The circuit this component lives in. Use it before any JS interop: while its connection is down,
/// the browser is unreachable, although the component itself keeps working.
/// </summary>
[Inject]
protected CircuitStateService CircuitState { get; init; } = null!;
private ILanguagePlugin Lang { get; set; } = PluginFactory.BaseLanguage;
#region Overrides of ComponentBase
@@ -21,7 +29,7 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
{
this.Lang = await this.SettingsManager.GetActiveLanguagePlugin();
this.MessageBus.RegisterComponent(this);
this.MessageBus.RegisterComponent(this, this.CircuitState);
await base.OnInitializedAsync();
}
@@ -103,10 +111,20 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
this.MessageBus.ApplyFilters(this, filterComponents, eventsList.ToHashSet());
}
/// <summary>
/// Releases what this component has acquired. Override this instead of implementing
/// IDisposable again, so the deregistration from the message bus cannot be lost.
/// </summary>
protected virtual void DisposeResources()
{
}
/// <summary>
/// Releases what this component has acquired and needs an await to release. Override this
/// instead of implementing IAsyncDisposable, see the remarks on DisposeAsync below.
/// </summary>
protected virtual ValueTask DisposeResourcesAsync() => ValueTask.CompletedTask;
#region Implementation of IDisposable
public void Dispose()
@@ -116,4 +134,25 @@ public abstract class MSGComponentBase : ComponentBase, IDisposable, IMessageBus
}
#endregion
#region Implementation of IAsyncDisposable
/// <summary>
/// Releases this component asynchronously.
/// </summary>
/// <remarks>
/// This base class implements both ways of disposing on purpose. Blazor calls only DisposeAsync
/// when a component offers both, so a derived component which implements IAsyncDisposable on
/// its own would silently skip everything Dispose does — above all the deregistration from the
/// message bus, which holds a strong reference to every receiver. Deriving components override
/// DisposeResources or DisposeResourcesAsync instead, and this stays the one place which knows
/// about both.
/// </remarks>
public async ValueTask DisposeAsync()
{
await this.DisposeResourcesAsync();
this.Dispose();
}
#endregion
}
@@ -0,0 +1,26 @@
@inherits MSGComponentBase
@if (this.NeedsToolCallingProvider)
{
@* Nothing runs at all, so the other two would only add noise: *@
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="@this.Class">
@T("Tools were selected for this run, but the chosen model cannot use tools. It runs without them. Please choose a model which supports tools.")
</MudAlert>
}
else
{
@* Two independent reasons a tool stays out of reach, so both may show at once: *@
@if (this.ToolsNeedingConfiguration.Count > 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="@this.Class">
@(string.Format(T("Some tools selected for this run are not fully configured and stay unused: {0}. Please complete their settings."), string.Join(", ", this.ToolsNeedingConfiguration)))
</MudAlert>
}
@if (this.ToolsBeyondProviderConfidence.Count > 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="@this.Class">
@(string.Format(T("Not all tools selected for this run can be used with the chosen AI provider: {0}. Please choose a provider with a higher confidence level to use all of them."), string.Join(", ", this.ToolsBeyondProviderConfidence)))
</MudAlert>
}
}
@@ -0,0 +1,111 @@
using AIStudio.Provider;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Says when tools an assistant was told to use cannot reach the selected provider.
/// </summary>
/// <remarks>
/// Whoever named these tools — a document analysis policy, an assistant plugin — did so without
/// knowing which provider the user would pick. The user cannot switch a blocked tool on either,
/// because there is no selection to switch. Saying nothing would let the run quietly proceed
/// without them, which is why this belongs next to the provider selection: choosing another
/// provider is what resolves it.
/// </remarks>
public partial class ManagedToolsWarning : MSGComponentBase
{
[Parameter]
public AIStudio.Tools.Components Component { get; set; } = AIStudio.Tools.Components.CHAT;
/// <summary>
/// The tools of this run, as named by the assistant's own rules.
/// </summary>
[Parameter]
public IReadOnlySet<string> ToolIds { get; set; } = new HashSet<string>();
[Parameter]
public AIStudio.Settings.Provider ProviderSettings { get; set; } = AIStudio.Settings.Provider.NONE;
[Parameter]
public string Class { get; set; } = "mb-3";
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
private IReadOnlyList<ToolCatalogItem> availableTools = [];
/// <summary>
/// Whether this run expects tools while the selected provider cannot call any.
/// </summary>
private bool NeedsToolCallingProvider => this.ToolIds.Count > 0 && this.SettingsManager.AreToolsEnabled() && !this.ProviderSettings.GetToolCallingAvailability().IsAvailable;
/// <summary>
/// The tools of this run whose settings are incomplete, so they cannot run at all.
/// </summary>
/// <remarks>
/// Unlike the confidence case, no provider resolves this: the tool itself is missing something,
/// such as the web search without a server address. Tools an organization switched off are left
/// out, because completing their settings would not bring them back either.
/// </remarks>
private IReadOnlyList<string> ToolsNeedingConfiguration
{
get
{
if (this.ToolIds.Count is 0 || !this.SettingsManager.AreToolsEnabled())
return [];
return this.availableTools
.Where(x => this.ToolIds.Contains(x.Definition.Id) && x.IsActive && !x.ConfigurationState.IsConfigured)
.Select(x => x.Implementation.GetDisplayName())
.ToList();
}
}
/// <summary>
/// The tools of this run which the selected provider is not trusted enough to receive.
/// </summary>
/// <remarks>
/// Tools switched off in the settings are not counted: choosing another provider would not
/// bring them back, so naming them here would send the user after the wrong fix.
/// </remarks>
private IReadOnlyList<string> ToolsBeyondProviderConfidence
{
get
{
if (this.ToolIds.Count is 0 || !this.SettingsManager.AreToolsEnabled())
return [];
var providerConfidence = this.ProviderSettings == AIStudio.Settings.Provider.NONE
? ConfidenceLevel.NONE
: this.ProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager).Level;
return this.availableTools
.Where(x => this.ToolIds.Contains(x.Definition.Id) && x.IsActive)
.Where(x => !ToolSelectionRules.IsProviderConfidenceAllowed(providerConfidence, x.MinimumProviderConfidence))
.Select(x => x.Implementation.GetDisplayName())
.ToList();
}
}
protected override async Task OnInitializedAsync()
{
this.availableTools = await this.ToolRegistry.GetCatalogAsync(this.Component);
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
await base.OnInitializedAsync();
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED:
this.availableTools = await this.ToolRegistry.GetCatalogAsync(this.Component);
await this.InvokeAsync(this.StateHasChanged);
break;
}
}
}
@@ -61,7 +61,7 @@ public partial class MediaTranscriptionStatus
private void OnStateChanged(MediaImportOwner owner)
{
if (owner == this.Owner)
_ = this.InvokeAsync(this.StateHasChanged);
this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(MediaTranscriptionStatus)}: rendering an import state transition");
}
/// <summary>Unsubscribes from singleton import state changes.</summary>
@@ -17,6 +17,4 @@ public partial class MudTextList : ComponentBase
public string Class { get; set; } = string.Empty;
private string Classes => $"mud-text-list {this.Class}";
}
public readonly record struct TextItem(string Header, string Text);
}
@@ -7,7 +7,7 @@
Color="Color.Error"
Variant="Variant.Text"
Size="Size.Medium"
Disabled="@this.IsBlockedByActiveWork"
OnClick="@this.DeleteAssistantPluginAsync" />
Disabled="@(this.isDeleting || this.IsBlockedByActiveWork)"
OnClick="@this.DeletePluginAsync" />
</MudTooltip>
}
@@ -0,0 +1,169 @@
using AIStudio.Dialogs;
using AIStudio.Tools.Media;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Components;
/// <summary>
/// Lets users remove a plugin they installed or placed themselves.
/// </summary>
/// <remarks>
/// Without this action, such a plugin could only be removed from the data directory by hand. That is
/// especially painful for configuration plugins, which have no activation switch at all. Plugins
/// shipped with AI Studio and plugins deployed by an organization stay untouched: the action does
/// not appear for them.
/// </remarks>
public partial class PluginDeleteAction : MSGComponentBase
{
[Parameter, EditorRequired]
public IAvailablePlugin Plugin { get; set; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private PluginInstallService PluginInstallService { get; init; } = null!;
[Inject]
private MediaTranscriptionService MediaTranscriptionService { get; init; } = null!;
[Inject]
private ILogger<PluginDeleteAction> Logger { get; init; } = null!;
private bool isDeleting;
private bool IsAssistant => this.Plugin.Type is PluginType.ASSISTANT;
private bool CanDelete => PluginInstallService.CanDeletePlugin(this.Plugin);
/// <summary>
/// True while an assistant still owns background work. We keep the action visible and block it
/// instead of hiding it, so that the tooltip can explain why it does nothing right now.
/// </summary>
private bool IsBlockedByActiveWork => this.IsAssistant && this.PluginInstallService.HasActiveAssistantWork(this.Plugin.Id);
private string Tooltip
{
get
{
if (this.IsBlockedByActiveWork)
return this.T("The assistant cannot be deleted while background work is still running.");
return this.Plugin.Type switch
{
PluginType.ASSISTANT => this.T("Delete assistant plugin"),
PluginType.CONFIGURATION => this.T("Delete configuration plugin"),
_ => this.T("Delete language plugin"),
};
}
}
#region Overrides of MSGComponentBase
protected override async Task OnInitializedAsync()
{
// Only an assistant can be busy. We watch its sessions and transcriptions, so the action
// reflects the current state without the user reloading the page:
this.ApplyFilters([], this.IsAssistant ? [Event.ASSISTANT_SESSION_CHANGED, Event.ASSISTANT_SESSION_FINISHED] : []);
if (this.IsAssistant)
this.MediaTranscriptionService.StateChanged += this.OnMediaTranscriptionStateChanged;
await base.OnInitializedAsync();
}
protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
if (triggeredEvent is Event.ASSISTANT_SESSION_CHANGED or Event.ASSISTANT_SESSION_FINISHED)
this.StateHasChanged();
return base.ProcessIncomingMessage(sendingComponent, triggeredEvent, data);
}
protected override void DisposeResources()
{
if (this.IsAssistant)
this.MediaTranscriptionService.StateChanged -= this.OnMediaTranscriptionStateChanged;
base.DisposeResources();
}
#endregion
private async Task DeletePluginAsync()
{
if (!this.CanDelete || this.isDeleting || this.IsBlockedByActiveWork)
return;
if (!await this.ConfirmDeletionAsync())
return;
this.isDeleting = true;
await this.InvokeAsync(this.StateHasChanged);
try
{
var result = await this.PluginInstallService.DeletePluginAsync(this.Plugin, CancellationToken.None);
if (!result.Success)
{
this.Logger.LogError("Failed to delete {PluginType} plugin '{PluginName}' ({PluginId}) from '{PluginDirectory}' with issue '{Issue}'.", this.Plugin.Type, result.PluginName, result.PluginId, result.PluginDirectory, result.Issue);
await this.MessageBus.SendError(new(Icons.Material.Filled.DeleteForever, string.Format(this.T("The plugin '{0}' could not be deleted: {1}"), this.Plugin.Name, result.Issue)));
return;
}
await this.MessageBus.SendSuccess(new(Icons.Material.Filled.Check, string.Format(this.T("The plugin '{0}' has been successfully removed."), result.PluginName)));
}
finally
{
this.isDeleting = false;
await this.InvokeAsync(this.StateHasChanged);
}
}
/// <summary>
/// Asks the user before the deletion. A configuration gets the dialog listing its consequences,
/// because removing it also removes the providers and settings it brought. Assistants and
/// language plugins only own their own files, so a plain confirmation is enough.
/// </summary>
private async Task<bool> ConfirmDeletionAsync()
{
if (this.Plugin.Type is PluginType.CONFIGURATION)
{
var configurationParameters = new DialogParameters<ConfigurationPluginDeleteDialog>
{
{ x => x.PluginName, this.Plugin.Name },
{ x => x.Summary, this.PluginInstallService.BuildConfigurationDeleteSummary(this.Plugin) },
};
var configurationDialog = await this.DialogService.ShowAsync<ConfigurationPluginDeleteDialog>(this.T("Delete Configuration Plugin"), configurationParameters, DialogOptions.FULLSCREEN);
return await configurationDialog.Result is { Canceled: false };
}
var title = this.IsAssistant
? this.T("Delete Assistant Plugin")
: this.T("Delete Language Plugin");
var message = this.IsAssistant
? string.Format(this.T("Do you really want to delete the assistant plugin '{0}'? This will permanently delete the local plugin files."), this.Plugin.Name)
: string.Format(this.T("Do you really want to delete the language plugin '{0}'? This permanently deletes its local plugin files. When it is your chosen language, AI Studio returns to choosing the language automatically."), this.Plugin.Name);
var parameters = new DialogParameters<ConfirmDialog>
{
{ x => x.Message, message },
};
var dialog = await this.DialogService.ShowAsync<ConfirmDialog>(title, parameters, DialogOptions.FULLSCREEN);
return await dialog.Result is { Canceled: false };
}
private void OnMediaTranscriptionStateChanged(MediaImportOwner owner)
{
if (owner.Kind is MediaImportOwnerKind.ASSISTANT && owner.Id.EndsWith($":{this.Plugin.Id}", StringComparison.Ordinal))
this.InvokeAsync(this.StateHasChanged).Observe($"{nameof(PluginDeleteAction)}: rendering a transcription state change");
}
}
@@ -0,0 +1 @@
<img src="@this.IconUrl" alt="" aria-hidden="true" draggable="false" class="@this.CssClass" style="@this.Style" />
@@ -0,0 +1,59 @@
using AIStudio.Provider;
using AIStudio.Settings;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Shows the icon of a provider.
/// </summary>
/// <remarks>
/// The icon is rendered as an image instead of inline SVG. That way the browser treats the icon as
/// a standalone, script-less document, which matters for the custom icons a configuration plugin
/// may supply.
/// </remarks>
public partial class ProviderIcon : ComponentBase
{
/// <summary>
/// The configured provider whose icon should be shown. Takes precedence over ProviderType.
/// </summary>
[Parameter]
public AIStudio.Settings.Provider? ProviderSettings { get; set; }
/// <summary>
/// The LLM provider whose icon should be shown when no ProviderSettings was given.
/// </summary>
[Parameter]
public LLMProviders ProviderType { get; set; } = LLMProviders.NONE;
/// <summary>
/// The validated custom icon supplied by a configuration plugin.
/// </summary>
[Parameter]
public string CustomIconDataUrl { get; set; } = string.Empty;
/// <summary>
/// Additional CSS class for the icon.
/// </summary>
[Parameter]
public string Class { get; set; } = string.Empty;
/// <summary>
/// Additional inline style for the icon.
/// </summary>
[Parameter]
public string Style { get; set; } = string.Empty;
[Inject]
private SettingsManager SettingsManager { get; init; } = null!;
/// <summary>
/// The provider-icon class carries the sizing from app.css. Callers add to it instead of
/// replacing it, so an icon cannot lose its size by setting a class of its own.
/// </summary>
private string CssClass => $"provider-icon {this.Class}".TrimEnd();
private string IconUrl => this.ProviderSettings?.GetIconUrl(this.SettingsManager.IsDarkMode)
?? this.ProviderType.GetIconUrl(this.SettingsManager.IsDarkMode, this.CustomIconDataUrl);
}
@@ -0,0 +1,4 @@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Wrap="Wrap.NoWrap">
<ProviderIcon ProviderSettings="@this.ProviderSettings" ProviderType="@this.ProviderType" CustomIconDataUrl="@this.CustomIconDataUrl" />
<MudText Class="@this.TextClass">@this.Text</MudText>
</MudStack>
@@ -0,0 +1,46 @@
using AIStudio.Provider;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Shows a provider icon next to its name.
/// </summary>
/// <remarks>
/// Providers appear in select items, in table cells, and in group headers. All of them need the
/// same icon and text pairing, so this component owns that layout once instead of repeating it at
/// every call site.
/// </remarks>
public partial class ProviderLabel : ComponentBase
{
/// <summary>
/// The configured provider whose icon should be shown. Takes precedence over ProviderType.
/// </summary>
[Parameter]
public AIStudio.Settings.Provider? ProviderSettings { get; set; }
/// <summary>
/// The LLM provider whose icon should be shown when no ProviderSettings was given.
/// </summary>
[Parameter]
public LLMProviders ProviderType { get; set; } = LLMProviders.NONE;
/// <summary>
/// The validated custom icon supplied by a configuration plugin.
/// </summary>
[Parameter]
public string CustomIconDataUrl { get; set; } = string.Empty;
/// <summary>
/// The text shown next to the icon.
/// </summary>
[Parameter]
public string Text { get; set; } = string.Empty;
/// <summary>
/// Additional CSS class for the text.
/// </summary>
[Parameter]
public string TextClass { get; set; } = string.Empty;
}
@@ -1,11 +1,11 @@
@using AIStudio.Settings
@inherits MSGComponentBase
<MudSelect T="Provider" Value="@this.ProviderSettings" ValueChanged="@this.SelectionChanged" Validation="@this.ValidateProvider" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Apps" Margin="Margin.Dense" Label="@T("Provider")" Class="mb-3 rounded-lg" OuterClass="flex-grow-0" Variant="Variant.Outlined" Disabled="@this.Disabled">
<MudSelect T="Provider" Value="@this.ProviderSettings" ValueChanged="@this.SelectionChanged" Validation="@this.ValidateProvider" Margin="Margin.Dense" Label="@T("Provider")" Class="mb-3 rounded-lg" OuterClass="flex-grow-0" Variant="Variant.Outlined" Disabled="@this.Disabled">
@foreach (var providerItem in this.GetAvailableProviderSelectionItems())
{
<MudSelectItem Value="@providerItem.Provider">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Class="w-100" Wrap="Wrap.NoWrap">
<MudText Class="me-2">@providerItem.Provider</MudText>
<ProviderLabel ProviderSettings="@providerItem.Provider" Text="@providerItem.Provider.ToString()" TextClass="me-2" />
@if (providerItem.CapabilityIcons.Count > 0)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Wrap="Wrap.NoWrap" Class="flex-grow-0">
@@ -1,5 +1,3 @@
using System.Diagnostics.CodeAnalysis;
using AIStudio.Provider;
using AIStudio.Settings;
@@ -83,7 +81,6 @@ public partial class ProviderSelection : MSGComponentBase
_ => this.T("Uses reasoning (thinking)"),
};
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
private IEnumerable<AIStudio.Settings.Provider> GetAvailableProviders()
{
switch (this.Component)
@@ -91,37 +88,41 @@ public partial class ProviderSelection : MSGComponentBase
case null:
this.Logger.LogError("Component is null! Cannot filter providers based on component settings. Missed CascadingParameter?");
yield break;
case Tools.Components.NONE:
this.Logger.LogError("Component is NONE! Cannot filter providers based on component settings. Used wrong component?");
yield break;
case { } component:
// Get the minimum confidence level for this component, and/or the global minimum if enforced:
var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(component);
// Override with the explicit minimum level if set and higher:
if (this.ExplicitMinimumConfidence is not ConfidenceLevel.UNKNOWN && this.ExplicitMinimumConfidence > minimumLevel)
minimumLevel = this.ExplicitMinimumConfidence;
// Filter providers based on the minimum confidence level:
foreach (var provider in this.SettingsManager.ConfigurationData.Providers)
if (provider.UsedLLMProvider != LLMProviders.NONE)
if (provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel)
yield return provider;
// Filter providers based on the minimum confidence level of this component, the
// enforced global minimum, and the explicit minimum level when it is higher:
foreach (var provider in this.SettingsManager.GetConfidentProviders(component, this.ExplicitMinimumConfidence))
yield return provider;
break;
}
}
#region Overrides of MSGComponentBase
protected override Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
if (triggeredEvent is Event.CONFIGURATION_CHANGED or Event.PLUGINS_RELOADED)
this.StateHasChanged();
{
//
// We hold a copy of the provider record, which is a snapshot taken when it was selected.
// Once the user edits that provider, our copy is stale and would keep showing the old
// name and the old icon, so we resolve it again and hand the fresh one to our parent:
//
var updatedProvider = this.SettingsManager.GetProviderById(this.ProviderSettings.Id);
if (updatedProvider != AIStudio.Settings.Provider.NONE && updatedProvider != this.ProviderSettings)
{
this.ProviderSettings = updatedProvider;
await this.ProviderSettingsChanged.InvokeAsync(updatedProvider);
}
return Task.CompletedTask;
this.StateHasChanged();
}
}
#endregion
@@ -129,4 +130,4 @@ public partial class ProviderSelection : MSGComponentBase
private readonly record struct CapabilityIcon(string Icon, string Tooltip);
private readonly record struct ProviderSelectionItem(AIStudio.Settings.Provider Provider, IReadOnlyList<CapabilityIcon> CapabilityIcons);
}
}
@@ -27,6 +27,12 @@ public partial class ReadFileContent : MSGComponentBase
[Parameter]
public EventCallback<string> FileContentChanged { get; set; }
/// <summary>
/// Reports the path after a file was loaded successfully.
/// </summary>
[Parameter]
public EventCallback<string> FilePathLoaded { get; set; }
/// <summary>
/// If true, the component will display the state of the attached document (if any).
/// </summary>
@@ -50,6 +56,13 @@ public partial class ReadFileContent : MSGComponentBase
/// </summary>
[Parameter]
public bool CatchAllDocuments { get; set; }
/// <summary>
/// Optionally restricts the file types offered by the native file picker
/// and accepted by this component.
/// </summary>
[Parameter]
public FileTypeFilter[]? Filter { get; set; }
[Inject]
private RustService RustService { get; init; } = null!;
@@ -119,12 +132,12 @@ public partial class ReadFileContent : MSGComponentBase
private void OnMediaImportStateChanged(MediaImportOwner owner)
{
if (owner == this.EffectiveImportOwner)
_ = this.InvokeAsync(async () =>
this.InvokeAsync(async () =>
{
await this.SyncCompletedMediaTextAsync();
await this.ConsumeStandaloneMediaOutcomeAsync();
this.StateHasChanged();
});
}).Observe($"{nameof(ReadFileContent)}: syncing transcribed text");
}
/// <summary>Consumes outcomes for dialog-local controls that have no assistant owner surface.</summary>
@@ -174,10 +187,16 @@ public partial class ReadFileContent : MSGComponentBase
this.MediaTranscriptionService.AcknowledgeDelivery(delivery);
}
/// <summary>Unsubscribes from the singleton media service.</summary>
/// <summary>Unsubscribes from the singleton media service and releases the drop area.</summary>
protected override void DisposeResources()
{
this.MediaTranscriptionService.StateChanged -= this.OnMediaImportStateChanged;
// Release the drop area. Without this, drop areas below this one would count this component
// forever and would stop catching dropped files:
if (this.EnableDragDrop)
this.MessageBus.SendMessage(this, Event.UNREGISTER_FILE_DROP_AREA, this.Layer).Observe($"{nameof(ReadFileContent)}: releasing the drop area");
base.DisposeResources();
}
@@ -246,7 +265,7 @@ public partial class ReadFileContent : MSGComponentBase
this.isFileDialogOpen = true;
try
{
var selectedFile = await this.RustService.SelectFile(T("Select file to read its content"));
var selectedFile = await this.RustService.SelectFile(T("Select file to read its content"), this.Filter);
if (selectedFile.UserCancelled)
{
this.Logger.LogInformation("User cancelled the file selection");
@@ -304,6 +323,13 @@ public partial class ReadFileContent : MSGComponentBase
return false;
}
if (this.Filter is { Length: > 0 } && !FileTypes.IsAllowedPath(filePath, this.Filter))
{
this.Logger.LogWarning("Selected file does not match the configured file type filter: '{FilePath}'", filePath);
await this.MessageBus.SendWarning(new(Icons.Material.Filled.Warning, this.T("Please select a file with a supported file type.")));
return false;
}
if (FileTypes.IsAllowedPath(filePath, FileTypes.AUDIO) || FileTypes.IsAllowedPath(filePath, FileTypes.VIDEO))
return await this.LoadMediaTranscriptAsync(filePath);
@@ -318,8 +344,13 @@ public partial class ReadFileContent : MSGComponentBase
try
{
var fileContent = await UserFile.LoadFileData(filePath, this.RustService, this.DialogService);
await this.ApplyFileContentAsync(fileContent, filePath);
var extraction = await UserFile.LoadFileData(filePath, this.RustService, this.PandocAvailabilityService);
// The failure was already reported by UserFile.LoadFileData, so we only stop here:
if (!extraction.HasUsableContent)
return false;
await this.ApplyFileContentAsync(extraction.Content, filePath);
this.Logger.LogInformation("Successfully loaded file content: {FilePath}", filePath);
return true;
}
@@ -334,6 +365,7 @@ public partial class ReadFileContent : MSGComponentBase
private async Task ApplyFileContentAsync(string fileContent, string filePath)
{
await this.FileContentChanged.InvokeAsync(fileContent);
await this.FilePathLoaded.InvokeAsync(filePath);
this.loadedFileName = Path.GetFileName(filePath);
this.hasLoadedFileContent = true;
}
@@ -412,4 +444,4 @@ public partial class ReadFileContent : MSGComponentBase
this.ClearDragClass();
this.StateHasChanged();
}
}
}
@@ -1,5 +1,7 @@
using AIStudio.Agents;
using AIStudio.Chat;
using AIStudio.Tools.Security;
using AIStudio.Tools.Web;
using Microsoft.AspNetCore.Components;
@@ -7,12 +9,27 @@ namespace AIStudio.Components;
public partial class ReadWebContent : MSGComponentBase
{
/// <summary>
/// How long loading one page may take.
/// </summary>
/// <remarks>
/// The user is watching a progress indicator while this runs, so it is shorter than what the
/// tools allow themselves for a page fetched in the background.
/// </remarks>
private const int TIMEOUT_SECONDS = 60;
[Inject]
private HTMLParser HTMLParser { get; init; } = null!;
private WebPageRetrievalService WebPageRetrievalService { get; init; } = null!;
[Inject]
private ILogger<ReadWebContent> Logger { get; init; } = null!;
[Inject]
private AgentTextContentCleaner AgentTextContentCleaner { get; init; } = null!;
[Inject]
private PromptInjectionGuardService PromptInjectionGuardService { get; init; } = null!;
[Parameter]
public string Content { get; set; } = string.Empty;
@@ -81,12 +98,24 @@ public partial class ReadWebContent : MSGComponentBase
{
this.processStep = this.process[ReadWebContentSteps.LOADING];
this.StateHasChanged();
var html = await this.HTMLParser.LoadWebContentHTML(new Uri(this.providedURL));
//
// The same retrieval the read web page tool uses, so a page is fetched and read one
// way throughout AI Studio. The difference is the target policy: here the user typed
// the URL, so their own network is not off limits.
//
var retrievedPage = await this.WebPageRetrievalService.RetrieveAsync(
new Uri(this.providedURL),
new WebPageRetrievalOptions
{
TimeoutSeconds = TIMEOUT_SECONDS,
TargetChosenByUser = true,
});
this.processStep = this.process[ReadWebContentSteps.PARSING];
this.StateHasChanged();
markdown = this.HTMLParser.ParseToMarkdown(html);
markdown = retrievedPage.ExtractedPage.Markdown;
markdown = await this.PromptInjectionGuardService.SanitizeAsync(markdown, PromptInjectionSource.WebContent(this.providedURL));
if (this.PreselectContentCleanerAgent && this.providerSettings != AIStudio.Settings.Provider.NONE)
{
@@ -120,7 +149,7 @@ public partial class ReadWebContent : MSGComponentBase
this.StateHasChanged();
}
}
catch
catch (Exception exception)
{
if (this.AgentIsRunning)
{
@@ -129,6 +158,14 @@ public partial class ReadWebContent : MSGComponentBase
await this.AgentIsRunningChanged.InvokeAsync(this.AgentIsRunning);
this.StateHasChanged();
}
//
// Say why nothing was loaded. An empty text field looks like a page without content,
// and the reasons a page cannot be read are things the user can act on: a link to a
// PDF rather than a page, a host that does not answer, a server refusing the request.
//
this.Logger.LogWarning(exception, "Could not load the web content from '{ProvidedUrl}'.", this.providedURL);
await this.MessageBus.SendError(new(Icons.Material.Filled.CloudOff, string.Format(this.T("The content of '{0}' could not be loaded: {1}"), this.providedURL, exception.Message)));
}
this.Content = markdown;
@@ -5,15 +5,16 @@
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Apps" HeaderText="@T("App Options")">
<ConfigurationSelect OptionDescription="@T("Language behavior")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguageBehavior)" Data="@ConfigurationSelectDataFactory.GetLangBehaviorData()" SelectionUpdate="@(selectedValue => _ = this.UpdateLangBehaviour(selectedValue))" OptionHelp="@T("Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?")"/>
<ConfigurationSelect OptionDescription="@T("Language behavior")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguageBehavior)" Data="@ConfigurationSelectDataFactory.GetLangBehaviorData()" SelectionUpdate="@(selectedValue => this.UpdateLangBehaviour(selectedValue).Observe(nameof(this.UpdateLangBehaviour)))" OptionHelp="@T("Select the language behavior for the app. The default is to use the system language. You might want to choose a language manually?")"/>
@if (this.SettingsManager.ConfigurationData.App.LanguageBehavior is LangBehavior.MANUAL)
{
<ConfigurationSelect OptionDescription="@T("Language")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguagePluginId)" Data="@ConfigurationSelectDataFactory.GetLanguagesData()" SelectionUpdate="@(selectedValue => _ = this.UpdateManuallySelectedLanguage(selectedValue))" OptionHelp="@T("Select the language for the app.")"/>
<ConfigurationSelect OptionDescription="@T("Language")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.LanguagePluginId)" Data="@ConfigurationSelectDataFactory.GetLanguagesData()" SelectionUpdate="@(selectedValue => this.UpdateManuallySelectedLanguage(selectedValue).Observe(nameof(this.UpdateManuallySelectedLanguage)))" OptionHelp="@T("Select the language for the app.")"/>
}
<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.")"/>
<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("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"/>
<ConfigurationSelect OptionDescription="@T("Check for updates")" SelectedValue="@(() => this.DisplayedUpdateInterval)" Data="@ConfigurationSelectDataFactory.GetManagedUpdateIntervalData(this.DisplayedUpdateInterval)" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UpdateInterval = selectedValue)" OptionHelp="@this.UpdateIntervalHelp" IsLocked="@this.IsUpdateIntervalLocked"/>
<ConfigurationSelect OptionDescription="@T("Update installation method")" SelectedValue="@(() => this.DisplayedUpdateInstallation)" Data="@ConfigurationSelectDataFactory.GetUpdateBehaviourData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UpdateInstallation = selectedValue)" OptionHelp="@this.UpdateInstallationHelp" IsLocked="@this.IsUpdateInstallationLocked"/>
@@ -27,7 +28,7 @@
var availablePreviewFeatures = ConfigurationSelectDataFactory.GetPreviewFeaturesData(this.SettingsManager).ToList();
if (availablePreviewFeatures.Count > 0)
{
<ConfigurationMultiSelect OptionDescription="@T("Select preview features")" SelectedValues="@this.GetSelectedPreviewFeatures" Data="@availablePreviewFeatures" SelectionUpdate="@this.UpdateEnabledPreviewFeatures" OptionHelp="@T("Which preview features would you like to enable?")" IsItemLocked="@this.IsPluginContributedPreviewFeature" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) && meta.IsLocked"/>
<ConfigurationMultiSelect OptionDescription="@T("Select preview features")" SelectedValues="@this.GetSelectedPreviewFeatures" Data="@availablePreviewFeatures" SelectionUpdate="@this.UpdateEnabledPreviewFeatures" OptionHelp="@T("Which preview features would you like to enable?")" IsItemLocked="@this.IsPluginContributedPreviewFeature" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) && meta.IsLocked" EmptySelectionText="@T("No preview features selected.")" SingleSelectionText="@T("You have selected 1 preview feature.")" MultipleSelectionText="@T("You have selected {0} preview features.")"/>
}
}
@@ -36,7 +37,18 @@
@if (PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager))
{
<ConfigurationSelect OptionDescription="@T("Select a transcription provider")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)" Data="@this.GetFilteredTranscriptionProviders()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider = selectedValue)" OptionHelp="@T("Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.UseTranscriptionProvider, out var meta) && meta.IsLocked"/>
<ConfigurationSelect OptionDescription="@T("Select a transcription provider")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider)" Data="@this.GetFilteredTranscriptionProviders()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.App.UseTranscriptionProvider = selectedValue)" OptionHelp="@T("Select a transcription provider for transcribing your voice. Without a selected provider, dictation and transcription features will be disabled.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.UseTranscriptionProvider, out var meta) && meta.IsLocked">
<ItemTemplate Context="providerData">
@if (this.GetTranscriptionProvider(providerData.Value) is { } provider)
{
<ProviderLabel ProviderType="@provider.UsedLLMProvider" CustomIconDataUrl="@provider.CustomIconDataUrl" Text="@providerData.Name" />
}
else
{
@providerData.Name
}
</ItemTemplate>
</ConfigurationSelect>
<ConfigurationShortcut Data="@this.VoiceRecordingShortcut" OptionDescription="@T("Voice recording shortcut")" OptionHelp="@T("The global keyboard shortcut for toggling voice recording. This shortcut works system-wide, even when the app is not focused.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.ShortcutVoiceRecording, out var meta) && meta.IsLocked"/>
}
@@ -15,11 +15,13 @@ public partial class SettingsPanelApp : SettingsPanelBase
private UpdatePolicyMode updatePolicyMode;
private UpdateInterval DisplayedUpdateInterval => this.updatePolicyMode is UpdatePolicyMode.FLATPAK
private bool CannotUpdateItself => this.updatePolicyMode is UpdatePolicyMode.FLATPAK or UpdatePolicyMode.MANAGED_INSTALLATION or UpdatePolicyMode.UNSUPPORTED_INSTALLATION_LOCATION or UpdatePolicyMode.DEVELOPMENT;
private UpdateInterval DisplayedUpdateInterval => this.CannotUpdateItself
? UpdateInterval.NO_CHECK
: this.SettingsManager.ConfigurationData.App.UpdateInterval;
private UpdateInstallation DisplayedUpdateInstallation => this.updatePolicyMode is UpdatePolicyMode.FLATPAK
private UpdateInstallation DisplayedUpdateInstallation => this.CannotUpdateItself
? UpdateInstallation.MANUAL
: this.SettingsManager.ConfigurationData.App.UpdateInstallation;
@@ -27,20 +29,26 @@ public partial class SettingsPanelApp : SettingsPanelBase
{
UpdatePolicyMode.ENTERPRISE_DISABLED => T("Your organization has disabled update checks and installations."),
UpdatePolicyMode.FLATPAK => T("AI Studio cannot check for updates when running as a Flatpak. Updates are managed outside the app."),
UpdatePolicyMode.MANAGED_INSTALLATION => T("This installation does not check for updates itself. Contact the person or organization that installed AI Studio for update information."),
UpdatePolicyMode.UNSUPPORTED_INSTALLATION_LOCATION => T("AI Studio cannot update itself from its current location, so it does not check for updates."),
UpdatePolicyMode.DEVELOPMENT => T("Development builds do not check for updates."),
_ => T("How often should we check for app updates?")
};
private string UpdateInstallationHelp => this.updatePolicyMode switch
{
UpdatePolicyMode.ENTERPRISE_DISABLED => T("This setting has no effect while updates are disabled by your organization."),
UpdatePolicyMode.FLATPAK => T("AI Studio cannot install updates when running as a Flatpak. Use the update method provided by your Flatpak distribution."),
UpdatePolicyMode.FLATPAK => T("AI Studio cannot install updates when running as a Flatpak. Update it using the Flatpak source or bundle from which you installed it."),
UpdatePolicyMode.MANAGED_INSTALLATION => T("AI Studio cannot install updates into this installation. Contact the person or organization that installed it for new versions."),
UpdatePolicyMode.UNSUPPORTED_INSTALLATION_LOCATION => T("AI Studio cannot install updates into its current installation location. Install new versions yourself."),
UpdatePolicyMode.DEVELOPMENT => T("Development builds do not install updates."),
_ => T("Should updates be installed automatically or manually?")
};
private bool IsUpdateIntervalLocked() => this.updatePolicyMode is UpdatePolicyMode.ENTERPRISE_DISABLED or UpdatePolicyMode.FLATPAK ||
private bool IsUpdateIntervalLocked() => this.updatePolicyMode is UpdatePolicyMode.ENTERPRISE_DISABLED || this.CannotUpdateItself ||
ManagedConfiguration.TryGet(x => x.App, x => x.UpdateInterval, out var meta) && meta.IsLocked;
private bool IsUpdateInstallationLocked() => this.updatePolicyMode is UpdatePolicyMode.ENTERPRISE_DISABLED or UpdatePolicyMode.FLATPAK ||
private bool IsUpdateInstallationLocked() => this.updatePolicyMode is UpdatePolicyMode.ENTERPRISE_DISABLED || this.CannotUpdateItself ||
ManagedConfiguration.TryGet(x => x.App, x => x.UpdateInstallation, out var meta) && meta.IsLocked;
protected override async Task OnInitializedAsync()
@@ -91,13 +99,19 @@ public partial class SettingsPanelApp : SettingsPanelBase
yield return new(T("Disable dictation and transcription"), string.Empty);
var minimumLevel = this.SettingsManager.GetMinimumConfidenceLevel(Tools.Components.APP_SETTINGS);
foreach (var provider in this.SettingsManager.ConfigurationData.TranscriptionProviders)
foreach (var provider in this.SettingsManager.GetAllTranscriptionProviders())
{
if (provider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level >= minimumLevel)
yield return new(provider.Name, provider.Id);
}
}
private TranscriptionProvider? GetTranscriptionProvider(string providerId)
{
var provider = this.SettingsManager.GetTranscriptionProviderById(providerId);
return provider == TranscriptionProvider.NONE ? null : provider;
}
private void UpdatePreviewFeatures(PreviewVisibility previewVisibility)
{
this.SettingsManager.ConfigurationData.App.PreviewVisibility = previewVisibility;
@@ -108,8 +122,10 @@ public partial class SettingsPanelApp : SettingsPanelBase
private HashSet<PreviewFeatures> GetPluginContributedPreviewFeatures()
{
// Several configuration plugins may contribute at the same time, e.g. one preview feature
// for the whole organization and another one for a single department:
if (ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) && meta.HasPluginContribution)
return meta.PluginContribution.Where(x => !x.IsReleased()).ToHashSet();
return meta.PluginContributions.Values.SelectMany(contribution => contribution).Where(x => !x.IsReleased()).ToHashSet();
return [];
}
@@ -122,7 +138,7 @@ public partial class SettingsPanelApp : SettingsPanelBase
if (!ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) || !meta.HasPluginContribution)
return false;
return meta.PluginContribution.Contains(feature);
return meta.PluginContributions.Values.Any(contribution => contribution.Contains(feature));
}
private HashSet<PreviewFeatures> GetSelectedPreviewFeatures()
@@ -17,41 +17,54 @@
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("This helps AI Studio understand and compare things in a way that's similar to how humans do. When you're working on something, AI Studio can automatically identify related documents and data by comparing their digital fingerprints. For instance, if you're writing about customer service, AI Studio can instantly find other documents in your data that discuss similar topics or experiences, even if they use different words.")
</MudJustifiedText>
<MudTable Items="@this.SettingsManager.ConfigurationData.EmbeddingProviders" Hover="@true" Class="border-dashed border rounded-lg">
<MudTable Items="@this.SettingsManager.GetAllEmbeddingProviders()" Hover="@true" GroupBy="@GROUP_CONFIG" Class="border-dashed border rounded-lg">
<ColGroup>
<col style="width: 3em;"/>
<col style="width: 12em;"/>
<col style="width: 12em;"/>
<col/>
<col style="width: 22em;"/>
</ColGroup>
<HeaderContent>
<MudTh>#</MudTh>
<MudTh>@T("Name")</MudTh>
<MudTh>@T("Provider")</MudTh>
<MudTh>@T("Model")</MudTh>
<MudTh>@T("Actions")</MudTh>
</HeaderContent>
<GroupHeaderTemplate>
<MudTh Class="provider-group-header" colspan="4">
@if (context.Key is LLMProviders llmProvider)
{
<ProviderLabel ProviderType="@llmProvider" Text="@llmProvider.ToName()" />
}
</MudTh>
</GroupHeaderTemplate>
<RowTemplate>
<MudTd>@context.Num</MudTd>
<MudTd>@context.Name</MudTd>
<MudTd>@context.UsedLLMProvider.ToName()</MudTd>
<MudTd>
<ProviderLabel ProviderType="@context.UsedLLMProvider" CustomIconDataUrl="@context.CustomIconDataUrl" Text="@context.Name" />
</MudTd>
<MudTd>@this.GetEmbeddingProviderModelName(context)</MudTd>
<MudTd>
<MudStack Row="true" Class="mb-2 mt-2" Spacing="1" Wrap="Wrap.Wrap">
@if (context.IsTrustedByConfiguration(this.SettingsManager))
@if (context.IsTrustedForDataSourceSecurityChecks(this.SettingsManager))
{
<MudTooltip Text="@T("This embedding provider is trusted by your organization for data source security checks. Local data can be sent to it without security warnings.")">
<MudTooltip Text="@(context.IsSelfHosted ? T("This self-hosted embedding provider is trusted for data source security checks. Local data can be sent to it without security warnings.") : T("This embedding provider is trusted by your organization for data source security checks. Local data can be sent to it without security warnings."))">
<MudIconButton Color="Color.Success" Icon="@Icons.Material.Filled.VerifiedUser" Disabled="true"/>
</MudTooltip>
}
@if (context.IsEnterpriseConfiguration)
@if (context.IsEnterpriseConfiguration && !context.AllowUserProvidedAPIKey)
{
<MudTooltip Text="@T("This embedding provider is managed by your organization.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Business" Disabled="true"/>
</MudTooltip>
}
else if (context.IsEnterpriseConfiguration && context.AllowUserProvidedAPIKey)
{
<MudTooltip Text="@T("This embedding provider is managed by your organization. You can set your own API key.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Key" OnClick="@(() => this.EditEmbeddingProvider(context))"/>
</MudTooltip>
<MudTooltip Text="@T("Test")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Api" OnClick="@(() => this.TestEmbeddingProvider(context))"/>
</MudTooltip>
}
else
{
<MudTooltip Text="@T("Open Dashboard")">
@@ -60,12 +73,7 @@
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditEmbeddingProvider(context))"/>
</MudTooltip>
@if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
{
<MudTooltip Text="@T("Export configuration")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@(() => this.ExportEmbeddingProvider(context))"/>
</MudTooltip>
}
<AdminExportButton OnClick="@(() => this.ExportEmbeddingProvider(context))" />
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteEmbeddingProvider(context))"/>
</MudTooltip>
@@ -16,6 +16,17 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
[Inject]
private DataSourceEmbeddingService DataSourceEmbeddingService { get; init; } = null!;
/// <summary>
/// Groups the table by the used LLM provider. The embedding provider list is already sorted by
/// that provider, so all instances of one LLM provider form a single, coherent group.
/// </summary>
private static readonly TableGroupDefinition<EmbeddingProvider> GROUP_CONFIG = new()
{
Expandable = true,
IsInitiallyExpanded = false,
Selector = provider => provider.UsedLLMProvider,
};
[Parameter]
public List<ConfigurationSelectData<string>> AvailableEmbeddingProviders { get; set; } = new();
@@ -68,12 +79,16 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
private async Task EditEmbeddingProvider(EmbeddingProvider embeddingProvider)
{
if (embeddingProvider.IsEnterpriseConfiguration && !embeddingProvider.AllowUserProvidedAPIKey)
return;
var dialogParameters = new DialogParameters<EmbeddingProviderDialog>
{
{ x => x.DataNum, embeddingProvider.Num },
{ x => x.DataId, embeddingProvider.Id },
{ x => x.DataName, embeddingProvider.Name },
{ x => x.DataLLMProvider, embeddingProvider.UsedLLMProvider },
{ x => x.DataCustomIconDataUrl, embeddingProvider.CustomIconDataUrl },
{ x => x.DataModel, embeddingProvider.Model },
{ x => x.DataHostname, embeddingProvider.Hostname },
{ x => x.IsSelfHosted, embeddingProvider.IsSelfHosted },
@@ -82,6 +97,8 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
{ x => x.DataTokenizerPath, embeddingProvider.TokenizerPath },
{ x => x.DataTokenLimit, embeddingProvider.EffectiveTokenLimit },
{ x => x.DataEmbeddingBatchSize, embeddingProvider.EffectiveEmbeddingBatchSize },
{ x => x.HFInferenceProviderId, embeddingProvider.HFInferenceProvider },
{ x => x.IsEnterpriseConfiguration, embeddingProvider.IsEnterpriseConfiguration },
};
var dialogReference = await this.DialogService.ShowAsync<EmbeddingProviderDialog>(T("Edit Embedding Provider"), dialogParameters, DialogOptions.FULLSCREEN);
@@ -89,6 +106,16 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
if (dialogResult is null || dialogResult.Canceled)
return;
if (embeddingProvider.IsEnterpriseConfiguration)
{
// Only the API key changed, and the dialog already stored it directly. The provider
// object itself is managed by the configuration plugin and must not be overwritten
// with the dialog's copy -- doing so would let the locked-but-technically-editable
// fields drift from what the organization configured.
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
return;
}
var editedEmbeddingProvider = (EmbeddingProvider)dialogResult.Data!;
// Set the provider number if it's not set. This is important for providers
@@ -170,7 +197,7 @@ public partial class SettingsPanelEmbeddings : SettingsPanelProviderBase
private async Task UpdateEmbeddingProviders()
{
this.AvailableEmbeddingProviders.Clear();
foreach (var provider in this.SettingsManager.ConfigurationData.EmbeddingProviders)
foreach (var provider in this.SettingsManager.GetAllEmbeddingProviders())
this.AvailableEmbeddingProviders.Add(new (provider.Name, provider.Id));
await this.AvailableEmbeddingProvidersChanged.InvokeAsync(this.AvailableEmbeddingProviders);
@@ -9,40 +9,50 @@
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("What we call a provider is the combination of an LLM provider such as OpenAI and a model like GPT-4o. You can configure as many providers as you want. This way, you can use the appropriate model for each task. As an LLM provider, you can also choose local providers. However, to use this app, you must configure at least one provider.")
</MudJustifiedText>
<MudTable Items="@this.SettingsManager.ConfigurationData.Providers" Hover="@true" Class="border-dashed border rounded-lg">
<MudTable Items="@this.SettingsManager.GetAllProviders()" Hover="@true" GroupBy="@GROUP_CONFIG" Class="border-dashed border rounded-lg">
<ColGroup>
<col style="width: 3em;"/>
<col style="width: 12em;"/>
<col style="width: 12em;"/>
<col/>
<col style="width: 22em;"/>
</ColGroup>
<HeaderContent>
<MudTh>#</MudTh>
<MudTh>@T("Instance Name")</MudTh>
<MudTh>@T("Provider")</MudTh>
<MudTh>@T("Model")</MudTh>
<MudTh>@T("Actions")</MudTh>
</HeaderContent>
<GroupHeaderTemplate>
<MudTh Class="provider-group-header" colspan="4">
@if (context.Key is LLMProviders llmProvider)
{
<ProviderLabel ProviderType="@llmProvider" Text="@llmProvider.ToName()" />
}
</MudTh>
</GroupHeaderTemplate>
<RowTemplate>
<MudTd>@context.Num</MudTd>
<MudTd>@context.InstanceName</MudTd>
<MudTd>@context.UsedLLMProvider.ToName()</MudTd>
<MudTd>
<ProviderLabel ProviderSettings="@context" Text="@context.InstanceName" />
</MudTd>
<MudTd>@this.GetLLMProviderModelName(context)</MudTd>
<MudTd>
<MudStack Row="true" Class="mb-2 mt-2" Spacing="1" Wrap="Wrap.Wrap">
@if (context.IsTrustedByConfiguration(this.SettingsManager))
@if (context.IsTrustedForDataSourceSecurityChecks(this.SettingsManager))
{
<MudTooltip Text="@T("This provider is trusted by your organization for data source security checks.")">
<MudTooltip Text="@(context.IsSelfHosted ? T("This self-hosted provider is trusted for data source security checks.") : T("This provider is trusted by your organization for data source security checks."))">
<MudIconButton Color="Color.Success" Icon="@Icons.Material.Filled.VerifiedUser" Disabled="true"/>
</MudTooltip>
}
@if (context.IsEnterpriseConfiguration)
@if (context.IsEnterpriseConfiguration && !context.AllowUserProvidedAPIKey)
{
<MudTooltip Text="@T("This provider is managed by your organization.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Business" Disabled="true"/>
</MudTooltip>
}
else if (context.IsEnterpriseConfiguration && context.AllowUserProvidedAPIKey)
{
<MudTooltip Text="@T("This provider is managed by your organization. You can set your own API key.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Key" OnClick="@(() => this.EditLLMProvider(context))"/>
</MudTooltip>
}
else
{
<MudTooltip Text="@T("Open Dashboard")">
@@ -51,12 +61,7 @@
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditLLMProvider(context))"/>
</MudTooltip>
@if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
{
<MudTooltip Text="@T("Export configuration")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@(() => this.ExportLLMProvider(context))"/>
</MudTooltip>
}
<AdminExportButton OnClick="@(() => this.ExportLLMProvider(context))" />
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteLLMProvider(context))"/>
</MudTooltip>
@@ -66,7 +71,7 @@
</RowTemplate>
</MudTable>
@if(this.SettingsManager.ConfigurationData.Providers.Count == 0)
@if(this.SettingsManager.GetAllProviders().Count == 0)
{
<MudText Typo="Typo.h6" Class="mt-3">
@T("No providers configured yet.")
@@ -13,6 +13,17 @@ namespace AIStudio.Components.Settings;
public partial class SettingsPanelProviders : SettingsPanelProviderBase
{
/// <summary>
/// Groups the table by the used LLM provider. The provider list is already sorted by that
/// provider, so all instances of one LLM provider form a single, coherent group.
/// </summary>
private static readonly TableGroupDefinition<AIStudio.Settings.Provider> GROUP_CONFIG = new()
{
Expandable = true,
IsInitiallyExpanded = false,
Selector = provider => provider.UsedLLMProvider,
};
[Parameter]
public List<ConfigurationSelectData<string>> AvailableLLMProviders { get; set; } = new();
@@ -29,7 +40,7 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
#endregion
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed", Justification = "Managing the provider list is the purpose of this settings panel. Reading providers goes through the settings manager, but adding, editing, and removing them stays here on purpose.")]
private async Task AddLLMProvider()
{
var dialogParameters = new DialogParameters<ProviderDialog>
@@ -52,21 +63,22 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed", Justification = "Managing the provider list is the purpose of this settings panel. Reading providers goes through the settings manager, but adding, editing, and removing them stays here on purpose.")]
private async Task EditLLMProvider(AIStudio.Settings.Provider provider)
{
if(provider == AIStudio.Settings.Provider.NONE)
return;
if (provider.IsEnterpriseConfiguration)
if (provider.IsEnterpriseConfiguration && !provider.AllowUserProvidedAPIKey)
return;
var dialogParameters = new DialogParameters<ProviderDialog>
{
{ x => x.DataNum, provider.Num },
{ x => x.DataId, provider.Id },
{ x => x.DataInstanceName, provider.InstanceName },
{ x => x.DataLLMProvider, provider.UsedLLMProvider },
{ x => x.DataCustomIconDataUrl, provider.CustomIconDataUrl },
{ x => x.DataModel, provider.Model },
{ x => x.DataHostname, provider.Hostname },
{ x => x.IsSelfHosted, provider.IsSelfHosted },
@@ -76,6 +88,7 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
{ x => x.AdditionalJsonApiParameters, provider.AdditionalJsonApiParameters },
{ x => x.DataTokenizerPath, provider.TokenizerPath },
{ x => x.DataCapabilityOverrides, provider.CapabilityOverrides },
{ x => x.IsEnterpriseConfiguration, provider.IsEnterpriseConfiguration },
};
var dialogReference = await this.DialogService.ShowAsync<ProviderDialog>(T("Edit LLM Provider"), dialogParameters, DialogOptions.FULLSCREEN);
@@ -83,21 +96,31 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
if (dialogResult is null || dialogResult.Canceled)
return;
if (provider.IsEnterpriseConfiguration)
{
// Only the API key changed, and the dialog already stored it directly. The provider
// object itself is managed by the configuration plugin and must not be overwritten
// with the dialog's copy -- doing so would let the locked-but-technically-editable
// fields drift from what the organization configured.
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
return;
}
var editedProvider = (AIStudio.Settings.Provider)dialogResult.Data!;
// Set the provider number if it's not set. This is important for providers
// added before we started saving the provider number.
if(editedProvider.Num == 0)
editedProvider = editedProvider with { Num = this.SettingsManager.ConfigurationData.NextProviderNum++ };
this.SettingsManager.ConfigurationData.Providers[this.SettingsManager.ConfigurationData.Providers.IndexOf(provider)] = editedProvider;
await this.UpdateProviders();
await this.SettingsManager.StoreSettings();
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed", Justification = "Managing the provider list is the purpose of this settings panel. Reading providers goes through the settings manager, but adding, editing, and removing them stays here on purpose.")]
private async Task DeleteLLMProvider(AIStudio.Settings.Provider provider)
{
var dialogParameters = new DialogParameters<ConfirmDialog>
@@ -172,11 +195,10 @@ public partial class SettingsPanelProviders : SettingsPanelProviderBase
return modelName.Length > MAX_LENGTH ? "[...] " + modelName[^Math.Min(MAX_LENGTH, modelName.Length)..] : modelName;
}
[SuppressMessage("Usage", "MWAIS0001:Direct access to `Providers` is not allowed")]
private async Task UpdateProviders()
{
this.AvailableLLMProviders.Clear();
foreach (var provider in this.SettingsManager.ConfigurationData.Providers)
foreach (var provider in this.SettingsManager.GetAllProviders())
this.AvailableLLMProviders.Add(new (provider.InstanceName, provider.Id));
await this.AvailableLLMProvidersChanged.InvokeAsync(this.AvailableLLMProviders);
@@ -0,0 +1,65 @@
@inherits SettingsPanelBase
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Build" HeaderText="@T("Tool Settings")">
<MudText Typo="Typo.body1" Class="mb-4">
@T("Configure global settings for each tool.")
</MudText>
<MudTable Items="@this.items" Hover="@true" Dense="@true">
<HeaderContent>
<MudTh>@T("Icon")</MudTh>
<MudTh>@T("Name")</MudTh>
<MudTh>@T("Description")</MudTh>
<MudTh>@T("Minimum provider confidence")</MudTh>
<MudTh>@T("Status")</MudTh>
<MudTh>@T("Settings")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIcon Icon="@context.Implementation.Icon" Color="Color.Info" />
</MudTd>
<MudTd>
<MudText Typo="Typo.body1">@context.Implementation.GetDisplayName()</MudText>
</MudTd>
<MudTd>
<MudText Typo="Typo.body2">@context.Implementation.GetDescription()</MudText>
</MudTd>
<MudTd>
<MudMenu StartIcon="@Icons.Material.Filled.Security" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@this.GetCurrentConfidenceLevelName(context)" Variant="Variant.Filled" Style="@this.SetCurrentConfidenceLevelColorStyle(context)" Disabled="@this.IsToolConfidenceManaged()">
@foreach (var confidenceLevel in this.GetSelectableConfidenceLevels())
{
<MudMenuItem OnClick="@(async () => await this.ChangeMinimumProviderConfidence(context, confidenceLevel))">
@this.GetConfidenceLevelName(confidenceLevel)
</MudMenuItem>
}
</MudMenu>
</MudTd>
<MudTd>
@if (!context.IsActive)
{
<MudTooltip Text="@T("This tool has been disabled by your organization.")">
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Error" />
</MudTooltip>
}
else if (context.ConfigurationState.IsConfigured)
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" />
}
else
{
<MudTooltip Text="@this.GetConfigurationTooltip(context)">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" />
</MudTooltip>
}
</MudTd>
<MudTd>
<MudStack Row="true" Wrap="Wrap.NoWrap" AlignItems="AlignItems.Center" Spacing="0">
<MudTooltip Text="@T("Settings")">
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@(async () => await this.OpenSettings(context.Definition.Id))" aria-label="@T("Settings")" />
</MudTooltip>
<AdminExportButton OnClick="@(() => this.OpenExport(context.Definition.Id))" />
</MudStack>
</MudTd>
</RowTemplate>
</MudTable>
</ExpansionPanel>
@@ -0,0 +1,102 @@
using AIStudio.Provider;
using AIStudio.Dialogs.Settings;
using AIStudio.Settings;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components.Settings;
public partial class SettingsPanelTools : SettingsPanelBase
{
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
private IReadOnlyList<ToolCatalogItem> items = [];
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
this.items = await this.ToolRegistry.GetCatalogAsync(this.ToolRegistry.GetAllDefinitions());
await base.OnInitializedAsync();
}
private async Task OpenSettings(string toolId)
{
var parameters = new DialogParameters<ToolSettingsDialog>
{
{ x => x.ToolId, toolId },
};
var dialog = await this.DialogService.ShowAsync<ToolSettingsDialog>(null, parameters, Dialogs.DialogOptions.FULLSCREEN);
await dialog.Result;
this.items = await this.ToolRegistry.GetCatalogAsync(this.ToolRegistry.GetAllDefinitions());
this.StateHasChanged();
}
private async Task OpenExport(string toolId)
{
if (!this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
return;
var parameters = new DialogParameters<ToolSettingsExportDialog>
{
{ x => x.ToolId, toolId },
};
await this.DialogService.ShowAsync<ToolSettingsExportDialog>(null, parameters, Dialogs.DialogOptions.FULLSCREEN);
}
private string GetConfigurationTooltip(ToolCatalogItem item) => item.ConfigurationState.MissingRequiredFields.Count switch
{
_ when !string.IsNullOrWhiteSpace(item.ConfigurationState.Message) => item.ConfigurationState.Message,
0 => this.T("This tool still needs to be configured."),
_ => string.Format(this.T("Missing required settings: {0}"), string.Join(", ", item.ConfigurationState.MissingRequiredFields.Select(fieldName => this.GetFieldDisplayName(item, fieldName))))
};
private string GetFieldDisplayName(ToolCatalogItem item, string fieldName)
{
var fieldDefinition = item.Definition.SettingsSchema.Properties.GetValueOrDefault(fieldName);
if (fieldDefinition is null)
return fieldName;
return item.Implementation.GetSettingsFieldLabel(fieldName, fieldDefinition);
}
private IEnumerable<ConfidenceLevel> GetSelectableConfidenceLevels() =>
Enum.GetValues<ConfidenceLevel>().OrderBy(x => x).Where(x => x is not ConfidenceLevel.UNKNOWN);
private string GetCurrentConfidenceLevelName(ToolCatalogItem item) => this.GetConfidenceLevelName(GetMinimumProviderConfidence(item));
private string GetConfidenceLevelName(ConfidenceLevel confidenceLevel) => confidenceLevel is ConfidenceLevel.NONE
? this.T("No minimum confidence level chosen")
: confidenceLevel.GetName();
private string SetCurrentConfidenceLevelColorStyle(ToolCatalogItem item) =>
$"background-color: {GetMinimumProviderConfidence(item).GetColor(this.SettingsManager)};";
private bool IsToolConfidenceManaged() =>
ManagedConfiguration.TryGet(x => x.Tools, x => x.MinimumProviderConfidenceByToolId, out var meta) && meta.IsLocked;
// The catalog already carries the resolved level, so there is nothing to look up again:
private static ConfidenceLevel GetMinimumProviderConfidence(ToolCatalogItem item) => item.MinimumProviderConfidence;
private async Task ChangeMinimumProviderConfidence(ToolCatalogItem item, ConfidenceLevel confidenceLevel)
{
this.SettingsManager.SetMinimumProviderConfidenceForTool(item.Definition.Id, confidenceLevel, item.Definition.MinimumProviderConfidence);
await this.SettingsManager.StoreSettings();
this.items = await this.ToolRegistry.GetCatalogAsync(this.ToolRegistry.GetAllDefinitions());
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED:
this.items = await this.ToolRegistry.GetCatalogAsync(this.ToolRegistry.GetAllDefinitions());
await this.InvokeAsync(this.StateHasChanged);
break;
}
}
}
@@ -13,41 +13,51 @@
@T("With the support of transcription models, MindWork AI Studio can convert human speech into text. This is useful, for example, when you need to dictate text. You can choose from dedicated transcription models, but not multimodal LLMs (large language models) that can handle both speech and text. The configuration of multimodal models is done in the 'Configure providers' section.")
</MudJustifiedText>
<MudTable Items="@this.SettingsManager.ConfigurationData.TranscriptionProviders" Hover="@true" Class="border-dashed border rounded-lg">
<MudTable Items="@this.SettingsManager.GetAllTranscriptionProviders()" Hover="@true" GroupBy="@GROUP_CONFIG" Class="border-dashed border rounded-lg">
<ColGroup>
<col style="width: 3em;"/>
<col style="width: 12em;"/>
<col style="width: 12em;"/>
<col/>
<col style="width: 22em;"/>
</ColGroup>
<HeaderContent>
<MudTh>#</MudTh>
<MudTh>@T("Name")</MudTh>
<MudTh>@T("Provider")</MudTh>
<MudTh>@T("Model")</MudTh>
<MudTh>@T("Actions")</MudTh>
</HeaderContent>
<GroupHeaderTemplate>
<MudTh Class="provider-group-header" colspan="4">
@if (context.Key is LLMProviders llmProvider)
{
<ProviderLabel ProviderType="@llmProvider" Text="@llmProvider.ToName()" />
}
</MudTh>
</GroupHeaderTemplate>
<RowTemplate>
<MudTd>@context.Num</MudTd>
<MudTd>@context.Name</MudTd>
<MudTd>@context.UsedLLMProvider.ToName()</MudTd>
<MudTd>
<ProviderLabel ProviderType="@context.UsedLLMProvider" CustomIconDataUrl="@context.CustomIconDataUrl" Text="@context.Name" />
</MudTd>
<MudTd>@this.GetTranscriptionProviderModelName(context)</MudTd>
<MudTd>
<MudStack Row="true" Class="mb-2 mt-2" Spacing="1" Wrap="Wrap.Wrap">
@if (context.IsTrustedByConfiguration(this.SettingsManager))
@if (context.IsTrustedForDataSourceSecurityChecks(this.SettingsManager))
{
<MudTooltip Text="@T("This transcription provider is trusted by your organization for data source security checks.")">
<MudTooltip Text="@(context.IsSelfHosted ? T("This self-hosted transcription provider is trusted for data source security checks.") : T("This transcription provider is trusted by your organization for data source security checks."))">
<MudIconButton Color="Color.Success" Icon="@Icons.Material.Filled.VerifiedUser" Disabled="true"/>
</MudTooltip>
}
@if (context.IsEnterpriseConfiguration)
@if (context.IsEnterpriseConfiguration && !context.AllowUserProvidedAPIKey)
{
<MudTooltip Text="@T("This transcription provider is managed by your organization.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Business" Disabled="true"/>
</MudTooltip>
}
else if (context.IsEnterpriseConfiguration && context.AllowUserProvidedAPIKey)
{
<MudTooltip Text="@T("This transcription provider is managed by your organization. You can set your own API key.")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Key" OnClick="@(() => this.EditTranscriptionProvider(context))"/>
</MudTooltip>
}
else
{
<MudTooltip Text="@T("Open Dashboard")">
@@ -56,12 +66,7 @@
<MudTooltip Text="@T("Edit")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Edit" OnClick="@(() => this.EditTranscriptionProvider(context))"/>
</MudTooltip>
@if (this.SettingsManager.ConfigurationData.App.ShowAdminSettings)
{
<MudTooltip Text="@T("Export configuration")">
<MudIconButton Color="Color.Info" Icon="@Icons.Material.Filled.Dataset" OnClick="@(() => this.ExportTranscriptionProvider(context))"/>
</MudTooltip>
}
<AdminExportButton OnClick="@(() => this.ExportTranscriptionProvider(context))" />
<MudTooltip Text="@T("Delete")">
<MudIconButton Color="Color.Error" Icon="@Icons.Material.Filled.Delete" OnClick="@(() => this.DeleteTranscriptionProvider(context))"/>
</MudTooltip>
@@ -9,6 +9,17 @@ namespace AIStudio.Components.Settings;
public partial class SettingsPanelTranscription : SettingsPanelProviderBase
{
/// <summary>
/// Groups the table by the used LLM provider. The transcription provider list is already sorted by
/// that provider, so all instances of one LLM provider form a single, coherent group.
/// </summary>
private static readonly TableGroupDefinition<TranscriptionProvider> GROUP_CONFIG = new()
{
Expandable = true,
IsInitiallyExpanded = false,
Selector = provider => provider.UsedLLMProvider,
};
[Parameter]
public List<ConfigurationSelectData<string>> AvailableTranscriptionProviders { get; set; } = new();
@@ -60,24 +71,40 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase
private async Task EditTranscriptionProvider(TranscriptionProvider transcriptionProvider)
{
if (transcriptionProvider.IsEnterpriseConfiguration && !transcriptionProvider.AllowUserProvidedAPIKey)
return;
var dialogParameters = new DialogParameters<TranscriptionProviderDialog>
{
{ x => x.DataNum, transcriptionProvider.Num },
{ x => x.DataId, transcriptionProvider.Id },
{ x => x.DataName, transcriptionProvider.Name },
{ x => x.DataLLMProvider, transcriptionProvider.UsedLLMProvider },
{ x => x.DataCustomIconDataUrl, transcriptionProvider.CustomIconDataUrl },
{ x => x.DataModel, transcriptionProvider.Model },
{ x => x.DataHostname, transcriptionProvider.Hostname },
{ x => x.IsSelfHosted, transcriptionProvider.IsSelfHosted },
{ x => x.IsEditing, true },
{ x => x.DataHost, transcriptionProvider.Host },
{ x => x.HFInferenceProviderId, transcriptionProvider.HFInferenceProvider },
{ x => x.IsEnterpriseConfiguration, transcriptionProvider.IsEnterpriseConfiguration },
};
var dialogReference = await this.DialogService.ShowAsync<TranscriptionProviderDialog>(T("Edit Transcription Provider"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
return;
if (transcriptionProvider.IsEnterpriseConfiguration)
{
// Only the API key changed, and the dialog already stored it directly. The provider
// object itself is managed by the configuration plugin and must not be overwritten
// with the dialog's copy -- doing so would let the locked-but-technically-editable
// fields drift from what the organization configured.
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
return;
}
var editedTranscriptionProvider = (TranscriptionProvider)dialogResult.Data!;
// Set the provider number if it's not set. This is important for providers
@@ -129,7 +156,7 @@ public partial class SettingsPanelTranscription : SettingsPanelProviderBase
private async Task UpdateTranscriptionProviders()
{
this.AvailableTranscriptionProviders.Clear();
foreach (var provider in this.SettingsManager.ConfigurationData.TranscriptionProviders)
foreach (var provider in this.SettingsManager.GetAllTranscriptionProviders())
this.AvailableTranscriptionProviders.Add(new (provider.Name, provider.Id));
await this.AvailableTranscriptionProvidersChanged.InvokeAsync(this.AvailableTranscriptionProviders);
@@ -0,0 +1,3 @@
namespace AIStudio.Components;
public readonly record struct TextItem(string Header, string Text);
@@ -0,0 +1,10 @@
@inherits MSGComponentBase
@if (this.availableTools.Count > 0)
{
@if (this.Component is not Components.CHAT && this.IncludeVisibilityToggle)
{
<ConfigurationOption OptionDescription="@T("Show tool selection in this assistant?")" LabelOn="@T("Tool selection is visible")" LabelOff="@T("Tool selection is hidden")" State="@(() => this.SettingsManager.IsToolSelectionVisible(this.Component))" StateUpdate="@(value => this.SettingsManager.SetToolSelectionVisibility(this.Component, value))" />
}
<ConfigurationMultiSelect TData="string" OptionDescription="@this.OptionTitle" SelectedValues="@this.GetSelectedValues" Data="@this.availableTools" SelectionUpdate="@this.UpdateSelection" OptionHelp="@this.OptionHelp" Disabled="@(() => this.AreDefaultToolsDisabled)" IsItemLocked="@this.IsToolDisabled" EmptySelectionText="@T("No tools selected.")" SingleSelectionText="@T("You have selected 1 tool.")" MultipleSelectionText="@T("You have selected {0} tools.")" />
}
@@ -0,0 +1,54 @@
using AIStudio.Settings;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class ToolDefaultsConfiguration : MSGComponentBase
{
[Parameter]
public AIStudio.Tools.Components Component { get; set; } = AIStudio.Tools.Components.CHAT;
[Parameter]
public bool IncludeVisibilityToggle { get; set; } = true;
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
private List<ConfigurationSelectData<string>> availableTools = [];
private string OptionTitle => this.Component is AIStudio.Tools.Components.CHAT ? this.T("Default tools for chat") : this.T("Default tools for this assistant");
private string OptionHelp => this.Component is AIStudio.Tools.Components.CHAT
? this.T("Choose which tools should be preselected for new chats.")
: this.T("Choose which tools should be preselected for new runs of this assistant.");
/// <summary>
/// Whether preselecting tools is pointless right now.
/// </summary>
/// <remarks>
/// Only where the toggle above decides whether the user ever sees a tool selection: a hidden
/// selection makes its defaults meaningless. Without that toggle the assistant reaches its
/// tools some other way — from a form field of its own, for instance — and the defaults do
/// apply.
/// </remarks>
private bool AreDefaultToolsDisabled =>
this.IncludeVisibilityToggle &&
this.Component is not AIStudio.Tools.Components.CHAT &&
!this.SettingsManager.IsToolSelectionVisible(this.Component);
private bool IsToolDisabled(string toolId) => !this.SettingsManager.IsToolActive(toolId);
protected override async Task OnInitializedAsync()
{
this.availableTools = (await this.ToolRegistry.GetCatalogAsync(this.Component))
.Select(x => new ConfigurationSelectData<string>(x.Implementation.GetDisplayName(), x.Definition.Id))
.ToList();
await base.OnInitializedAsync();
}
private HashSet<string> GetSelectedValues() => this.SettingsManager.GetDefaultToolIds(this.Component);
private void UpdateSelection(HashSet<string> values) => this.SettingsManager.ConfigurationData.Tools.DefaultToolIdsByComponent[this.Component.ToString()] = [..ToolSelectionRules.NormalizeSelection(values)];
}
@@ -0,0 +1,97 @@
@inherits MSGComponentBase
<div class="d-flex">
<MudTooltip Text="@this.ToolButtonTooltip" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Build" Class="@this.PopoverButtonClasses" OnClick="@this.ToggleSelection"/>
</MudTooltip>
<MudPopover Open="@this.showSelection" AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft" DropShadow="@true" Class="border-solid border-4 rounded-lg">
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">@T("Tool Selection")</MudText>
<MudSpacer />
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Style="min-width: 28em; max-height: 60vh; max-width: 48vw; overflow: auto;">
<MudText Typo="Typo.body1" Class="mb-3">
@T("Tools allow the LLM to perform targeted additional actions such as web searches or reading web pages.")
</MudText>
@if (!this.SupportsTools)
{
<MudText Typo="Typo.body1">@this.UnsupportedToolsMessage</MudText>
}
else if (this.Disabled)
{
<MudAlert Dense="@true" Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
@T("Tool changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.")
</MudAlert>
}
else if (this.catalog.Count == 0)
{
<MudText Typo="Typo.body1">@T("No tools are available in this context.")</MudText>
}
@if (this.SupportsTools && this.catalog.Count > 0)
{
@foreach (var item in this.catalog)
{
var isSelected = this.SelectedToolIds.Contains(item.Definition.Id);
var isConfigured = item.ConfigurationState.IsConfigured;
var providerConfidenceHint = this.GetProviderConfidenceHint(item);
<MudPaper Class="pa-2 mb-2 border rounded-lg">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
@*
Everything but the settings button switches the tool, so aiming for the
small switch is optional. The button spans that part of the row, which
keeps the settings button outside of it without any event plumbing.
*@
<MudButton Variant="Variant.Text" Color="Color.Default" Class="px-2 py-1 justify-start"
Style="min-width:auto; text-transform:none; flex-grow:1;"
Disabled="@this.IsRowDisabled(item)" OnClick="@(async () => await this.ToggleToolFromRow(item))">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@*
The switch only shows the state; the surrounding button does the switching.
It therefore takes no pointer events at all: its label reaches past the visible
switch and would otherwise swallow the clicks landing in that strip.
*@
<MudSwitch T="bool" Color="Color.Primary" Value="@isSelected" ReadOnly="@true" Disabled="@this.IsRowDisabled(item)" Style="pointer-events: none;" />
<MudIcon Icon="@item.Implementation.Icon" Color="Color.Info" />
@if (!item.IsActive)
{
<MudTooltip Text="@T("This tool has been disabled by your organization.")">
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
<MudTooltip Text="@item.Implementation.GetDescription()">
<MudText Typo="Typo.body1">@item.Implementation.GetDisplayName()</MudText>
</MudTooltip>
</MudStack>
</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@(async () => await this.OpenSettings(item.Definition.Id))" />
</MudStack>
@if (!isConfigured)
{
<MudText Typo="Typo.caption" Color="Color.Warning">@(string.IsNullOrWhiteSpace(item.ConfigurationState.Message) ? T("Required settings are missing. Configure this tool before enabling it.") : item.ConfigurationState.Message)</MudText>
}
@if (!item.IsActive)
{
<MudText Typo="Typo.caption" Color="Color.Warning">@T("This tool has been disabled by your organization.")</MudText>
}
@if (!string.IsNullOrWhiteSpace(providerConfidenceHint))
{
<MudText Typo="Typo.caption" Color="Color.Warning">@providerConfidenceHint</MudText>
}
</MudPaper>
}
}
</MudCardContent>
<MudCardActions>
<MudSpacer />
<MudButton Variant="Variant.Filled" OnClick="@this.Hide">@T("Close")</MudButton>
</MudCardActions>
</MudCard>
</MudPopover>
</div>
@@ -0,0 +1,155 @@
using AIStudio.Dialogs.Settings;
using AIStudio.Provider;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class ToolSelection : MSGComponentBase
{
[Parameter]
public AIStudio.Tools.Components Component { get; set; } = AIStudio.Tools.Components.CHAT;
[Parameter]
public required AIStudio.Settings.Provider LLMProvider { get; set; }
[Parameter]
public HashSet<string> SelectedToolIds { get; set; } = [];
[Parameter]
public EventCallback<HashSet<string>> SelectedToolIdsChanged { get; set; }
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public string PopoverButtonClasses { get; set; } = string.Empty;
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
private bool showSelection;
private IReadOnlyList<ToolCatalogItem> catalog = [];
protected override void OnParametersSet()
{
this.SelectedToolIds = ToolSelectionRules.NormalizeSelection(this.SelectedToolIds);
base.OnParametersSet();
}
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
await base.OnInitializedAsync();
}
private ToolCallingAvailability ToolCallingAvailability => this.LLMProvider.GetToolCallingAvailability();
private bool SupportsTools => this.ToolCallingAvailability.IsAvailable;
private string ToolButtonTooltip => this.SupportsTools
? this.T("Select tools")
: this.UnsupportedToolsMessage;
private string UnsupportedToolsMessage => this.ToolCallingAvailability.Message;
private ConfidenceLevel ProviderConfidence => this.LLMProvider == AIStudio.Settings.Provider.NONE
? ConfidenceLevel.NONE
: this.LLMProvider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level;
private async Task ToggleSelection()
{
this.showSelection = !this.showSelection;
if (this.showSelection)
this.catalog = await this.ToolRegistry.GetCatalogAsync(this.Component);
}
private void Hide() => this.showSelection = false;
/// <summary>
/// Whether this tool can be switched at all right now.
/// </summary>
/// <remarks>
/// The switch and the row click share this, so both agree on when a tool is out of reach: the
/// organization disabled it, it is not configured, the provider lacks the confidence it needs,
/// a response is running, or the model cannot call tools in the first place.
/// </remarks>
private bool IsRowDisabled(ToolCatalogItem item) => !item.IsActive || !item.ConfigurationState.IsConfigured || this.IsBlockedByProviderConfidence(item) ||
this.Disabled || !this.SupportsTools;
/// <summary>
/// Switches a tool when the user clicks anywhere in its row.
/// </summary>
/// <remarks>
/// Hitting the switch itself is needless precision work, so the text, the icon, and the empty
/// space count as well. Only the settings button is left out, because it sits outside the
/// button that spans the rest of the row.
/// </remarks>
private async Task ToggleToolFromRow(ToolCatalogItem item)
{
if (this.IsRowDisabled(item))
return;
await this.ChangeSelection(item.Definition.Id, !this.SelectedToolIds.Contains(item.Definition.Id));
}
private async Task ChangeSelection(string toolId, bool isSelected)
{
if (isSelected && !this.SettingsManager.IsToolActive(toolId))
return;
var updated = new HashSet<string>(this.SelectedToolIds, StringComparer.Ordinal);
if (isSelected)
updated.Add(toolId);
else
updated.Remove(toolId);
updated = ToolSelectionRules.NormalizeSelection(updated);
this.SelectedToolIds = updated;
await this.SelectedToolIdsChanged.InvokeAsync(updated);
}
// The catalog already carries the resolved level, so there is nothing to look up again:
private static ConfidenceLevel GetMinimumProviderConfidence(ToolCatalogItem item) => item.MinimumProviderConfidence;
private bool IsBlockedByProviderConfidence(ToolCatalogItem item) => !ToolSelectionRules.IsProviderConfidenceAllowed(this.ProviderConfidence, GetMinimumProviderConfidence(item));
private string? GetProviderConfidenceHint(ToolCatalogItem item)
{
if (!this.IsBlockedByProviderConfidence(item))
return null;
return string.Format(
this.T("This tool requires provider confidence {0}. The selected provider has {1}."),
GetMinimumProviderConfidence(item).GetName(),
this.ProviderConfidence.GetName());
}
private async Task OpenSettings(string toolId)
{
var parameters = new DialogParameters<ToolSettingsDialog>
{
{ x => x.ToolId, toolId },
};
var dialog = await this.DialogService.ShowAsync<ToolSettingsDialog>(null, parameters, Dialogs.DialogOptions.FULLSCREEN);
await dialog.Result;
this.catalog = await this.ToolRegistry.GetCatalogAsync(this.Component);
this.StateHasChanged();
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED when this.showSelection:
this.catalog = await this.ToolRegistry.GetCatalogAsync(this.Component);
await this.InvokeAsync(this.StateHasChanged);
break;
}
}
}
@@ -0,0 +1,16 @@
@inherits MSGComponentBase
@if (this.availableTools.Count > 0)
{
<ConfigurationMultiSelect TData="string"
OptionDescription="@this.Label"
OptionHelp="@this.Help"
Data="@this.availableTools"
SelectedValues="@(() => this.SelectedToolIds)"
SelectionUpdateAsync="@this.OptionChangedAsync"
Disabled="@(() => this.ReadOnly || this.Disabled)"
IsItemLocked="@this.IsToolLocked"
EmptySelectionText="@T("No tools selected")"
SingleSelectionText="@T("1 tool selected")"
MultipleSelectionText="@T("{0} tools selected")"/>
}
@@ -0,0 +1,83 @@
using AIStudio.Settings;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Picks the tools of a run as an ordinary form field, next to the settings they belong to.
/// </summary>
/// <remarks>
/// The counterpart to the tool selection in the footer, which floats above a whole chat or
/// assistant. Where the tools belong to one specific setting — the instructions of a batch job,
/// say — they are easier to grasp right there, and a read-only field is the honest way to show
/// tools somebody else decided on.
/// </remarks>
public partial class ToolSelectionField : MSGComponentBase
{
[Parameter]
public AIStudio.Tools.Components Component { get; set; } = AIStudio.Tools.Components.CHAT;
[Parameter]
public HashSet<string> SelectedToolIds { get; set; } = [];
[Parameter]
public EventCallback<HashSet<string>> SelectedToolIdsChanged { get; set; }
/// <summary>
/// Shows the tools without letting the user change them.
/// </summary>
/// <remarks>
/// For tools that were decided elsewhere, such as by a document analysis policy. The user
/// still gets to see what the run will do.
/// </remarks>
[Parameter]
public bool ReadOnly { get; set; }
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public string Help { get; set; } = string.Empty;
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
private List<ConfigurationSelectData<string>> availableTools = [];
protected override async Task OnInitializedAsync()
{
this.availableTools = (await this.ToolRegistry.GetCatalogAsync(this.Component))
.Select(x => new ConfigurationSelectData<string>(x.Implementation.GetDisplayName(), x.Definition.Id))
.ToList();
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
await base.OnInitializedAsync();
}
private bool IsToolLocked(string toolId) => !this.SettingsManager.IsToolActive(toolId);
private async Task OptionChangedAsync(HashSet<string> updatedToolIds)
{
this.SelectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
await this.SelectedToolIdsChanged.InvokeAsync(this.SelectedToolIds);
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED:
this.availableTools = (await this.ToolRegistry.GetCatalogAsync(this.Component))
.Select(x => new ConfigurationSelectData<string>(x.Implementation.GetDisplayName(), x.Definition.Id))
.ToList();
await this.InvokeAsync(this.StateHasChanged);
break;
}
}
}
@@ -152,35 +152,10 @@ public partial class VoiceRecorder : MSGComponentBase
return;
}
try
{
if (runtimeState.Backend is ShortcutBackend.LOCAL
&& !runtimeState.IsSuspended
&& !string.IsNullOrWhiteSpace(runtimeState.Shortcut))
{
await this.JsRuntime.InvokeVoidAsync(
"localShortcut.register",
"voice-recording-toggle",
runtimeState.Shortcut,
this.localShortcutDotNetReference);
}
else
{
await this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle");
}
}
catch (JSDisconnectedException)
{
this.Logger.LogDebug("The focused-window shortcut listener could not be updated because the JS runtime disconnected.");
}
catch (OperationCanceledException)
{
this.Logger.LogDebug("Updating the focused-window shortcut listener was canceled.");
}
catch (JSException ex)
{
this.Logger.LogWarning(ex, "Failed to update the focused-window shortcut listener.");
}
if (runtimeState.Backend is ShortcutBackend.LOCAL && !runtimeState.IsSuspended && !string.IsNullOrWhiteSpace(runtimeState.Shortcut))
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "localShortcut.register", "voice-recording-toggle", runtimeState.Shortcut, this.localShortcutDotNetReference);
else
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "localShortcut.unregister", "voice-recording-toggle");
}
private bool ShouldRenderVoiceRecording => PreviewFeatures.PRE_SPEECH_TO_TEXT_2026.IsEnabled(this.SettingsManager)
@@ -561,13 +536,27 @@ public partial class VoiceRecorder : MSGComponentBase
#region Overrides of MSGComponentBase
/// <summary>
/// Hands the focused-window shortcut back to the browser before this component goes away.
/// </summary>
/// <remarks>
/// This belongs into the asynchronous part of the disposal: the base class runs it before
/// DisposeResources, and only here we can await the call. Discarding it instead left the
/// unregistration unfinished, and its failure on an already-disconnected circuit surfaced as an
/// unobserved task exception once the finalizer got to it.
/// </remarks>
protected override async ValueTask DisposeResourcesAsync()
{
if (this.localShortcutInteropReady)
await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, "localShortcut.unregister", "voice-recording-toggle");
await base.DisposeResourcesAsync();
}
protected override void DisposeResources()
{
this.GlobalShortcutService.RuntimeStateChanged -= this.OnShortcutRuntimeStateChanged;
if (this.localShortcutInteropReady)
_ = this.JsRuntime.InvokeVoidAsync("localShortcut.unregister", "voice-recording-toggle");
this.localShortcutDotNetReference?.Dispose();
this.localShortcutDotNetReference = null;
this.localShortcutInteropReady = false;
@@ -63,7 +63,7 @@ public partial class Workspaces : MSGComponentBase
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
await base.OnInitializedAsync();
this.ApplyFilters([], [ Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_CREATED ]);
_ = this.LoadTreeItemsAsync(startPrefetch: true);
this.LoadTreeItemsAsync(startPrefetch: true).Observe($"{nameof(Workspaces)}: loading the workspace tree");
}
#endregion
@@ -445,7 +445,7 @@ public partial class Workspaces : MSGComponentBase
private void OnMediaImportStateChanged(MediaImportOwner owner)
{
if (owner.Kind is MediaImportOwnerKind.CHAT)
_ = this.SafeStateHasChanged();
this.SafeStateHasChanged().Observe($"{nameof(Workspaces)}: rendering a media import change");
}
private async Task SafeStateHasChanged()