Let the sources of your own documents open at the page they were found on (#974)
Build and Release / Collect Flatpak artifacts (push) Blocked by required conditions
Build and Release / Verify (push) Waiting to run
Build and Release / Determine run mode (push) Waiting to run
Build and Release / Read metadata (push) Blocked by required conditions
Build and Release / Sync Flatpak repo (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-apple-darwin, osx-arm64, macos-latest, aarch64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-pc-windows-msvc.exe, win-arm64, windows-latest, aarch64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-aarch64-unknown-linux-gnu, linux-arm64, ubuntu-22.04-arm, aarch64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-apple-darwin, osx-x64, macos-latest, x86_64-apple-darwin, dmg,app,updater, dmg) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-pc-windows-msvc.exe, win-x64, windows-latest, x86_64-pc-windows-msvc, nsis,updater, nsis) (push) Blocked by required conditions
Build and Release / Build app (${{ matrix.dotnet_runtime }}) (-x86_64-unknown-linux-gnu, linux-x64, ubuntu-22.04, x86_64-unknown-linux-gnu, appimage,updater, appimage) (push) Blocked by required conditions
Build and Release / Prepare & create release (push) Blocked by required conditions
Build and Release / Publish release (push) Blocked by required conditions

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-09-15 19:11:39 +02:00
1 parent 6ce7d856a3
commit 1ebe8eb2a4
37 files changed
+1831 -120

No files matched your search

