using AIStudio.Dialogs; using AIStudio.Tools.RAG; using AIStudio.Tools.Services; using Microsoft.AspNetCore.Components; namespace AIStudio.Components; /// /// Shows the sources an answer rests on, grouped and numbered the way the export is. /// /// /// This list used to be Markdown, which read correctly but could not be clicked where it mattered: /// a Markdown renderer hands every link to the browser, and the browser refuses a file address on a /// page it loaded over http. A source of the user's own documents therefore did nothing at all. /// Written out as components, an entry can hand its document to the runtime instead, together with /// the page the passage was found on. /// public partial class SourcesList : MSGComponentBase { // // The name is about the alignment the function uses, not about the page: it brings the element // into view with its end at the bottom, which for a list at the end of an answer shows all of it. // private const string SCROLL_INTO_VIEW_FUNCTION = "scrollToBottom"; /// /// The sources to show. /// [Parameter] public IList Sources { get; set; } = []; /// /// The passages the AI received from the sources, which the user can read below each of them. /// [Parameter] public IReadOnlyList Passages { get; set; } = []; [Inject] private RustService RustService { get; init; } = null!; [Inject] private IDialogService DialogService { get; init; } = null!; [Inject] private IJSRuntime JsRuntime { get; init; } = null!; private readonly List groups = []; private ElementReference listElement; /// /// Brings this list into view. /// /// /// The counter above an answer says how many sources it rests on; this is how it takes the /// reader to them. The element stays here, where it is rendered, rather than being handed to /// whoever wants to scroll to it. /// public async Task ScrollIntoViewAsync() => await this.JsRuntime.TryInvokeVoidAsync(this.CircuitState, SCROLL_INTO_VIEW_FUNCTION, this.listElement); #region Overrides of ComponentBase protected override async Task OnParametersSetAsync() { this.RebuildGroups(); await base.OnParametersSetAsync(); } #endregion /// /// Reads the sources once per render instead of once per entry and render. /// /// /// Where a source points is answered by looking at its link, and while an answer streams, this /// runs again for every chunk. The previous Markdown list was rebuilt and parsed just as often, /// so this is the cheaper of the two, but it is still worth doing once for the whole list. /// private void RebuildGroups() { this.groups.Clear(); var passagesBySource = this.Passages.SortBySource(); foreach (var group in this.Sources.GroupSources()) { var entries = new List(group.Sources.Count); foreach (var numberedSource in group.Sources) { var document = numberedSource.Source.TryGetDocumentLocation(out var location) ? location : (SourceDocumentLocation?)null; var passages = passagesBySource.GetPassagesOf(numberedSource.Source); // // Pages are links only into a document, and only under the name the title of the // source was made with, see SourcePassageExtensions.NamePassagesInTitles. Parts of a // document without pages are nothing a program can open, so their title stays as it is: // var name = passages.Count > 0 ? passages[0].DataSourceName : string.Empty; var pages = document is not null && !string.IsNullOrWhiteSpace(name) && passages.GetLocation() is { ArePages: true } pageLocation ? pageLocation : null; entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document, numberedSource.Source.HasNoLink(), passages, name, pages)); } this.groups.Add(new(group.Heading, entries)); } } private string PassagesTooltip(SourceEntry entry) => entry.Passages.Count is 1 ? T("Show the passage the AI received from this source") : string.Format(T("Show the {0} passages the AI received from this source"), entry.Passages.Count.CompactCount()); private async Task ShowPassages(SourceEntry entry) => await SourcePassagesDialog.OpenDialogAsync(this.DialogService, entry.Passages, entry.Document); private async Task OpenDocument(SourceDocumentLocation document) => await document.OpenAsync(this.RustService); /// /// The files of sources which are no longer at their place, by their paths, as FileActions found out. /// private readonly HashSet missingFiles = new(PathTools.COMPARER); private bool IsMissing(SourceEntry entry) => entry.Document is { } document && this.missingFiles.Contains(document.Path); /// /// Keeps whether the file of a source is at its place, so that its title stops being a link when it is not. /// private void NoteExistence(SourceEntry entry, bool exists) { if (entry.Document is not { } document) return; if (exists) this.missingFiles.Remove(document.Path); else this.missingFiles.Add(document.Path); } /// /// One group of the list, prepared so that the markup only has to show it. /// /// The heading above the group. /// The entries of the group, in the order they are shown. private readonly record struct SourceEntryGroup(string Heading, IReadOnlyList Entries); /// /// One entry of the list, prepared so that the markup only has to show it. /// /// The number the source is listed under. /// The title of the source. /// The address of the source, which a web source is opened by. /// The document the source names, or null when it names none. /// Whether nothing can open the source by its address, e.g., a mail, so that it is shown as text. /// The passages the AI received from the source; empty when it received none, or the chat is older than these. /// The name of the source without its location, or empty when it has no passages. /// The pages of the passages in the document, each to be opened on its own, or null when there are none. private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document, bool HasNoLink, IReadOnlyList Passages, string Name, PassageLocation? Location); }