mirror of
https://github.com/MindWorkAI/AI-Studio.git
synced 2026-09-16 18:43:38 +00:00
Let the sources of your own documents open at the page they were found on (#974)
Some checks are pending
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
Some checks are pending
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:
parent
6ce7d856a3
commit
1ebe8eb2a4
@ -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;
|
||||
|
||||
39
app/MindWork AI Studio/Components/SourcesList.razor
Normal file
39
app/MindWork AI Studio/Components/SourcesList.razor
Normal file
@ -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>
|
||||
164
app/MindWork AI Studio/Components/SourcesList.razor.cs
Normal file
164
app/MindWork AI Studio/Components/SourcesList.razor.cs
Normal file
@ -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>
|
||||
|
||||
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);
|
||||
@ -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.")));
|
||||
|
||||
@ -9,6 +9,35 @@ 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; }
|
||||
}
|
||||
8
app/MindWork AI Studio/Tools/Rust/OpenDocumentRequest.cs
Normal file
8
app/MindWork AI Studio/Tools/Rust/OpenDocumentRequest.cs
Normal file
@ -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);
|
||||
14
app/MindWork AI Studio/Tools/Rust/OpenDocumentResponse.cs
Normal file
14
app/MindWork AI Studio/Tools/Rust/OpenDocumentResponse.cs
Normal file
@ -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;
|
||||
|
||||
8
app/MindWork AI Studio/Tools/SourceDocumentLocation.cs
Normal file
8
app/MindWork AI Studio/Tools/SourceDocumentLocation.cs
Normal file
@ -0,0 +1,8 @@
|
||||
namespace AIStudio.Tools;
|
||||
|
||||
/// <summary>
|
||||
/// Where a source points in the file system, and where inside the document it was found.
|
||||
/// </summary>
|
||||
/// <param name="Path">The document in the file system, spelled the way this system spells a path.</param>
|
||||
/// <param name="PageNumber">The page the passage stands on, counted from one, or null when no page is known.</param>
|
||||
public readonly record struct SourceDocumentLocation(string Path, int? PageNumber);
|
||||
@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@ -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(sb.Length > 0)
|
||||
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)
|
||||
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>
|
||||
|
||||
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);
|
||||
@ -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.
|
||||
|
||||
157
app/Tests/Tools/ContentStreamPageNumberTests.cs
Normal file
157
app/Tests/Tools/ContentStreamPageNumberTests.cs
Normal file
@ -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
app/Tests/Tools/FileExportFormatTests.cs
Normal file
39
app/Tests/Tools/FileExportFormatTests.cs
Normal 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.");
|
||||
}
|
||||
}
|
||||
84
app/Tests/Tools/RetrievalContextDescriptionTests.cs
Normal file
84
app/Tests/Tools/RetrievalContextDescriptionTests.cs
Normal file
@ -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,
|
||||
};
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
use log::{error, info};
|
||||
use log::{error, info, warn};
|
||||
use axum::extract::Query;
|
||||
use axum::Json;
|
||||
use file_format::FileFormat;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri_plugin_dialog::{DialogExt, FileDialogBuilder};
|
||||
use crate::api_token::APIToken;
|
||||
use crate::app_window::MAIN_WINDOW;
|
||||
use crate::file_data::is_executable_content;
|
||||
|
||||
#[cfg(any(windows, target_os = "macos"))]
|
||||
use std::process::Command;
|
||||
@ -55,6 +57,14 @@ pub struct OpenPathOptions {
|
||||
path: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct OpenDocumentOptions {
|
||||
path: String,
|
||||
|
||||
/// The page to show, counted from one, or `None` when the document has no page to show.
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct DirectorySelectionResponse {
|
||||
user_cancelled: bool,
|
||||
@ -85,6 +95,20 @@ pub struct OpenPathResponse {
|
||||
issue: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct OpenDocumentResponse {
|
||||
success: bool,
|
||||
|
||||
/// Whether the document was handed to a program together with the page it should show.
|
||||
///
|
||||
/// False means the document opens on its first page: no page was asked for, the system uses a
|
||||
/// program we cannot tell a page, or the attempt to start that program failed. None of these
|
||||
/// is an error — the document opens either way — so the app only notes it in its log.
|
||||
page_applied: bool,
|
||||
|
||||
issue: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
pub struct PreviousFile {
|
||||
file_path: String,
|
||||
@ -386,6 +410,432 @@ async fn open_file_manager_target(requested_path: &Path) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens a document in the program the system uses for it, on the given page where that is possible.
|
||||
///
|
||||
/// The page is best effort and never decides whether this succeeded: a viewer which cannot be told
|
||||
/// a page still shows the document, which is what the user asked for by clicking a source.
|
||||
pub async fn open_document(
|
||||
_token: APIToken,
|
||||
payload: Json<OpenDocumentOptions>,
|
||||
) -> Json<OpenDocumentResponse> {
|
||||
let requested_path = PathBuf::from(payload.path.trim());
|
||||
if let Some(issue) = refuse_document(&requested_path) {
|
||||
error!(Source = "Tauri"; "Refused to open a document: {issue}");
|
||||
return Json(OpenDocumentResponse {
|
||||
success: false,
|
||||
page_applied: false,
|
||||
issue,
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// A page of zero is how a caller says it has none: a slide and a spreadsheet row are not
|
||||
// pages, and neither is a passage whose page the index never learned.
|
||||
//
|
||||
let page = payload.page.filter(|page| *page > 0);
|
||||
if let Some(page) = page && try_open_at_page(&requested_path, page).await {
|
||||
info!("Opened document at page {page}: {requested_path:?}");
|
||||
return Json(OpenDocumentResponse {
|
||||
success: true,
|
||||
page_applied: true,
|
||||
issue: String::new(),
|
||||
});
|
||||
}
|
||||
|
||||
match tauri_plugin_opener::open_path(&requested_path, None::<&str>) {
|
||||
Ok(()) => {
|
||||
info!("Opened document: {requested_path:?}");
|
||||
Json(OpenDocumentResponse {
|
||||
success: true,
|
||||
page_applied: false,
|
||||
issue: String::new(),
|
||||
})
|
||||
},
|
||||
|
||||
Err(error) => {
|
||||
let issue = format!("Failed to open the document: {error}");
|
||||
error!(Source = "Tauri"; "{issue}");
|
||||
Json(OpenDocumentResponse {
|
||||
success: false,
|
||||
page_applied: false,
|
||||
issue,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Extensions which start something instead of being something.
|
||||
///
|
||||
/// Such a file gives nothing away by its content — a `.desktop` entry and a `.cmd` script are
|
||||
/// plain text, a `.lnk` is a shortcut — so its name is the only thing left to recognize it by.
|
||||
const LAUNCHER_EXTENSIONS: [&str; 10] = [
|
||||
"desktop", "command", "lnk", "url", "bat", "cmd", "ps1", "vbs", "scpt", "app",
|
||||
];
|
||||
|
||||
/// Says why a document must not be opened, or `None` when it may be.
|
||||
///
|
||||
/// The path arrives from a data source: a folder the user pointed us at, or an ERI server which is
|
||||
/// free to name any file it likes. This endpoint hands a file to whatever the system has registered
|
||||
/// for it, so the line worth drawing is that a document is opened and a program is never started.
|
||||
/// It is drawn here because this is the one place every caller passes through.
|
||||
fn refuse_document(requested_path: &Path) -> Option<String> {
|
||||
if requested_path.as_os_str().is_empty() {
|
||||
return Some(String::from("The path is empty."));
|
||||
}
|
||||
|
||||
if !requested_path.is_file() {
|
||||
return Some(format!("The path is not a file: {}", requested_path.to_string_lossy()));
|
||||
}
|
||||
|
||||
let extension = requested_path.extension()
|
||||
.map(|extension| extension.to_string_lossy().to_ascii_lowercase())
|
||||
.unwrap_or_default();
|
||||
|
||||
if LAUNCHER_EXTENSIONS.contains(&extension.as_str()) {
|
||||
return Some(format!(
|
||||
"A file of type '{extension}' starts a program instead of showing a document and is not opened: {}",
|
||||
requested_path.to_string_lossy(),
|
||||
));
|
||||
}
|
||||
|
||||
match FileFormat::from_file(requested_path) {
|
||||
Ok(format) if is_executable_content(format) => Some(format!(
|
||||
"The file is a program, not a document, and is not opened: {}",
|
||||
requested_path.to_string_lossy(),
|
||||
)),
|
||||
|
||||
//
|
||||
// A file whose content we cannot place is not a file we refuse. The format is asked in
|
||||
// order to catch a program carrying a harmless extension, nothing else; what the system
|
||||
// makes of anything else is the system's decision, as it is for every other file.
|
||||
//
|
||||
Ok(_) => None,
|
||||
|
||||
Err(error) => {
|
||||
warn!(Source = "Tauri"; "Could not identify the content of '{}': {error}", requested_path.to_string_lossy());
|
||||
None
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Tries to show the document on the given page, and says whether it did.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
async fn try_open_at_page(path: &Path, page: u32) -> bool {
|
||||
let DocumentOpenPlan::WithPage { program, arguments } = resolve_document_open_plan(path, page).await else {
|
||||
return false;
|
||||
};
|
||||
|
||||
match start_page_aware_viewer(&program, &arguments) {
|
||||
Ok(()) => true,
|
||||
|
||||
//
|
||||
// Failing to start the viewer ourselves is not something the user has to hear about: the
|
||||
// caller opens the document plainly afterwards, only without the page.
|
||||
//
|
||||
Err(issue) => {
|
||||
warn!(Source = "Tauri"; "Could not open '{}' at page {page}, opening it without a page instead: {issue}", path.to_string_lossy());
|
||||
false
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Never shows a page on macOS.
|
||||
///
|
||||
/// `open` drops the fragment of a URL before the program it starts ever sees it, with and without
|
||||
/// `-a`, so a page cannot be named from the command line at all. The document opens on its first
|
||||
/// page, and the source names the page for the reader.
|
||||
#[cfg(target_os = "macos")]
|
||||
async fn try_open_at_page(_path: &Path, _page: u32) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// How a document viewer wants to be told which page to show.
|
||||
///
|
||||
/// They all mean the same thing and every one of them spells it differently. A viewer which is not
|
||||
/// covered here shows its first page, which is what the system would have done anyway.
|
||||
///
|
||||
/// Which spellings exist follows from where a viewer is found: Acrobat is named by the Windows
|
||||
/// registration and by nothing else, and the three Linux viewers are named by a desktop entry and
|
||||
/// by nothing else. Only a browser is reached on both, so only its spelling is needed everywhere.
|
||||
#[cfg(any(windows, target_os = "linux", test))]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PageArgument {
|
||||
/// The page travels in the URL fragment, the way the PDF Open Parameters define it. Browsers
|
||||
/// read it, and on Windows a browser is what most people open a PDF with.
|
||||
UrlFragment,
|
||||
|
||||
/// Acrobat and Acrobat Reader take an open action: `/A page=12`.
|
||||
#[cfg(any(windows, test))]
|
||||
AcrobatOpenAction,
|
||||
|
||||
/// The GNOME document viewer and its forks count from zero, so page 12 is index 11.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
ZeroBasedIndex,
|
||||
|
||||
/// Okular takes `-p 12`.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
OkularPage,
|
||||
|
||||
/// Zathura takes `-P 12`.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
ZathuraPage,
|
||||
}
|
||||
|
||||
/// What it takes to show a document on a page.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DocumentOpenPlan {
|
||||
/// Hand the file to the system and let it decide. The document opens on its first page.
|
||||
Plain,
|
||||
|
||||
/// Start this program ourselves, because it takes the page as an argument.
|
||||
WithPage { program: String, arguments: Vec<String> },
|
||||
}
|
||||
|
||||
/// Whether this file is a PDF.
|
||||
///
|
||||
/// Only PDFs are sent to a page: the handler is looked up for PDFs, and the arguments below are
|
||||
/// the ones PDF viewers understand. A Word file has a page too, but the programs which show one
|
||||
/// cannot be told to go there.
|
||||
#[cfg(any(windows, target_os = "linux", test))]
|
||||
fn is_pdf_document(path: &Path) -> bool {
|
||||
path.extension().is_some_and(|extension| extension.eq_ignore_ascii_case("pdf"))
|
||||
}
|
||||
|
||||
/// Builds the arguments which name the page, in the spelling this viewer expects.
|
||||
#[cfg(any(windows, target_os = "linux", test))]
|
||||
fn page_arguments(argument: PageArgument, path: &Path, page: u32) -> Option<Vec<String>> {
|
||||
let path_argument = path.to_string_lossy().to_string();
|
||||
Some(match argument {
|
||||
PageArgument::UrlFragment => vec![document_url_with_page(path, page)?],
|
||||
|
||||
#[cfg(any(windows, test))]
|
||||
PageArgument::AcrobatOpenAction => vec![String::from("/A"), format!("page={page}"), path_argument],
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
PageArgument::ZeroBasedIndex => vec![format!("--page-index={}", page.saturating_sub(1)), path_argument],
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
PageArgument::OkularPage => vec![String::from("-p"), page.to_string(), path_argument],
|
||||
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
PageArgument::ZathuraPage => vec![String::from("-P"), page.to_string(), path_argument],
|
||||
})
|
||||
}
|
||||
|
||||
/// Builds a `file:` URL which names the page, the way the PDF Open Parameters define it.
|
||||
///
|
||||
/// The URL is built instead of written by hand because a path may hold spaces, umlauts or a hash
|
||||
/// of its own, and writing one by hand turns those into a different path or into a second fragment.
|
||||
#[cfg(any(windows, target_os = "linux", test))]
|
||||
fn document_url_with_page(path: &Path, page: u32) -> Option<String> {
|
||||
let mut url = tauri::Url::from_file_path(path).ok()?;
|
||||
url.set_fragment(Some(&format!("page={page}")));
|
||||
Some(url.to_string())
|
||||
}
|
||||
|
||||
/// Starts the viewer. Success means the program was started, not that it showed the page.
|
||||
///
|
||||
/// Waiting for it to say so is not possible: a viewer runs until the user closes it, so waiting
|
||||
/// would hold the request open for as long as the document stays on screen.
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
fn start_page_aware_viewer(program: &str, arguments: &[String]) -> Result<(), String> {
|
||||
let mut command = std::process::Command::new(program);
|
||||
command.args(arguments);
|
||||
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
|
||||
command.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("Failed to start '{program}': {error}"))
|
||||
}
|
||||
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
async fn resolve_document_open_plan(path: &Path, page: u32) -> DocumentOpenPlan {
|
||||
if !is_pdf_document(path) {
|
||||
return DocumentOpenPlan::Plain;
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let Some(prog_id) = windows_default_pdf_prog_id() else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some(argument) = windows_page_argument(&prog_id) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some(program) = windows_handler_executable(&prog_id) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some(arguments) = page_arguments(argument, path, page) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
DocumentOpenPlan::WithPage { program, arguments }
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let Some(desktop_id) = linux_default_pdf_handler().await else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some((program, argument)) = linux_page_aware_program(&desktop_id) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
let Some(arguments) = page_arguments(argument, path, page) else {
|
||||
return DocumentOpenPlan::Plain;
|
||||
};
|
||||
|
||||
DocumentOpenPlan::WithPage { program, arguments }
|
||||
}
|
||||
}
|
||||
|
||||
/// Reads which program the user opens PDFs with.
|
||||
///
|
||||
/// The user's own choice comes first; the class registration is what is left when they never made
|
||||
/// one, for instance right after the system was installed.
|
||||
#[cfg(windows)]
|
||||
fn windows_default_pdf_prog_id() -> Option<String> {
|
||||
use windows_registry::*;
|
||||
|
||||
const USER_CHOICE_KEY: &str = r"SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.pdf\UserChoice";
|
||||
|
||||
if let Ok(key) = CURRENT_USER.open(USER_CHOICE_KEY) && let Ok(prog_id) = key.get_string("ProgId") {
|
||||
return Some(prog_id);
|
||||
}
|
||||
|
||||
CLASSES_ROOT.open(".pdf").ok()
|
||||
.and_then(|key| key.get_string("").ok())
|
||||
.filter(|prog_id| !prog_id.is_empty())
|
||||
}
|
||||
|
||||
/// Reads the program behind a registered file type.
|
||||
#[cfg(windows)]
|
||||
fn windows_handler_executable(prog_id: &str) -> Option<String> {
|
||||
use windows_registry::*;
|
||||
|
||||
let command = CLASSES_ROOT.open(format!(r"{prog_id}\shell\open\command")).ok()?
|
||||
.get_string("").ok()?;
|
||||
|
||||
executable_from_command(&command)
|
||||
}
|
||||
|
||||
/// Picks the program out of a registry open command such as
|
||||
/// `"C:\Program Files\...\msedge.exe" --single-argument %1`.
|
||||
///
|
||||
/// The arguments the command carries are dropped on purpose: they are written for a file name, and
|
||||
/// what follows is a URL naming a page instead.
|
||||
#[cfg(any(windows, test))]
|
||||
fn executable_from_command(command: &str) -> Option<String> {
|
||||
let command = command.trim();
|
||||
let executable = match command.strip_prefix('"') {
|
||||
Some(quoted) => quoted.split('"').next()?,
|
||||
None => command.split_whitespace().next()?,
|
||||
};
|
||||
|
||||
let executable = executable.trim();
|
||||
if executable.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(String::from(executable))
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps the registered file type onto the way its program wants to hear about a page.
|
||||
#[cfg(any(windows, test))]
|
||||
fn windows_page_argument(prog_id: &str) -> Option<PageArgument> {
|
||||
let prog_id = prog_id.to_ascii_lowercase();
|
||||
|
||||
//
|
||||
// Acrobat is asked about first, because its registration says nothing about a browser while
|
||||
// the browsers below are recognized by their own name in it.
|
||||
//
|
||||
if prog_id.contains("acroexch") || prog_id.contains("acrobat") {
|
||||
return Some(PageArgument::AcrobatOpenAction);
|
||||
}
|
||||
|
||||
const BROWSERS: [&str; 5] = ["msedge", "chrome", "firefox", "opera", "brave"];
|
||||
if BROWSERS.iter().any(|browser| prog_id.contains(browser)) {
|
||||
return Some(PageArgument::UrlFragment);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Reads which program the desktop opens PDFs with.
|
||||
///
|
||||
/// Inside a Flatpak there is nothing to read: the sandbox has its own list of registered programs
|
||||
/// rather than the desktop's, and even the right answer would name a program which is not in the
|
||||
/// sandbox to be started. The document is handed to the desktop portal instead, which opens it on
|
||||
/// its first page.
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn linux_default_pdf_handler() -> Option<String> {
|
||||
if crate::environment::is_flatpak() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let output = tokio::process::Command::new("xdg-mime")
|
||||
.args(["query", "default", "application/pdf"])
|
||||
.output()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return None;
|
||||
}
|
||||
|
||||
//
|
||||
// More than one entry can be registered, and the first one is the one the desktop uses.
|
||||
//
|
||||
let desktop_id = String::from_utf8_lossy(&output.stdout).lines().next()?.trim().to_string();
|
||||
if desktop_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(desktop_id)
|
||||
}
|
||||
}
|
||||
|
||||
/// Maps a desktop entry onto the program behind it and the way that program wants to hear about a page.
|
||||
///
|
||||
/// A desktop id is not the name of a binary — GNOME's viewer answers `org.gnome.Evince.desktop` —
|
||||
/// so reading the desktop file would be the thorough way to find the program. Recognizing the few
|
||||
/// viewers which can be sent to a page at all is the short one, and everything else opens the way
|
||||
/// it always did, through the desktop's own handler.
|
||||
#[cfg(any(target_os = "linux", test))]
|
||||
fn linux_page_aware_program(desktop_id: &str) -> Option<(String, PageArgument)> {
|
||||
const KNOWN_VIEWERS: [(&str, &str, PageArgument); 9] = [
|
||||
//
|
||||
// Atril and Xreader are forks of Evince and count their pages from zero just as it does.
|
||||
//
|
||||
("evince", "evince", PageArgument::ZeroBasedIndex),
|
||||
("atril", "atril", PageArgument::ZeroBasedIndex),
|
||||
("xreader", "xreader", PageArgument::ZeroBasedIndex),
|
||||
|
||||
("okular", "okular", PageArgument::OkularPage),
|
||||
("zathura", "zathura", PageArgument::ZathuraPage),
|
||||
|
||||
//
|
||||
// Chrome is asked about before Chromium, so that a desktop entry naming both lands on the
|
||||
// program the user actually installed.
|
||||
//
|
||||
("google-chrome", "google-chrome", PageArgument::UrlFragment),
|
||||
("chromium", "chromium", PageArgument::UrlFragment),
|
||||
("microsoft-edge", "microsoft-edge", PageArgument::UrlFragment),
|
||||
("firefox", "firefox", PageArgument::UrlFragment),
|
||||
];
|
||||
|
||||
let desktop_id = desktop_id.to_ascii_lowercase();
|
||||
KNOWN_VIEWERS.iter()
|
||||
.find(|(needle, _, _)| desktop_id.contains(needle))
|
||||
.map(|(_, program, argument)| (String::from(*program), *argument))
|
||||
}
|
||||
|
||||
/// Applies an optional file type filter to a FileDialogBuilder.
|
||||
fn apply_filter<R: tauri::Runtime>(file_dialog: FileDialogBuilder<R>, filter: &Option<FileTypeFilter>) -> FileDialogBuilder<R> {
|
||||
match filter {
|
||||
@ -625,4 +1075,153 @@ mod tests {
|
||||
|
||||
assert!(resolve_file_manager_target(&invalid_path).is_none());
|
||||
}
|
||||
|
||||
/// The bytes an ELF binary starts with. A file which begins like this is a program, whatever
|
||||
/// its name promises.
|
||||
const ELF_HEADER: &[u8] = b"\x7fELF\x02\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x3e\x00";
|
||||
|
||||
#[test]
|
||||
fn a_document_may_be_opened() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let document_path = temp_dir.path().join("handbook.pdf");
|
||||
fs::write(&document_path, b"%PDF-1.7\n% a handbook\n").unwrap();
|
||||
|
||||
assert_eq!(refuse_document(&document_path), None);
|
||||
}
|
||||
|
||||
/// A program which carries a harmless extension is the case this guard exists for: nothing
|
||||
/// about the name says what it is, so the content has to.
|
||||
#[test]
|
||||
fn a_program_named_like_a_document_is_refused() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let disguised_path = temp_dir.path().join("handbook.pdf");
|
||||
fs::write(&disguised_path, ELF_HEADER).unwrap();
|
||||
|
||||
let refusal = refuse_document(&disguised_path).unwrap();
|
||||
|
||||
assert!(refusal.contains("is a program"), "The refusal says why: {refusal}");
|
||||
}
|
||||
|
||||
/// The other way round: a launcher is plain text and gives nothing away, so it is refused by
|
||||
/// its name.
|
||||
#[test]
|
||||
fn a_launcher_is_refused_although_it_reads_like_text() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let launcher_path = temp_dir.path().join("handbook.desktop");
|
||||
fs::write(&launcher_path, "[Desktop Entry]\nExec=rm -rf ~\n").unwrap();
|
||||
|
||||
let refusal = refuse_document(&launcher_path).unwrap();
|
||||
|
||||
assert!(refusal.contains("starts a program"), "The refusal says why: {refusal}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_launcher_is_refused_whatever_its_extension_is_spelled_like() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let launcher_path = temp_dir.path().join("handbook.CMD");
|
||||
fs::write(&launcher_path, "echo nothing to see here\n").unwrap();
|
||||
|
||||
assert!(refuse_document(&launcher_path).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_path_which_is_no_file_is_refused() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
|
||||
assert!(refuse_document(&temp_dir.path().join("missing.pdf")).is_some(), "A file which is not there cannot be opened.");
|
||||
assert!(refuse_document(temp_dir.path()).is_some(), "A folder is not a document.");
|
||||
assert!(refuse_document(Path::new("")).is_some(), "An empty path names nothing.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_pdf_is_sent_to_a_page() {
|
||||
assert!(is_pdf_document(Path::new("/docs/handbook.pdf")));
|
||||
assert!(is_pdf_document(Path::new("/docs/handbook.PDF")), "How the extension is spelled says nothing about the file.");
|
||||
assert!(!is_pdf_document(Path::new("/docs/handbook.docx")), "A Word file has pages, but no program which shows one can be told to go there.");
|
||||
assert!(!is_pdf_document(Path::new("/docs/handbook")));
|
||||
}
|
||||
|
||||
/// Writing the URL by hand would leave the space in the name as it is, and the browser would
|
||||
/// look for a file whose name ends before it.
|
||||
#[test]
|
||||
fn a_browser_is_told_the_page_in_the_url() {
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
let document_path = temp_dir.path().join("Größere Übersicht.pdf");
|
||||
|
||||
let arguments = page_arguments(PageArgument::UrlFragment, &document_path, 12).unwrap();
|
||||
|
||||
assert_eq!(arguments.len(), 1, "A browser takes the document and the page as one URL.");
|
||||
|
||||
let url = tauri::Url::parse(&arguments[0]).unwrap();
|
||||
assert_eq!(url.fragment(), Some("page=12"), "The page travels in the fragment, the way the PDF Open Parameters define it.");
|
||||
assert_eq!(url.to_file_path().unwrap(), document_path, "A name with spaces and umlauts still names the same file.");
|
||||
}
|
||||
|
||||
/// Everybody means page twelve, and everybody says it differently.
|
||||
#[test]
|
||||
fn every_viewer_spells_the_page_its_own_way() {
|
||||
let document = Path::new("/docs/handbook.pdf");
|
||||
|
||||
assert_eq!(
|
||||
page_arguments(PageArgument::AcrobatOpenAction, document, 12).unwrap(),
|
||||
vec![String::from("/A"), String::from("page=12"), String::from("/docs/handbook.pdf")],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
page_arguments(PageArgument::ZeroBasedIndex, document, 12).unwrap(),
|
||||
vec![String::from("--page-index=11"), String::from("/docs/handbook.pdf")],
|
||||
"The GNOME viewer counts from zero, so page twelve is index eleven.",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
page_arguments(PageArgument::OkularPage, document, 12).unwrap(),
|
||||
vec![String::from("-p"), String::from("12"), String::from("/docs/handbook.pdf")],
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
page_arguments(PageArgument::ZathuraPage, document, 12).unwrap(),
|
||||
vec![String::from("-P"), String::from("12"), String::from("/docs/handbook.pdf")],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_recognizes_the_programs_it_can_send_to_a_page() {
|
||||
assert_eq!(windows_page_argument("AcroExch.Document.DC"), Some(PageArgument::AcrobatOpenAction));
|
||||
assert_eq!(windows_page_argument("MSEdgePDF"), Some(PageArgument::UrlFragment));
|
||||
assert_eq!(windows_page_argument("ChromePDF"), Some(PageArgument::UrlFragment));
|
||||
assert_eq!(windows_page_argument("FirefoxPDF"), Some(PageArgument::UrlFragment));
|
||||
assert_eq!(windows_page_argument("Applications\\SumatraPDF.exe"), None, "A viewer we know nothing about opens its first page.");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_program_is_read_out_of_the_registered_command() {
|
||||
assert_eq!(
|
||||
executable_from_command(r#""C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe" --single-argument %1"#).as_deref(),
|
||||
Some(r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"),
|
||||
"A quoted program keeps the spaces in its path and loses the arguments written for a file name.",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
executable_from_command(r#"C:\Windows\System32\viewer.exe "%1""#).as_deref(),
|
||||
Some(r"C:\Windows\System32\viewer.exe"),
|
||||
);
|
||||
|
||||
assert_eq!(executable_from_command(" "), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linux_recognizes_the_programs_it_can_send_to_a_page() {
|
||||
assert_eq!(
|
||||
linux_page_aware_program("org.gnome.Evince.desktop"),
|
||||
Some((String::from("evince"), PageArgument::ZeroBasedIndex)),
|
||||
"A desktop entry is not the name of a binary, and the binary is what we have to start.",
|
||||
);
|
||||
|
||||
assert_eq!(linux_page_aware_program("okularApplication_pdf.desktop"), Some((String::from("okular"), PageArgument::OkularPage)));
|
||||
assert_eq!(linux_page_aware_program("org.pwmt.zathura.desktop"), Some((String::from("zathura"), PageArgument::ZathuraPage)));
|
||||
assert_eq!(linux_page_aware_program("firefox.desktop"), Some((String::from("firefox"), PageArgument::UrlFragment)));
|
||||
assert_eq!(linux_page_aware_program("google-chrome.desktop"), Some((String::from("google-chrome"), PageArgument::UrlFragment)));
|
||||
assert_eq!(linux_page_aware_program("chromium_chromium.desktop"), Some((String::from("chromium"), PageArgument::UrlFragment)));
|
||||
assert_eq!(linux_page_aware_program("com.example.SomeViewer.desktop"), None, "A viewer we know nothing about opens its first page.");
|
||||
}
|
||||
}
|
||||
@ -827,6 +827,16 @@ fn route_from_content(fmt: FileFormat) -> Option<ExtractionRoute> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the content of a file is a program rather than something to read.
|
||||
///
|
||||
/// The extension is not asked: recognizing a program by its content is the whole point, because a
|
||||
/// program which carries a harmless extension is exactly the case worth stopping. Answering this
|
||||
/// here keeps one place in charge of what counts as a program — the reader which refuses to read
|
||||
/// one, and the endpoint which refuses to hand one to the system.
|
||||
pub(crate) fn is_executable_content(fmt: FileFormat) -> bool {
|
||||
matches!(route_from_content(fmt), Some(ExtractionRoute::Executable))
|
||||
}
|
||||
|
||||
async fn stream_data(file_path: &str, extract_images: bool, stream_id: &str) -> Result<ChunkStream> {
|
||||
if !Path::new(file_path).exists() {
|
||||
error!("File does not exist: '{file_path}'");
|
||||
|
||||
@ -222,12 +222,21 @@ fn encode(image: &DynamicImage, format: ImageFormat) -> Result<Vec<u8>, (StatusC
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A path no other test works on.
|
||||
///
|
||||
/// The name is counted rather than timed. The clock looks unique but is not: these tests run
|
||||
/// in parallel, and two of them reading it within the same tick got the same path, so one
|
||||
/// removed the file the other was still working on. That failed about one run in twelve, and
|
||||
/// never when the tests ran one after another.
|
||||
fn temporary_image_path(extension: &str) -> std::path::PathBuf {
|
||||
let unique = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("mwai-visual-briefing-test-{unique}.{extension}"))
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
|
||||
//
|
||||
// The process id is part of it as well, so that two test runs at once stay apart.
|
||||
//
|
||||
static NEXT_IMAGE: AtomicU32 = AtomicU32::new(0);
|
||||
let unique = NEXT_IMAGE.fetch_add(1, Ordering::Relaxed);
|
||||
std::env::temp_dir().join(format!("mwai-visual-briefing-test-{}-{unique}.{extension}", std::process::id()))
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -55,6 +55,7 @@ pub fn start_runtime_api() {
|
||||
.route("/select/files", post(crate::file_actions::select_files))
|
||||
.route("/save/file", post(crate::file_actions::save_file))
|
||||
.route("/open/path", post(crate::file_actions::open_path_in_file_manager))
|
||||
.route("/open/document", post(crate::file_actions::open_document))
|
||||
.route("/secrets/get", post(crate::secret::get_secret))
|
||||
.route("/secrets/store", post(crate::secret::store_secret))
|
||||
.route("/secrets/delete", post(crate::secret::delete_secret))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user