Show data sources as collapsible panels and reach their settings

This commit is contained in:
Thorsten Sommer committed 2026-09-09 18:36:22 +02:00
1 parent 29a9c0f35d
commit 2f7f513677
8 files changed
+263 -121

No files matched your search

+138 -117
View File
@@ -3,7 +3,13 @@
<MudStack Spacing="3" Class="pr-2 pb-4" Style="height: 100%; min-height: 0; overflow-y: auto;">
<MudPaper Class="pa-4 border-dashed border rounded-lg" Elevation="0">
<MudText Typo="Typo.h5">@T("Background embeddings")</MudText>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">@T("Background embeddings")</MudText>
<MudSpacer/>
<MudTooltip Text="@T("Manage your data sources")" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@this.OpenDataSourceSettings"/>
</MudTooltip>
</MudStack>
<MudText Typo="Typo.body1" Class="mt-2">
@T("AI Studio indexes local RAG data sources in the background. Finished files stay recorded so unchanged files can be skipped after a restart, while added or deleted files are detected during the next run. The same applies to documents without readable text, such as scanned pages: AI Studio remembers them and reads them again only once they change.")
</MudText>
@@ -23,133 +29,148 @@
}
else
{
@foreach (var status in this.Statuses)
{
<MudPaper Class="pa-4 border rounded-lg" Elevation="0">
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h6">@status.DataSourceName</MudText>
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
@*
One panel per data source, and only one of them open: a folder with thousands of files
fills the page by itself. What a closed panel still has to say stays in its header, so
nobody has to open every one of them to see how their data sources are doing.
*@
<MudExpansionPanels>
@foreach (var status in this.Statuses)
{
<MudExpansionPanel Class="border-solid border rounded-lg" Expanded="@(this.expandedDataSourceId == status.DataSourceId)" ExpandedChanged="@(isExpanded => this.DataSourcePanelExpandedChanged(status, isExpanded))">
<TitleContent>
<div class="d-flex align-center">
<MudText Typo="Typo.h6">@string.Format(T("Data source: {0}"), status.DataSourceName)</MudText>
<MudSpacer/>
<MudChip T="string" Color="@GetStatusColor(status)" Variant="Variant.Filled">@status.StateLabel</MudChip>
@if (CanRefresh(status))
{
@*
This opens or closes the panel along the way. The header is what
toggles it, and neither stopping the event nor swallowing the
click works in this project — see the end button of our own
ExpansionPanel component, which behaves the same way.
*@
<MudTooltip Text="@T("Refresh this data source")">
<MudIconButton Icon="@Icons.Material.Filled.Sync" Color="Color.Info" Size="Size.Small" OnClick="@(async () => await this.RefreshDataSource(status))" />
</MudTooltip>
}
</MudStack>
</MudStack>
</div>
</TitleContent>
<ChildContent>
<MudStack Spacing="2">
<MudProgressLinear Value="@status.ProgressPercent" Rounded="@true" Color="@GetStatusColor(status)" />
<MudProgressLinear Value="@status.ProgressPercent" Rounded="@true" Color="@GetStatusColor(status)" />
<MudText Typo="Typo.body2">
@string.Format(T("{0} of {1} files are indexed."), status.IndexedFiles, status.TotalFiles)
</MudText>
<MudText Typo="Typo.body2">
@string.Format(T("{0} of {1} files are indexed."), status.IndexedFiles, status.TotalFiles)
</MudText>
@if (status.PermanentlySkippedFiles > 0)
{
<MudText Typo="Typo.body2">
@string.Format(T("Skipped files: {0}. AI Studio reads them again once they change."), status.PermanentlySkippedFiles)
</MudText>
}
@if (status.FailedFiles > 0)
{
<MudText Typo="Typo.body2">
@string.Format(T("Failed files: {0}"), status.FailedFiles)
</MudText>
}
@if (!string.IsNullOrWhiteSpace(status.CurrentFile))
{
<MudText Typo="Typo.body2">
@string.Format(T("Current file: {0}"), status.CurrentFile)
</MudText>
}
@*
One panel per cause, the most pressing one first. A folder in which nine
hundred scanned documents were skipped otherwise buries the handful of
files somebody has to look at.
*@
@if (status.Failures.Count > 0)
{
<MudExpansionPanels MultiExpansion="@true" Class="mt-2">
@foreach (var group in this.GetFailureGroups(status))
@if (status.PermanentlySkippedFiles > 0)
{
<ExpansionPanel HeaderIcon="@group.Cause.Icon" IconColor="@group.Cause.Color" IconSize="Size.Small" HeaderText="@GetGroupHeader(group)">
<MudStack Spacing="3">
<MudStack Row="@true" Wrap="Wrap.Wrap" Spacing="2" AlignItems="AlignItems.Center">
<MudChip T="string" Size="Size.Small" Color="@group.Cause.Color" Variant="Variant.Outlined">
@(group.Cause.IsPermanent ? T("Skipped until the file changes") : T("Tried again during the next run"))
</MudChip>
@if (!string.IsNullOrWhiteSpace(group.EmbeddingProviderName))
{
<MudText Typo="Typo.caption">@string.Format(T("Embedding provider: {0}"), group.EmbeddingProviderName)</MudText>
}
@if (group.Cause.NeedsProviderSettings)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Settings" OnClick="@this.OpenEmbeddingProviderSettings">
@T("Open the settings")
</MudButton>
}
</MudStack>
<MudTable Items="@group.Failures" Dense="@true" Hover="@true" Elevation="0" Breakpoint="Breakpoint.None" RowsPerPage="10" Class="border-dashed border rounded-lg">
<ColGroup>
<col/>
<col style="width: 11em;"/>
<col style="width: 7em;"/>
</ColGroup>
<HeaderContent>
<MudTh>@T("File")</MudTh>
<MudTh>@T("Noticed")</MudTh>
<MudTh>@T("Actions")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd Style="white-space: normal; overflow-wrap: anywhere;">
@* Captions render as spans, so each of them needs to be told to take its own line. *@
<MudText Typo="Typo.body2">@GetFileName(context.FilePath)</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary d-block">@context.FilePath</MudText>
@if (group.Cause.ShowsMessagePerFile)
{
<MudText Typo="Typo.caption" Color="Color.Warning" Class="d-block">@context.Reason</MudText>
}
</MudTd>
<MudTd Style="white-space: nowrap;">
<MudText Typo="Typo.caption">@GetOccurrenceText(context)</MudText>
</MudTd>
<MudTd>
@if (CanShowInFileManager(context))
{
<MudTooltip Text="@T("Show this file in the file browser of your system")">
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Color="Color.Info" Size="Size.Small" OnClick="@(async () => await this.ShowInFileManager(context))"/>
</MudTooltip>
}
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="@PAGE_SIZE_OPTIONS"/>
</PagerContent>
</MudTable>
</MudStack>
</ExpansionPanel>
<MudText Typo="Typo.body2">
@string.Format(T("Skipped files: {0}. AI Studio reads them again once they change."), status.PermanentlySkippedFiles)
</MudText>
}
</MudExpansionPanels>
}
@*
Shown next to the list, not instead of it: the list says which files failed,
while this is the one sentence about the data source as a whole. Hiding it
as soon as a single file failed is what made it invisible in practice.
*@
@if (!string.IsNullOrWhiteSpace(status.LastError))
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Text">
@status.LastError
</MudAlert>
}
</MudStack>
</MudPaper>
}
@if (status.FailedFiles > 0)
{
<MudText Typo="Typo.body2">
@string.Format(T("Failed files: {0}"), status.FailedFiles)
</MudText>
}
@if (!string.IsNullOrWhiteSpace(status.CurrentFile))
{
<MudText Typo="Typo.body2">
@string.Format(T("Current file: {0}"), status.CurrentFile)
</MudText>
}
@*
One panel per cause, the most pressing one first. A folder in which nine
hundred scanned documents were skipped otherwise buries the handful of
files somebody has to look at.
*@
@if (status.Failures.Count > 0)
{
<MudExpansionPanels MultiExpansion="@true" Class="mt-2">
@foreach (var group in this.GetFailureGroups(status))
{
<ExpansionPanel HeaderIcon="@group.Cause.Icon" IconColor="@group.Cause.Color" IconSize="Size.Small" HeaderTypo="Typo.subtitle1" HeaderClass="expansion-panel-header-compact" HeaderText="@GetGroupHeader(group)">
<MudStack Spacing="3">
<MudStack Row="@true" Wrap="Wrap.Wrap" Spacing="2" AlignItems="AlignItems.Center">
<MudChip T="string" Size="Size.Small" Color="@group.Cause.Color" Variant="Variant.Outlined">
@(group.Cause.IsPermanent ? T("Skipped until the file changes") : T("Tried again during the next run"))
</MudChip>
@if (!string.IsNullOrWhiteSpace(group.EmbeddingProviderName))
{
<MudText Typo="Typo.caption">@string.Format(T("Embedding provider: {0}"), group.EmbeddingProviderName)</MudText>
}
@if (group.Cause.NeedsProviderSettings)
{
<MudButton Variant="Variant.Text" Size="Size.Small" Color="Color.Primary" StartIcon="@Icons.Material.Filled.Settings" OnClick="@this.OpenEmbeddingProviderSettings">
@T("Open the settings")
</MudButton>
}
</MudStack>
<MudTable Items="@group.Failures" Dense="@true" Hover="@true" Elevation="0" Breakpoint="Breakpoint.None" RowsPerPage="10" Class="border-dashed border rounded-lg">
<ColGroup>
<col/>
<col style="width: 11em;"/>
<col style="width: 7em;"/>
</ColGroup>
<HeaderContent>
<MudTh>@T("File")</MudTh>
<MudTh>@T("Noticed")</MudTh>
<MudTh>@T("Actions")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd Style="white-space: normal; overflow-wrap: anywhere;">
@* Captions render as spans, so each of them needs to be told to take its own line. *@
<MudText Typo="Typo.body2">@GetFileName(context.FilePath)</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary d-block">@context.FilePath</MudText>
@if (group.Cause.ShowsMessagePerFile)
{
<MudText Typo="Typo.caption" Color="Color.Warning" Class="d-block">@context.Reason</MudText>
}
</MudTd>
<MudTd Style="white-space: nowrap;">
<MudText Typo="Typo.caption">@GetOccurrenceText(context)</MudText>
</MudTd>
<MudTd>
@if (CanShowInFileManager(context))
{
<MudTooltip Text="@T("Show this file in the file browser of your system")">
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Color="Color.Info" Size="Size.Small" OnClick="@(async () => await this.ShowInFileManager(context))"/>
</MudTooltip>
}
</MudTd>
</RowTemplate>
<PagerContent>
<MudTablePager PageSizeOptions="@PAGE_SIZE_OPTIONS"/>
</PagerContent>
</MudTable>
</MudStack>
</ExpansionPanel>
}
</MudExpansionPanels>
}
@*
Shown next to the list, not instead of it: the list says which files failed,
while this is the one sentence about the data source as a whole. Hiding it
as soon as a single file failed is what made it invisible in practice.
*@
@if (!string.IsNullOrWhiteSpace(status.LastError))
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Text">
@status.LastError
</MudAlert>
}
</MudStack>
</ChildContent>
</MudExpansionPanel>
}
</MudExpansionPanels>
}
</MudStack>
@@ -1,4 +1,5 @@
using AIStudio.Components;
using AIStudio.Dialogs.Settings;
using AIStudio.Provider;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Rust;
@@ -6,6 +7,8 @@ using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
using DialogOptions = AIStudio.Dialogs.DialogOptions;
namespace AIStudio.Pages;
public partial class Embeddings : MSGComponentBase
@@ -21,11 +24,17 @@ public partial class Embeddings : MSGComponentBase
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
[Inject]
private ILogger<Embeddings> Logger { get; init; } = null!;
private IReadOnlyList<DataSourceEmbeddingStatus> Statuses { get; set; } = [];
private string? expandedDataSourceId;
private bool userChoseExpansion;
private int TotalIndexedFiles => this.Statuses.Sum(status => status.IndexedFiles);
private int TotalPendingFiles => this.Statuses.Sum(status => Math.Max(0, status.TotalFiles - status.IndexedFiles - status.FailedFiles - status.PermanentlySkippedFiles));
@@ -70,6 +79,67 @@ public partial class Embeddings : MSGComponentBase
.OrderBy(status => status.SortOrder)
.ThenBy(status => status.DataSourceName, StringComparer.OrdinalIgnoreCase)
.ToList();
this.UpdateAutoExpansion();
}
/// <summary>
/// Opens the data source which is worth reading, as long as the user has not chosen one.
/// </summary>
/// <remarks>
/// It never closes what is open. A data source which finishes its run while somebody is reading
/// it would otherwise fold up at the very moment its result becomes interesting. From the first
/// click on, the page stops rearranging itself at all.
/// </remarks>
private void UpdateAutoExpansion()
{
if (this.userChoseExpansion)
return;
// The list is sorted by state, so the first match is the most pressing one: a running data
// source before a queued one, and a failed one before a completed one:
var worthOpening = this.Statuses.FirstOrDefault(IsWorthOpening);
if (worthOpening is null)
return;
this.expandedDataSourceId = worthOpening.DataSourceId;
}
/// <remarks>
/// Whoever opens this page does so because a run is under way or because something went wrong.
/// Meeting nothing but closed panels would be a step back from the version which showed every
/// data source at once.
/// </remarks>
private static bool IsWorthOpening(DataSourceEmbeddingStatus status) =>
status.State is DataSourceEmbeddingState.RUNNING or DataSourceEmbeddingState.QUEUED or DataSourceEmbeddingState.FAILED || status.FailedFiles > 0;
/// <remarks>
/// MudBlazor keeps track of which panel is open on its own, so this only records the decision.
/// Both events of a switch arrive, in either order — the one closing the old panel and the one
/// opening the new one — which is why the closing event only clears what it actually named.
/// </remarks>
private void DataSourcePanelExpandedChanged(DataSourceEmbeddingStatus status, bool isExpanded)
{
this.userChoseExpansion = true;
if (isExpanded)
this.expandedDataSourceId = status.DataSourceId;
else if (this.expandedDataSourceId == status.DataSourceId)
this.expandedDataSourceId = null;
}
/// <summary>
/// Opens the data source settings, the same dialog the chat offers next to its data source selection.
/// </summary>
/// <remarks>
/// Nothing is left to do once it closes: the dialog writes the settings itself and publishes
/// CONFIGURATION_CHANGED, which this page already listens to.
/// </remarks>
private async Task OpenDataSourceSettings()
{
var dialogParameters = new DialogParameters();
var dialogReference = await this.DialogService.ShowAsync<SettingsDialogDataSources>(null, dialogParameters, DialogOptions.FULLSCREEN);
await dialogReference.Result;
}
private static Color GetStatusColor(DataSourceEmbeddingStatus status) => status.State switch