@@ -5029,6 +5029,27 @@ 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."
-- 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 this document in the program your system uses for it
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3169582185"] = "Opens this document in the program your system uses for it"
-- 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 +12220,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 +12250,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 +12271,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."
@@ -48,7 +48,7 @@
{
<MudTooltip Text="@T("Number of sources")" Placement="Placement.Bottom">
<MudBadge Content="@this.Content.Sources.Count" Color="Color.Primary" Overlap="true" BadgeClass="sources-card-header">
<MudIconButton Icon="@Icons.Material.Filled.Link"/>
<MudIconButton Icon="@Icons.Material.Filled.Link" Disabled="@(!this.HasSourcesToShow)" OnClick="@this.ShowSources"/>
</MudBadge>
</MudTooltip>
}
@@ -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 @ref="this.sourcesList" Sources="@textContent.Sources"/>
}
</div>
}
@@ -123,6 +123,7 @@ public partial class ContentBlockComponent : MSGComponentBase
private IReadOnlyList<MessageTable> cachedMessageTables = [];
private char csvSeparator = ',';
private ElementReference mathContentContainer;
private SourcesList? sourcesList;
private string lastMathRenderSignature = string.Empty;
private bool hasActiveMathContainer;
private bool isDisposed;
@@ -815,6 +816,25 @@ public partial class ContentBlockComponent : MSGComponentBase
this.Content.FileAttachments = [.. result];
}
/// <summary>
/// Whether the sources of this block stand below the answer, where the counter can take the reader.
/// </summary>
/// <remarks>
/// The same condition the block itself renders the list under. While an answer is still coming
/// in, its sources may already be known, but there is nothing on the page yet to scroll to --
/// so the counter says it cannot do anything rather than doing nothing when clicked.
/// </remarks>
private bool HasSourcesToShow => this.Content is { InitialRemoteWait: false, IsStreaming: false, Sources.Count: > 0 };
/// <summary>
/// Takes the reader from the source counter down to the sources themselves.
/// </summary>
private async Task ShowSources()
{
if (this.sourcesList is not null)
await this.sourcesList.ScrollIntoViewAsync();
}
protected override async ValueTask DisposeResourcesAsync()
{
if (this.isDisposed)
@@ -55,8 +55,11 @@ public static class IContentExtensions
/// </remarks>
/// <param name="content">The content to read.</param>
/// <param name="markdown">The Markdown text including its sources, or an empty string when there is none.</param>
/// <param name="keepPageAnchors">Whether a link into a local file may name its page. Only a
/// format whose reader stumbles over such a link says no here; the clipboard and every text
/// format keep the page.</param>
/// <returns>True, when this content carries Markdown text.</returns>
public static bool TryGetExportMarkdown(this IContent content, out string markdown)
public static bool TryGetExportMarkdown(this IContent content, out string markdown, bool keepPageAnchors = true)
{
if (content is not ContentText text)
{
@@ -65,7 +68,7 @@ public static class IContentExtensions
}
var answer = text.Text.Trim();
var sources = text.Sources.ToExportMarkdown();
var sources = text.Sources.ToExportMarkdown(keepPageAnchors);
if (sources.Length == 0)
{
markdown = answer;
@@ -0,0 +1,39 @@
@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 @ref="this.listElement" class="mud-markdown-body">
@foreach (var group in this.groups)
{
@* A level-two heading was shown as h5 while this list was Markdown, because that is what
Markdown.DefaultConfig overrides it to. The heading keeps that size here. *@
<MudText Typo="Typo.h5">
@group.Heading
</MudText>
<ul>
@foreach (var entry in group.Entries)
{
<li>
@($"[{entry.Number}] ")
@if (entry.Document is { } document)
{
<MudTooltip Text="@T("Opens this document in the program your system uses for it")" 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>
@@ -0,0 +1,164 @@
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
{
//
// 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";
/// <summary>
/// The sources to show.
/// </summary>
[Parameter]
public IList<Source> Sources { get; set; } = [];
[Inject]
private RustService RustService { get; init; } = null!;
[Inject]
private IJSRuntime JsRuntime { get; init; } = null!;
[Inject]
private ILogger<SourcesList> Logger { get; init; } = null!;
private readonly List<SourceEntryGroup> groups = [];
private ElementReference listElement;
/// <summary>
/// Brings this list into view.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
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
/// <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));
}
}
/// <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);
}
@@ -5031,6 +5031,27 @@ 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"] = "Möchten Sie den Anbieter für Transkriptionen „{0}“ wirklich löschen?"
-- Could not open the file location.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1118835751"] = "Der Speicherort der Datei konnte nicht geöffnet werden."
-- Could not open the file location: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1455637941"] = "Der Speicherort der Datei konnte nicht geöffnet werden: {0}"
-- Show this file in the file manager of your system
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T1587653504"] = "Diese Datei im Dateimanager Ihres Systems anzeigen"
-- Opens this document in the program your system uses for it
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3169582185"] = "Öffnet dieses Dokument in dem Programm, das Ihr System dafür verwendet."
-- Unknown error
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3461425987"] = "Unbekannter Fehler"
-- Could not open the document.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3570758363"] = "Das Dokument konnte nicht geöffnet werden."
-- Could not open the document: {0}
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T945417289"] = "Dokument konnte nicht geöffnet werden: {0}"
-- Copy {0} to the clipboard
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::TEXTINFOLINE::T2206391442"] = "Kopiere {0} in die Zwischenablage"
@@ -12201,6 +12222,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1238078807"] = "Es ist
-- Failed to store the API key due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1704298921"] = "Fehler beim Speichern des API-Schlüssels aufgrund eines API-Problems."
-- The runtime document endpoint returned '{0}'.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T1843760475"] = "Der Endpunkt des Laufzeitdokuments gab „{0}“ zurück."
-- The global shortcut could not be registered because of a desktop integration error.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T2032590244"] = "Die globale Tastenkombination konnte aufgrund eines Fehlers bei der Desktop-Integration nicht registriert werden."
@@ -12228,6 +12252,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3351807428"] = "Der Te
-- The desktop service returned an invalid response while registering the global shortcut.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3369097283"] = "Der Desktop-Dienst hat beim Registrieren des globalen Tastaturkürzels eine ungültige Antwort zurückgegeben."
-- The runtime document endpoint failed without details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T353458993"] = "Der Endpunkt für das Laufzeitdokument ist ohne weitere Details fehlgeschlagen."
-- 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 konnte nicht auf den sicheren Speicher zugreifen, da keine Standardsammlung konfiguriert ist. Öffnen Sie einen kompatiblen Passwortmanager, erstellen Sie eine Sammlung oder wählen Sie eine aus, entsperren sie und legen Sie diese als Standard fest."
@@ -12246,6 +12273,9 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T3929880252"] = "Es wur
-- Failed to get the secret data due to an API issue.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T4007657575"] = "Abrufen der geheimen Daten aufgrund eines API-Problems fehlgeschlagen."
-- The runtime document endpoint is not available.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T541638186"] = "Der Laufzeit-Dokumentendpunkt ist nicht verfügbar."
-- AI Studio could not access secure storage. See the log for technical details.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::SERVICES::RUSTSERVICE::T624023541"] = "AI Studio konnte nicht auf den sicheren Speicher zugreifen. Technische Details finden Sie im Protokoll."
@@ -5031,6 +5031,27 @@ 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."
-- 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 this document in the program your system uses for it
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::SOURCESLIST::T3169582185"] = "Opens this document in the program your system uses for it"
-- 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"
@@ -12201,6 +12222,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."
@@ -12228,6 +12252,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."
@@ -12246,6 +12273,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."
@@ -1,17 +1,21 @@
namespace AIStudio.Tools;
/// <summary>
/// Content which a reader held back, together with the token count of exactly that content.
/// Content which a reader held back, together with the token count and the page of exactly that
/// content.
/// </summary>
/// <remarks>
/// Readers which assemble a page or a slide from several stream events cannot pass their content
/// on right away. Its token count has to travel with it: the count describes the content, not the
/// event which happened to arrive at the moment the content was released. Keeping the two together
/// is what stops a page from being sized by the text of the page after it.
/// is what stops a page from being sized by the text of the page after it. The page number travels
/// for the very same reason, and because a number the runtime already stated must not be derived
/// from the text again further down the line.
/// </remarks>
/// <param name="Content">The assembled content.</param>
/// <param name="TokenCount">The number of tokens of that content, or null when it is unknown.</param>
public readonly record struct ContentStreamPendingContent(string Content, int? TokenCount)
/// <param name="PageNumber">The page that content came from, or null when it has none.</param>
public readonly record struct ContentStreamPendingContent(string Content, int? TokenCount, int? PageNumber = null)
{
/// <summary>
/// Adds up two token counts, where an unknown count makes the sum unknown as well.
@@ -12,7 +12,8 @@ namespace AIStudio.Tools;
/// <param name="Error">The reported failure, or null when the event was processed successfully.</param>
/// <param name="PromptInjection">What the runtime filtered out of the content, or null when it filtered nothing.</param>
/// <param name="TokenCount">The number of tokens of the content, or null when it is unknown.</param>
public readonly record struct ContentStreamProcessedEvent(string? Content, ContentStreamErrorDetails? Error, ContentStreamPromptInjectionDetails? PromptInjection = null, int? TokenCount = null)
/// <param name="PageNumber">The page the content came from, or null when it has none.</param>
public readonly record struct ContentStreamProcessedEvent(string? Content, ContentStreamErrorDetails? Error, ContentStreamPromptInjectionDetails? PromptInjection = null, int? TokenCount = null, int? PageNumber = null)
{
/// <summary>
/// An event which neither produced content nor reported a failure.
@@ -20,16 +21,18 @@ public readonly record struct ContentStreamProcessedEvent(string? Content, Conte
public static readonly ContentStreamProcessedEvent NOTHING = new(null, null);
/// <summary>
/// An event which produced content, with the token count of that very content.
/// An event which produced content, with the token count and the page of that very content.
/// </summary>
/// <remarks>
/// The count travels with the content because a reader may hold content back across several
/// events: pairing it with the count of the event which released it would size it by the
/// wrong text.
/// wrong text. The page travels along for the same reason, and so that whoever indexes the
/// content is told where it came from instead of having to read it back out of the text.
/// </remarks>
/// <param name="content">The content to append.</param>
/// <param name="tokenCount">The number of tokens of that content, or null when it is unknown.</param>
public static ContentStreamProcessedEvent FromContent(string? content, int? tokenCount = null) => new(content, null, TokenCount: tokenCount);
/// <param name="pageNumber">The page that content came from, or null when it has none.</param>
public static ContentStreamProcessedEvent FromContent(string? content, int? tokenCount = null, int? pageNumber = null) => new(content, null, TokenCount: tokenCount, PageNumber: pageNumber);
public static ContentStreamProcessedEvent FromError(ContentStreamErrorDetails? error) => new(null, error);
@@ -19,13 +19,19 @@ public static class ContentStreamSseHandler
case ContentStreamTextMetadata:
return ContentStreamProcessedEvent.FromContent(sseEvent.Content, sseEvent.TokenCount);
//
// The heading tells the AI which page it is reading. The number is handed on
// separately as well, because whoever indexes this content needs it as a
// number: reading it back out of the heading would mean guessing at something
// the runtime already stated.
//
case ContentStreamPdfMetadata pdfMetadata:
var pageNumber = pdfMetadata.Pdf?.PageNumber ?? 0;
return ContentStreamProcessedEvent.FromContent($"""
# Page {pageNumber}
{sseEvent.Content}
""", sseEvent.TokenCount);
""", sseEvent.TokenCount, pageNumber > 0 ? pageNumber : null);
case ContentStreamSpreadsheetMetadata spreadsheetMetadata:
var sheetName = spreadsheetMetadata.Spreadsheet?.SheetName;
@@ -45,9 +51,10 @@ public static class ContentStreamSseHandler
// a page can follow its Markdown. Documents converted as a whole, e.g. by Pandoc,
// carry no page number and are passed on unchanged.
//
// The buffering is why the count comes back from the reader rather than from
// this event: the page which is released here arrived one event ago, and this
// event's count belongs to the page which is now being buffered.
// The buffering is why the count and the page come back from the reader rather
// than from this event: the page which is released here arrived one event ago,
// and this event's count and number belong to the page which is now being
// buffered.
//
case ContentStreamDocumentMetadata documentMetadata:
if (documentMetadata.Document?.PageNumber is not > 0)
@@ -55,7 +62,7 @@ public static class ContentStreamSseHandler
var documentManager = DOCUMENT_MANAGERS.GetOrAdd(sseEvent.StreamId!, _ => new());
var documentContent = documentManager.AddPage(documentMetadata, sseEvent.Content, sseEvent.TokenCount, extractImages);
return documentContent is null ? ContentStreamProcessedEvent.NOTHING : ContentStreamProcessedEvent.FromContent(documentContent.Value.Content, documentContent.Value.TokenCount);
return documentContent is null ? ContentStreamProcessedEvent.NOTHING : ContentStreamProcessedEvent.FromContent(documentContent.Value.Content, documentContent.Value.TokenCount, documentContent.Value.PageNumber);
case ContentStreamImageMetadata:
return ContentStreamProcessedEvent.FromContent(sseEvent.Content, sseEvent.TokenCount);
@@ -184,7 +191,9 @@ public static class ContentStreamSseHandler
/// <remarks>
/// The readers which assemble pages or slides always keep the last one of them: nothing tells
/// them that no further image is coming. It is released here, and it carries its own token
/// count, because a chunk without one cannot be sized by the caller.
/// count, because a chunk without one cannot be sized by the caller. Only the page reader
/// states a page; a stream is read by one of them, so there is no second number to weigh
/// against.
/// </remarks>
/// <param name="streamId">The stream to release and forget.</param>
/// <returns>The content which was held back, or null when there was none.</returns>
@@ -195,6 +204,7 @@ public static class ContentStreamSseHandler
var finalContentChunk = new StringBuilder();
int? tokenCount = 0;
int? pageNumber = null;
if(SLIDE_MANAGERS.TryGetValue(streamId, out var slideManager)
&& slideManager.GetAllSlidesInOrder() is { } slides
&& !string.IsNullOrWhiteSpace(slides.Content))
@@ -209,6 +219,7 @@ public static class ContentStreamSseHandler
{
finalContentChunk.Append(page.Content);
tokenCount = ContentStreamPendingContent.AddTokenCounts(tokenCount, page.TokenCount);
pageNumber = page.PageNumber;
}
SLIDE_MANAGERS.TryRemove(streamId, out _);
@@ -217,6 +228,6 @@ public static class ContentStreamSseHandler
foreach (var key in CHUNKED_IMAGES.Keys.Where(k => k.StartsWith(imageIdPrefix, StringComparison.InvariantCultureIgnoreCase)))
CHUNKED_IMAGES.TryRemove(key, out _);
return finalContentChunk.Length > 0 ? new ContentStreamPendingContent(finalContentChunk.ToString(), tokenCount) : null;
return finalContentChunk.Length > 0 ? new ContentStreamPendingContent(finalContentChunk.ToString(), tokenCount, pageNumber) : null;
}
}
@@ -10,6 +10,7 @@ public sealed class DocumentManager
{
private StringBuilder? currentPageContent;
private int? currentPageTokenCount;
private int? currentPageNumber;
public ContentStreamPendingContent? AddPage(ContentStreamDocumentMetadata metadata, string? content, int? tokenCount, bool extractImages)
{
@@ -36,9 +37,12 @@ public sealed class DocumentManager
//
// The count waits here together with the page it belongs to. Handing it out along with
// the page we just completed would size that page by the text of this one.
// the page we just completed would size that page by the text of this one. The page
// number waits for the same reason: it belongs to the page being buffered, not to the
// one leaving here.
//
this.currentPageTokenCount = tokenCount;
this.currentPageNumber = pageNumber;
return completedPage;
}
@@ -72,8 +76,10 @@ public sealed class DocumentManager
var result = this.currentPageContent.ToString();
var tokenCount = this.currentPageTokenCount;
var pageNumber = this.currentPageNumber;
this.currentPageContent = null;
this.currentPageTokenCount = null;
return string.IsNullOrWhiteSpace(result) ? null : new ContentStreamPendingContent(result, tokenCount);
this.currentPageNumber = null;
return string.IsNullOrWhiteSpace(result) ? null : new ContentStreamPendingContent(result, tokenCount, pageNumber);
}
}
@@ -204,6 +204,26 @@ public static class FileExportFormatExtensions
_ => WITHOUT_BYTE_ORDER_MARK,
};
/// <summary>
/// Determines whether a link into a local file may name the page it points at.
/// </summary>
/// <remarks>
/// A page is named by the fragment of the link, the way the PDF open parameters call for. A
/// browser and a PDF reader follow that and open the document on the page; Word and LibreOffice
/// take the fragment for part of the file name, look for a file which does not exist, and refuse
/// the link altogether. There the page is dropped, so the link at least opens the document --
/// which page it was stays in the title of the source. Verified on 2026-09-15 with LibreOffice
/// on an exported .odt. A format added later keeps the page unless it is known to stumble too.
/// </remarks>
/// <param name="format">The format.</param>
/// <returns>True, when a reader of this format follows such a link.</returns>
public static bool FollowsPageAnchors(this FileExportFormat format) => format switch
{
FileExportFormat.MICROSOFT_WORD or FileExportFormat.OPEN_DOCUMENT_TEXT => false,
_ => true,
};
/// <summary>
/// Returns the name Pandoc knows the format by.
/// </summary>
@@ -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);
+1 -1
View File
@@ -118,7 +118,7 @@ public static class PandocExport
// We read the text before we ask for a path: when there is nothing to convert, the user
// should learn that right away instead of picking a file first and getting an error afterwards.
//
if (!markdownContent.TryGetExportMarkdown(out var markdownText))
if (!markdownContent.TryGetExportMarkdown(out var markdownText, format.FollowsPageAnchors()))
{
LOGGER.LogWarning("Cannot export the content as {ExportFormat}, because it carries no text.", format);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Only text messages can be exported.")));
@@ -8,7 +8,36 @@ namespace AIStudio.Tools.RAG;
public static class IRetrievalContextExtensions
{
private static readonly ILogger<IRetrievalContext> LOGGER = Program.LOGGER_FACTORY.CreateLogger<IRetrievalContext>();
/// <summary>
/// Writes what the AI is told about a retrieval context, before its content follows.
/// </summary>
/// <remarks>
/// The location is what lets the AI say where an answer comes from. Naming only the file is
/// not enough in a document of two hundred pages, and we know the page: it travels from the
/// runtime through the index into the context. A slide or a sheet has no page, and then
/// nothing is claimed rather than something made up.
/// </remarks>
/// <param name="contextBuilder">The builder to write into.</param>
/// <param name="retrievalContext">The context to describe.</param>
internal static void AppendContextDescription(StringBuilder contextBuilder, IRetrievalContext retrievalContext)
{
contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}");
contextBuilder.AppendLine($"Content category: {retrievalContext.Category}");
contextBuilder.AppendLine($"Content type: {retrievalContext.Type}");
contextBuilder.AppendLine($"Content path: {retrievalContext.Path}");
if(retrievalContext is RetrievalTextContext { PageNumber: > 0 } locatedContext)
contextBuilder.AppendLine($"Content location: page {locatedContext.PageNumber}");
if(retrievalContext.Links.Count is 0)
return;
contextBuilder.AppendLine("Additional links:");
foreach(var link in retrievalContext.Links)
contextBuilder.AppendLine($"- {link}");
}
public static async Task<string> AsMarkdown(this IReadOnlyList<IRetrievalContext> retrievalContexts, StringBuilder? sb = null, CancellationToken token = default)
{
sb ??= new StringBuilder();
@@ -49,17 +78,7 @@ public static class IRetrievalContextExtensions
break;
}
contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}");
contextBuilder.AppendLine($"Content category: {retrievalContext.Category}");
contextBuilder.AppendLine($"Content type: {retrievalContext.Type}");
contextBuilder.AppendLine($"Content path: {retrievalContext.Path}");
if(retrievalContext.Links.Count > 0)
{
contextBuilder.AppendLine("Additional links:");
foreach(var link in retrievalContext.Links)
contextBuilder.AppendLine($"- {link}");
}
AppendContextDescription(contextBuilder, retrievalContext);
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
var source = PromptInjectionSource.RetrievalContext(retrievalContext.DataSourceName, retrievalContext.Path);
@@ -50,4 +50,14 @@ public sealed class RetrievalTextContext : IRetrievalContext
/// Optional link used when this context is displayed as a source reference.
/// </summary>
public string ReferenceLink { get; init; } = string.Empty;
/// <summary>
/// The page this passage was found on, or null when it has none.
/// </summary>
/// <remarks>
/// Kept as a number rather than only inside the reference title: the AI is told the page so it
/// can say where an answer comes from, and a source has to name a page a program can be sent
/// to. A slide or a sheet has no page and leaves this empty.
/// </remarks>
public int? PageNumber { get; init; }
}
@@ -0,0 +1,8 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Asks the runtime to open a document in the program the system uses for it.
/// </summary>
/// <param name="Path">The document to open.</param>
/// <param name="Page">The page to show, counted from one, or null when the document has none.</param>
public readonly record struct OpenDocumentRequest(string Path, int? Page);
@@ -0,0 +1,14 @@
namespace AIStudio.Tools.Rust;
/// <summary>
/// Says how opening a document went.
/// </summary>
/// <param name="Success">Whether the document was opened at all.</param>
/// <param name="PageApplied">
/// Whether the document was handed to its program together with the page. False means it opens on
/// its first page: no page was asked for, the system uses a program which cannot be told one, or
/// starting that program failed. None of these is an error, so this belongs in the log rather than
/// in front of the user, who is told the page by the source itself.
/// </param>
/// <param name="Issue">Why the document could not be opened, or an empty text when it was.</param>
public readonly record struct OpenDocumentResponse(bool Success, bool PageApplied, string Issue);
@@ -1,3 +1,9 @@
namespace AIStudio.Tools.Services;
public sealed record ArbitraryFileDataSegment(string Content, int TokenCount);
/// <summary>
/// One piece of an extracted file, as the runtime delivered it.
/// </summary>
/// <param name="Content">The extracted text.</param>
/// <param name="TokenCount">The number of tokens of that text.</param>
/// <param name="PageNumber">The page that text came from, or null when it has none. Presentations and spreadsheets have none.</param>
public sealed record ArbitraryFileDataSegment(string Content, int TokenCount, int? PageNumber);
@@ -16,6 +16,22 @@ public sealed partial class DataSourceEmbeddingService
internal const int DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH = 300;
private const bool IMAGE_EMBEDDING_ENABLED = false;
/// <summary>
/// What this build writes next to a chunk besides its text. Raise it whenever that changes.
/// </summary>
/// <remarks>
/// A stored chunk keeps the metadata of the run which wrote it, and nothing recomputes it: the
/// fingerprint of a file says whether the file changed, not whether we got better at reading
/// it. Raising this number makes the embedding signature differ, which drops the index and
/// builds it again — the only way corrected page numbers reach a data source somebody indexed
/// earlier.
///
/// Version 2: the page of a chunk is taken from the runtime metadata instead of being read back
/// out of the chunk text, which is what left Word and OpenDocument files, and passages
/// continuing across a page break, without a page.
/// </remarks>
private const string CHUNK_METADATA_VERSION = "2";
private enum RagFileIndexingDecision
{
INDEXABLE,
@@ -23,10 +39,23 @@ public sealed partial class DataSourceEmbeddingService
UNSUPPORTED,
}
private sealed record ExtractedFileSegment(string Text, int? TokenCount);
private sealed record ExtractedFileSegment(string Text, int? TokenCount, int? PageNumber);
private sealed record ExtractedFileContent(string Text, IReadOnlyList<ExtractedFileSegment> SourceSegments);
/// <summary>
/// One chunk as the chunking produced it, together with the page it starts on.
/// </summary>
/// <remarks>
/// The page is carried rather than read back out of the chunk text. The runtime states it, and
/// the chunking knows which source segment a chunk begins in, so nothing has to be derived from
/// a marker in the text — which is what used to leave Word files and continued passages without
/// a page.
/// </remarks>
/// <param name="Text">The chunk itself, overlap prefix included.</param>
/// <param name="PageNumber">The page the chunk's own content starts on, or null when it has none.</param>
private sealed record EmbeddingChunk(string Text, int? PageNumber);
private sealed record EmbeddingChunkDraft(string ChunkId, string Text, int ChunkIndex, int? PageNumber);
private sealed record ChunkingOptions(int MaxChunkTokenLength, int OverlapTokenLength);
@@ -37,7 +66,7 @@ public sealed partial class DataSourceEmbeddingService
private sealed record DataSourceMetadataSnapshot(string SourceHash, IReadOnlyDictionary<string, string> FileHashes);
private async IAsyncEnumerable<string> StreamEmbeddingChunksAsync(string filePath, IDataSource dataSource, EmbeddingProvider embeddingProvider, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
private async IAsyncEnumerable<EmbeddingChunk> StreamEmbeddingChunksAsync(string filePath, IDataSource dataSource, EmbeddingProvider embeddingProvider, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
{
var options = this.GetChunkingOptions(dataSource, embeddingProvider);
var strategy = this.GetChunkingStrategy(filePath);
@@ -55,26 +84,31 @@ public sealed partial class DataSourceEmbeddingService
{
var normalized = NormalizeChunkSegment(segment.Content);
if (!string.IsNullOrWhiteSpace(normalized))
segments.Add(new(normalized, segment.TokenCount));
segments.Add(new(normalized, segment.TokenCount, segment.PageNumber));
}
return new(string.Join("\n", segments.Select(segment => segment.Text)).Trim(), segments);
}
private async IAsyncEnumerable<string> SplitByChunkingStrategyAsync(ExtractedFileContent content, ChunkingStrategy strategy, ChunkingOptions options, EmbeddingProvider embeddingProvider, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
private async IAsyncEnumerable<EmbeddingChunk> SplitByChunkingStrategyAsync(ExtractedFileContent content, ChunkingStrategy strategy, ChunkingOptions options, EmbeddingProvider embeddingProvider, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token)
{
var estimatedTokenCount = SumTokenCounts(content.SourceSegments);
await foreach (var chunk in this.SplitTextByRulesAsync(content.Text, content.SourceSegments, strategy, 0, options, embeddingProvider, token, estimatedTokenCount: estimatedTokenCount))
// The whole text starts where the first segment starts, so that is the page it is on until
// the splitting reaches a segment boundary:
var firstPageNumber = content.SourceSegments.Count > 0 ? content.SourceSegments[0].PageNumber : null;
await foreach (var chunk in this.SplitTextByRulesAsync(content.Text, content.SourceSegments, strategy, 0, options, embeddingProvider, firstPageNumber, token, estimatedTokenCount: estimatedTokenCount))
yield return chunk;
}
private async IAsyncEnumerable<string> SplitTextByRulesAsync(
private async IAsyncEnumerable<EmbeddingChunk> SplitTextByRulesAsync(
string text,
IReadOnlyList<ExtractedFileSegment> sourceSegments,
ChunkingStrategy strategy,
int ruleIndex,
ChunkingOptions options,
EmbeddingProvider embeddingProvider,
int? currentPageNumber,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token,
string requiredOverlapPrefix = "",
int? estimatedTokenCount = null)
@@ -91,14 +125,14 @@ public sealed partial class DataSourceEmbeddingService
tokenCount = await this.GetEmbeddingTokenCountAsync(embeddingProvider, textWithOverlap, token);
if (tokenCount <= options.MaxChunkTokenLength)
{
yield return textWithOverlap;
yield return new(textWithOverlap, currentPageNumber);
yield break;
}
}
if (ruleIndex >= strategy.Rules.Count)
{
await foreach (var hardChunk in this.SplitTextByHardCutAsync(text, options, embeddingProvider, token, requiredOverlapPrefix, estimatedTokenCount))
await foreach (var hardChunk in this.SplitTextByHardCutAsync(text, options, embeddingProvider, currentPageNumber, token, requiredOverlapPrefix, estimatedTokenCount))
yield return hardChunk;
yield break;
@@ -107,7 +141,7 @@ public sealed partial class DataSourceEmbeddingService
var rule = strategy.Rules[ruleIndex];
if (rule.Split is null)
{
await foreach (var hardChunk in this.SplitTextByHardCutAsync(text, options, embeddingProvider, token, requiredOverlapPrefix, estimatedTokenCount))
await foreach (var hardChunk in this.SplitTextByHardCutAsync(text, options, embeddingProvider, currentPageNumber, token, requiredOverlapPrefix, estimatedTokenCount))
yield return hardChunk;
yield break;
@@ -116,7 +150,7 @@ public sealed partial class DataSourceEmbeddingService
var units = NormalizeSplitUnits(rule.Split(text, sourceSegments.Select(segment => segment.Text).ToList()), text);
if (units.Count <= 1)
{
await foreach (var chunk in this.SplitTextByRulesAsync(text, sourceSegments, strategy, ruleIndex + 1, options, embeddingProvider, token, requiredOverlapPrefix, estimatedTokenCount))
await foreach (var chunk in this.SplitTextByRulesAsync(text, sourceSegments, strategy, ruleIndex + 1, options, embeddingProvider, currentPageNumber, token, requiredOverlapPrefix, estimatedTokenCount))
yield return chunk;
yield break;
@@ -135,6 +169,15 @@ public sealed partial class DataSourceEmbeddingService
var overlapPrefix = requiredOverlapPrefix;
var unitTokenCounts = EstimateSplitUnitTokenCounts(units, sourceSegments, rule.UsesSourceSegmentCounts, estimatedTokenCount);
//
// The first rule of every strategy cuts along the segments the runtime delivered, so there
// a unit is a segment and carries that segment's page. Every later rule cuts inside a
// single segment, where all units share the page they were handed. This is what ties a
// chunk to a page without anybody reading the text.
//
var unitsAreSourceSegments = rule.UsesSourceSegmentCounts && sourceSegments.Count == units.Count;
int? PageOfUnit(int unitIndex) => unitsAreSourceSegments ? sourceSegments[unitIndex].PageNumber ?? currentPageNumber : currentPageNumber;
while (index < units.Count)
{
token.ThrowIfCancellationRequested();
@@ -145,8 +188,14 @@ public sealed partial class DataSourceEmbeddingService
var rawChunk = string.Concat(units.Skip(index).Take(unitCount)).Trim();
var chunk = AddOverlapPrefix(rawChunk, overlapPrefix);
overlapPrefix = string.Empty;
//
// The page of the first unit this chunk covers, not of the overlap prefix in front
// of it: the prefix repeats what the chunk before already said, while the page has
// to name where this chunk's own content begins.
//
if (!string.IsNullOrWhiteSpace(chunk))
yield return chunk;
yield return new(chunk, PageOfUnit(index));
var nextIndex = index + unitCount;
if (nextIndex >= units.Count)
@@ -178,9 +227,10 @@ public sealed partial class DataSourceEmbeddingService
string? lastSplitUnit = null;
var unitTokenCount = unitTokenCounts?[index];
await foreach (var splitUnit in this.SplitTextByRulesAsync(units[index], [new(units[index], unitTokenCount)], strategy, ruleIndex + 1, options, embeddingProvider, token, overlapPrefix, unitTokenCount))
var unitPageNumber = PageOfUnit(index);
await foreach (var splitUnit in this.SplitTextByRulesAsync(units[index], [new(units[index], unitTokenCount, unitPageNumber)], strategy, ruleIndex + 1, options, embeddingProvider, unitPageNumber, token, overlapPrefix, unitTokenCount))
{
lastSplitUnit = splitUnit;
lastSplitUnit = splitUnit.Text;
yield return splitUnit;
}
@@ -372,10 +422,15 @@ public sealed partial class DataSourceEmbeddingService
return bestStartIndex <= chunkStartIndex ? chunkEndIndex : bestStartIndex;
}
private async IAsyncEnumerable<string> SplitTextByHardCutAsync(
/// <remarks>
/// The hard cut is only ever reached inside a single piece of text which no rule could split
/// any further, so every chunk it produces sits on the page that piece was handed.
/// </remarks>
private async IAsyncEnumerable<EmbeddingChunk> SplitTextByHardCutAsync(
string text,
ChunkingOptions options,
EmbeddingProvider embeddingProvider,
int? currentPageNumber,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken token,
string requiredOverlapPrefix = "",
int? estimatedTokenCount = null)
@@ -455,7 +510,7 @@ public sealed partial class DataSourceEmbeddingService
var chunk = AddOverlapPrefix(text[startIndex..bestEndIndex].Trim(), overlapPrefix);
if (!string.IsNullOrWhiteSpace(chunk))
yield return chunk;
yield return new(chunk, currentPageNumber);
if (bestEndIndex >= text.Length)
yield break;
@@ -934,6 +989,7 @@ public sealed partial class DataSourceEmbeddingService
private string BuildEmbeddingSignature(IDataSource dataSource, EmbeddingProvider embeddingProvider, ChunkingOptions chunkingOptions)
{
return string.Join('|',
CHUNK_METADATA_VERSION,
embeddingProvider.Id,
embeddingProvider.UsedLLMProvider,
embeddingProvider.Model.Id,
@@ -1086,14 +1142,6 @@ public sealed partial class DataSourceEmbeddingService
return string.IsNullOrWhiteSpace(extension) ? "unknown" : extension;
}
private static int? TryExtractPageNumber(string chunk)
{
var match = Regex.Match(chunk, @"^\s*#\s+Page\s+(\d+)\b", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase);
return match.Success && int.TryParse(match.Groups[1].Value, out var pageNumber) && pageNumber > 0
? pageNumber
: null;
}
private string CreatePointId(string dataSourceId, string fingerprint, int chunkIndex) =>
CreateStableGuid($"{dataSourceId}:chunk:{fingerprint}:{chunkIndex}");
@@ -843,7 +843,7 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
await foreach (var chunk in this.StreamEmbeddingChunksAsync(file.FullName, dataSource, embeddingProvider, token))
{
batch.Add(new(this.CreatePointId(dataSource.Id, fingerprint, totalChunkCount), chunk, totalChunkCount, TryExtractPageNumber(chunk)));
batch.Add(new(this.CreatePointId(dataSource.Id, fingerprint, totalChunkCount), chunk.Text, totalChunkCount, chunk.PageNumber));
totalChunkCount++;
if (batch.Count >= embeddingBatchSize)
@@ -395,6 +395,7 @@ public sealed class DataSourceLocalRetrievalService(
SurroundingContent = [],
ReferenceTitle = BuildReferenceTitle(hit),
ReferenceLink = referenceLink,
PageNumber = hit.PageNumber is > 0 ? hit.PageNumber : null,
};
}
@@ -413,11 +414,19 @@ public sealed class DataSourceLocalRetrievalService(
return $"{sourceName} ({location})";
}
/// <remarks>
/// A known page is written as the fragment `#page=N`, which is what the PDF open parameters
/// call for: a program which understands them opens the document where the passage is. Without
/// a page there is nothing to send a program to, and the chunk stays in the link so the
/// reference still points at something.
/// </remarks>
private static string BuildReferenceLink(string path, LocalRetrievalHit hit)
{
var link = NormalizeLocalReferencePath(path);
var separator = link.Contains('#', StringComparison.Ordinal) ? "&" : "#";
return $"{link}{separator}chunk={hit.ChunkIndex}";
return hit.PageNumber is > 0
? $"{link}{separator}page={hit.PageNumber}"
: $"{link}{separator}chunk={hit.ChunkIndex}";
}
private static string NormalizeLocalReferencePath(string path)
@@ -168,4 +168,66 @@ public sealed partial class RustService
result.Dispose();
}
}
/// <summary>
/// Opens a document in the program the system uses for it, on the given page where possible.
/// </summary>
/// <remarks>
/// The page is best effort and never decides whether this succeeded. Which programs can be
/// told a page is the runtime's business, and it says afterwards whether it managed to.
/// </remarks>
/// <param name="path">The document to open.</param>
/// <param name="pageNumber">The page to show, counted from one, or null when there is none.</param>
/// <returns>Whether the document was opened, whether the page was applied, and what went wrong.</returns>
public async Task<OpenDocumentResponse> TryOpenDocumentInSystemViewer(string path, int? pageNumber)
{
HttpResponseMessage result;
try
{
result = await this.http.PostAsJsonAsync("/open/document", new OpenDocumentRequest(path, pageNumber), this.jsonRustSerializerOptions);
}
catch (HttpRequestException e)
{
this.logger!.LogWarning(e, "Failed to reach the Rust runtime document endpoint.");
return new OpenDocumentResponse(false, false, TB("The runtime document endpoint is not available."));
}
catch (TaskCanceledException e)
{
this.logger!.LogWarning(e, "Timed out while reaching the Rust runtime document endpoint.");
return new OpenDocumentResponse(false, false, TB("The runtime document endpoint is not available."));
}
try
{
if (!result.IsSuccessStatusCode)
{
this.logger!.LogWarning("Failed to open a document through the Rust runtime: '{StatusCode}'", result.StatusCode);
return new OpenDocumentResponse(false, false, string.Format(TB("The runtime document endpoint returned '{0}'."), result.StatusCode));
}
var response = await result.Content.ReadFromJsonAsync<OpenDocumentResponse>(this.jsonRustSerializerOptions);
if (response.Success)
{
//
// A page which was asked for but not applied is noted here and nowhere else: the
// document is open, and the source the user clicked names the page anyway.
//
if (pageNumber is > 0 && !response.PageApplied)
this.logger!.LogInformation("Opened a document without the requested page {PageNumber}, because the system uses a program which cannot be told one.", pageNumber);
return response;
}
return new OpenDocumentResponse(false, false, string.IsNullOrWhiteSpace(response.Issue) ? TB("The runtime document endpoint failed without details.") : response.Issue);
}
catch (Exception e)
{
this.logger!.LogWarning(e, "Failed to process the Rust runtime document endpoint response.");
return new OpenDocumentResponse(false, false, TB("The runtime document endpoint failed without details."));
}
finally
{
result.Dispose();
}
}
}
@@ -278,7 +278,7 @@ public sealed partial class RustService
{
if (segment.TokenCount is { } tokenCount)
{
yield return new(segment.Content, tokenCount);
yield return new(segment.Content, tokenCount, segment.PageNumber);
continue;
}
@@ -291,7 +291,7 @@ public sealed partial class RustService
var countedSegment = await this.GetTokenCount(embeddingProvider, segment.Content, token);
if (countedSegment is { Success: true } counted)
{
yield return new(segment.Content, counted.TokenCount);
yield return new(segment.Content, counted.TokenCount, segment.PageNumber);
continue;
}
@@ -303,7 +303,7 @@ public sealed partial class RustService
}
}
private async IAsyncEnumerable<(string Content, int? TokenCount)> StreamArbitraryFileDataCore(
private async IAsyncEnumerable<(string Content, int? TokenCount, int? PageNumber)> StreamArbitraryFileDataCore(
string path,
bool extractImages,
bool includeTokenCount,
@@ -420,12 +420,13 @@ public sealed partial class RustService
}
//
// The count comes from the processed event, not from the event which was just read:
// a reader may hold content back across several events, and the count of the content
// it releases is the count of that content, not of the event that released it.
// The count and the page come from the processed event, not from the event which
// was just read: a reader may hold content back across several events, and the
// count and page of the content it releases describe that content, not the event
// that released it.
//
if (!string.IsNullOrWhiteSpace(processedEvent.Content))
yield return (processedEvent.Content, processedEvent.TokenCount);
yield return (processedEvent.Content, processedEvent.TokenCount, processedEvent.PageNumber);
}
}
finally
@@ -434,7 +435,7 @@ public sealed partial class RustService
}
if (finalContentChunk is { } pendingContent && !string.IsNullOrWhiteSpace(pendingContent.Content))
yield return (pendingContent.Content, pendingContent.TokenCount);
yield return (pendingContent.Content, pendingContent.TokenCount, pendingContent.PageNumber);
if (promptInjectionRedactedCount is 0)
yield break;
@@ -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);
+130 -47
View File
@@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
@@ -80,72 +81,81 @@ public static partial class SourceExtensions
}
/// <summary>
/// Converts a list of sources to a markdown-formatted string.
/// Sorts a list of sources into the groups it is shown in, and numbers them.
/// </summary>
/// <param name="sources">The list of sources to convert.</param>
/// <returns>A markdown-formatted string representing the sources.</returns>
public static string ToMarkdown(this IList<Source> sources)
/// <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 sb = new StringBuilder();
var ragSources = new List<ISource>();
var toolSources = new List<ISource>();
var sourceNum = 0;
var addedLLMHeaders = false;
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.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();
llmSources.Add(source);
break;
case SourceOrigin.TOOL:
toolSources.Add(source);
break;
case SourceOrigin.RAG:
ragSources.Add(source);
break;
}
}
if(toolSources.Count > 0)
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>
/// <param name="sources">The list of sources to convert.</param>
/// <param name="keepPageAnchors">Whether a link into a local file may name its page; see the method below.</param>
/// <returns>A markdown-formatted string representing the sources.</returns>
public static string ToMarkdown(this IList<Source> sources, bool keepPageAnchors = true)
{
var sb = new StringBuilder();
foreach (var group in sources.GroupSources())
{
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);
var url = keepPageAnchors ? numberedSource.Source.URL : WithoutPageAnchor(numberedSource.Source.URL);
sb.Append($"- [{numberedSource.Number}] ");
AppendMarkdownLink(sb, numberedSource.Source.Title, url);
sb.AppendLine();
}
}
@@ -153,6 +163,29 @@ public static partial class SourceExtensions
return sb.ToString();
}
/// <summary>
/// Takes the page off a link into a local file, for a reader which cannot follow it.
/// </summary>
/// <remarks>
/// Everything a local link carries in its fragment is dropped, not only a page: a chunk is no
/// use to any reader either, and what breaks such a link is the fragment itself rather than what
/// stands in it. A web address keeps its fragment untouched, because there the fragment is part
/// of the address and naming a section of a page is exactly what it is for.
/// </remarks>
/// <param name="url">The link of the source.</param>
/// <returns>The link without its fragment, or the link itself when it carries none.</returns>
private static string WithoutPageAnchor(string url)
{
if (string.IsNullOrWhiteSpace(url))
return url;
var cleanedUrl = url.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty);
if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri) || !absoluteUri.IsFile || absoluteUri.Fragment.Length == 0)
return url;
return absoluteUri.GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Fragment, UriFormat.UriEscaped);
}
/// <summary>
/// Converts a list of sources to a markdown-formatted string, headed by a title of its own.
/// </summary>
@@ -163,16 +196,66 @@ public static partial class SourceExtensions
/// for this and the chat does not.
/// </remarks>
/// <param name="sources">The list of sources to convert.</param>
/// <param name="keepPageAnchors">Whether a link into a local file may name its page.</param>
/// <returns>A markdown-formatted string representing the sources, or an empty string when there are none.</returns>
public static string ToExportMarkdown(this IList<Source> sources)
public static string ToExportMarkdown(this IList<Source> sources, bool keepPageAnchors = true)
{
var sourcesMarkdown = sources.ToMarkdown();
var sourcesMarkdown = sources.ToMarkdown(keepPageAnchors);
if (string.IsNullOrWhiteSpace(sourcesMarkdown))
return string.Empty;
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>
@@ -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);
@@ -17,6 +17,10 @@
- Added model plugins, so IT departments can describe the models their organization runs itself.
- Added local RAG as a beta feature, so the AI can answer from your own documents. You point AI Studio at a folder or at a single file, and it prepares those documents in the background so their contents can be found again later. Ask a question with such a data source selected, and AI Studio looks for the passages that fit your question and hands only those to the model, along with where each one came from. We will keep developing it together with the people who use it: to try it, open the app settings, allow preview features down to beta, and then enable the RAG feature. Many thanks to Paul Koudelka (`PaulKoudelka`) for around ten months of work on the concept and the implementation.
- Added the setup for local data sources. You pick an embedding provider, and AI Studio asks for your confirmation before any document goes to a cloud service. It keeps up with your files as they change, shows the progress on a page of its own, and checks every document for hidden instructions before indexing it. Documents without readable text, such as scanned pages, are remembered as such, so AI Studio does not work through them again after every start — it comes back to them once they change.
- Added a way to open the sources of your own documents: click a source below an answer, and the document opens in the program your system uses for it.
- Added a jump to the right page for the sources of your own documents (RAG), so a PDF opens directly where the passage was found, wherever your system and its program support it.
- Added a way to show a source of your own documents (RAG) in your file manager.
- Added the page of a passage to what the AI is told when it answers from your own documents (RAG), so it can name the page an answer rests on.
- Added support for several drop areas on the same page. More complex assistants can now receive files or folders by drag and drop at more than one place.
- Added drag and drop to the input and output folder of the Batch Processing assistant: drop a folder onto either field to choose it.
- Added ways to load text from a file and drop zones for them, throughout the assistants and dialogs. We went through them one by one, so many fields that used to accept typed text only now take the content of a file as well.
@@ -45,4 +49,5 @@
- Fixed the data sources you picked for a chat being forgotten the moment you changed your selection while one of them could not be used. Such a source stays selected and is used again as soon as it is available.
- Fixed the silence when the step that picks the fitting passages out of your documents fails. You are told that the answer rests on everything that was found.
- Fixed the regenerate button taking an answer away without producing a new one. This happened in chats started from a template that holds no question of your own.
- Fixed the counter above an answer, which shows how many sources it rests on, doing nothing when you clicked it. It now takes you down to the sources.
- Upgraded the Visual Briefing Assistant (in preview) from the prototype to the beta state. The assistant is now completely implemented and is undergoing a deeper testing phase in preparation for release. To try it, open the app settings, allow preview features down to beta, and then enable the Visual Briefing Assistant there.
@@ -0,0 +1,157 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks that the page a passage came from is handed on as a number.
/// </summary>
/// <remarks>
/// The runtime states the page of every page it reads. That number used to be written into the
/// text as a heading and read back out of it further down, which left Word and OpenDocument files
/// without a page for good: they are marked with a comment, not with a heading, so the search for
/// a heading never found anything. The tests here pin the number to the metadata, which is the one
/// place it is actually stated.
/// </remarks>
[TestFixture]
public sealed class ContentStreamPageNumberTests
{
[Test]
public void APdfPageStatesItsNumber()
{
var processed = ContentStreamSseHandler.ProcessEvent(PdfEvent(7, "The mixing console is described here."));
Assert.Multiple(() =>
{
Assert.That(processed.PageNumber, Is.EqualTo(7), "The page comes from the metadata of the event.");
Assert.That(processed.Content, Does.Contain("# Page 7"), "The heading stays, because it is what tells the AI which page it reads.");
});
}
[Test]
public void APdfPageWithoutANumberStatesNone()
{
var processed = ContentStreamSseHandler.ProcessEvent(PdfEvent(null, "A page the runtime could not number."));
Assert.That(processed.PageNumber, Is.Null, "Without a number in the metadata there is no page to state.");
}
/// <remarks>
/// This is the case the old approach got wrong: a document which writes about page numbers
/// looks exactly like the marker that used to be searched for.
/// </remarks>
[Test]
public void ATextWhichReadsLikeAPageMarkerIsNotOne()
{
var processed = ContentStreamSseHandler.ProcessEvent(new()
{
Content = "# Page 42\nStill nothing but the text of the document.",
StreamId = NewStreamId(),
Metadata = new ContentStreamTextMetadata(),
});
Assert.Multiple(() =>
{
Assert.That(processed.PageNumber, Is.Null, "Nothing is read out of the text, so a line which looks like a marker stays text.");
Assert.That(processed.Content, Is.EqualTo("# Page 42\nStill nothing but the text of the document."), "The text itself is passed on untouched.");
});
}
/// <remarks>
/// A Word or OpenDocument page is held back until it is clear that no image follows it, so the
/// page leaving the reader is always the one before the event which released it. Its number has
/// to wait together with it; handing out the number of the arriving event would put every
/// passage one page too far ahead.
/// </remarks>
[Test]
public void ADocumentPageCarriesItsOwnNumberAndNotTheOneWhichReleasedIt()
{
var streamId = NewStreamId();
try
{
var first = ContentStreamSseHandler.ProcessEvent(DocumentEvent(streamId, 1, "What the first page says."));
var second = ContentStreamSseHandler.ProcessEvent(DocumentEvent(streamId, 2, "What the second page says."));
Assert.Multiple(() =>
{
Assert.That(first.Content, Is.Null, "The first page is still being buffered, so nothing is released yet.");
Assert.That(second.PageNumber, Is.EqualTo(1), "What is released here is the first page, so it carries page one.");
Assert.That(second.Content, Does.Contain("What the first page says."), "The content released belongs to the page whose number is stated.");
});
}
finally
{
ContentStreamSseHandler.Clear(streamId);
}
}
[Test]
public void TheLastDocumentPageIsReleasedWithItsNumber()
{
var streamId = NewStreamId();
ContentStreamSseHandler.ProcessEvent(DocumentEvent(streamId, 1, "What the first page says."));
ContentStreamSseHandler.ProcessEvent(DocumentEvent(streamId, 2, "What the second page says."));
var remainder = ContentStreamSseHandler.Clear(streamId);
Assert.That(remainder, Is.Not.Null, "The reader always keeps its last page, so there is something left to release.");
Assert.Multiple(() =>
{
Assert.That(remainder!.Value.PageNumber, Is.EqualTo(2), "The page kept back is the second one.");
Assert.That(remainder.Value.Content, Does.Contain("What the second page says."), "The content released belongs to the page whose number is stated.");
});
}
/// <remarks>
/// A slide is not a page, and no program can be told to open one. Stating none is what later
/// lets a click on such a source open the file and stop there.
/// </remarks>
[Test]
public void ASlideStatesNoPage()
{
var processed = ContentStreamSseHandler.ProcessEvent(new()
{
Content = "What the third slide says.",
StreamId = NewStreamId(),
Metadata = new ContentStreamPresentationMetadata { Presentation = new() { SlideNumber = 3 } },
}, extractImages: false);
Assert.Multiple(() =>
{
Assert.That(processed.PageNumber, Is.Null, "A slide number is not a page number.");
Assert.That(processed.Content, Does.Contain("# Slide 3"), "The heading stays, so the AI still knows which slide it reads.");
});
}
[Test]
public void ASpreadsheetRowStatesNoPage()
{
var processed = ContentStreamSseHandler.ProcessEvent(new()
{
Content = "| Console | Channels |",
StreamId = NewStreamId(),
Metadata = new ContentStreamSpreadsheetMetadata { Spreadsheet = new() { SheetName = "Inventory", RowNumber = 0 } },
});
Assert.That(processed.PageNumber, Is.Null, "A sheet has rows, not pages.");
}
private static ContentStreamSseEvent PdfEvent(int? pageNumber, string content) => new()
{
Content = content,
StreamId = NewStreamId(),
Metadata = new ContentStreamPdfMetadata { Pdf = new() { PageNumber = pageNumber } },
};
private static ContentStreamSseEvent DocumentEvent(string streamId, int pageNumber, string content) => new()
{
Content = content,
StreamId = streamId,
Metadata = new ContentStreamDocumentMetadata { Document = new() { PageNumber = pageNumber } },
};
//
// The readers are kept in static tables keyed by the stream. A test which reuses an ID would
// read the pages another test left behind.
//
private static string NewStreamId() => Guid.NewGuid().ToString();
}
+39
View File
@@ -0,0 +1,39 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks what AI Studio assumes about the readers of the formats it writes.
/// </summary>
[TestFixture]
public sealed class FileExportFormatTests
{
[Test]
public void OnlyTheTwoOfficeFormatsRefuseAPageInALocalLink()
{
Assert.Multiple(() =>
{
Assert.That(FileExportFormat.MICROSOFT_WORD.FollowsPageAnchors(), Is.False, "Word looks for a file whose name ends in the fragment, finds none, and refuses the link.");
Assert.That(FileExportFormat.OPEN_DOCUMENT_TEXT.FollowsPageAnchors(), Is.False, "LibreOffice does the same, verified on 2026-09-15 with an exported .odt.");
Assert.That(FileExportFormat.HTML.FollowsPageAnchors(), Is.True, "A browser opens the document on the page the fragment names.");
Assert.That(FileExportFormat.MARKDOWN.FollowsPageAnchors(), Is.True);
Assert.That(FileExportFormat.LATEX.FollowsPageAnchors(), Is.True);
});
}
[Test]
public void EveryFormatAnAnswerIsWrittenAsHasAnAnswerHere()
{
// Whoever adds a format decides what its reader can follow, rather than inheriting an
// assumption. This fails for a format which nobody thought about, because the list below
// has to name it:
Assert.That(FileExportFormatExtensions.ANSWER_FORMATS, Is.EquivalentTo(new[]
{
FileExportFormat.MICROSOFT_WORD,
FileExportFormat.OPEN_DOCUMENT_TEXT,
FileExportFormat.LATEX,
FileExportFormat.MARKDOWN,
FileExportFormat.HTML,
}), "A format was added to or removed from the export menu: say in FollowsPageAnchors whether its reader follows a page in a local link, then name it here.");
}
}
@@ -0,0 +1,84 @@
using System.Text;
using AIStudio.Tools.RAG;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks what the AI is told about a passage before it reads it.
/// </summary>
/// <remarks>
/// The page a passage sits on travels from the runtime through the index into the retrieval
/// context, but it used to stop there: the AI was given the file and nothing else, so an answer
/// could name the document it rests on but never the place in it. A source which has no page, a
/// slide for instance, must stay silent rather than claim one.
/// </remarks>
[TestFixture]
public sealed class RetrievalContextDescriptionTests
{
[Test]
public void AKnownPageIsPartOfWhatTheAIIsTold()
{
var description = Describe(TextContext(pageNumber: 12));
Assert.That(description, Does.Contain("Content location: page 12"), "The AI is told the page, so it can say where an answer comes from.");
}
[Test]
public void APassageWithoutAPageClaimsNone()
{
var description = Describe(TextContext(pageNumber: null));
Assert.That(description, Does.Not.Contain("Content location"), "A slide or a sheet has no page, and none is invented for it.");
}
/// <remarks>
/// The location belongs to the document, so it is stated with it and before the passage itself
/// follows further down.
/// </remarks>
[Test]
public void ThePageIsStatedWithTheDocumentItBelongsTo()
{
var description = Describe(TextContext(pageNumber: 12));
var lines = description.Split('\n').Select(line => line.Trim()).Where(line => line.Length > 0).ToArray();
Assert.That(lines, Is.EqualTo(new[]
{
"Data source name: Handbooks",
"Content category: TEXT",
"Content type: TEXT_DOCUMENT",
"Content path: /docs/handbook.pdf",
"Content location: page 12",
}), "Name, kind, path and place of the document, in that order.");
}
[Test]
public void AdditionalLinksStillFollowTheLocation()
{
var description = Describe(TextContext(pageNumber: 12, links: ["https://example.com/handbook"]));
Assert.Multiple(() =>
{
Assert.That(description, Does.Contain("Additional links:"), "The links a data source delivers are still passed on.");
Assert.That(description.IndexOf("Content location", StringComparison.Ordinal), Is.LessThan(description.IndexOf("Additional links", StringComparison.Ordinal)), "The place inside the document is stated before links pointing elsewhere.");
});
}
private static string Describe(IRetrievalContext retrievalContext)
{
var builder = new StringBuilder();
IRetrievalContextExtensions.AppendContextDescription(builder, retrievalContext);
return builder.ToString();
}
private static RetrievalTextContext TextContext(int? pageNumber, IReadOnlyList<string>? links = null) => new()
{
DataSourceName = "Handbooks",
Category = RetrievalContentCategory.TEXT,
Type = RetrievalContentType.TEXT_DOCUMENT,
Path = "/docs/handbook.pdf",
Links = links ?? [],
MatchedText = "The mixing console is described here.",
PageNumber = pageNumber,
};
}
+158
View File
@@ -85,6 +85,164 @@ 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 AReaderWhichCannotFollowAPageGetsTheDocumentWithoutOne()
{
IList<Source> sources =
[
new("Handbook (Page 266)", "file:///Users/someone/My Documents/handbook.pdf#page=266", SourceOrigin.RAG),
new("An older answer", "file:///Users/someone/handbook.pdf#chunk=3", SourceOrigin.RAG),
new("A section of an article", "https://example.org/article#results", SourceOrigin.LLM),
];
Assert.That(EntriesOf(sources.ToMarkdown(keepPageAnchors: false)), Is.EqualTo(new[]
{
"- [1] [A section of an article](<https://example.org/article#results>)",
"- [2] [Handbook (Page 266)](<file:///Users/someone/My%20Documents/handbook.pdf>)",
"- [3] [An older answer](<file:///Users/someone/handbook.pdf>)",
}), "Word and LibreOffice take the fragment of a local link for part of the file name and refuse the link, so the local links lose it -- and the web link keeps its own, where a fragment names a section of the page and belongs to the address.");
}
[Test]
public void AReaderWhichFollowsAPageIsToldIt()
{
IList<Source> sources = [new("Handbook (Page 266)", "file:///Users/someone/handbook.pdf#page=266", SourceOrigin.RAG)];
Assert.Multiple(() =>
{
Assert.That(EntriesOf(sources.ToMarkdown()).Single(), Does.EndWith("handbook.pdf#page=266>)"), "A browser and a PDF reader open the document where the passage is, so they are told the page.");
Assert.That(EntriesOf(sources.ToExportMarkdown()).Single(), Does.EndWith("handbook.pdf#page=266>)"), "The clipboard and every text format keep it as well; only the two office formats ask for it to be dropped.");
});
}
[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>