mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-27 01:53:36 +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}'?
|
-- 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}'?"
|
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
|
-- Copy {0} to the clipboard
|
||||||
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "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.
|
-- 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."
|
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.
|
-- 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."
|
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.
|
-- 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."
|
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.
|
-- 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."
|
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.
|
-- 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."
|
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.
|
-- 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."
|
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)
|
@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>
|
</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;
|
||||||
using System.Text.RegularExpressions;
|
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 '=';
|
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>
|
/// <summary>
|
||||||
/// Converts a list of sources to a markdown-formatted string.
|
/// Converts a list of sources to a markdown-formatted string.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@ -87,65 +142,18 @@ public static partial class SourceExtensions
|
|||||||
public static string ToMarkdown(this IList<Source> sources)
|
public static string ToMarkdown(this IList<Source> sources)
|
||||||
{
|
{
|
||||||
var sb = new StringBuilder();
|
var sb = new StringBuilder();
|
||||||
var ragSources = new List<ISource>();
|
foreach (var group in sources.GroupSources())
|
||||||
var toolSources = new List<ISource>();
|
|
||||||
var sourceNum = 0;
|
|
||||||
var addedLLMHeaders = false;
|
|
||||||
foreach (var source in sources)
|
|
||||||
{
|
{
|
||||||
switch (source.Origin)
|
if (sb.Length > 0)
|
||||||
{
|
|
||||||
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)
|
|
||||||
sb.AppendLine();
|
sb.AppendLine();
|
||||||
|
|
||||||
sb.Append("## ");
|
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}] ");
|
sb.Append($"- [{numberedSource.Number}] ");
|
||||||
AppendMarkdownLink(sb, source.Title, source.URL);
|
AppendMarkdownLink(sb, numberedSource.Source.Title, numberedSource.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.AppendLine();
|
sb.AppendLine();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -173,6 +181,55 @@ public static partial class SourceExtensions
|
|||||||
return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}";
|
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>
|
/// <summary>
|
||||||
/// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs.
|
/// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs.
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
/// Reads the entries of a source list, without the headings above them.
|
/// Reads the entries of a source list, without the headings above them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user