Added a way to read what the AI received from your sources (#1100)

Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
Paul KoudelkaandThorsten Sommer authored and GitHub committed 2026-10-26 10:00:01 +01:00
1 parent e0d8e18f34
commit cbc4ce5413
229 files changed
+12739 -1269

No files matched your search

@@ -14,11 +14,4 @@ public partial class AssistantDraftDialog : MSGComponentBase
private void Cancel() => this.MudDialog.Cancel();
private void Confirm() => this.MudDialog.Close(DialogResult.Ok(this.DraftMarkdown));
private CodeBlockTheme CodeColorPalette => this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default;
private MudMarkdownStyling MarkdownStyling => new()
{
CodeBlock = { Theme = this.CodeColorPalette },
};
}
@@ -183,7 +183,7 @@ public partial class ChatTemplateDialog : MSGComponentBase
if (this.CreateFromExistingChatThread && this.ExistingChatThread is not null)
{
this.DataSystemPrompt = this.ExistingChatThread.SystemPrompt;
this.dataExampleConversation = this.ExistingChatThread.Blocks.Select(n => n.DeepClone(true)).ToList();
this.dataExampleConversation = this.ExistingChatThread.Blocks.Select(n => n.CloneApartFromChat(true)).ToList();
this.DataName = this.ExistingChatThread.Name;
}
@@ -211,7 +211,8 @@ public partial class ChatTemplateDialog : MSGComponentBase
SystemPrompt = this.DataSystemPrompt,
PredefinedUserPrompt = this.PredefinedUserPrompt,
ExampleConversation = this.dataExampleConversation,
FileAttachments = this.fileAttachments.Select(attachment => attachment.Normalize()).ToList(),
// Without the entries of their texts: every chat started from the template reads its files anew, cf. FileAttachment.ProvenanceId.
FileAttachments = this.fileAttachments.Select(attachment => attachment.Normalize().WithoutStoredText()).ToList(),
AllowProfileUsage = this.AllowProfileUsage,
ToolIds = this.preselectTools ? new HashSet<string>(this.selectedToolIds, StringComparer.Ordinal) : null,
DataSourceOptions = this.preselectDataSources ? this.templateDataSourceOptions.CreateCopy() : null,
@@ -1,12 +1,18 @@
@inherits MSGComponentBase
<MudDialog>
<MudDialog ContentClass="dialog-content-fit">
<TitleContent>
@* Named after the document once there is one, and after what the caller opened us for until then.
A file which is no longer at its place is told about below, together with what is shown instead: *@
<FileDialogTitle Title="@(this.document?.FileName ?? this.MudDialog.Title ?? string.Empty)" Path="@(this.document?.FilePath ?? string.Empty)" WarnIfMissing="@false"/>
</TitleContent>
<DialogContent>
@* A drop anywhere in this dialog belongs to the dialog, not to the page behind it: *@
<PathDropZone IsArea="@true"
IdPrefix="document-check"
OnPathsDropped="@this.DropCallback"
Disabled="@this.IsZoneDisabled"
Class="dialog-fit"
Context="isDropTarget">
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
@T("See how we load your file. Review the content before we process it further.")
@@ -21,38 +27,13 @@
@if (this.document is null)
{
<ReadFileContent Text="@T("Load file")" FileContent="@this.fileContent" FileContentChanged="@this.ApplyLoadedFileContent" EnableDragDrop="true" CatchAllDocuments="true"/>
<ReadFileContent Text="@T("Load file")" @bind-FileContent="@this.fileContent" EnableDragDrop="true" CatchAllDocuments="true"/>
}
else
{
@* Keys have to be unique among siblings, no matter the component: this field and the
tabs below both stand for the document and would otherwise collide on its path. *@
<MudTextField
@key="@($"file-path-{this.document.FilePath}")"
T="string"
Text="@this.document.FilePath"
AdornmentIcon="@Icons.Material.Filled.FileOpen"
Adornment="Adornment.Start"
Immediate="@true"
Label="@T("File Path")"
Variant="Variant.Outlined"
Lines="1"
MaxLines="3"
Class="mt-10"
ReadOnly="true"
/>
}
@* The frame shows where a dropped file would land. It is drawn only while this dialog can
take one, and keeps its width in both states so that nothing jumps during a drag: *@
<div class="@this.PreviewAreaClass(isDropTarget)">
@if (!this.document?.Exists ?? false)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
@T("The specified file could not be found. The file have been moved, deleted, renamed, or is otherwise inaccessible.")
</MudAlert>
}
else if (this.isLoadingContent)
<div class="dialog-fit @this.PreviewAreaClass(isDropTarget)">
@if (this.isLoadingContent)
{
<MudJustifiedText Typo="Typo.body1" Class="my-3">
@T("Please wait while we load the content of your file. Depending on the file type and size, this may take a moment.")
@@ -62,6 +43,12 @@
<MudSkeleton Width="100%"/>
<MudSkeleton Width="90%"/>
}
else if (this.IsFileMissing && !this.showsStoredText)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
@T("The file could not be found. It may have been moved, renamed, or deleted, or it is otherwise inaccessible.")
</MudAlert>
}
else if (this.loadFailureMessage is not null)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="my-2">
@@ -70,56 +57,34 @@
}
else
{
@if (this.previewCutOffCharacters > 0)
@if (this.IsFileMissing)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="my-2">
@string.Format(T("Your document is large, so we show you only its beginning. We hide the remaining {0:N0} characters here. Rest assured: the AI still receives your entire document."), this.previewCutOffCharacters)
@* The text the AI received outlives the file, so the file being gone is worth a note, not an error: *@
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Dense="@true" Class="my-2">
@T("The file is no longer in its original location. Below is the text the AI received from it when you sent it.")
</MudAlert>
}
@* Keyed by the document: a switch from an image to a text file changes which panels
exist, and a leftover active panel would point at one that is gone. *@
<MudTabs @key="@($"preview-tabs-{this.document?.FilePath}")" Elevation="0" Rounded="true" ApplyEffectsToContainer="true" Outlined="true" PanelClass="pa-2" Class="mb-2">
@if (this.document?.IsImage ?? false)
{
<MudTabPanel Text="@T("Image View")" Icon="@Icons.Material.Filled.Image">
<MudImage ObjectFit="ObjectFit.ScaleDown" Style="width: 100%;" Src="@this.document.FilePathAsUrl"/>
</MudTabPanel>
}
else
{
<MudTabPanel Text="@T("Markdown View")" Icon="@Icons.Material.Filled.TextSnippet">
<MudField
Variant="Variant.Outlined"
AdornmentIcon="@Icons.Material.Filled.Article"
Adornment="Adornment.Start"
Label="@T("Loaded Content")"
FullWidth="true"
Class="ma-2 pe-4"
HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")">
<div style="max-height: 40vh; overflow-y: auto;">
<MudMarkdown Value="@this.previewContent" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE"/>
</div>
</MudField>
</MudTabPanel>
<MudTabPanel Text="@T("Simple View")" Icon="@Icons.Material.Filled.Terminal">
<MudTextField
T="string"
Text="@this.previewContent"
AdornmentIcon="@Icons.Material.Filled.Article"
Adornment="Adornment.Start"
Immediate="@true"
Label="@T("Loaded Content")"
Variant="Variant.Outlined"
Lines="6"
AutoGrow="@true"
MaxLines="25"
ReadOnly="true"
Class="ma-2"
HelperText="@T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected.")"/>
</MudTabPanel>
}
</MudTabs>
@* Keyed by the document: every document starts on the first panel of its own. *@
<ContentViewer
@key="@($"preview-tabs-{this.document?.FilePath}")"
Kind="@this.ViewerKind"
Text="@this.fileContent"
MediaUrl="@(this.document?.FilePathAsUrl ?? string.Empty)"
HelperText="@(this.showsStoredText ? T("This is the text the AI received from your file when you first sent it. Later changes to the file do not reach the AI in this chat.") : T("This is the content we loaded from your file — including headings, lists, and formatting. Use this to verify your file loads as expected."))"
CharacterLimit="@PREVIEW_CHARACTER_LIMIT"
CutOffNotice="@this.CutOffNotice"
MaxHeight="@ContentViewer.NO_HEIGHT_LIMIT">
<Toolbar>
@* The label takes the free width, so the button stands right, next to the views. *@
<MudText Typo="Typo.subtitle2" Class="flex-grow-1">
@(this.showsStoredText ? T("Text the AI received") : T("Loaded Content"))
</MudText>
@* At its beginning: the preview shows how we read the file, not a place in it. *@
<FileActions Path="@(this.document?.FilePath ?? string.Empty)" ShowCopy="@false" ShowFileManager="@false" WarnIfMissing="@false"/>
</Toolbar>
</ContentViewer>
}
</div>
</PathDropZone>
@@ -1,5 +1,6 @@
using AIStudio.Chat;
using AIStudio.Components;
using AIStudio.Tools.Databases;
using AIStudio.Tools.Services;
using Microsoft.AspNetCore.Components;
@@ -43,6 +44,19 @@ public partial class DocumentCheckDialog : MSGComponentBase
[Parameter]
public Func<bool>? IsAttachingUnavailable { get; set; }
/// <summary>
/// Keeps what this dialog read of a document which was never sent, so that sending it does not
/// read the file again, see FileAttachment.ProvenanceId.
/// </summary>
/// <remarks>
/// Gets the document, the text read from it, and the state of its file before it was read, so
/// that a file which changed afterward is read again; never throws. Only the chat hands
/// one over, for the attachments of the message being written: a sent message has its texts
/// already, and elsewhere, nothing is sent later.
/// </remarks>
[Parameter]
public Func<FileAttachment, string, FileVersion, Task>? KeepReadText { get; set; }
/// <summary>
/// The document we show right now. It starts out as the one we were opened with and changes
/// whenever the user drops another file onto this dialog.
@@ -74,17 +88,6 @@ public partial class DocumentCheckDialog : MSGComponentBase
/// </summary>
private string? loadFailureMessage;
/// <summary>
/// What we show to the user: either the entire file content, or its beginning. We keep this in
/// its own field so that we cut the content only once, instead of on every render.
/// </summary>
private string previewContent = string.Empty;
/// <summary>
/// How many characters we cut off from the preview. Zero when we show the entire content.
/// </summary>
private int previewCutOffCharacters;
/// <summary>
/// Ends the extraction when this dialog is gone, or when another document took the place of
/// the one being read, before that file was read completely.
@@ -113,6 +116,20 @@ public partial class DocumentCheckDialog : MSGComponentBase
/// </summary>
private bool isLoadingContent;
/// <summary>
/// True when the content is the text the AI received from the document when it was sent, rather
/// than what the file holds now, see FileAttachment.ProvenanceId.
/// </summary>
private bool showsStoredText;
/// <summary>
/// Whether the document we show is no longer at its place.
/// </summary>
private bool IsFileMissing => this.document is not null && !this.document.Exists;
[Inject]
private DatabaseClientProvider DatabaseClientProvider { get; init; } = null!;
[Inject]
private RustService RustService { get; init; } = null!;
@@ -128,7 +145,6 @@ public partial class DocumentCheckDialog : MSGComponentBase
this.fileContent = this.FileContent;
this.isLoadingContent = this.NeedsExtraction();
this.UpdatePreview();
await base.OnInitializedAsync();
}
@@ -148,13 +164,14 @@ public partial class DocumentCheckDialog : MSGComponentBase
/// <summary>
/// Whether the document we show has to be read before we can show anything of it. Images are
/// shown as they are, a missing file shows its own message, and content a caller already handed
/// us is reused instead of being extracted a second time.
/// shown as they are, a missing file shows its own message unless the text the AI received from
/// it is stored, and content a caller already handed us is reused instead of being extracted a
/// second time.
/// </summary>
private bool NeedsExtraction() =>
this.document is not null &&
!this.document.IsImage &&
this.document.Exists &&
(this.document.ProvenanceId is not null || this.document.Exists) &&
string.IsNullOrWhiteSpace(this.fileContent);
/// <summary>
@@ -187,8 +204,36 @@ public partial class DocumentCheckDialog : MSGComponentBase
if (this.isDisposed || generation != this.loadGeneration)
return;
string? textToKeep = null;
FileVersion? version = null;
try
{
//
// A document sent before shows the text the AI received from it, which the provenance
// store keeps, also when the file changed or is gone by now. Without it, we read the file:
//
if (documentToLoad.ProvenanceId is { } provenanceId)
{
var storedText = await this.ReadStoredTextAsync(provenanceId);
if (this.isDisposed || generation != this.loadGeneration)
return;
if (storedText is not null)
{
this.fileContent = storedText;
this.showsStoredText = true;
return;
}
// Nothing left to read, which the dialog says:
if (!documentToLoad.Exists)
return;
}
// Taken before reading, so that a change while reading counts as a change:
if (this.KeepReadText is not null)
version = await Task.Run(() => FileVersion.Of(documentToLoad.FilePath), cancellationToken);
var extraction = await UserFile.LoadFileData(documentToLoad.FilePath, this.RustService, this.PandocAvailability, cancellationToken);
if (this.isDisposed || generation != this.loadGeneration)
return;
@@ -201,6 +246,8 @@ public partial class DocumentCheckDialog : MSGComponentBase
//
if (!extraction.HasUsableContent)
this.loadFailureMessage = extraction.ToUserMessage(documentToLoad.FileName);
else if (this.KeepReadText is not null && documentToLoad.ProvenanceId is null)
textToKeep = extraction.Content;
}
catch (OperationCanceledException)
{
@@ -221,10 +268,17 @@ public partial class DocumentCheckDialog : MSGComponentBase
if (!this.isDisposed && generation == this.loadGeneration)
{
this.isLoadingContent = false;
this.UpdatePreview();
this.StateHasChanged();
}
}
//
// Kept once the user sees the text, since keeping may first have to create the chat. Reading
// the file told the user already what could not be read, so sending the kept text tells
// nothing of it again:
//
if (textToKeep is not null && version is not null)
await this.KeepReadText!(documentToLoad, textToKeep, version);
}
/// <summary>
@@ -300,45 +354,42 @@ public partial class DocumentCheckDialog : MSGComponentBase
this.document = attachment;
this.fileContent = string.Empty;
this.loadFailureMessage = null;
this.showsStoredText = false;
this.isLoadingContent = this.NeedsExtraction();
this.UpdatePreview();
}
/// <summary>
/// Called when the user loads a file through this dialog. We don't use a two-way binding here,
/// since we have to refresh the preview whenever the content changes.
/// Reads the text the AI received from a document when it was sent.
/// </summary>
/// <param name="loadedContent">The content of the file the user has loaded.</param>
private void ApplyLoadedFileContent(string loadedContent)
/// <param name="provenanceId">The entry of the text.</param>
/// <returns>The text, or null when the provenance store no longer holds it, or cannot be read.</returns>
private async Task<string?> ReadStoredTextAsync(Guid provenanceId)
{
this.fileContent = loadedContent;
this.UpdatePreview();
}
/// <summary>
/// Determines what part of the file content we show to the user.
/// </summary>
private void UpdatePreview()
{
if (this.fileContent.Length <= PREVIEW_CHARACTER_LIMIT)
try
{
this.previewContent = this.fileContent;
this.previewCutOffCharacters = 0;
return;
var store = await this.DatabaseClientProvider.GetProvenanceStoreAsync();
var entries = await store.GetEntriesAsync([provenanceId], CancellationToken.None);
return entries.GetValueOrDefault(provenanceId)?.Content;
}
catch (Exception exception)
{
this.Logger.LogWarning(exception, "Failed to read the stored text of the document '{ProvenanceId}'. Its file is read instead.", provenanceId);
return null;
}
//
// We cut at the last line break before our limit. Otherwise, we might tear apart a Markdown
// construct like a table row or a code fence in the middle of a line:
//
var cutIndex = this.fileContent.LastIndexOf('\n', PREVIEW_CHARACTER_LIMIT - 1) + 1;
if (cutIndex < 1)
cutIndex = PREVIEW_CHARACTER_LIMIT;
this.previewContent = this.fileContent[..cutIndex];
this.previewCutOffCharacters = this.fileContent.Length - cutIndex;
}
/// <summary>
/// An image is shown as it is; everything else is shown as the text we read from it.
/// </summary>
private ContentViewerKind ViewerKind => (this.document?.IsImage ?? false) ? ContentViewerKind.IMAGE : ContentViewerKind.TEXT;
/// <summary>
/// Explains why we show only the beginning of a large document. The preview only answers how we
/// read the file, so it may leave out the rest; the AI does not.
/// </summary>
/// <param name="cutOffCharacters">How many characters the preview leaves out.</param>
private string CutOffNotice(int cutOffCharacters) => string.Format(T("Your document is large, so we show you only its beginning. We hide the remaining {0} characters here. Rest assured: the AI still receives your entire document."), cutOffCharacters.CompactCount());
/// <summary>
/// Ends a running extraction. Without this, reading a large document would continue after the
/// user closed this dialog and would keep this component, the extracted content, and the
@@ -357,11 +408,4 @@ public partial class DocumentCheckDialog : MSGComponentBase
base.DisposeResources();
}
private CodeBlockTheme CodeColorPalette => this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default;
private MudMarkdownStyling MarkdownStyling => new()
{
CodeBlock = { Theme = this.CodeColorPalette },
};
}
@@ -12,11 +12,4 @@ public partial class PromptingGuidelineDialog : MSGComponentBase
public string GuidelineMarkdown { get; set; } = string.Empty;
private void Close() => this.MudDialog.Cancel();
private CodeBlockTheme CodeColorPalette => this.SettingsManager.IsDarkMode ? CodeBlockTheme.Dark : CodeBlockTheme.Default;
private MudMarkdownStyling MarkdownStyling => new()
{
CodeBlock = { Theme = this.CodeColorPalette },
};
}
@@ -89,10 +89,22 @@
</div>
<MudToolBar WrapContent="true" Gutters="false" Class="ml-2" Style="flex-shrink: 0; min-height: 1em;">
<MudIconButton Icon="@Icons.Material.Filled.Search"
Color="Color.Primary"
Disabled="true"/>
@* A file sent before keeps the text the AI received from it, which outlives the file: *@
@if (fileAttachment.ProvenanceId is not null)
{
<MudTooltip Text="@T("Show the text the AI received from this file.")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Search"
Color="Color.Primary"
OnClick="@(() => this.InvestigateFile(fileAttachment))"/>
</MudTooltip>
}
else
{
<MudIconButton Icon="@Icons.Material.Filled.Search"
Color="Color.Primary"
Disabled="true"/>
}
<MudTooltip Text="@T("Remove this attachment.")" Placement="Placement.Bottom">
<MudIconButton Icon="@Icons.Material.Filled.Delete"
Color="Color.Error"
@@ -38,6 +38,15 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
[Parameter]
public Func<bool>? IsAttachingUnavailable { get; set; }
/// <summary>
/// Keeps what the preview reads of an attachment, so that sending it does not read the file again, cf. DocumentCheckDialog.KeepReadText.
/// </summary>
/// <remarks>
/// Null for a message which was already sent, and wherever nothing is sent later.
/// </remarks>
[Parameter]
public Func<FileAttachment, string, FileVersion, Task>? KeepReadText { get; set; }
[Inject]
private IDialogService DialogService { get; set; } = null!;
@@ -120,7 +129,7 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
private async Task PathsDropped(List<string> paths) => await this.AttachPathsAsync(paths);
public static async Task<HashSet<FileAttachment>> OpenDialogAsync(IDialogService dialogService, HashSet<FileAttachment> documentPaths, Func<List<string>, Task<IReadOnlyList<FileAttachment>>>? attachPaths = null, Func<bool>? isAttachingUnavailable = null)
public static async Task<HashSet<FileAttachment>> OpenDialogAsync(IDialogService dialogService, HashSet<FileAttachment> documentPaths, Func<List<string>, Task<IReadOnlyList<FileAttachment>>>? attachPaths = null, Func<bool>? isAttachingUnavailable = null, Func<FileAttachment, string, FileVersion, Task>? keepReadText = null)
{
var dialogParameters = new DialogParameters<ReviewAttachmentsDialog>
{
@@ -133,6 +142,9 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
if (isAttachingUnavailable is not null)
dialogParameters.Add(x => x.IsAttachingUnavailable, isAttachingUnavailable);
if (keepReadText is not null)
dialogParameters.Add(x => x.KeepReadText, keepReadText);
var dialogReference = await dialogService.ShowAsync<ReviewAttachmentsDialog>(TB("Your attached files"), dialogParameters, DialogOptions.FULLSCREEN);
var dialogResult = await dialogReference.Result;
if (dialogResult is null || dialogResult.Canceled)
@@ -161,6 +173,7 @@ public partial class ReviewAttachmentsDialog : MSGComponentBase
var dialogParameters = new DialogParameters<DocumentCheckDialog>
{
{ x => x.Document, fileAttachment },
{ x => x.KeepReadText, this.KeepReadText },
};
//
@@ -12,6 +12,7 @@
<DialogContent>
<ConfigurationSelect OptionDescription="@T("Shortcut to send input")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior)" Data="@ConfigurationSelectDataFactory.GetSendBehaviorData()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.Chat.ShortcutSendBehavior = selectedValue)" OptionHelp="@T("Do you want to use any shortcut to send your input?")"/>
<ConfigurationOption OptionDescription="@T("Show the latest message after loading?")" LabelOn="@T("Latest message is shown, after loading a chat")" LabelOff="@T("First (oldest) message is shown, after loading a chat")" State="@(() => this.SettingsManager.ConfigurationData.Chat.ShowLatestMessageAfterLoading)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.Chat.ShowLatestMessageAfterLoading = updatedState)" OptionHelp="@T("When enabled, the latest message is shown after loading a chat. When disabled, the first (oldest) message is shown.")"/>
<ConfigurationOption OptionDescription="@T("Read changed attachments again?")" LabelOn="@T("Changed files are read again for the next message")" LabelOff="@T("Files are sent as they were first read")" State="@(() => this.SettingsManager.ConfigurationData.Chat.ReadChangedAttachmentsAgain)" StateUpdate="@(updatedState => this.SettingsManager.ConfigurationData.Chat.ReadChangedAttachmentsAgain = updatedState)" OptionHelp="@T("When enabled, AI Studio reads an attached file again when it changed since it was sent, and the AI receives the new content with the next message. Viewing the attachment then shows the content that was sent last. When disabled, the AI keeps receiving the content a file had when it was first sent, also when the file changed or is gone.")" IsLocked="() => ManagedConfiguration.TryGet(x => x.Chat, x => x.ReadChangedAttachmentsAgain, out var meta) && meta.IsLocked"/>
<ConfigurationSelect OptionDescription="@T("Provider selection when creating new chats")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.Chat.AddChatProviderBehavior)" Data="@ConfigurationSelectDataFactory.GetAddChatProviderBehavior()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.Chat.AddChatProviderBehavior = selectedValue)" OptionHelp="@T("Control how the LLM provider for added chats is selected.")"/>
<ConfigurationSelect OptionDescription="@T("Provider selection when loading a chat and sending assistant results to chat")" SelectedValue="@(() => this.SettingsManager.ConfigurationData.Chat.LoadingProviderBehavior)" Data="@ConfigurationSelectDataFactory.GetLoadingChatProviderBehavior()" SelectionUpdate="@(selectedValue => this.SettingsManager.ConfigurationData.Chat.LoadingProviderBehavior = selectedValue)" OptionHelp="@T("Control how the LLM provider for loaded chats is selected and when assistant results are sent to chat.")"/>
<MudPaper Class="pa-3 mb-8 border-dashed border rounded-lg">
@@ -0,0 +1,35 @@
@using AIStudio.Tools.RAG
@inherits MSGComponentBase
<MudDialog ContentClass="dialog-content-fit">
<TitleContent>
<FileDialogTitle Title="@this.fileName" Path="@this.path"/>
</TitleContent>
<DialogContent>
<MudText Typo="Typo.body2" Class="mb-4">
@T("This is exactly what the AI received from this source for its answer, after AI Studio removed any suspicious instructions.")
</MudText>
<div class="source-passages">
@* Numbers are enough here: the bar above the passage names its page or part. *@
@if (this.passagesInDocumentOrder.Count > 1)
{
<MudList T="SourcePassage" @bind-SelectedValue="@this.selectedPassage" SelectionMode="SelectionMode.SingleSelection" Color="Color.Primary" Dense="@true" Class="source-passages-menu">
@for (var index = 0; index < this.passagesInDocumentOrder.Count; index++)
{
<MudListItem T="SourcePassage" Value="@this.passagesInDocumentOrder[index]" Text="@($"{index + 1}")"/>
}
</MudList>
}
<div class="source-passages-content dialog-fit">
<SourcePassageView Passage="@this.selectedPassage" Document="@this.Document" MaxHeight="@ContentViewer.NO_HEIGHT_LIMIT"/>
</div>
</div>
</DialogContent>
<DialogActions>
<MudButton OnClick="@this.Close" Variant="Variant.Filled" Color="Color.Primary">
@T("Close")
</MudButton>
</DialogActions>
</MudDialog>
@@ -0,0 +1,93 @@
using AIStudio.Components;
using AIStudio.Tools.PluginSystem;
using AIStudio.Tools.RAG;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Dialogs;
/// <summary>
/// Shows the passages the AI received from one file, so the user can check what an answer rests on.
/// </summary>
/// <remarks>
/// Opened from the list of sources below an answer, and meant for every other place which hands
/// passages of data sources to the AI and wants to show them, such as an assistant. The file is
/// named once, above everything else; its passages are numbered at the side when there are several,
/// in the order of the document, and the one picked is shown in full.<br/><br/>
/// Only the text of a passage scrolls: the dialog fits the window, see dialog-content-fit in app.css,
/// so that it never has to scroll as well.
/// </remarks>
public partial class SourcePassagesDialog : MSGComponentBase
{
private static string TB(string fallbackEN) => I18N.I.T(fallbackEN, typeof(SourcePassagesDialog).Namespace, nameof(SourcePassagesDialog));
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = null!;
/// <summary>
/// The passages to show, in the order the AI received them; they are shown in the order of the document.
/// </summary>
[Parameter]
public IReadOnlyList<SourcePassage> Passages { get; set; } = [];
/// <summary>
/// The file the passages are from, or null when it is no file this computer can open.
/// </summary>
[Parameter]
public SourceDocumentLocation? Document { get; set; }
/// <summary>
/// The passages in the order of the document. A passage which does not say where it is comes
/// last, in the order the AI received it.
/// </summary>
/// <remarks>
/// The order the AI received them in tells the user nothing: the AI gets no weight with them,
/// and each one counts as much as every other. The order of the document is what lets the user
/// read them alongside it.
/// </remarks>
private List<SourcePassage> passagesInDocumentOrder = [];
private SourcePassage selectedPassage = new();
private string fileName = string.Empty;
private string path = string.Empty;
#region Overrides of ComponentBase
protected override async Task OnInitializedAsync()
{
this.passagesInDocumentOrder = [..this.Passages.OrderBy(passage => passage.GetLocationRange()?.First ?? int.MaxValue)];
this.selectedPassage = this.passagesInDocumentOrder.Count > 0 ? this.passagesInDocumentOrder[0] : new();
this.fileName = GetFileName(this.Passages);
this.path = this.Document?.Path ?? this.selectedPassage.Path;
await base.OnInitializedAsync();
}
#endregion
/// <summary>
/// The name of the file the passages are from, without their pages: those are what the dialog lets the user pick.
/// </summary>
private static string GetFileName(IReadOnlyList<SourcePassage> passages) => passages is [{ DataSourceName.Length: > 0 } firstPassage, ..] ? firstPassage.DataSourceName : TB("Passages");
private void Close() => this.MudDialog.Cancel();
/// <summary>
/// Shows the passages the AI received from a file.
/// </summary>
/// <param name="dialogService">The dialog service.</param>
/// <param name="passages">The passages to show, in any order: they are shown in the order of the document.</param>
/// <param name="document">The file the passages are from, or null when it is no file this computer can open.</param>
public static async Task OpenDialogAsync(IDialogService dialogService, IReadOnlyList<SourcePassage> passages, SourceDocumentLocation? document = null)
{
var dialogParameters = new DialogParameters<SourcePassagesDialog>
{
{ x => x.Passages, passages },
{ x => x.Document, document },
};
var dialogReference = await dialogService.ShowAsync<SourcePassagesDialog>(GetFileName(passages), dialogParameters, DialogOptions.FULLSCREEN);
await dialogReference.Result;
}
}