mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 00:53:37 +00:00
Fixed sources of your own documents not reacting to a click
This commit is contained in:
parent
9e9efb80e4
commit
0c7fffa2cf
@ -5029,6 +5029,30 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T78
|
||||
-- Are you sure you want to delete the transcription provider '{0}'?
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SETTINGS::SETTINGSPANELTRANSCRIPTION::T789660305"] = "Are you sure you want to delete the transcription provider '{0}'?"
|
||||
|
||||
-- Could not open the file location.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1118835751"] = "Could not open the file location."
|
||||
|
||||
-- Opens {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1170655868"] = "Opens {0}"
|
||||
|
||||
-- Could not open the file location: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1455637941"] = "Could not open the file location: {0}"
|
||||
|
||||
-- Show this file in the file manager of your system
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1587653504"] = "Show this file in the file manager of your system"
|
||||
|
||||
-- Opens {0} on page {1}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T2402202635"] = "Opens {0} on page {1}"
|
||||
|
||||
-- Unknown error
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3461425987"] = "Unknown error"
|
||||
|
||||
-- Could not open the document.
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3570758363"] = "Could not open the document."
|
||||
|
||||
-- Could not open the document: {0}
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T945417289"] = "Could not open the document: {0}"
|
||||
|
||||
-- Copy {0} to the clipboard
|
||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Copy {0} to the clipboard"
|
||||
|
||||
@ -12199,6 +12223,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1238078807"] = "No com
|
||||
-- Failed to store the API key due to an API issue.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Failed to store the API key due to an API issue."
|
||||
|
||||
-- The runtime document endpoint returned '{0}'.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1843760475"] = "The runtime document endpoint returned '{0}'."
|
||||
|
||||
-- The global shortcut could not be registered because of a desktop integration error.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2032590244"] = "The global shortcut could not be registered because of a desktop integration error."
|
||||
|
||||
@ -12226,6 +12253,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Succes
|
||||
-- The desktop service returned an invalid response while registering the global shortcut.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3369097283"] = "The desktop service returned an invalid response while registering the global shortcut."
|
||||
|
||||
-- The runtime document endpoint failed without details.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T353458993"] = "The runtime document endpoint failed without details."
|
||||
|
||||
-- AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3611400673"] = "AI Studio could not access secure storage because no default collection is configured. Open a compatible password manager, create or select a collection, unlock it, and set it as the default."
|
||||
|
||||
@ -12244,6 +12274,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3929880252"] = "No sav
|
||||
-- Failed to get the secret data due to an API issue.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T4007657575"] = "Failed to get the secret data due to an API issue."
|
||||
|
||||
-- The runtime document endpoint is not available.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T541638186"] = "The runtime document endpoint is not available."
|
||||
|
||||
-- AI Studio could not access secure storage. See the log for technical details.
|
||||
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T624023541"] = "AI Studio could not access secure storage. See the log for technical details."
|
||||
|
||||
|
||||
@ -223,7 +223,7 @@
|
||||
}
|
||||
@if (textContent.Sources.Count > 0)
|
||||
{
|
||||
<MudMarkdown Value="@textContent.Sources.ToMarkdown()" Props="Markdown.DefaultConfig" Styling="@this.MarkdownStyling" MarkdownPipeline="Markdown.SAFE_MARKDOWN_PIPELINE" />
|
||||
<SourcesList Sources="@textContent.Sources"/>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
37
app/MindWork AI Studio/Components/SourcesList.razor
Normal file
37
app/MindWork AI Studio/Components/SourcesList.razor
Normal file
@ -0,0 +1,37 @@
|
||||
@inherits MSGComponentBase
|
||||
|
||||
@* The class is what the Markdown renderer wraps its own output in, so the headings and the list
|
||||
keep the look they had while this list was Markdown. *@
|
||||
<div class="mud-markdown-body">
|
||||
@foreach (var group in this.groups)
|
||||
{
|
||||
<MudText Typo="Typo.h2">
|
||||
@group.Heading
|
||||
</MudText>
|
||||
<ul>
|
||||
@foreach (var entry in group.Entries)
|
||||
{
|
||||
<li>
|
||||
@($"[{entry.Number}] ")
|
||||
@if (entry.Document is { } document)
|
||||
{
|
||||
<MudTooltip Text="@this.GetDocumentTooltip(document)" Placement="Placement.Top">
|
||||
<MudLink Typo="Typo.body1" OnClick="@(() => this.OpenDocument(document))">
|
||||
@entry.Title
|
||||
</MudLink>
|
||||
</MudTooltip>
|
||||
<MudTooltip Text="@T("Show this file in the file manager of your system")" Placement="Placement.Top">
|
||||
<MudIconButton Icon="@Icons.Material.Filled.FolderOpen" Size="Size.Small" OnClick="@(() => this.ShowInFileManager(document))"/>
|
||||
</MudTooltip>
|
||||
}
|
||||
else
|
||||
{
|
||||
<MudLink Href="@entry.Link" Target="_blank" Typo="Typo.body1">
|
||||
@entry.Title
|
||||
</MudLink>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
147
app/MindWork AI Studio/Components/SourcesList.razor.cs
Normal file
147
app/MindWork AI Studio/Components/SourcesList.razor.cs
Normal file
@ -0,0 +1,147 @@
|
||||
using AIStudio.Tools.Rust;
|
||||
using AIStudio.Tools.Services;
|
||||
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace AIStudio.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Shows the sources an answer rests on, grouped and numbered the way the export is.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public partial class SourcesList : MSGComponentBase
|
||||
{
|
||||
/// <summary>
|
||||
/// The sources to show.
|
||||
/// </summary>
|
||||
[Parameter]
|
||||
public IList<Source> Sources { get; set; } = [];
|
||||
|
||||
[Inject]
|
||||
private RustService RustService { get; init; } = null!;
|
||||
|
||||
[Inject]
|
||||
private ILogger<SourcesList> Logger { get; init; } = null!;
|
||||
|
||||
private readonly List<SourceEntryGroup> groups = [];
|
||||
|
||||
#region Overrides of ComponentBase
|
||||
|
||||
protected override async Task OnParametersSetAsync()
|
||||
{
|
||||
this.RebuildGroups();
|
||||
await base.OnParametersSetAsync();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
/// Reads the sources once per render instead of once per entry and render.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
private void RebuildGroups()
|
||||
{
|
||||
this.groups.Clear();
|
||||
foreach (var group in this.Sources.GroupSources())
|
||||
{
|
||||
var entries = new List<SourceEntry>(group.Sources.Count);
|
||||
foreach (var numberedSource in group.Sources)
|
||||
{
|
||||
var document = numberedSource.Source.TryGetDocumentLocation(out var location) ? location : (SourceDocumentLocation?)null;
|
||||
entries.Add(new(numberedSource.Number, numberedSource.Source.Title, numberedSource.Source.URL, document));
|
||||
}
|
||||
|
||||
this.groups.Add(new(group.Heading, entries));
|
||||
}
|
||||
}
|
||||
|
||||
private string GetDocumentTooltip(SourceDocumentLocation document) => document.PageNumber is > 0
|
||||
? string.Format(T("Opens {0} on page {1}"), document.Path, document.PageNumber)
|
||||
: string.Format(T("Opens {0}"), document.Path);
|
||||
|
||||
/// <summary>
|
||||
/// Opens a document in the program the system uses for it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Whether the program can be sent to a page is the runtime's business, and it says afterwards
|
||||
/// whether it managed to. Nothing is shown about that here: the document is open, and the title
|
||||
/// of the source names the page anyway.
|
||||
/// </remarks>
|
||||
/// <param name="document">The document to open, and the page to show.</param>
|
||||
private async Task OpenDocument(SourceDocumentLocation document)
|
||||
{
|
||||
OpenDocumentResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.TryOpenDocumentInSystemViewer(document.Path, document.PageNumber);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not open a source document.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, T("Could not open the document.")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
return;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.Description, string.Format(T("Could not open the document: {0}"), issue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Opens the file browser of the system and selects the document in it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The second way out of the list: a document which the system opens in the wrong program, or
|
||||
/// which the user wants to move or send on instead of read, is reached from here without being
|
||||
/// opened. This is the same way out the embeddings page offers for a file it could not read.
|
||||
/// </remarks>
|
||||
/// <param name="document">The document to show.</param>
|
||||
private async Task ShowInFileManager(SourceDocumentLocation document)
|
||||
{
|
||||
OpenPathResponse response;
|
||||
try
|
||||
{
|
||||
response = await this.RustService.TryOpenPathInRuntimeFileManager(document.Path);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
this.Logger.LogWarning(e, "Could not show a source document in the file manager.");
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, T("Could not open the file location.")));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.Success)
|
||||
return;
|
||||
|
||||
var issue = string.IsNullOrWhiteSpace(response.Issue) ? T("Unknown error") : response.Issue;
|
||||
await this.MessageBus.SendError(new(Icons.Material.Filled.FolderOpen, string.Format(T("Could not open the file location: {0}"), issue)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One group of the list, prepared so that the markup only has to show it.
|
||||
/// </summary>
|
||||
/// <param name="Heading">The heading above the group.</param>
|
||||
/// <param name="Entries">The entries of the group, in the order they are shown.</param>
|
||||
private readonly record struct SourceEntryGroup(string Heading, IReadOnlyList<SourceEntry> Entries);
|
||||
|
||||
/// <summary>
|
||||
/// One entry of the list, prepared so that the markup only has to show it.
|
||||
/// </summary>
|
||||
/// <param name="Number">The number the source is listed under.</param>
|
||||
/// <param name="Title">The title of the source.</param>
|
||||
/// <param name="Link">The address of the source, which a web source is opened by.</param>
|
||||
/// <param name="Document">The document the source names, or null when it names none.</param>
|
||||
private readonly record struct SourceEntry(int Number, string Title, string Link, SourceDocumentLocation? Document);
|
||||
}
|
||||
13
app/MindWork AI Studio/Tools/NumberedSource.cs
Normal file
13
app/MindWork AI Studio/Tools/NumberedSource.cs
Normal file
@ -0,0 +1,13 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// A source together with the number it is listed under.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The number runs through the whole list rather than starting over per group, because that is how
|
||||
/// an answer refers to a source. It is assigned once, where the groups are formed, so the chat and
|
||||
/// an exported document cannot end up numbering the same list differently.
|
||||
/// </remarks>
|
||||
/// <param name="Number">The number this source is listed under, counted from one.</param>
|
||||
/// <param name="Source">The source itself.</param>
|
||||
public readonly record struct NumberedSource(int Number, Source Source);
|
||||
8
app/MindWork AI Studio/Tools/SourceDocumentLocation.cs
Normal file
8
app/MindWork AI Studio/Tools/SourceDocumentLocation.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Where a source points in the file system, and where inside the document it was found.
|
||||
/// </summary>
|
||||
/// <param name="Path">The document in the file system, spelled the way this system spells a path.</param>
|
||||
/// <param name="PageNumber">The page the passage stands on, counted from one, or null when no page is known.</param>
|
||||
public readonly record struct SourceDocumentLocation(string Path, int? PageNumber);
|
||||
@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@ -79,6 +80,60 @@ public static partial class SourceExtensions
|
||||
return c is '-' or '.' or '_' or '~' or ':' or '/' or '?' or '#' or '[' or ']' or '@' or '!' or '$' or '&' or '\'' or '(' or ')' or '*' or '+' or ',' or ';' or '=';
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sorts a list of sources into the groups it is shown in, and numbers them.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The order of the groups and the running number are what a reader follows, and they have to
|
||||
/// be the same wherever the list appears: in the chat, in an exported document, and in the
|
||||
/// clipboard. This is why both the chat and the Markdown below ask here instead of sorting the
|
||||
/// list themselves.
|
||||
/// </remarks>
|
||||
/// <param name="sources">The list of sources to sort.</param>
|
||||
/// <returns>The groups which have sources, in the order they are shown; empty when there are none.</returns>
|
||||
public static IReadOnlyList<SourceGroup> GroupSources(this IList<Source> sources)
|
||||
{
|
||||
var llmSources = new List<Source>();
|
||||
var toolSources = new List<Source>();
|
||||
var ragSources = new List<Source>();
|
||||
foreach (var source in sources)
|
||||
{
|
||||
switch (source.Origin)
|
||||
{
|
||||
case SourceOrigin.LLM:
|
||||
llmSources.Add(source);
|
||||
break;
|
||||
|
||||
case SourceOrigin.TOOL:
|
||||
toolSources.Add(source);
|
||||
break;
|
||||
|
||||
case SourceOrigin.RAG:
|
||||
ragSources.Add(source);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
var groups = new List<SourceGroup>(3);
|
||||
var sourceNum = 0;
|
||||
AddGroup(groups, TB("Sources provided by the AI"), llmSources, ref sourceNum);
|
||||
AddGroup(groups, TB("Sources used by tools"), toolSources, ref sourceNum);
|
||||
AddGroup(groups, TB("Sources provided by the data providers"), ragSources, ref sourceNum);
|
||||
return groups;
|
||||
}
|
||||
|
||||
private static void AddGroup(ICollection<SourceGroup> groups, string heading, IReadOnlyList<Source> sources, ref int sourceNum)
|
||||
{
|
||||
if (sources.Count == 0)
|
||||
return;
|
||||
|
||||
var numberedSources = new List<NumberedSource>(sources.Count);
|
||||
foreach (var source in sources)
|
||||
numberedSources.Add(new(++sourceNum, source));
|
||||
|
||||
groups.Add(new(heading, numberedSources));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a list of sources to a markdown-formatted string.
|
||||
/// </summary>
|
||||
@ -87,65 +142,18 @@ public static partial class SourceExtensions
|
||||
public static string ToMarkdown(this IList<Source> sources)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
var ragSources = new List<ISource>();
|
||||
var toolSources = new List<ISource>();
|
||||
var sourceNum = 0;
|
||||
var addedLLMHeaders = false;
|
||||
foreach (var source in sources)
|
||||
foreach (var group in sources.GroupSources())
|
||||
{
|
||||
switch (source.Origin)
|
||||
{
|
||||
case SourceOrigin.RAG:
|
||||
ragSources.Add(source);
|
||||
break;
|
||||
|
||||
case SourceOrigin.LLM:
|
||||
if (!addedLLMHeaders)
|
||||
{
|
||||
sb.Append("## ");
|
||||
sb.AppendLine(TB("Sources provided by the AI"));
|
||||
addedLLMHeaders = true;
|
||||
}
|
||||
|
||||
sb.Append($"- [{++sourceNum}] ");
|
||||
AppendMarkdownLink(sb, source.Title, source.URL);
|
||||
sb.AppendLine();
|
||||
break;
|
||||
|
||||
case SourceOrigin.TOOL:
|
||||
toolSources.Add(source);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if(toolSources.Count > 0)
|
||||
{
|
||||
if(sb.Length > 0)
|
||||
if (sb.Length > 0)
|
||||
sb.AppendLine();
|
||||
|
||||
sb.Append("## ");
|
||||
sb.AppendLine(TB("Sources used by tools"));
|
||||
sb.AppendLine(group.Heading);
|
||||
|
||||
foreach (var source in toolSources)
|
||||
foreach (var numberedSource in group.Sources)
|
||||
{
|
||||
sb.Append($"- [{++sourceNum}] ");
|
||||
AppendMarkdownLink(sb, source.Title, source.URL);
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
|
||||
if(ragSources.Count > 0)
|
||||
{
|
||||
if(sb.Length > 0)
|
||||
sb.AppendLine();
|
||||
|
||||
sb.Append("## ");
|
||||
sb.AppendLine(TB("Sources provided by the data providers"));
|
||||
|
||||
foreach (var source in ragSources)
|
||||
{
|
||||
sb.Append($"- [{++sourceNum}] ");
|
||||
AppendMarkdownLink(sb, source.Title, source.URL);
|
||||
sb.Append($"- [{numberedSource.Number}] ");
|
||||
AppendMarkdownLink(sb, numberedSource.Source.Title, numberedSource.Source.URL);
|
||||
sb.AppendLine();
|
||||
}
|
||||
}
|
||||
@ -173,6 +181,55 @@ public static partial class SourceExtensions
|
||||
return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads which document a source names, and which page of it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Only a source which names a file has such a location; a web source is opened by the browser
|
||||
/// and never asks. The page rides in the fragment of the link as `page=N`, which is what the PDF
|
||||
/// open parameters call for. A chat written before v26.9.1 carries `chunk=N` instead, which names
|
||||
/// nothing a program could be sent to: such a source keeps its document and loses only the page.
|
||||
/// </remarks>
|
||||
/// <param name="source">The source to read.</param>
|
||||
/// <param name="location">The document and its page, or the default when the source names no file.</param>
|
||||
/// <returns>Whether the source names a file.</returns>
|
||||
public static bool TryGetDocumentLocation(this ISource source, out SourceDocumentLocation location)
|
||||
{
|
||||
location = default;
|
||||
if (string.IsNullOrWhiteSpace(source.URL))
|
||||
return false;
|
||||
|
||||
var cleanedUrl = source.URL.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty);
|
||||
if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri) || !absoluteUri.IsFile)
|
||||
return false;
|
||||
|
||||
//
|
||||
// The link was made from a path of this system, so reading it back gives that path again --
|
||||
// percent-encoded spaces and umlauts included, and with the separators this system uses.
|
||||
//
|
||||
var path = absoluteUri.LocalPath;
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return false;
|
||||
|
||||
location = new(path, ReadPageFromFragment(absoluteUri.Fragment));
|
||||
return true;
|
||||
}
|
||||
|
||||
private static int? ReadPageFromFragment(string fragment)
|
||||
{
|
||||
const string PAGE_PARAMETER = "page=";
|
||||
foreach (var parameter in fragment.TrimStart('#').Split('&', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
if (!parameter.StartsWith(PAGE_PARAMETER, StringComparison.OrdinalIgnoreCase))
|
||||
continue;
|
||||
|
||||
if (int.TryParse(parameter.AsSpan(PAGE_PARAMETER.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var pageNumber) && pageNumber > 0)
|
||||
return pageNumber;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs.
|
||||
/// </summary>
|
||||
|
||||
8
app/MindWork AI Studio/Tools/SourceGroup.cs
Normal file
8
app/MindWork AI Studio/Tools/SourceGroup.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// One group of a source list: a heading and the sources below it.
|
||||
/// </summary>
|
||||
/// <param name="Heading">The heading above the group.</param>
|
||||
/// <param name="Sources">The sources of the group, in the order they are shown.</param>
|
||||
public readonly record struct SourceGroup(string Heading, IReadOnlyList<NumberedSource> Sources);
|
||||
@ -85,6 +85,134 @@ public sealed class SourceExtensionsTests
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheGroupingIsWhatTheChatAndTheExportBothRead()
|
||||
{
|
||||
// Mixed on purpose, and with two sources of one origin, so neither the order of the groups
|
||||
// nor the order inside a group can come from the order of the input:
|
||||
IList<Source> sources =
|
||||
[
|
||||
new("Handbook", "https://example.org/handbook", SourceOrigin.RAG),
|
||||
new("Search result", "https://example.org/search", SourceOrigin.TOOL),
|
||||
new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM),
|
||||
new("Second handbook", "https://example.org/handbook-2", SourceOrigin.RAG),
|
||||
];
|
||||
|
||||
var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList();
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(sources.GroupSources(), Has.Count.EqualTo(3), "Each of the three origins has a source, so each of them is a group.");
|
||||
Assert.That(listed.Select(numbered => numbered.Source.Title), Is.EqualTo(new[] { "Cited by the model", "Search result", "Handbook", "Second handbook" }), "What the AI cited comes first, then what the tools read, then what the data providers gave.");
|
||||
Assert.That(listed.Select(numbered => numbered.Number), Is.EqualTo(new[] { 1, 2, 3, 4 }), "The number runs through the whole list instead of starting over per group.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AnOriginWithoutSourcesIsNoGroup()
|
||||
{
|
||||
IList<Source> sources = [new("Search result", "https://example.org/search", SourceOrigin.TOOL)];
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(sources.GroupSources().Select(group => group.Sources.Count), Is.EqualTo(new[] { 1 }), "An answer which only used a tool gets one group, not three with two of them empty.");
|
||||
Assert.That(new List<Source>().GroupSources(), Is.Empty, "An answer nobody had to look up gets no group at all.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void TheMarkdownListsExactlyWhatTheGroupingSaysItShould()
|
||||
{
|
||||
IList<Source> sources =
|
||||
[
|
||||
new("Handbook (Page 12)", "file:///Users/someone/handbook.pdf#page=12", SourceOrigin.RAG),
|
||||
new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM),
|
||||
];
|
||||
|
||||
var entries = EntriesOf(sources.ToMarkdown());
|
||||
var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList();
|
||||
|
||||
Assert.That(entries, Has.Count.EqualTo(listed.Count), "Every source the grouping lists is written out, and nothing else is.");
|
||||
for (var index = 0; index < entries.Count; index++)
|
||||
Assert.That(entries[index], Does.StartWith($"- [{listed[index].Number}] ").And.Contains(listed[index].Source.Title), "The Markdown and the chat read the same grouping, so a source cannot be numbered one way here and another way there.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AKnownPageRidesInTheLinkOfASource()
|
||||
{
|
||||
var location = LocationOf("file:///Users/someone/My%20Documents/Gr%C3%B6%C3%9Fere%20%C3%9Cbersicht.pdf#page=12");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(location.Path, Does.EndWith("Größere Übersicht.pdf").And.Contains("My Documents"), "The percent-encoding of the link is undone, so the program is handed the name the file really has.");
|
||||
Assert.That(location.PageNumber, Is.EqualTo(12), "This is the page the passage was found on, and the page the document is opened at.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APathOfAWindowsMachineComesBackAsOne()
|
||||
{
|
||||
var location = LocationOf("file:///C:/Users/someone/Documents/handbook.pdf#page=3");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(location.Path, Is.EqualTo(@"C:\Users\someone\Documents\handbook.pdf"), "A drive letter and backslashes are what a program on Windows is handed -- and what the link was made from there.");
|
||||
Assert.That(location.PageNumber, Is.EqualTo(3));
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AChatFromBeforeThisReleaseKeepsItsDocumentAndLosesOnlyItsPage()
|
||||
{
|
||||
var location = LocationOf("file:///Users/someone/handbook.pdf#chunk=3");
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(location.Path, Does.EndWith("handbook.pdf"), "Such a source still names its document, so the click still opens it.");
|
||||
Assert.That(location.PageNumber, Is.Null, "A chunk is not a page: no program can be sent to one, so the document opens on its first page.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ALinkWithoutAFragmentNamesNoPage()
|
||||
{
|
||||
Assert.That(LocationOf("file:///Users/someone/handbook.pdf").PageNumber, Is.Null);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void APageWhichIsNoPageIsReadAsNone()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=0").PageNumber, Is.Null, "Pages are counted from one, so a zero is not a page.");
|
||||
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=-2").PageNumber, Is.Null);
|
||||
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=twelve").PageNumber, Is.Null);
|
||||
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#chunk=3&page=12").PageNumber, Is.EqualTo(12), "A link which already carried a fragment gets the page appended with an ampersand, and it is found there too.");
|
||||
});
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void AWebSourceNamesNoDocumentAtAll()
|
||||
{
|
||||
// The fragment reads like a page on purpose: what decides is the scheme, not the fragment.
|
||||
ISource source = new Source("Article", "https://example.org/article#page=12", SourceOrigin.LLM);
|
||||
|
||||
Assert.That(source.TryGetDocumentLocation(out _), Is.False, "A web source is opened by the browser and has no path to hand to a program.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads where the link of a source points, and fails the test when it points nowhere.
|
||||
/// </summary>
|
||||
/// <param name="url">The link of the source.</param>
|
||||
/// <returns>The document and the page the link names.</returns>
|
||||
private static SourceDocumentLocation LocationOf(string url)
|
||||
{
|
||||
ISource source = new Source("Handbook", url, SourceOrigin.RAG);
|
||||
|
||||
Assert.That(source.TryGetDocumentLocation(out var location), Is.True, "This link names a file, so a location is what it has.");
|
||||
return location;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the entries of a source list, without the headings above them.
|
||||
/// </summary>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user