Merge branch 'main' into improve_document_analysis

This commit is contained in:
Thorsten Sommer 2026-09-16 10:44:32 +02:00 committed by GitHub
commit 1d775108a9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
79 changed files with 2881 additions and 335 deletions

View File

@ -3901,6 +3901,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T168406579"] = "AI-S
-- AI-based data validation
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1744745490"] = "AI-based data validation"
-- These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1852534051"] = "These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:"
-- Yes, I want to use data sources.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1975014927"] = "Yes, I want to use data sources."
@ -5032,6 +5035,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"
@ -11578,9 +11602,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::PLUGINSYSTEM::PLUGINTYPEEXTENSIONS::T335338363
-- Standard augmentation process
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T1072508429"] = "Standard augmentation process"
-- No provider is trusted enough to check which passages fit your question. This answer uses all passages that were found.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T2710880477"] = "No provider is trusted enough to check which passages fit your question. This answer uses all passages that were found."
-- This is the standard augmentation process, which uses all retrieval contexts to augment the chat thread.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T3240406069"] = "This is the standard augmentation process, which uses all retrieval contexts to augment the chat thread."
@ -11593,9 +11614,6 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::DATASOURCESELECTIONPROCESSES::AGENTICSRCS
-- Automatically selects the appropriate data sources based on the last prompt. Applies a heuristic reduction at the end to reduce the number of data sources.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::DATASOURCESELECTIONPROCESSES::AGENTICSRCSELWITHDYNHEUR::T648937779"] = "Automatically selects the appropriate data sources based on the last prompt. Applies a heuristic reduction at the end to reduce the number of data sources."
-- None of your selected data sources is available for the chosen provider. This answer was created without them.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T1696726639"] = "None of your selected data sources is available for the chosen provider. This answer was created without them."
-- This RAG process filters data sources, automatically selects appropriate sources, optionally allows manual source selection, retrieves data, and automatically validates the retrieval context.
UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::RAGPROCESSES::AISRCSELWITHRETCTXVAL::T3047786484"] = "This RAG process filters data sources, automatically selects appropriate sources, optionally allows manual source selection, retrieves data, and automatically validates the retrieval context."
@ -12208,6 +12226,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."
@ -12235,6 +12256,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."
@ -12253,6 +12277,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."

View File

@ -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>
}

View File

@ -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)

View File

@ -55,6 +55,49 @@ public sealed class ContentText : IContent
[JsonIgnore]
public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new();
/// <summary>
/// What the tool conversation of the running request adds to it, as far as it has got.
/// </summary>
/// <remarks>
/// A model which calls tools asks several times before it answers, and every one of those
/// requests carries everything the tools returned so far -- up to three hundred thousand
/// characters of it. None of that is in this block's text, and none of it is in the traces
/// either: those say what happened, not what it costs. So it is kept here, where whoever
/// counts the conversation walks past anyway.<br/><br/>
/// Replaced as a whole, never appended to: it is written by the thread which runs the tools
/// and read by the one which renders, and an exchange leaves the reader with a list which was
/// true at some moment rather than with one being rewritten under it.<br/><br/>
/// Gone when the answer is there, and never persisted. The accumulated tool conversation lives
/// in the provider adapter, which is created for one request and dropped with it -- so the next
/// request does not carry it, and a number which still counted it would promise a cost nobody
/// is going to pay.
/// </remarks>
[JsonIgnore]
public IReadOnlyList<string> PendingToolConversation { get; set; } = [];
/// <summary>
/// Clears what the previous run of the tools left behind.
/// </summary>
/// <remarks>
/// Both parts at once, because both belong to one request: the traces the user reads and the
/// payload the counting needs. They were cleared separately for exactly as long as there was
/// only one of them.
/// </remarks>
public void BeginToolRun()
{
this.ToolInvocations.Clear();
this.PendingToolConversation = [];
}
/// <summary>
/// Says that no request is running anymore.
/// </summary>
/// <remarks>
/// The traces stay -- they are what the user reads afterwards to see how the answer came
/// about. What goes is the payload, which belonged to a request that is over.
/// </remarks>
public void EndToolRun() => this.PendingToolConversation = [];
/// <inheritdoc />
public async Task<ChatThread> CreateFromProviderAsync(IProvider provider, Model chatModel, IContent? lastUserPrompt, ChatThread? chatThread, CancellationToken token = default)
{
@ -177,7 +220,8 @@ public sealed class ContentText : IContent
finally
{
this.Text = this.Text.RemoveThinkTags().Trim();
this.EndToolRun();
// Inform the UI that the streaming is done:
await this.StreamingDone();
}

View File

@ -1,3 +1,7 @@
using System.Text.Json;
using AIStudio.Tools.ToolCallingSystem;
namespace AIStudio.Chat;
/// <summary>
@ -6,9 +10,15 @@ namespace AIStudio.Chat;
/// <remarks>
/// Collected here rather than while counting, so that what counts towards a token budget is one
/// question with one answer which a test can ask. It follows what the message builder actually
/// sends: the system prompt, the text of every block, and the attachments hanging off those
/// blocks -- plus whatever is standing in the composer but has not been sent yet, because that is
/// the part a person is deciding about while they look at the number.
/// sends: the system prompt, the schema of every tool the model may call, the text of every block,
/// and the attachments hanging off those blocks -- plus whatever is standing in the composer but
/// has not been sent yet, because that is the part a person is deciding about while they look at
/// the number.
///
/// And, while a request is running, what its tools have returned so far. That is the one part
/// which is not about the next request but about the one in flight: it is what the model is
/// reading at this moment, it is what fills the window while somebody watches, and it is gone
/// again once the answer stands.
/// </remarks>
public sealed record ConversationParts
{
@ -23,13 +33,17 @@ public sealed record ConversationParts
public IReadOnlyList<string> Texts { get; init; } = [];
/// <summary>
/// The texts which are still being written.
/// The texts which belong to this moment alone.
/// </summary>
/// <remarks>
/// They cost exactly what the others cost; what sets them apart is that they will never be seen
/// again in this shape. The sentence somebody is typing changes with the next pause, and an
/// answer being streamed is a different text three seconds later -- so remembering what they
/// cost fills memory with answers nobody will ask for again.
///
/// What a model's tools have returned so far belongs here for the same reason, although nobody
/// is writing it: it travels with every further round of one request and with nothing after
/// that, so it is measured while it matters and forgotten when the answer is there.
/// </remarks>
public IReadOnlyList<string> GrowingTexts { get; init; } = [];
@ -48,7 +62,9 @@ public sealed record ConversationParts
/// </summary>
/// <remarks>
/// Blocks without text are skipped, because the message builder skips them too: a block whose
/// text is empty never becomes a message, whatever else hangs off it.
/// text is empty never becomes a message, whatever else hangs off it. What such a block may
/// still carry is the tool conversation of a request which is running right now -- that one
/// does travel, and it is read before the text is looked at.
/// </remarks>
/// <param name="thread">The conversation so far, or null when there is none yet.</param>
/// <param name="systemPrompt">
@ -59,8 +75,12 @@ public sealed record ConversationParts
/// <param name="draft">What stands in the composer.</param>
/// <param name="draftAttachments">What is attached to the composer.</param>
/// <param name="imagesAreSent">Whether the model takes images at all. When it does not, none are sent.</param>
/// <param name="toolDefinitions">
/// The tools the model may call, filtered for the provider the same way they are before
/// sending, or null when there are none.
/// </param>
/// <returns>The parts of the conversation.</returns>
public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent)
public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable<FileAttachment>? draftAttachments, bool imagesAreSent, IEnumerable<ToolDefinition>? toolDefinitions)
{
var texts = new List<string>();
var growing = new List<string>();
@ -70,6 +90,15 @@ public sealed record ConversationParts
if (!string.IsNullOrWhiteSpace(systemPrompt))
texts.Add(systemPrompt);
//
// The tools ride along beside the messages, one schema each, in every single request of a
// conversation. Counted with the lasting texts rather than with the growing ones: a schema
// is the same string all session long, so measuring it once and remembering it is exactly
// what the cache is for.
//
foreach (var definition in toolDefinitions ?? [])
texts.Add(Describe(definition));
if (thread is not null)
{
//
@ -79,7 +108,18 @@ public sealed record ConversationParts
//
foreach (var block in thread.Blocks)
{
if (block.ContentType is not ContentType.TEXT || block.Content is not ContentText text || string.IsNullOrWhiteSpace(text.Text))
if (block.ContentType is not ContentType.TEXT || block.Content is not ContentText text)
continue;
//
// Asked before the text is, because while a model calls tools there is no text yet:
// the answer arrives in one piece at the end, and everything in between travels as
// the tool conversation. A block skipped for having nothing to say is exactly the
// block whose request is growing the fastest.
//
growing.AddRange(text.PendingToolConversation);
if (string.IsNullOrWhiteSpace(text.Text))
continue;
if (text.IsStreaming)
@ -106,6 +146,27 @@ public sealed record ConversationParts
};
}
/// <summary>
/// What one tool costs the request it is offered in.
/// </summary>
/// <remarks>
/// Its name, what it tells the model it does, and the arguments it takes -- that is what the
/// provider adapters put into the tool list of the request body. The wire shape differs
/// between the APIs: they name the fields differently, and a strict schema is rewritten for
/// the OpenAI ones. None of that changes the length by an amount which matters next to a
/// conversation, and the number is reported as an estimate anyway.
/// </remarks>
/// <param name="definition">The tool as it was declared.</param>
/// <returns>The text to count for it.</returns>
private static string Describe(ToolDefinition definition)
{
var parameters = definition.Function.Parameters.ValueKind is JsonValueKind.Undefined
? string.Empty
: definition.Function.Parameters.GetRawText();
return $"{definition.Function.Name}{definition.Function.DescriptionForLLM}{parameters}";
}
/// <summary>
/// Puts attachments into the two groups they are counted in.
/// </summary>

View File

@ -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;

View File

@ -1475,8 +1475,9 @@ public partial class ChatComponent : MSGComponentBase
// of it would tell a person their window is empty while their first message is not.
//
var thread = this.ChatThread ?? this.NewChatThread(string.Empty);
var toolDefinitions = this.GetRunnableToolDefinitions();
provider = this.Provider;
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput());
parts = ConversationParts.Of(thread, this.BuildSystemPromptFor(thread, toolDefinitions), this.UserInput, this.ComposerState.FileAttachments, provider.SupportsImageInput(), toolDefinitions);
});
var counted = await this.ConversationTokenCounter.CountAsync(provider, parts, token);
@ -1501,22 +1502,29 @@ public partial class ChatComponent : MSGComponentBase
/// source is appended to it, the selected profile adds a paragraph, and the policy of the
/// selected tools adds another. Switching a profile while writing therefore moves the number,
/// which is the whole reason this is asked rather than read off the thread.
///
/// The tools are filtered for the provider the same way they are before sending, so that a tool
/// the provider is not trusted enough to receive does not count either.
/// </remarks>
/// <param name="thread">The thread to build the prompt for.</param>
/// <param name="toolDefinitions">The tools whose policy the prompt states.</param>
/// <returns>The system prompt as it would be sent.</returns>
private string BuildSystemPromptFor(ChatThread thread)
{
var toolDefinitions = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
.Select(this.ToolRegistry.GetDefinition)
.Where(definition => definition is not null)
.Select(definition => definition!)
.ToList();
private string BuildSystemPromptFor(ChatThread thread, IReadOnlyList<ToolDefinition> toolDefinitions) => thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text;
return thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text;
}
/// <summary>
/// The tools the next request would offer the model.
/// </summary>
/// <remarks>
/// Filtered for the provider the same way they are before sending, so that a tool the provider
/// is not trusted enough to receive does not count either.
///
/// Asked for once and used twice: their policy goes into the system prompt, and their schemas
/// travel next to it in the request body. Both cost tokens, and both change the moment somebody
/// switches a tool on.
/// </remarks>
/// <returns>The definitions of the selected tools.</returns>
private IReadOnlyList<ToolDefinition> GetRunnableToolDefinitions() => this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds)
.Select(this.ToolRegistry.GetDefinition)
.Where(definition => definition is not null)
.Select(definition => definition!)
.ToList();
/// <summary>
/// The thread a new chat starts with, as the selections made so far decide it.

View File

@ -7,11 +7,11 @@
<MudTooltip Text="@T("Select the data you want to use here.")" Placement="Placement.Top">
@if (this.PopoverTriggerMode is PopoverTriggerMode.ICON)
{
<MudIconButton Icon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@(() => this.ToggleDataSourceSelection())"/>
<MudIconButton Icon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@this.ToggleDataSourceSelection"/>
}
else
{
<MudButton Variant="Variant.Filled" StartIcon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@(() => this.ToggleDataSourceSelection())">
<MudButton Variant="Variant.Filled" StartIcon="@AppIcons.DATABASE" Class="@this.PopoverButtonClasses" OnClick="@this.ToggleDataSourceSelection">
@T("Select data")
</MudButton>
}
@ -19,13 +19,13 @@
<MudPopover Open="@this.showDataSourceSelection" AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft" DropShadow="@true" Class="border-solid border-4 rounded-lg">
<MudCard>
<MudCardHeader>
<MudCardHeader Class="pa-2 pb-0">
<CardHeaderContent>
<PreviewBeta/>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">
@T("Data Source Selection")
</MudText>
<PreviewBeta ChipClass=""/>
<MudSpacer/>
<MudTooltip Text="@T("Manage your data sources")" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@this.OpenSettingsDialog"/>
@ -33,7 +33,7 @@
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Style="min-width: 24em; max-height: 60vh; max-width: 45vw; overflow: auto;">
<MudCardContent Class="pa-2" Style="min-width: 24em; max-height: 60vh; max-width: 45vw; overflow: auto;">
@if (this.waitingForDataSources)
{
<MudSkeleton Width="30%" Height="42px;"/>
@ -42,7 +42,7 @@
}
else if (this.SettingsManager.ConfigurationData.DataSources.Count == 0)
{
<MudJustifiedText Typo="Typo.body1" Class="mb-3">
<MudJustifiedText Typo="Typo.body2" Class="mb-2">
@T("You haven't configured any data sources. To grant the AI access to your data, you need to add such a source. However, if you wish to use data from your device, you first have to set up a so-called embedding. This embedding is necessary so the AI can effectively search your data, find and retrieve the correct information required for each task. In addition to local data, you can also incorporate your company's data. To do so, your company must provide the data through an ERI (External Retrieval Interface).")
</MudJustifiedText>
@ -57,51 +57,51 @@
}
else if (this.showDataSourceSelection)
{
<MudTextSwitch Label="@T("Are data sources enabled?")" Value="@this.areDataSourcesEnabled" LabelOn="@T("Yes, I want to use data sources.")" LabelOff="@T("No, I don't want to use data sources.")" ValueChanged="@this.EnabledChanged"/>
<MudTextSwitch Label="@T("Are data sources enabled?")" Value="@this.areDataSourcesEnabled" LabelOn="@T("Yes, I want to use data sources.")" LabelOff="@T("No, I don't want to use data sources.")" ValueChanged="@this.EnabledChanged" Dense="@true"/>
@if (this.areDataSourcesEnabled)
{
<MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged"/>
<MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged" Dense="@true"/>
@if (this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation)
{
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged"/>
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged" Dense="@true"/>
}
@switch (this.aiBasedSourceSelection)
{
case true when this.availableDataSources.Count == 0:
<MudText Typo="Typo.body1" Class="mb-3">
<MudText Typo="Typo.body2" Class="mb-2">
@T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.")
</MudText>
break;
case true when this.DataSourcesAISelected.Count == 0:
<MudText Typo="Typo.body1" Class="mb-3">
<MudText Typo="Typo.body2" Class="mb-2">
@T("The AI evaluates each of your inputs to determine whether and which data sources are necessary. Currently, the AI has not selected any source.")
</MudText>
break;
case false when this.availableDataSources.Count == 0:
<MudText Typo="Typo.body1" Class="mb-3">
<MudText Typo="Typo.body2" Class="mb-2">
@T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.")
</MudText>
break;
case false:
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-3" Disabled="@this.aiBasedSourceSelection">
<MudList T="IDataSource" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@(x => this.SelectionChanged(x))" Style="max-height: 14em;">
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-2" Disabled="@this.aiBasedSourceSelection">
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@this.SelectionChanged" Style="max-height: 14em;">
@foreach (var source in this.availableDataSources)
{
<MudListItem Value="@source">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
@source.Name
</MudText>
@if (source is IInternalDataSource internalSource)
{
<MudSpacer/>
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
</MudTooltip>
}
</MudStack>
@ -113,20 +113,20 @@
case true:
<MudExpansionPanels MultiExpansion="@false" Class="mt-3" Style="max-height: 14em;">
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.TouchApp" HeaderText="@T("Available Data Sources")">
<MudList T="IDataSource" SelectionMode="MudBlazor.SelectionMode.SingleSelection" SelectedValues="@this.selectedDataSources" Style="max-height: 14em;">
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.TouchApp" IconSize="Size.Small" HeaderTypo="Typo.subtitle1" HeaderClass="expansion-panel-header-compact" HeaderText="@T("Available Data Sources")">
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="MudBlazor.SelectionMode.SingleSelection" SelectedValues="@this.selectedDataSources" Style="max-height: 14em;">
@foreach (var source in this.availableDataSources)
{
<MudListItem Value="@source">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
@source.Name
</MudText>
@if (source is IInternalDataSource internalSource)
{
<MudSpacer/>
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
</MudTooltip>
}
</MudStack>
@ -134,21 +134,21 @@
}
</MudList>
</ExpansionPanel>
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Filter" HeaderText="@T("AI-Selected Data Sources")">
<MudList T="DataSourceAgentSelected" SelectionMode="MudBlazor.SelectionMode.MultiSelection" ReadOnly="@true" SelectedValues="@this.GetSelectedDataSourcesWithAI()" Style="max-height: 14em;">
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Filter" IconSize="Size.Small" HeaderTypo="Typo.subtitle1" HeaderClass="expansion-panel-header-compact" HeaderText="@T("AI-Selected Data Sources")">
<MudList T="DataSourceAgentSelected" Dense="@true" Class="data-source-rows" SelectionMode="MudBlazor.SelectionMode.MultiSelection" ReadOnly="@true" SelectedValues="@this.GetSelectedDataSourcesWithAI()" Style="max-height: 14em;">
@foreach (var source in this.DataSourcesAISelected)
{
<MudListItem Value="@source">
<ChildContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
@source.DataSource.Name
</MudText>
@if (source.DataSource is IInternalDataSource internalSource)
{
<MudSpacer/>
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
</MudTooltip>
}
</MudStack>
@ -165,11 +165,24 @@
</MudExpansionPanels>
break;
}
@if (!this.aiBasedSourceSelection && this.GetUnavailablePreselectedDataSources().Count > 0)
{
<MudJustifiedText Typo="Typo.body2" Color="Color.Warning">
@T("These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:")
</MudJustifiedText>
<ul class="unavailable-data-sources mb-3 mt-1">
@foreach (var source in this.GetUnavailablePreselectedDataSources())
{
<li>@source.Name</li>
}
</ul>
}
}
}
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" OnClick="@(() => this.HideDataSourceSelection())">
<MudButton Variant="Variant.Filled" OnClick="@this.HideDataSourceSelection">
@T("Close")
</MudButton>
</MudCardActions>
@ -187,7 +200,7 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
@if (!string.IsNullOrWhiteSpace(this.ConfigurationHeaderMessage))
{
<MudText Typo="Typo.body1">
<MudText Typo="Typo.body2">
@this.ConfigurationHeaderMessage
</MudText>
}
@ -198,19 +211,19 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
<MudTextSwitch Label="@T("AI-based data source selection")" Value="@this.aiBasedSourceSelection" LabelOn="@T("Yes, let the AI decide which data sources are needed.")" LabelOff="@T("No, I manually decide which data source to use.")" ValueChanged="@this.AutoModeChanged" Disabled="@this.IsPreselectedDataSourcesAutomaticSelectionLocked()"/>
<MudTextSwitch Label="@T("AI-based data validation")" Value="@this.aiBasedValidation" LabelOn="@T("Yes, let the AI validate & filter the retrieved data.")" LabelOff="@T("No, use all data retrieved from the data sources.")" ValueChanged="@this.ValidationModeChanged" Disabled="@this.IsPreselectedDataSourcesAutomaticValidationLocked()"/>
<MudField Label="@T("Available Data Sources")" Variant="Variant.Outlined" Class="mb-3" Disabled="@(this.aiBasedSourceSelection || this.IsPreselectedDataSourceIdsLocked())">
<MudList T="IDataSource" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@(x => this.SelectionChanged(x))" ReadOnly="@this.IsPreselectedDataSourceIdsLocked()">
<MudList T="IDataSource" Dense="@true" Class="data-source-rows" SelectionMode="@this.GetListSelectionMode()" @bind-SelectedValues:get="@this.selectedDataSources" @bind-SelectedValues:set="@(x => this.SelectionChanged(x))" ReadOnly="@this.IsPreselectedDataSourceIdsLocked()">
@foreach (var source in this.availableDataSources)
{
<MudListItem Value="@source">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Style="min-width: 0; width: 100%;">
<MudText Typo="Typo.body1" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
<MudText Typo="Typo.body2" Style="min-width: 0; white-space: normal; overflow-wrap: anywhere;">
@source.Name
</MudText>
@if (source is IInternalDataSource internalSource)
{
<MudSpacer/>
<MudTooltip Text="@internalSource.ConfidenceLevel.GetName()">
<MudIcon Icon="@Icons.Material.Filled.Security" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
<MudIcon Icon="@Icons.Material.Filled.Security" Size="Size.Small" Class="confidence-icon" Style="@this.GetConfidenceIconStyle(internalSource)"/>
</MudTooltip>
}
</MudStack>
@ -220,4 +233,4 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE)
</MudField>
}
</MudPaper>
}
}

View File

@ -181,6 +181,22 @@ public partial class DataSourceSelection : MSGComponentBase
var preselectedDataSourceIds = this.DataSourceOptions.PreselectedDataSourceIds.ToHashSet(StringComparer.Ordinal);
return this.GetConfiguredDataSourcesSnapshot().Where(ds => preselectedDataSourceIds.Contains(ds.Id)).ToList();
}
/// <summary>
/// Collects the preselected data sources which the filters removed.
/// </summary>
/// <remarks>
/// The list of available sources shows what survived the filters, while the preselection keeps
/// what the user asked for. Without this, a preselected source which cannot be used right now
/// is simply missing from that list, and nothing says so. Preselected ids without a configured
/// source are left out: that source is gone, not unavailable.
/// </remarks>
/// <returns>The unusable preselected data sources, or an empty list when there are none.</returns>
private IReadOnlyList<IDataSource> GetUnavailablePreselectedDataSources()
{
var availableDataSourceIds = this.availableDataSources.Select(ds => ds.Id).ToHashSet(StringComparer.Ordinal);
return this.GetDataSourcesFromConfiguredIds().Where(ds => !availableDataSourceIds.Contains(ds.Id)).ToList();
}
private async Task LoadAndApplyFilters()
{
@ -200,8 +216,12 @@ public partial class DataSourceSelection : MSGComponentBase
this.waitingForDataSources = true;
this.StateHasChanged();
// Load the data sources:
var sources = await this.DataSourceService.GetDataSources(this.LLMProvider, this.DataSourceOptions, this.selectedDataSources);
//
// Load the data sources. We ask with the preselection rather than with the field below:
// that field holds what was usable the last time we looked, so a source filtered out once
// would never come back, while the RAG process keeps reading it from the preselection.
//
var sources = await this.DataSourceService.GetDataSources(this.LLMProvider, this.DataSourceOptions, this.GetDataSourcesFromConfiguredIds());
if (generation != this.loadAndApplyFiltersGeneration)
return;
@ -242,7 +262,16 @@ public partial class DataSourceSelection : MSGComponentBase
private async Task SelectionChanged(IReadOnlyCollection<IDataSource>? chosenDataSources)
{
this.selectedDataSources = chosenDataSources ?? [];
this.DataSourceOptions.PreselectedDataSourceIds = this.selectedDataSources.Select(ds => ds.Id).ToList();
//
// The list offers only the data sources which survived the filters, so what the user picks
// there says nothing about the preselected ones it could not show. Those are kept: dropping
// them would undo a choice the user never revisited, and it is these ids -- not this list --
// which the RAG process reads when an answer is created. The query has to run before the
// assignment, because it reads what we are about to replace.
//
var keptDataSourceIds = this.GetUnavailablePreselectedDataSources().Select(ds => ds.Id).ToList();
this.DataSourceOptions.PreselectedDataSourceIds = [..keptDataSourceIds, ..this.selectedDataSources.Select(ds => ds.Id)];
await this.OptionsChanged();
}

View File

@ -0,0 +1,18 @@
/*
* A plain list renders without markers and without indentation here: something in the global
* styles takes both off. This is an enumeration of names and wants to read as one, so it states
* marker, indentation and spacing itself. MudBlazor's Markdown styles fight the same fight for
* their own lists, and need an !important on the display to win it -- hence the one below.
*/
.unavailable-data-sources {
max-height: 10em;
overflow-y: auto;
overflow-wrap: anywhere;
margin-top: 0;
padding-left: 1.5em;
list-style: disc outside;
}
.unavailable-data-sources li {
display: list-item !important;
}

View File

@ -1,5 +1,5 @@
<MudField Label="@this.Label" Variant="Variant.Outlined" Class="mb-3" Disabled="@this.Disabled">
<MudSwitch T="bool" Value="@this.Value" ValueChanged="@this.ValueChanged" Color="@this.Color" Validation="@this.Validation" Disabled="@this.Disabled">
<MudField Label="@this.Label" Variant="Variant.Outlined" Class="@this.FieldClasses" Disabled="@this.Disabled">
<MudSwitch T="bool" Size="@this.SwitchSize" Value="@this.Value" ValueChanged="@this.ValueChanged" Color="@this.Color" Validation="@this.Validation" Disabled="@this.Disabled">
@(this.Value ? this.LabelOn : this.LabelOff)
</MudSwitch>
</MudField>

View File

@ -27,4 +27,19 @@ public partial class MudTextSwitch : ComponentBase
[Parameter]
public string LabelOff { get; set; } = string.Empty;
/// <summary>
/// Whether to render this switch in its compact form.
/// </summary>
/// <remarks>
/// For places which stack several of these switches above other content, such as the data source
/// selection the chat opens from its footer. The roomy form stays the default, so that nothing
/// changes where this was never asked for.
/// </remarks>
[Parameter]
public bool Dense { get; set; }
private string FieldClasses => this.Dense ? "mb-2 text-switch-dense" : "mb-3";
private Size SwitchSize => this.Dense ? Size.Small : Size.Medium;
}

View File

@ -1,7 +1,7 @@
@inherits MSGComponentBase
<MudTooltip Placement="Placement.Bottom" Arrow="@true" Class="@this.Classes">
<ChildContent>
<MudChip T="string" Icon="@Icons.Material.Filled.HourglassTop" Color="Color.Info" Class="mb-3">
<MudChip T="string" Icon="@Icons.Material.Filled.HourglassTop" Color="Color.Info" Class="@this.ChipClass">
@T("Beta")
</MudChip>
</ChildContent>

View File

@ -7,5 +7,16 @@ public partial class PreviewBeta : MSGComponentBase
[Parameter]
public bool ApplyInnerScrollingFix { get; set; }
/// <summary>
/// Additional class names for the chip itself, separated by space.
/// </summary>
/// <remarks>
/// The default is the margin every caller relied on before this parameter existed, because the
/// chip usually sits on a line of its own above a heading. A header which puts it beside the
/// heading instead passes an empty value.
/// </remarks>
[Parameter]
public string ChipClass { get; set; } = "mb-3";
private string Classes => this.ApplyInnerScrollingFix ? "InnerScrollingFix" : string.Empty;
}

View 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>

View 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);
}

View File

@ -58,11 +58,15 @@
Disabled="@this.IsRowDisabled(item)" OnClick="@(async () => await this.ToggleToolFromRow(item))">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@*
The switch only shows the state; the surrounding button does the switching.
A checkbox rather than a switch, because this row is one entry of a set the
user picks from, not a setting of its own -- the same question the data source
selection next to it asks, and it should not look like a different one.
The checkbox only shows the state; the surrounding button does the switching.
It therefore takes no pointer events at all: its label reaches past the visible
switch and would otherwise swallow the clicks landing in that strip.
box and would otherwise swallow the clicks landing in that strip.
*@
<MudSwitch T="bool" Size="Size.Small" Color="Color.Primary" Value="@isSelected" ReadOnly="@true" Disabled="@this.IsRowDisabled(item)" Style="pointer-events: none;" />
<MudCheckBox T="bool" Size="Size.Small" Dense="@true" Color="Color.Primary" Value="@isSelected" ReadOnly="@true" Disabled="@this.IsRowDisabled(item)" Style="pointer-events: none;" />
<MudIcon Icon="@item.Implementation.Icon" Color="Color.Info" />
@if (!item.IsActive)
{

View File

@ -3903,6 +3903,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T168406579"] = "KI-a
-- AI-based data validation
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1744745490"] = "KI-gestützte Datenvalidierung"
-- These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1852534051"] = "Diese Datenquellen sind vorausgewählt, können derzeit jedoch nicht verwendet werden – entweder aufgrund von Datenschutz- oder Vertrauensanforderungen oder weil sie nicht verfügbar sind:"
-- Yes, I want to use data sources.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1975014927"] = "Ja, ich möchte Datenquellen verwenden."
@ -5034,6 +5037,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"
@ -12210,6 +12234,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."
@ -12237,6 +12264,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."
@ -12255,6 +12285,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."

View File

@ -3903,6 +3903,9 @@ UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T168406579"] = "AI-S
-- AI-based data validation
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1744745490"] = "AI-based data validation"
-- These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1852534051"] = "These data sources are preselected, but cannot be used right now, either due to data privacy or confidence-level requirements, or because they are unavailable:"
-- Yes, I want to use data sources.
UI_TEXT_CONTENT["AISTUDIO::COMPONENTS::DATASOURCESELECTION::T1975014927"] = "Yes, I want to use data sources."
@ -5034,6 +5037,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"
@ -12210,6 +12234,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."
@ -12237,6 +12264,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."
@ -12255,6 +12285,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."

View File

@ -19,9 +19,13 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
{
private readonly List<IMessageBase> internalMessages = [];
private readonly List<AnthropicToolResultContent> pendingToolResults = [];
private readonly List<string> recordedRequestTexts = [];
private readonly List<AnthropicTool> tools = runnableTools.Select(x => ProviderToolAdapters.ToAnthropicTool(x.Definition)).ToList();
private AnthropicResponse? lastResponse;
/// <inheritdoc />
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
/// <inheritdoc />
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
{
@ -76,13 +80,30 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList<IMessageB
// returned unchanged for the model to continue from them.
//
this.internalMessages.Add(new AnthropicMessage([..this.lastResponse.Content]));
//
// And they are counted exactly as they arrived, for the same reason: a thinking block is
// sent back whole, so what it costs is what it says, not what we could read out of it.
//
foreach (var contentBlock in this.lastResponse.Content)
this.recordedRequestTexts.Add(contentBlock.GetRawText());
}
/// <inheritdoc />
public void RecordToolResult(string callId, string content, bool isError = false) => this.pendingToolResults.Add(new AnthropicToolResultContent
public void RecordToolResult(string callId, string content, bool isError = false)
{
ToolUseId = callId,
Content = content,
IsError = isError,
});
this.pendingToolResults.Add(new AnthropicToolResultContent
{
ToolUseId = callId,
Content = content,
IsError = isError,
});
//
// Noted here rather than when the results are flushed into their message: the round they
// belong to is over, and whoever asks in the meantime has to see what it cost.
//
if (!string.IsNullOrWhiteSpace(content))
this.recordedRequestTexts.Add(content);
}
}

View File

@ -81,7 +81,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n
var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>();
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText;
currentAssistantContent?.ToolInvocations.Clear();
currentAssistantContent?.BeginToolRun();
var providerSettings = this.CreateSettingsProvider(chatModel);
var runnableTools = toolRegistry is null

View File

@ -1270,7 +1270,7 @@ public abstract class BaseProvider : IProvider, ISecretId
var toolRegistry = Program.SERVICE_PROVIDER.GetService<ToolRegistry>();
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText;
currentAssistantContent?.ToolInvocations.Clear();
currentAssistantContent?.BeginToolRun();
TextMessage systemPrompt;
if (toolRegistry is not null && toolExecutor is not null)

View File

@ -22,9 +22,13 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
: IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest
{
private readonly List<IMessageBase> internalMessages = [];
private readonly List<string> recordedRequestTexts = [];
private ChatCompletionResponseMessage? lastResponseMessage;
private List<ChatCompletionToolCall> lastToolCalls = [];
/// <inheritdoc />
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
/// <inheritdoc />
public async Task<ToolCallingRound?> ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default)
{
@ -79,23 +83,55 @@ public sealed class ChatCompletionToolCallingAdapter<TRequest>(
}
/// <inheritdoc />
public void RecordAssistantTurn() => this.internalMessages.Add(new AssistantToolCallMessage
public void RecordAssistantTurn()
{
Content = this.lastResponseMessage?.RawContent,
ReasoningContent = this.lastResponseMessage?.ReasoningContent,
ToolCalls = this.lastToolCalls,
});
this.internalMessages.Add(new AssistantToolCallMessage
{
Content = this.lastResponseMessage?.RawContent,
ReasoningContent = this.lastResponseMessage?.ReasoningContent,
ToolCalls = this.lastToolCalls,
});
//
// The text of the message, not the message: this adapter builds the message itself, so it
// knows which of its fields carry words rather than wire format. The name of a call travels
// with its arguments because the model is charged for both.
//
this.Record(this.lastResponseMessage?.Content);
this.Record(this.lastResponseMessage?.ReasoningContent);
foreach (var toolCall in this.lastToolCalls)
this.Record($"{toolCall.Function?.Name}{toolCall.Function?.Arguments}");
}
/// <inheritdoc />
/// <remarks>
/// Chat Completions has no error flag on a tool message, so a failure travels in the content
/// like any other result.
/// </remarks>
public void RecordToolResult(string callId, string content, bool isError = false) => this.internalMessages.Add(new ToolResultMessage
public void RecordToolResult(string callId, string content, bool isError = false)
{
Content = content,
ToolCallId = callId,
});
this.internalMessages.Add(new ToolResultMessage
{
Content = content,
ToolCallId = callId,
});
this.Record(content);
}
/// <summary>
/// Notes one piece of text as part of what the next round sends.
/// </summary>
/// <remarks>
/// Empty pieces are left out rather than noted as nothing. A round without text and a round
/// without reasoning are the normal case here, and a list of empty strings would be carried
/// through the whole counting for no answer it could change.
/// </remarks>
private void Record(string? text)
{
if (!string.IsNullOrWhiteSpace(text))
this.recordedRequestTexts.Add(text);
}
/// <summary>
/// Normalizes the tool calls of one response.

View File

@ -176,7 +176,7 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur
var toolExecutor = Program.SERVICE_PROVIDER.GetService<ToolExecutor>();
var currentAssistantContent = chatThread.Blocks.LastOrDefault(x => x.Role is ChatRole.AI)?.Content as ContentText;
currentAssistantContent?.ToolInvocations.Clear();
currentAssistantContent?.BeginToolRun();
IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools = toolRegistry is null
? []

View File

@ -16,8 +16,12 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
Func<ResponsesAPIRequest, CancellationToken, Task<ResponsesResponse?>> executeRequestAsync) : IToolCallingProviderAdapter
{
private readonly List<object> internalItems = [];
private readonly List<string> recordedRequestTexts = [];
private ResponsesResponse? lastResponse;
/// <inheritdoc />
public IReadOnlyList<string> RecordedRequestTexts => this.recordedRequestTexts;
/// <summary>
/// The tools offered to the model: the provider-native ones plus our local functions.
/// </summary>
@ -77,7 +81,17 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
// Every output item, not just the function calls: the API rejects a continuation whose
// reasoning items are missing.
foreach (var outputItem in this.lastResponse.Output)
{
this.internalItems.Add(outputItem);
//
// The item as it came in, because that is how it goes back out. Reading the text out
// of it would mean knowing every item type the API has, including the ones it gains
// later -- and a reasoning item nobody recognized would then cost nothing here while
// costing its tokens on the wire.
//
this.recordedRequestTexts.Add(outputItem.GetRawText());
}
}
/// <inheritdoc />
@ -85,11 +99,17 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList<object> b
/// The Responses API has no error flag on a function call output, so a failure travels in the
/// output like any other result.
/// </remarks>
public void RecordToolResult(string callId, string content, bool isError = false) => this.internalItems.Add(new ResponsesFunctionCallOutputItem
public void RecordToolResult(string callId, string content, bool isError = false)
{
CallId = callId,
Output = content,
});
this.internalItems.Add(new ResponsesFunctionCallOutputItem
{
CallId = callId,
Output = content,
});
if (!string.IsNullOrWhiteSpace(content))
this.recordedRequestTexts.Add(content);
}
private static IList<object> BuildEffectiveProviderTools(IList<object> providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools)
{

View File

@ -28,6 +28,15 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
public DateTimeOffset LastCheckpoint { get; set; }
/// <summary>
/// When the chat was last told that something happened which was not a streamed chunk.
/// </summary>
/// <remarks>
/// Kept on the job rather than in the loop which streams, because the tool calling reports
/// from outside that loop: it runs inside the provider call the loop is waiting on.
/// </remarks>
public DateTimeOffset LastActivityNotification { get; set; }
public bool IsCompletionStarted { get; set; }
public readonly Lock SyncRoot = new();
@ -79,6 +88,44 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
return this.jobs.TryGetValue(jobId, out var job) ? job.ChatGenerationRequest?.ChatThread : null;
}
/// <summary>
/// Says that the answer of a chat has moved without a chunk having arrived.
/// </summary>
/// <remarks>
/// A model which calls tools asks several times before it says anything, and while it does,
/// this service sits in the provider call and hands nothing to the screen. But the request is
/// growing the whole time -- every tool result travels with the next round -- and the chat is
/// what recounts the tokens when it renders. Without this, the only thing which would ever ask
/// again is the ten-second heartbeat of the token tracker.
///
/// Throttled like the streamed chunks, and by the same setting: a round which calls five tools
/// in a row must not turn into five renders of the whole chat when somebody asked us to go easy
/// on their battery.
///
/// A chat without a running job is not an error. The same tool calling loop runs for the
/// assistants, which have no job behind them and no token count to update.
/// </remarks>
/// <param name="chatId">The chat whose answer moved.</param>
public async Task NotifyChatActivityAsync(Guid chatId)
{
if (!this.activeChatJobsByChatId.TryGetValue(chatId, out var jobId))
return;
if (!this.jobs.TryGetValue(jobId, out var job))
return;
lock (job.SyncRoot)
{
var now = DateTimeOffset.Now;
if (settingsManager.ConfigurationData.App.IsSavingEnergy && now - job.LastActivityNotification < STREAMING_EVENT_MIN_TIME)
return;
job.LastActivityNotification = now;
}
await this.NotifyChangedAsync(job);
}
public async Task<AIJobSnapshot?> TryStartChatGenerationAsync(ChatGenerationRequest request)
{
if (this.activeChatJobsByChatId.TryGetValue(request.ChatThread.ChatId, out var existingJobId))
@ -309,6 +356,7 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes
aiText.InitialRemoteWait = false;
aiText.IsStreaming = false;
aiText.Text = aiText.Text.RemoveThinkTags().Trim();
aiText.EndToolRun();
RemoveEmptyAIResponse(state);

View File

@ -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.

View File

@ -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);

View File

@ -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;
}
}

View File

@ -11,6 +11,4 @@ public sealed record EmbeddingStateFile(
DateTimeOffset CreationUtc,
DateTimeOffset LastWriteUtc,
DateTimeOffset EmbeddedAtUtc,
int ChunkCount,
string ConfidenceLevel,
int ConfidenceLevelRank);
int ChunkCount);

View File

@ -26,10 +26,6 @@ internal sealed class EmbeddingStateFileEntity
public int ChunkCount { get; set; }
public string ConfidenceLevel { get; set; } = string.Empty;
public int ConfidenceLevelRank { get; set; }
public EmbeddingStateDataSourceEntity? DataSource { get; set; }
public List<EmbeddingStateChunkEntity> Chunks { get; set; } = [];

View File

@ -67,13 +67,10 @@ internal sealed class IndexStoreDbContext(DbContextOptions<IndexStoreDbContext>
entity.Property(file => file.LastWriteUtc).HasColumnName("last_write_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(file => file.EmbeddedAtUtc).HasColumnName("embedded_at_utc").HasConversion(utcDateTimeOffsetConverter).IsRequired();
entity.Property(file => file.ChunkCount).HasColumnName("chunk_count");
entity.Property(file => file.ConfidenceLevel).HasColumnName("confidence_level").IsRequired();
entity.Property(file => file.ConfidenceLevelRank).HasColumnName("confidence_level_rank");
entity.HasIndex(file => file.DataSourceId).HasDatabaseName("idx_embedded_files_data_source");
entity.HasIndex(file => file.AbsolutePath).HasDatabaseName("idx_embedded_files_absolute_path");
entity.HasIndex(file => file.FileType).HasDatabaseName("idx_embedded_files_file_type");
entity.HasIndex(file => file.ConfidenceLevelRank).HasDatabaseName("idx_embedded_files_confidence");
entity.HasIndex(file => new { file.DataSourceId, file.AbsolutePath }).HasDatabaseName("idx_embedded_files_data_source_absolute_path").IsUnique();
entity

View File

@ -8,6 +8,7 @@ internal static class IndexStoreSchemaMigrator
{
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.InitialRagIndex))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.PermanentIndexingFailures))]
[DynamicDependency(DynamicallyAccessedMemberTypes.All, typeof(Migrations.DropFileConfidenceLevel))]
public static async Task MigrateAsync(IndexStoreDbContext context, CancellationToken token)
{
await context.Database.MigrateAsync(token);

View File

@ -19,6 +19,4 @@ public sealed record IndexStoreSearchResult(
DateTimeOffset CreationUtc,
DateTimeOffset LastWriteUtc,
DateTimeOffset EmbeddedAtUtc,
int ChunkCount,
string ConfidenceLevel,
int ConfidenceLevelRank);
int ChunkCount);

View File

@ -39,8 +39,4 @@ internal sealed class IndexStoreSearchResultEntity
public DateTimeOffset EmbeddedAtUtc { get; set; }
public int ChunkCount { get; set; }
public string ConfidenceLevel { get; set; } = string.Empty;
public int ConfidenceLevelRank { get; set; }
}

View File

@ -0,0 +1,51 @@
#nullable disable
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace AIStudio.Tools.Databases.IndexStore.Migrations;
/// <summary>
/// Drops the copy of the data source confidence level which every indexed file carried.
/// </summary>
/// <remarks>
/// The confidence level is what a data source asks of a provider. It is a property of the data
/// source, it is enforced live before anything is indexed or answered, and it changes no vector.
/// Keeping a copy per file only meant the index had to be thrown away whenever the setting changed.
/// </remarks>
[DbContext(typeof(IndexStoreDbContext))]
[Migration("20260915000000_DropFileConfidenceLevel")]
public partial class DropFileConfidenceLevel : Migration
{
/// <remarks>
/// The columns go through raw SQL instead of DropColumn on purpose. The SQLite provider answers
/// DropColumn by rebuilding the table, and a rebuild drops the table the trigger
/// embedded_files_file_name_au hangs on, which would silently stop the full-text index from
/// following a renamed file. A native ALTER TABLE ... DROP COLUMN leaves the table itself alone.
/// It does refuse a column an index names, so the index has to go first.
/// </remarks>
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "idx_embedded_files_confidence",
table: "embedded_files");
migrationBuilder.Sql("""
ALTER TABLE embedded_files DROP COLUMN confidence_level;
ALTER TABLE embedded_files DROP COLUMN confidence_level_rank;
""");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.Sql("""
ALTER TABLE embedded_files ADD COLUMN confidence_level TEXT NOT NULL DEFAULT '';
ALTER TABLE embedded_files ADD COLUMN confidence_level_rank INTEGER NOT NULL DEFAULT 0;
""");
migrationBuilder.CreateIndex(
name: "idx_embedded_files_confidence",
table: "embedded_files",
column: "confidence_level_rank");
}
}

View File

@ -80,15 +80,6 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
.HasColumnType("INTEGER")
.HasColumnName("chunk_count");
entity.Property<string>("ConfidenceLevel")
.IsRequired()
.HasColumnType("TEXT")
.HasColumnName("confidence_level");
entity.Property<int>("ConfidenceLevelRank")
.HasColumnType("INTEGER")
.HasColumnName("confidence_level_rank");
entity.Property<DateTimeOffset>("CreationUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT")
@ -138,9 +129,6 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
entity.HasIndex("AbsolutePath")
.HasDatabaseName("idx_embedded_files_absolute_path");
entity.HasIndex("ConfidenceLevelRank")
.HasDatabaseName("idx_embedded_files_confidence");
entity.HasIndex("DataSourceId")
.HasDatabaseName("idx_embedded_files_data_source");
@ -278,13 +266,6 @@ partial class IndexStoreDbContextModelSnapshot : ModelSnapshot
.IsRequired()
.HasColumnType("TEXT");
entity.Property<string>("ConfidenceLevel")
.IsRequired()
.HasColumnType("TEXT");
entity.Property<int>("ConfidenceLevelRank")
.HasColumnType("INTEGER");
entity.Property<DateTimeOffset>("CreationUtc")
.HasConversion(utcDateTimeOffsetConverter)
.HasColumnType("TEXT");

View File

@ -302,9 +302,7 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
f.creation_utc AS CreationUtc,
f.last_write_utc AS LastWriteUtc,
c.embedded_at_utc AS EmbeddedAtUtc,
f.chunk_count AS ChunkCount,
f.confidence_level AS ConfidenceLevel,
f.confidence_level_rank AS ConfidenceLevelRank
f.chunk_count AS ChunkCount
FROM embedding_chunks_fts
JOIN embedding_chunks c ON c.id = embedding_chunks_fts.rowid
JOIN embedded_files f ON f.parent_file_id = c.parent_file_id
@ -394,8 +392,6 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
fileEntity.LastWriteUtc = file.LastWriteUtc;
fileEntity.EmbeddedAtUtc = file.EmbeddedAtUtc;
fileEntity.ChunkCount = file.ChunkCount;
fileEntity.ConfidenceLevel = file.ConfidenceLevel;
fileEntity.ConfidenceLevelRank = file.ConfidenceLevelRank;
}
private static void ApplyPermanentFailure(IndexingFailureEntity failureEntity, string dataSourceId, PermanentIndexingFailure failure)
@ -445,9 +441,7 @@ public sealed class SqliteIndexStoreClientImplementation(string name, string dat
result.CreationUtc,
result.LastWriteUtc,
result.EmbeddedAtUtc,
result.ChunkCount,
result.ConfidenceLevel,
result.ConfidenceLevelRank);
result.ChunkCount);
private static string BuildFtsQuery(string query)
{

View File

@ -19,6 +19,4 @@ public sealed record VectorSearchResult(
string Fingerprint,
string CreationUtc,
string LastWriteUtc,
string EmbeddedAtUtc,
string ConfidenceLevel,
int ConfidenceLevelRank);
string EmbeddedAtUtc);

View File

@ -19,6 +19,4 @@ public sealed record VectorStoragePoint(
string Fingerprint,
DateTimeOffset CreationUtc,
DateTimeOffset LastWriteUtc,
DateTimeOffset EmbeddedAtUtc,
string ConfidenceLevel,
int ConfidenceLevelRank);
DateTimeOffset EmbeddedAtUtc);

View File

@ -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);
}
}

View File

@ -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>

View File

@ -1,3 +1,4 @@
using System.Collections.Concurrent;
using System.Net;
using System.Net.Http.Headers;
using System.Net.Sockets;
@ -14,18 +15,39 @@ public sealed class HTMLParser
private const int DEFAULT_MAX_RESPONSE_BYTES = 5 * 1024 * 1024;
/// <summary>
/// The HTML to Markdown converter, built once from a fixed configuration.
/// The fixed configuration every HTML to Markdown conversion runs with.
/// </summary>
/// <remarks>
/// Shared rather than built per call: the configuration never changes, and one web search
/// converts a page per result.
/// This one is shared, because it is only ever read: a configuration holds no counters and no
/// collections which get written to. The converters reading it are not shared, see the pool
/// below.
/// </remarks>
private static readonly Converter MARKDOWN_CONVERTER = new(new Config
private static readonly Config MARKDOWN_CONFIG = new()
{
UnknownTags = Config.UnknownTagsOption.Bypass,
RemoveComments = true,
SmartHrefHandling = true,
});
};
/// <summary>
/// The converters not currently in use, kept so that the reflection in their constructor does
/// not run for every page.
/// </summary>
/// <remarks>
/// One converter per conversion rather than one for all of them: a converter tracks the
/// ancestors of the node it is at in state of its own, updates that state at every single node,
/// and does so without any synchronization. A web search converts up to four pages at the same
/// time, which let those conversions tear each other's ancestor lists apart — sometimes loudly,
/// as an index outside the bounds of an array, and sometimes quietly, as a list indented by the
/// depth another page happened to be at.<br/><br/>
/// Which converter gets which page does not matter, so the pool needs no key: that ancestor
/// state is entered and left in pairs around every node, which leaves it empty once a
/// conversion returns. Nothing of a page outlives its own conversion. A key would, in fact, do
/// harm — two conversions of the same page at the same time would share one converter again.
/// <br/><br/>
/// The pool holds no more converters than are ever converting at once, which is a handful.
/// </remarks>
private static readonly ConcurrentBag<Converter> CONVERTER_POOL = [];
/// <summary>
/// Loads a web page.
@ -238,5 +260,21 @@ public sealed class HTMLParser
/// </summary>
/// <param name="html">The HTML content to parse.</param>
/// <returns>The converted Markdown content.</returns>
public static string ParseToMarkdown(string html) => MARKDOWN_CONVERTER.Convert(html);
/// <remarks>
/// The converter returns to the pool only after it converted without throwing, and that is
/// deliberately not done in a finally block: a conversion which throws leaves the ancestors it
/// entered behind, because the library does not unwind them itself. Such a converter would
/// count those ancestors into every page it is handed afterwards, so it is left to the garbage
/// collector rather than passed on.
/// </remarks>
public static string ParseToMarkdown(string html)
{
if (!CONVERTER_POOL.TryTake(out var converter))
converter = new Converter(MARKDOWN_CONFIG);
var markdown = converter.Convert(html);
CONVERTER_POOL.Add(converter);
return markdown;
}
}

View 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);

View File

@ -118,7 +118,7 @@ public static class PandocExport
// We read the text before we ask for a path: when there is nothing to convert, the user
// should learn that right away instead of picking a file first and getting an error afterwards.
//
if (!markdownContent.TryGetExportMarkdown(out var markdownText))
if (!markdownContent.TryGetExportMarkdown(out var markdownText, format.FollowsPageAnchors()))
{
LOGGER.LogWarning("Cannot export the content as {ExportFormat}, because it carries no text.", format);
await MessageBus.INSTANCE.SendError(new(Icons.Material.Filled.Cancel, TB("Only text messages can be exported.")));

View File

@ -80,8 +80,12 @@ public sealed class AugmentationOne : IAugmentationProcess
}
else
{
//
// No message to the user here: which providers are trusted enough is a setting, not
// an event. It does not change between two answers, so a message would repeat itself
// with every single one until the setting changes.
//
LOGGER.LogWarning("Skipping retrieval context validation because no sufficiently trusted validation agent provider is available. Continuing augmentation with all retrieved contexts.");
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.FactCheck, TB("No provider is trusted enough to check which passages fit your question. This answer uses all passages that were found.")));
}
}

View File

@ -8,7 +8,36 @@ namespace AIStudio.Tools.RAG;
public static class IRetrievalContextExtensions
{
private static readonly ILogger<IRetrievalContext> LOGGER = Program.LOGGER_FACTORY.CreateLogger<IRetrievalContext>();
/// <summary>
/// Writes what the AI is told about a retrieval context, before its content follows.
/// </summary>
/// <remarks>
/// The location is what lets the AI say where an answer comes from. Naming only the file is
/// not enough in a document of two hundred pages, and we know the page: it travels from the
/// runtime through the index into the context. A slide or a sheet has no page, and then
/// nothing is claimed rather than something made up.
/// </remarks>
/// <param name="contextBuilder">The builder to write into.</param>
/// <param name="retrievalContext">The context to describe.</param>
internal static void AppendContextDescription(StringBuilder contextBuilder, IRetrievalContext retrievalContext)
{
contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}");
contextBuilder.AppendLine($"Content category: {retrievalContext.Category}");
contextBuilder.AppendLine($"Content type: {retrievalContext.Type}");
contextBuilder.AppendLine($"Content path: {retrievalContext.Path}");
if(retrievalContext is RetrievalTextContext { PageNumber: > 0 } locatedContext)
contextBuilder.AppendLine($"Content location: page {locatedContext.PageNumber}");
if(retrievalContext.Links.Count is 0)
return;
contextBuilder.AppendLine("Additional links:");
foreach(var link in retrievalContext.Links)
contextBuilder.AppendLine($"- {link}");
}
public static async Task<string> AsMarkdown(this IReadOnlyList<IRetrievalContext> retrievalContexts, StringBuilder? sb = null, CancellationToken token = default)
{
sb ??= new StringBuilder();
@ -49,17 +78,7 @@ public static class IRetrievalContextExtensions
break;
}
contextBuilder.AppendLine($"Data source name: {retrievalContext.DataSourceName}");
contextBuilder.AppendLine($"Content category: {retrievalContext.Category}");
contextBuilder.AppendLine($"Content type: {retrievalContext.Type}");
contextBuilder.AppendLine($"Content path: {retrievalContext.Path}");
if(retrievalContext.Links.Count > 0)
{
contextBuilder.AppendLine("Additional links:");
foreach(var link in retrievalContext.Links)
contextBuilder.AppendLine($"- {link}");
}
AppendContextDescription(contextBuilder, retrievalContext);
var guardService = Program.SERVICE_PROVIDER.GetRequiredService<PromptInjectionGuardService>();
var source = PromptInjectionSource.RetrievalContext(retrievalContext.DataSourceName, retrievalContext.Path);

View File

@ -104,17 +104,16 @@ public sealed class AISrcSelWithRetCtxVal : IRagProcess
if(selectedDataSources.Count == 0)
{
//
// Reaching this point means the user never saw a source of theirs selected: the
// selection shows what survived the filters, so an empty result there is an empty
// selection on screen as well. Telling them per answer that their sources were
// lost would announce a loss they were never shown in the first place. This state
// belongs into the selection instead, which names the preselected sources it
// cannot use.
//
LOGGER.LogWarning("No data sources are selected. The RAG process is skipped.");
proceedWithRAG = false;
//
// When the user picked the sources, none of them survived the security and
// confidence checks. That is worth saying out loud: the user chose them and
// expects this answer to use them. When the AI picked instead, finding nothing
// suitable for this prompt is a normal outcome and stays in the log.
//
if(!chatThread.DataSourceOptions.AutomaticDataSourceSelection)
await MessageBus.INSTANCE.SendWarning(new(Icons.Material.Filled.Source, TB("None of your selected data sources is available for the chosen provider. This answer was created without them.")));
}
else
{

View File

@ -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; }
}

View 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);

View 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);

View File

@ -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);

View File

@ -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,13 +39,26 @@ 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);
internal sealed record ChunkingOptions(int MaxChunkTokenLength, int OverlapTokenLength);
private sealed record ChunkingStrategy(string Name, IReadOnlyList<ChunkingRule> Rules);
@ -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;
@ -931,9 +986,26 @@ public sealed partial class DataSourceEmbeddingService
}
}
private string BuildEmbeddingSignature(IDataSource dataSource, EmbeddingProvider embeddingProvider, ChunkingOptions chunkingOptions)
/// <summary>
/// Describes how the vectors of a data source were made.
/// </summary>
/// <remarks>
/// What appears here decides when stored embeddings are thrown away: a signature differing from
/// the persisted one drops the whole index and builds it again. So it names the embedding model,
/// where it runs, how the text was cut for it, and the chunk metadata version — the things a
/// vector actually depends on.
///
/// The confidence level a data source asks of a provider is deliberately not among them. It
/// changes no vector, and it is enforced live on every request anyway: DataSourceService checks
/// it against the participating chat providers and against the embedding provider, and this
/// service checks it again before each indexing run. It was part of this signature once, which
/// re-embedded every file of a data source whenever somebody raised or lowered it — real money
/// at a cloud embedding provider, for nothing.
/// </remarks>
internal static string BuildEmbeddingSignature(IDataSource dataSource, EmbeddingProvider embeddingProvider, ChunkingOptions chunkingOptions)
{
return string.Join('|',
CHUNK_METADATA_VERSION,
embeddingProvider.Id,
embeddingProvider.UsedLLMProvider,
embeddingProvider.Model.Id,
@ -941,7 +1013,6 @@ public sealed partial class DataSourceEmbeddingService
embeddingProvider.Hostname,
embeddingProvider.TokenizerPath,
embeddingProvider.EffectiveTokenLimit,
GetDataSourceConfidenceLevel(dataSource).ToString(),
dataSource is IInternalDataSource internalDataSource ? internalDataSource.MaxChunkTokenLength : 0,
dataSource is IInternalDataSource overlapDataSource ? overlapDataSource.ChunkOverlapTokenLength : DEFAULT_CHUNK_OVERLAP_TOKEN_LENGTH,
chunkingOptions.MaxChunkTokenLength,
@ -1045,7 +1116,6 @@ public sealed partial class DataSourceEmbeddingService
{
file.Refresh();
var absolutePath = Path.GetFullPath(file.FullName);
var confidenceLevel = GetDataSourceConfidenceLevel(dataSource);
return new(
this.CreateParentFileId(dataSource.Id, absolutePath),
absolutePath,
@ -1057,9 +1127,7 @@ public sealed partial class DataSourceEmbeddingService
file.Exists ? new DateTimeOffset(file.CreationTimeUtc) : DateTimeOffset.UnixEpoch,
file.Exists ? new DateTimeOffset(file.LastWriteTimeUtc) : DateTimeOffset.UnixEpoch,
embeddedAtUtc,
chunkCount,
confidenceLevel.ToString(),
(int)confidenceLevel);
chunkCount);
}
private IReadOnlyList<EmbeddingStateChunk> CreateEmbeddingStateChunks(EmbeddingStateFile parentFile, IReadOnlyList<EmbeddingChunkDraft> batch, DateTimeOffset embeddedAtUtc)
@ -1075,25 +1143,12 @@ public sealed partial class DataSourceEmbeddingService
.ToList();
}
private static ConfidenceLevel GetDataSourceConfidenceLevel(IDataSource dataSource) =>
dataSource is not IInternalDataSource internalDataSource || internalDataSource.ConfidenceLevel is ConfidenceLevel.NONE
? ConfidenceLevel.UNKNOWN
: internalDataSource.ConfidenceLevel;
private static string GetFileType(FileInfo file)
{
var extension = file.Extension.TrimStart('.').ToLowerInvariant();
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}");

View File

@ -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)
@ -1030,9 +1030,7 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
fingerprint,
parentFile.CreationUtc,
parentFile.LastWriteUtc,
embeddedAtUtc,
parentFile.ConfidenceLevel,
parentFile.ConfidenceLevelRank)).ToList();
embeddedAtUtc)).ToList();
await vectorStore.InsertEmbedding(collectionName, points, token);
}
@ -1237,7 +1235,7 @@ public sealed partial class DataSourceEmbeddingService(SettingsManager settingsM
CancellationToken token)
{
var chunkingOptions = this.GetChunkingOptions(dataSource, embeddingProvider);
var embeddingSignature = this.BuildEmbeddingSignature(dataSource, embeddingProvider, chunkingOptions);
var embeddingSignature = BuildEmbeddingSignature(dataSource, embeddingProvider, chunkingOptions);
var manifest = await indexStore.GetManifestAsync(dataSource.Id, token);
logger.LogInformation(

View File

@ -52,9 +52,7 @@ public sealed class DataSourceLocalRetrievalService(
int ChunkIndex,
string Text,
double Score,
int Rank,
string ConfidenceLevel,
int ConfidenceLevelRank);
int Rank);
// ReSharper restore NotAccessedPositionalProperty.Local
public Task<IReadOnlyList<IRetrievalContext>> RetrieveDataAsync(DataSourceLocalFile dataSource, IContent lastUserPrompt, ChatThread thread, CancellationToken token = default) =>
@ -354,9 +352,7 @@ public sealed class DataSourceLocalRetrievalService(
result.ChunkIndex,
result.Text,
result.Score,
rank,
result.ConfidenceLevel,
result.ConfidenceLevelRank);
rank);
private static LocalRetrievalHit FromBm25Result(IndexStoreSearchResult result, int rank) =>
new(
@ -374,9 +370,7 @@ public sealed class DataSourceLocalRetrievalService(
result.ChunkIndex,
result.ChunkText,
result.Score,
rank,
result.ConfidenceLevel,
result.ConfidenceLevelRank);
rank);
private static RetrievalTextContext ToRetrievalContext(LocalRetrievalHit hit)
{
@ -395,6 +389,7 @@ public sealed class DataSourceLocalRetrievalService(
SurroundingContent = [],
ReferenceTitle = BuildReferenceTitle(hit),
ReferenceLink = referenceLink,
PageNumber = hit.PageNumber is > 0 ? hit.PageNumber : null,
};
}
@ -413,11 +408,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)

View File

@ -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();
}
}
}

View File

@ -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;

View 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);

View File

@ -1,3 +1,4 @@
using System.Globalization;
using System.Text;
using System.Text.RegularExpressions;
@ -80,72 +81,81 @@ public static partial class SourceExtensions
}
/// <summary>
/// Converts a list of sources to a markdown-formatted string.
/// Sorts a list of sources into the groups it is shown in, and numbers them.
/// </summary>
/// <param name="sources">The list of sources to convert.</param>
/// <returns>A markdown-formatted string representing the sources.</returns>
public static string ToMarkdown(this IList<Source> sources)
/// <remarks>
/// The order of the groups and the running number are what a reader follows, and they have to
/// be the same wherever the list appears: in the chat, in an exported document, and in the
/// clipboard. This is why both the chat and the Markdown below ask here instead of sorting the
/// list themselves.
/// </remarks>
/// <param name="sources">The list of sources to sort.</param>
/// <returns>The groups which have sources, in the order they are shown; empty when there are none.</returns>
public static IReadOnlyList<SourceGroup> GroupSources(this IList<Source> sources)
{
var sb = new StringBuilder();
var ragSources = new List<ISource>();
var toolSources = new List<ISource>();
var sourceNum = 0;
var addedLLMHeaders = false;
var llmSources = new List<Source>();
var toolSources = new List<Source>();
var ragSources = new List<Source>();
foreach (var source in sources)
{
switch (source.Origin)
{
case SourceOrigin.RAG:
ragSources.Add(source);
break;
case SourceOrigin.LLM:
if (!addedLLMHeaders)
{
sb.Append("## ");
sb.AppendLine(TB("Sources provided by the AI"));
addedLLMHeaders = true;
}
sb.Append($"- [{++sourceNum}] ");
AppendMarkdownLink(sb, source.Title, source.URL);
sb.AppendLine();
llmSources.Add(source);
break;
case SourceOrigin.TOOL:
toolSources.Add(source);
break;
case SourceOrigin.RAG:
ragSources.Add(source);
break;
}
}
if(toolSources.Count > 0)
var groups = new List<SourceGroup>(3);
var sourceNum = 0;
AddGroup(groups, TB("Sources provided by the AI"), llmSources, ref sourceNum);
AddGroup(groups, TB("Sources used by tools"), toolSources, ref sourceNum);
AddGroup(groups, TB("Sources provided by the data providers"), ragSources, ref sourceNum);
return groups;
}
private static void AddGroup(ICollection<SourceGroup> groups, string heading, IReadOnlyList<Source> sources, ref int sourceNum)
{
if (sources.Count == 0)
return;
var numberedSources = new List<NumberedSource>(sources.Count);
foreach (var source in sources)
numberedSources.Add(new(++sourceNum, source));
groups.Add(new(heading, numberedSources));
}
/// <summary>
/// Converts a list of sources to a markdown-formatted string.
/// </summary>
/// <param name="sources">The list of sources to convert.</param>
/// <param name="keepPageAnchors">Whether a link into a local file may name its page; see the method below.</param>
/// <returns>A markdown-formatted string representing the sources.</returns>
public static string ToMarkdown(this IList<Source> sources, bool keepPageAnchors = true)
{
var sb = new StringBuilder();
foreach (var group in sources.GroupSources())
{
if(sb.Length > 0)
if (sb.Length > 0)
sb.AppendLine();
sb.Append("## ");
sb.AppendLine(TB("Sources used by tools"));
sb.AppendLine(group.Heading);
foreach (var source in toolSources)
foreach (var numberedSource in group.Sources)
{
sb.Append($"- [{++sourceNum}] ");
AppendMarkdownLink(sb, source.Title, source.URL);
sb.AppendLine();
}
}
if(ragSources.Count > 0)
{
if(sb.Length > 0)
sb.AppendLine();
sb.Append("## ");
sb.AppendLine(TB("Sources provided by the data providers"));
foreach (var source in ragSources)
{
sb.Append($"- [{++sourceNum}] ");
AppendMarkdownLink(sb, source.Title, source.URL);
var url = keepPageAnchors ? numberedSource.Source.URL : WithoutPageAnchor(numberedSource.Source.URL);
sb.Append($"- [{numberedSource.Number}] ");
AppendMarkdownLink(sb, numberedSource.Source.Title, url);
sb.AppendLine();
}
}
@ -153,6 +163,29 @@ public static partial class SourceExtensions
return sb.ToString();
}
/// <summary>
/// Takes the page off a link into a local file, for a reader which cannot follow it.
/// </summary>
/// <remarks>
/// Everything a local link carries in its fragment is dropped, not only a page: a chunk is no
/// use to any reader either, and what breaks such a link is the fragment itself rather than what
/// stands in it. A web address keeps its fragment untouched, because there the fragment is part
/// of the address and naming a section of a page is exactly what it is for.
/// </remarks>
/// <param name="url">The link of the source.</param>
/// <returns>The link without its fragment, or the link itself when it carries none.</returns>
private static string WithoutPageAnchor(string url)
{
if (string.IsNullOrWhiteSpace(url))
return url;
var cleanedUrl = url.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty);
if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri) || !absoluteUri.IsFile || absoluteUri.Fragment.Length == 0)
return url;
return absoluteUri.GetComponents(UriComponents.AbsoluteUri & ~UriComponents.Fragment, UriFormat.UriEscaped);
}
/// <summary>
/// Converts a list of sources to a markdown-formatted string, headed by a title of its own.
/// </summary>
@ -163,16 +196,66 @@ public static partial class SourceExtensions
/// for this and the chat does not.
/// </remarks>
/// <param name="sources">The list of sources to convert.</param>
/// <param name="keepPageAnchors">Whether a link into a local file may name its page.</param>
/// <returns>A markdown-formatted string representing the sources, or an empty string when there are none.</returns>
public static string ToExportMarkdown(this IList<Source> sources)
public static string ToExportMarkdown(this IList<Source> sources, bool keepPageAnchors = true)
{
var sourcesMarkdown = sources.ToMarkdown();
var sourcesMarkdown = sources.ToMarkdown(keepPageAnchors);
if (string.IsNullOrWhiteSpace(sourcesMarkdown))
return string.Empty;
return $"# {TB("Sources")}{Environment.NewLine}{Environment.NewLine}{sourcesMarkdown}";
}
/// <summary>
/// Reads which document a source names, and which page of it.
/// </summary>
/// <remarks>
/// Only a source which names a file has such a location; a web source is opened by the browser
/// and never asks. The page rides in the fragment of the link as `page=N`, which is what the PDF
/// open parameters call for. A chat written before v26.9.1 carries `chunk=N` instead, which names
/// nothing a program could be sent to: such a source keeps its document and loses only the page.
/// </remarks>
/// <param name="source">The source to read.</param>
/// <param name="location">The document and its page, or the default when the source names no file.</param>
/// <returns>Whether the source names a file.</returns>
public static bool TryGetDocumentLocation(this ISource source, out SourceDocumentLocation location)
{
location = default;
if (string.IsNullOrWhiteSpace(source.URL))
return false;
var cleanedUrl = source.URL.Trim().Replace("\r", string.Empty).Replace("\n", string.Empty);
if (!Uri.TryCreate(cleanedUrl, UriKind.Absolute, out var absoluteUri) || !absoluteUri.IsFile)
return false;
//
// The link was made from a path of this system, so reading it back gives that path again --
// percent-encoded spaces and umlauts included, and with the separators this system uses.
//
var path = absoluteUri.LocalPath;
if (string.IsNullOrWhiteSpace(path))
return false;
location = new(path, ReadPageFromFragment(absoluteUri.Fragment));
return true;
}
private static int? ReadPageFromFragment(string fragment)
{
const string PAGE_PARAMETER = "page=";
foreach (var parameter in fragment.TrimStart('#').Split('&', StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries))
{
if (!parameter.StartsWith(PAGE_PARAMETER, StringComparison.OrdinalIgnoreCase))
continue;
if (int.TryParse(parameter.AsSpan(PAGE_PARAMETER.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var pageNumber) && pageNumber > 0)
return pageNumber;
}
return null;
}
/// <summary>
/// Merges a list of added sources into an existing list of sources, avoiding duplicates based on normalized URLs.
/// </summary>

View 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);

View File

@ -49,4 +49,19 @@ public interface IToolCallingProviderAdapter
/// the others carry the failure in the content, which is where it has to be legible anyway.
/// </param>
public void RecordToolResult(string callId, string content, bool isError = false);
/// <summary>
/// The texts which everything recorded so far adds to the request of every following round.
/// </summary>
/// <remarks>
/// Kept by the adapter rather than by the loop, because the adapter is the only place which
/// knows what actually travels. The loop hands over arguments and results and would count
/// those; what the Responses API additionally demands back -- its reasoning items -- never
/// passes through the loop at all, and a conversation whose largest part is invisible is the
/// very thing this is here to rule out.<br/><br/>
/// These texts exist for as long as the adapter does, which is one streaming call. Nothing of
/// this reaches the next request the user sends: the accumulated conversation goes away with
/// the adapter.
/// </remarks>
public IReadOnlyList<string> RecordedRequestTexts { get; }
}

View File

@ -114,6 +114,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
// The model's turn has to be recorded before its results, or the provider sees
// results for a turn it does not know about:
adapter.RecordAssistantTurn();
await context.PublishPendingToolConversationAsync(adapter);
foreach (var call in round.Calls)
{
@ -124,6 +125,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
toolResultCharacterCount += invalidContent.Length;
await context.AddToolInvocationAsync(invalidTrace);
adapter.RecordToolResult(call.CallId, invalidContent, isError: true);
await context.PublishPendingToolConversationAsync(adapter);
continue;
}
@ -135,6 +137,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
if (callsUnavailableInstruction is not null)
{
adapter.RecordToolResult(call.CallId, callsUnavailableInstruction);
await context.PublishPendingToolConversationAsync(adapter);
continue;
}
@ -156,6 +159,7 @@ public sealed class ToolCallingLoop(ILogger<ToolCallingLoop> logger) : IToolCall
// A blocked call counts as a failure towards the model as much as an errored
// one does: in both cases it did not get the data it asked for.
adapter.RecordToolResult(call.CallId, toolContent, trace.Status is not ToolInvocationTraceStatus.SUCCESS);
await context.PublishPendingToolConversationAsync(adapter);
}
}
finally

View File

@ -1,5 +1,6 @@
using AIStudio.Chat;
using AIStudio.Provider;
using AIStudio.Tools.AIJobs;
namespace AIStudio.Tools.ToolCallingSystem.Harness;
@ -55,7 +56,25 @@ public sealed class ToolCallingLoopContext
return;
this.CurrentAssistantContent.ToolInvocations.Add(trace);
await this.CurrentAssistantContent.StreamingEvent();
await this.AnnounceAsync(this.CurrentAssistantContent);
}
/// <summary>
/// Hands the conversation the adapter has accumulated to the assistant message.
/// </summary>
/// <remarks>
/// Called after every recording, not once per round: a round which reads five web pages is the
/// one during which the request grows the most, and a number which only moves between rounds
/// would stand still through exactly that.
/// </remarks>
/// <param name="adapter">The adapter of this run, which knows what it has recorded.</param>
public async Task PublishPendingToolConversationAsync(IToolCallingProviderAdapter adapter)
{
if (this.CurrentAssistantContent is null)
return;
this.CurrentAssistantContent.PendingToolConversation = [..adapter.RecordedRequestTexts];
await this.AnnounceAsync(this.CurrentAssistantContent);
}
/// <summary>
@ -72,7 +91,7 @@ public sealed class ToolCallingLoopContext
ToolNames = [.. toolNames],
};
await this.CurrentAssistantContent.StreamingEvent();
await this.AnnounceAsync(this.CurrentAssistantContent);
}
/// <summary>
@ -88,6 +107,32 @@ public sealed class ToolCallingLoopContext
return;
this.CurrentAssistantContent.ToolRuntimeStatus = new();
await this.CurrentAssistantContent.StreamingEvent();
await this.AnnounceAsync(this.CurrentAssistantContent);
}
/// <summary>
/// Says that something about the running answer has changed.
/// </summary>
/// <remarks>
/// Two receivers, because the screen is built from two of them. The content's own event
/// renders the message block, which is what shows a running tool and the calls it has made.
/// The job service renders the chat around it, and that is what recounts the tokens -- which
/// nothing else would ask for during a tool run: the chat hears about progress one streamed
/// chunk at a time, and a tool run produces none until it is over.<br/><br/>
/// One method rather than two calls at each of the four places above, because the second of
/// them is the one which is easy to forget.
/// </remarks>
/// <param name="content">The assistant message which changed.</param>
private async Task AnnounceAsync(ContentText content)
{
await content.StreamingEvent();
//
// Asked for here rather than taken as a dependency: the same loop runs for the assistants,
// where there is no job to tell and nothing which counts tokens.
//
var jobService = Program.SERVICE_PROVIDER.GetService<AIJobService>();
if (jobService is not null)
await jobService.NotifyChatActivityAsync(this.ChatThread.ChatId);
}
}

View File

@ -4,6 +4,8 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch;
internal sealed class WebSearchResultRetrievalService(WebPageRetrievalService webPageRetrievalService)
{
private static readonly ILogger<WebSearchResultRetrievalService> LOGGER = Program.LOGGER_FACTORY.CreateLogger<WebSearchResultRetrievalService>();
private const int MAX_PARALLEL_RETRIEVALS = 4;
/// <summary>
@ -110,9 +112,16 @@ internal sealed class WebSearchResultRetrievalService(WebPageRetrievalService we
Interlocked.Increment(ref counters.PageTimedOut);
return new(candidate, null, WebSearchPageRetrievalOutcome.PAGE_TIMED_OUT);
}
catch (InvalidOperationException)
catch (InvalidOperationException exception)
{
//
// The only outcome here which is not an expected one: a page was blocked on purpose,
// and a timeout is a limit the user set, but this is something going wrong. It is
// logged rather than only counted, because a search which quietly returns one result
// fewer is a search nobody can tell was incomplete.
//
Interlocked.Increment(ref counters.Failed);
LOGGER.LogError(exception, "Reading a search result page failed. Url={Url}", candidate.RetrievalUrl);
return new(candidate, null, WebSearchPageRetrievalOutcome.FAILED);
}
finally

View File

@ -76,7 +76,7 @@ internal static class WebPageContentExtractor
.Select(x => LimitLength(x, MAX_OUTLINE_ITEM_CHARACTERS))
.Distinct(StringComparer.Ordinal)
.ToList();
var markdown = HTMLParser.ParseToMarkdown(contentRoot.InnerHtml)
var markdown = ConvertToMarkdown(contentRoot.InnerHtml, finalUrl)
.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\r', '\n')
.Trim();
@ -141,6 +141,30 @@ internal static class WebPageContentExtractor
};
}
/// <summary>
/// Converts the readable part of the page to Markdown.
/// </summary>
/// <remarks>
/// Only the call into the Markdown library is wrapped, not the extraction around it: a fault of
/// our own has to keep surfacing as what it is, instead of being filed away as an unreadable
/// page.<br/><br/>
/// What the library throws depends on the HTML it was handed, and it says nothing beyond "this
/// page could not be converted". Reported as an InvalidOperationException, the retrieval treats
/// it like any other page it could not read, which costs this one page rather than the whole
/// search it belongs to.
/// </remarks>
private static string ConvertToMarkdown(string html, Uri finalUrl)
{
try
{
return HTMLParser.ParseToMarkdown(html);
}
catch (Exception exception) when (exception is not OperationCanceledException)
{
throw new InvalidOperationException($"Converting the HTML of '{finalUrl}' to Markdown failed: {exception.Message}", exception);
}
}
private static JsonLdMetadata ExtractJsonLdMetadata(HtmlDocument document, Uri finalUrl)
{
JsonLdCandidate? bestCandidate = null;

View File

@ -475,20 +475,59 @@ tr:has(> .provider-group-header) .mud-icon-button {
* Rows of the tool selection which the chat and the assistants open from their footer. There will be
* far more tools than the ones we start with, so a row must not waste height: MudBlazor's settings
* button alone puts 12px of padding around a 24px icon, which makes a row 48px tall before the
* switch and the frame are counted at all. Size.Small takes most of that away; the rule below takes
* the rest, and it has to name the MudBlazor class to outweigh its specificity. Alternating rows
* carry a grey ground, which tells a long list apart better than a separator line does and costs no
* height at all. The colors are MudBlazor palette variables, so both grounds follow the theme.
* switch and the frame are counted at all. Size.Small takes most of that away; the rules below take
* the rest, and the second one has to name the MudBlazor class to outweigh its specificity.
*/
.tool-selection-rows > .tool-selection-row {
padding: 0.15rem 0.25rem;
border-radius: var(--mud-default-borderradius);
}
.tool-selection-rows > .tool-selection-row:nth-child(odd) {
background-color: var(--mud-palette-background-gray);
}
.tool-selection-row .mud-icon-button {
padding: 0.2em;
}
/*
* Rows of the data source lists, in the popover next to the tool selection as well as in the
* settings dialog. A row carries a name and at most one icon, so there is no reason for it to be
* 48px tall: MudBlazor pads the item with 8px on both sides and the text slot with another 4px,
* which is more frame than content. Dense on the list halves the first part, the rule below takes
* the second one away, and it has to name the MudBlazor class to outweigh its specificity.
*/
.data-source-rows .mud-list-item-text {
margin-top: 0;
margin-bottom: 0;
}
/*
* The checkboxes MudBlazor renders into a multi-selection list come out larger than the box of the
* tool selection next to it, and MudList has no parameter for their size. So the three rules below
* state it: the 20px icon and the 4px of padding which Size.Small together with Dense produce over
* there, plus the 4px between the box and the name which the tool row takes from the spacing of its
* stack -- the list puts its checkbox outside the slot that holds our own markup, so no stack of
* ours reaches it. The icon needs a rule of its own because MudBlazor gives it an explicit font
* size, which no inherited one can outrank.
*/
.data-source-rows .mud-checkbox {
margin-inline-end: 0.25rem;
}
.data-source-rows .mud-checkbox .mud-icon-button {
padding: 0.25rem;
}
.data-source-rows .mud-checkbox .mud-icon-root {
font-size: 1.25rem;
}
/*
* The frame around a text switch in its compact form. MudBlazor pads the slot of an outlined field
* with 18.5px above and below, which is the right amount for the line of text such a field usually
* holds -- a switch of 24px is left swimming in the middle of it. Size.Small already took the switch
* down; this brings the frame with it, and it has to name the MudBlazor classes to outweigh their
* specificity. Only the two vertical values of that shorthand are replaced, so the 14px to the left
* and right stay as they are.
*/
.text-switch-dense .mud-input-slot.mud-input-root-outlined {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}

View File

@ -11,12 +11,16 @@
- Added support for OpenAI's GPT-6 Astra.
- Added the context window to what AI Studio knows about a model, wherever its metadata states one.
- Added a live read of that context window at the providers which report it, among them Mistral, Groq, OpenRouter, and self-hosted vLLM servers. You then get the window your own server was started with, not the one the model card advertises.
- Added a token count below the message field, so you always see how much of the conversation you have used. It can be an estimate when a provider does not give AI Studio everything it needs to count exactly.
- Added a token count below the message field, so you always see how much of the conversation you have used. It counts everything that travels along: your messages, the files you attached, what your data sources contributed, and the tools you offered the AI. It can be an estimate when a provider does not give AI Studio everything it needs to count exactly.
- Added a warning when your conversation holds more images than the model accepts, wherever we know that limit. The Visual Briefing assistant stops before anything is uploaded, instead of letting the provider refuse it afterward.
- Added the context window and the image limits to the expert provider settings, next to the abilities you could already state there. Leave a field empty, and AI Studio keeps its own answer, which you see as the placeholder. IT departments can state the same numbers for the providers they roll out.
- 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.
@ -42,7 +46,9 @@
- Fixed the copy button leaving the sources behind. Copy an answer, and its sources come along.
- Fixed a tile that opens a chat directly always demanding a workspace. Leave the workspace empty in the Assistant Builder, and the tile opens a disappearing chat instead.
- Fixed the same restriction for plugin authors: a direct-chat launcher can now open a chat without naming a workspace. The example assistant plugin shows both ways.
- Fixed an answer built without your data sources looking exactly like one that used them. When AI Studio cannot reach the sources you picked, it now tells you instead of quietly answering without them.
- Fixed the same silence when the step that picks the fitting passages out of your documents cannot run. You are told that the answer rests on everything that was found.
- Fixed data sources you picked for your chats vanishing from the selection without a word when they cannot be used. AI Studio now lists them by name, so you can see why an answer was created without them.
- 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.

View File

@ -1,4 +1,7 @@
using System.Text.Json;
using AIStudio.Chat;
using AIStudio.Tools.ToolCallingSystem;
namespace AIStudio.Tests.Chat;
@ -11,6 +14,10 @@ namespace AIStudio.Tests.Chat;
/// travels with it. So what is collected here has to be what the message builder actually sends --
/// no more, because a number which counts something that stays behind is wrong in the direction
/// that makes a person stop writing.
///
/// Beyond the messages, a request carries the schema of every tool the model may call, and, while
/// it runs, everything those tools have returned so far. Both are invisible on the screen, and the
/// second one is where a window fills up fastest.
/// </remarks>
[TestFixture]
public sealed class ConversationPartsTests
@ -44,7 +51,7 @@ public sealed class ConversationPartsTests
],
};
var parts = ConversationParts.Of(thread, "You are helpful.", "And of Italy?", null, imagesAreSent: true);
var parts = ConversationParts.Of(thread, "You are helpful.", "And of Italy?", null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
@ -65,7 +72,7 @@ public sealed class ConversationPartsTests
((ContentText)streaming.Content!).IsStreaming = true;
var thread = new ChatThread { Blocks = [Block("A question."), streaming] };
var parts = ConversationParts.Of(thread, string.Empty, "a draft", null, imagesAreSent: true);
var parts = ConversationParts.Of(thread, string.Empty, "a draft", null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
@ -80,7 +87,7 @@ public sealed class ConversationPartsTests
var finished = Block("The whole answer.");
((ContentText)finished.Content!).IsStreaming = false;
var parts = ConversationParts.Of(new() { Blocks = [finished] }, string.Empty, string.Empty, null, imagesAreSent: true);
var parts = ConversationParts.Of(new() { Blocks = [finished] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
@ -100,7 +107,7 @@ public sealed class ConversationPartsTests
//
var thread = new ChatThread { SystemPrompt = "What the person typed." };
var parts = ConversationParts.Of(thread, "What the request carries.", string.Empty, null, imagesAreSent: true);
var parts = ConversationParts.Of(thread, "What the request carries.", string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.Texts, Is.EqualTo(new[] { "What the request carries." }));
}
@ -115,7 +122,7 @@ public sealed class ConversationPartsTests
var hidden = Block("An instruction the user does not see.");
var thread = new ChatThread { Blocks = [new() { ContentType = hidden.ContentType, Role = hidden.Role, Content = hidden.Content, HideFromUser = true }] };
var parts = ConversationParts.Of(thread, string.Empty, string.Empty, null, imagesAreSent: true);
var parts = ConversationParts.Of(thread, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.Texts, Is.EqualTo(new[] { "An instruction the user does not see." }));
}
@ -123,7 +130,7 @@ public sealed class ConversationPartsTests
[Test]
public void WithoutAConversationOnlyTheDraftCounts()
{
var parts = ConversationParts.Of(null, string.Empty, "Hello", null, imagesAreSent: true);
var parts = ConversationParts.Of(null, string.Empty, "Hello", null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
@ -136,7 +143,7 @@ public sealed class ConversationPartsTests
[TestCase(" ")]
public void NothingWrittenIsNothingToCount(string draft)
{
var parts = ConversationParts.Of(null, string.Empty, draft, null, imagesAreSent: true);
var parts = ConversationParts.Of(null, string.Empty, draft, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
@ -157,7 +164,7 @@ public sealed class ConversationPartsTests
var empty = Block(string.Empty);
((ContentText)empty.Content!).FileAttachments.Add(FileAttachment.FromPath(document));
var parts = ConversationParts.Of(new() { Blocks = [empty] }, string.Empty, string.Empty, null, imagesAreSent: true);
var parts = ConversationParts.Of(new() { Blocks = [empty] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
@ -179,7 +186,7 @@ public sealed class ConversationPartsTests
var block = Block("Please read this.");
((ContentText)block.Content!).FileAttachments.Add(FileAttachment.FromPath(older));
var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And this one.", [FileAttachment.FromPath(draft)], imagesAreSent: true);
var parts = ConversationParts.Of(new() { Blocks = [block] }, string.Empty, "And this one.", [FileAttachment.FromPath(draft)], imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.Documents.Select(document => document.FileName), Is.EqualTo(new[] { "older.txt", "draft.txt" }));
}
@ -192,7 +199,7 @@ public sealed class ConversationPartsTests
//
var attachment = FileAttachment.FromPath(Path.Combine(this.directory, "never-existed.txt"));
var parts = ConversationParts.Of(null, string.Empty, "Here", [attachment], imagesAreSent: true);
var parts = ConversationParts.Of(null, string.Empty, "Here", [attachment], imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.Documents, Is.Empty);
}
@ -203,7 +210,7 @@ public sealed class ConversationPartsTests
var document = this.WriteFile("notes.txt", "content");
var image = this.WriteFile("photo.png", "not really a png");
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(document), FileAttachment.FromPath(image)], imagesAreSent: true);
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(document), FileAttachment.FromPath(image)], imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
@ -221,11 +228,123 @@ public sealed class ConversationPartsTests
//
var image = this.WriteFile("photo.png", "not really a png");
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(image)], imagesAreSent: false);
var parts = ConversationParts.Of(null, string.Empty, "Look", [FileAttachment.FromPath(image)], imagesAreSent: false, toolDefinitions: null);
Assert.That(parts.Images, Is.Zero);
}
[Test]
public void ABlockWithoutTextCountsWhileItsToolsAreStillRunning()
{
//
// While a model calls tools there is no text yet: the answer arrives in one piece at the
// end, and everything in between travels with every further round of the same request. The
// block which looks emptiest is therefore the one whose request is growing the fastest --
// and the one which used to be skipped for having nothing to say.
//
var running = Block(string.Empty);
((ContentText)running.Content!).PendingToolConversation = ["What the web search found.", "What the page said."];
var parts = ConversationParts.Of(new() { Blocks = [running] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.Empty);
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "What the web search found.", "What the page said." }));
});
}
[Test]
public void TwoToolResultsWhichReadTheSameCostTwice()
{
//
// The request carries both, so both are paid for. Folding them into one would promise a
// smaller request than the one which is sent -- and a model reading the same page twice is
// not a rare accident but a thing that happens on any busy search.
//
var running = Block(string.Empty);
((ContentText)running.Content!).PendingToolConversation = ["The same page.", "The same page."];
var parts = ConversationParts.Of(new() { Blocks = [running] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.That(parts.GrowingTexts, Is.EqualTo(new[] { "The same page.", "The same page." }));
}
[Test]
public void OnceTheAnswerStandsTheToolConversationIsGone()
{
//
// It travels with the rounds of one request and with nothing afterwards: the next request is
// built from the messages alone. A number which kept counting it would report a window
// fuller than it is, and would never fall back.
//
var answered = Block("Here is what I found.");
var content = (ContentText)answered.Content!;
content.PendingToolConversation = ["What the web search found."];
content.EndToolRun();
var parts = ConversationParts.Of(new() { Blocks = [answered] }, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions: null);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[] { "Here is what I found." }));
Assert.That(parts.GrowingTexts, Is.Empty);
});
}
[Test]
public void TheToolSchemasCountAndTheyCountWithWhatStands()
{
//
// Every request carries the schema of every offered tool, whether or not the model calls a
// single one of them. They belong with the lasting texts: a schema is the same string all
// session long, so its count is worth remembering.
//
var parts = ConversationParts.Of(null, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions:
[
Tool("web_search", "Searches the web.", """{"type":"object"}"""),
Tool("read_web_page", "Reads one page.", """{"type":"string"}"""),
]);
Assert.Multiple(() =>
{
Assert.That(parts.Texts, Is.EqualTo(new[]
{
"""web_searchSearches the web.{"type":"object"}""",
"""read_web_pageReads one page.{"type":"string"}""",
}));
Assert.That(parts.GrowingTexts, Is.Empty);
});
}
[Test]
public void AToolWhichStatesNoArgumentsCountsLikeAnyOther()
{
//
// A definition which never names a parameter schema leaves an empty JSON element behind,
// and asking such an element for its text throws. A tool arriving from a plugin may well
// say nothing about its arguments, and the number under the input field is not the place
// to find that out.
//
var parts = ConversationParts.Of(null, string.Empty, string.Empty, null, imagesAreSent: true, toolDefinitions:
[
new() { Function = new() { Name = "ping", DescriptionForLLM = "Says hello." } },
]);
Assert.That(parts.Texts, Is.EqualTo(new[] { "pingSays hello." }));
}
private static ToolDefinition Tool(string name, string description, string parameterSchema) => new()
{
Function = new()
{
Name = name,
DescriptionForLLM = description,
Parameters = JsonDocument.Parse(parameterSchema).RootElement.Clone(),
},
};
private static ContentBlock Block(string text) => new()
{
ContentType = ContentType.TEXT,

View 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();
}

View File

@ -0,0 +1,72 @@
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.Services;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks what makes the stored embeddings of a data source invalid.
/// </summary>
/// <remarks>
/// The embedding signature decides whether an index survives: when it differs from the one persisted
/// for a data source, everything stored is thrown away and embedded again. That is the right answer
/// for anything a vector depends on, and an expensive mistake for everything else. The confidence
/// level a data source asks of a provider used to be part of it, so changing that one setting
/// re-embedded every file of the source — at a cloud embedding provider, for real money and no gain.
/// </remarks>
[TestFixture]
public sealed class EmbeddingSignatureTests
{
[Test]
public void ChangingTheConfidenceLevelKeepsTheStoredEmbeddings()
{
var low = DataSource(ConfidenceLevel.LOW);
var high = DataSource(ConfidenceLevel.HIGH);
Assert.That(Signature(high), Is.EqualTo(Signature(low)), "The confidence level changes no vector, so the stored index stays valid and nothing is embedded again.");
}
[Test]
public void ChangingTheChunkSizeDropsTheStoredEmbeddings()
{
var small = DataSource(ConfidenceLevel.LOW) with { MaxChunkTokenLength = 512 };
var large = DataSource(ConfidenceLevel.LOW) with { MaxChunkTokenLength = 1024 };
Assert.That(Signature(large), Is.Not.EqualTo(Signature(small)), "Other chunk boundaries mean other vectors, so the index has to be built again.");
}
[Test]
public void ChangingTheEmbeddingModelDropsTheStoredEmbeddings()
{
var dataSource = DataSource(ConfidenceLevel.LOW);
Assert.That(
Signature(dataSource, EmbeddingProviderFor("text-embedding-3-large")),
Is.Not.EqualTo(Signature(dataSource, EmbeddingProviderFor("text-embedding-3-small"))),
"Another model means another vector space, so nothing stored may be kept.");
}
private static string Signature(DataSourceLocalDirectory dataSource, EmbeddingProvider? embeddingProvider = null) =>
DataSourceEmbeddingService.BuildEmbeddingSignature(
dataSource,
embeddingProvider ?? EmbeddingProviderFor("text-embedding-3-small"),
new(512, 100));
private static DataSourceLocalDirectory DataSource(ConfidenceLevel confidenceLevel) => new()
{
Num = 1,
Id = "6f1d6a4e-6a5e-4c62-9a4f-0f2d2c8b7a11",
Name = "Test data",
Description = "Documents used by the tests.",
Type = DataSourceType.LOCAL_DIRECTORY,
EmbeddingId = "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01",
MaxChunkTokenLength = 512,
ChunkOverlapTokenLength = 100,
ConfidenceLevel = confidenceLevel,
Path = "/tmp/test-data",
};
private static EmbeddingProvider EmbeddingProviderFor(string modelId) =>
new(1, "b0a4c4d2-1f3e-4f0a-8c9d-5a6b7c8d9e01", "Test embeddings", LLMProviders.OPEN_AI, new(modelId, modelId));
}

View File

@ -0,0 +1,39 @@
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks what AI Studio assumes about the readers of the formats it writes.
/// </summary>
[TestFixture]
public sealed class FileExportFormatTests
{
[Test]
public void OnlyTheTwoOfficeFormatsRefuseAPageInALocalLink()
{
Assert.Multiple(() =>
{
Assert.That(FileExportFormat.MICROSOFT_WORD.FollowsPageAnchors(), Is.False, "Word looks for a file whose name ends in the fragment, finds none, and refuses the link.");
Assert.That(FileExportFormat.OPEN_DOCUMENT_TEXT.FollowsPageAnchors(), Is.False, "LibreOffice does the same, verified on 2026-09-15 with an exported .odt.");
Assert.That(FileExportFormat.HTML.FollowsPageAnchors(), Is.True, "A browser opens the document on the page the fragment names.");
Assert.That(FileExportFormat.MARKDOWN.FollowsPageAnchors(), Is.True);
Assert.That(FileExportFormat.LATEX.FollowsPageAnchors(), Is.True);
});
}
[Test]
public void EveryFormatAnAnswerIsWrittenAsHasAnAnswerHere()
{
// Whoever adds a format decides what its reader can follow, rather than inheriting an
// assumption. This fails for a format which nobody thought about, because the list below
// has to name it:
Assert.That(FileExportFormatExtensions.ANSWER_FORMATS, Is.EquivalentTo(new[]
{
FileExportFormat.MICROSOFT_WORD,
FileExportFormat.OPEN_DOCUMENT_TEXT,
FileExportFormat.LATEX,
FileExportFormat.MARKDOWN,
FileExportFormat.HTML,
}), "A format was added to or removed from the export menu: say in FollowsPageAnchors whether its reader follows a page in a local link, then name it here.");
}
}

View File

@ -0,0 +1,136 @@
using System.Collections.Concurrent;
using AIStudio.Tools;
namespace AIStudio.Tests.Tools;
/// <summary>
/// Checks that converting several pages to Markdown at the same time keeps them apart.
/// </summary>
/// <remarks>
/// A web search reads up to four result pages in parallel, and every one of them is converted
/// through the same entry point. The converter doing that work tracks the ancestors of the node it
/// is at, and it does so without any synchronization, so sharing one converter between those
/// conversions let them write into each other's ancestor lists.<br/><br/>
/// That went wrong in two ways, and this test covers both. Loudly, as a torn list throwing an index
/// out of range — which is what showed up in the logs. And quietly, as a list indented by the depth
/// a different page happened to be at, which nothing reports and which only a comparison against a
/// known-good conversion catches.
/// </remarks>
[TestFixture]
public sealed class HTMLParserConcurrencyTests
{
private const int THREAD_COUNT = 8;
private const int CONVERSIONS_PER_THREAD = 40;
[Test]
public void ParallelConversionsDoNotInterfereWithEachOther()
{
var html = BuildPageHtml();
// Converted alone, with nothing else running, this is what the page has to come back as:
var expected = HTMLParser.ParseToMarkdown(html);
var results = new ConcurrentBag<string>();
var failures = new ConcurrentBag<Exception>();
//
// Real threads released by a barrier rather than a parallel loop: the conversions have to
// overlap for this test to mean anything, and only starting them together makes that
// certain.
//
// What a thread works with is handed over when it starts rather than captured. The barrier
// is disposed at the end of this method, and while the joins below make sure no thread is
// still at it by then, that is nothing one can see from inside a lambda.
//
using var startSignal = new Barrier(THREAD_COUNT);
var threads = new List<Thread>(THREAD_COUNT);
for (var threadIndex = 0; threadIndex < THREAD_COUNT; threadIndex++)
{
var thread = new Thread(ConvertRepeatedly);
thread.Start(new ConversionRun(startSignal, html, results, failures));
threads.Add(thread);
}
foreach (var thread in threads)
thread.Join();
var failureKinds = string.Join(", ", failures.Select(x => x.GetType().Name).Distinct(StringComparer.Ordinal));
var deviatingCount = results.Count(x => !string.Equals(x, expected, StringComparison.Ordinal));
Assert.Multiple(() =>
{
Assert.That(failures, Is.Empty, $"Converting in parallel threw {failures.Count} times ({failureKinds}). A conversion must not depend on what another thread is converting.");
Assert.That(deviatingCount, Is.Zero, $"{deviatingCount} of {results.Count} conversions came back different from the same page converted on its own. Their indentation was counted from ancestors belonging to another conversion.");
});
}
/// <summary>
/// Converts the same page over and over, once every thread has arrived at the barrier.
/// </summary>
private static void ConvertRepeatedly(object? state)
{
var run = (ConversionRun)state!;
run.StartSignal.SignalAndWait();
for (var conversion = 0; conversion < CONVERSIONS_PER_THREAD; conversion++)
{
try
{
run.Results.Add(HTMLParser.ParseToMarkdown(run.Html));
}
catch (Exception exception)
{
run.Failures.Add(exception);
}
}
}
/// <summary>
/// Builds a page out of the elements the reported stack traces named.
/// </summary>
/// <remarks>
/// The nested lists are what makes this sharp: their indentation is computed from the ancestors
/// the converter is tracking, so a conversion which picked up somebody else's ancestors comes
/// back indented differently rather than failing outright. The block is repeated so that the
/// conversions take long enough to actually overlap.
/// </remarks>
private static string BuildPageHtml()
{
const string BLOCK =
"""
<div>
<p>An introduction to the topic at hand.</p>
<ol>
<li>First item
<ul>
<li>Nested item
<ol>
<li>Deeply nested item</li>
<li>Another one
<ul><li>And one level deeper still</li></ul>
</li>
</ol>
</li>
</ul>
</li>
<li>Second item</li>
</ol>
<table>
<tr><th>Column A</th><th>Column B</th></tr>
<tr>
<td><div><p>A cell holding a paragraph.</p></div></td>
<td><ul><li>A cell holding a list</li><li>with two entries</li></ul></td>
</tr>
</table>
<p>A closing paragraph with <strong>bold</strong> and <em>emphasized</em> text.</p>
</div>
""";
return string.Concat(Enumerable.Repeat(BLOCK, 20));
}
/// <summary>
/// Everything one thread of this test needs, so that it is passed rather than captured.
/// </summary>
private sealed record ConversionRun(Barrier StartSignal, string Html, ConcurrentBag<string> Results, ConcurrentBag<Exception> Failures);
}

View 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,
};
}

View File

@ -85,6 +85,164 @@ public sealed class SourceExtensionsTests
});
}
[Test]
public void TheGroupingIsWhatTheChatAndTheExportBothRead()
{
// Mixed on purpose, and with two sources of one origin, so neither the order of the groups
// nor the order inside a group can come from the order of the input:
IList<Source> sources =
[
new("Handbook", "https://example.org/handbook", SourceOrigin.RAG),
new("Search result", "https://example.org/search", SourceOrigin.TOOL),
new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM),
new("Second handbook", "https://example.org/handbook-2", SourceOrigin.RAG),
];
var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList();
Assert.Multiple(() =>
{
Assert.That(sources.GroupSources(), Has.Count.EqualTo(3), "Each of the three origins has a source, so each of them is a group.");
Assert.That(listed.Select(numbered => numbered.Source.Title), Is.EqualTo(new[] { "Cited by the model", "Search result", "Handbook", "Second handbook" }), "What the AI cited comes first, then what the tools read, then what the data providers gave.");
Assert.That(listed.Select(numbered => numbered.Number), Is.EqualTo(new[] { 1, 2, 3, 4 }), "The number runs through the whole list instead of starting over per group.");
});
}
[Test]
public void AnOriginWithoutSourcesIsNoGroup()
{
IList<Source> sources = [new("Search result", "https://example.org/search", SourceOrigin.TOOL)];
Assert.Multiple(() =>
{
Assert.That(sources.GroupSources().Select(group => group.Sources.Count), Is.EqualTo(new[] { 1 }), "An answer which only used a tool gets one group, not three with two of them empty.");
Assert.That(new List<Source>().GroupSources(), Is.Empty, "An answer nobody had to look up gets no group at all.");
});
}
[Test]
public void TheMarkdownListsExactlyWhatTheGroupingSaysItShould()
{
IList<Source> sources =
[
new("Handbook (Page 12)", "file:///Users/someone/handbook.pdf#page=12", SourceOrigin.RAG),
new("Cited by the model", "https://example.org/cited", SourceOrigin.LLM),
];
var entries = EntriesOf(sources.ToMarkdown());
var listed = sources.GroupSources().SelectMany(group => group.Sources).ToList();
Assert.That(entries, Has.Count.EqualTo(listed.Count), "Every source the grouping lists is written out, and nothing else is.");
for (var index = 0; index < entries.Count; index++)
Assert.That(entries[index], Does.StartWith($"- [{listed[index].Number}] ").And.Contains(listed[index].Source.Title), "The Markdown and the chat read the same grouping, so a source cannot be numbered one way here and another way there.");
}
[Test]
public void AReaderWhichCannotFollowAPageGetsTheDocumentWithoutOne()
{
IList<Source> sources =
[
new("Handbook (Page 266)", "file:///Users/someone/My Documents/handbook.pdf#page=266", SourceOrigin.RAG),
new("An older answer", "file:///Users/someone/handbook.pdf#chunk=3", SourceOrigin.RAG),
new("A section of an article", "https://example.org/article#results", SourceOrigin.LLM),
];
Assert.That(EntriesOf(sources.ToMarkdown(keepPageAnchors: false)), Is.EqualTo(new[]
{
"- [1] [A section of an article](<https://example.org/article#results>)",
"- [2] [Handbook (Page 266)](<file:///Users/someone/My%20Documents/handbook.pdf>)",
"- [3] [An older answer](<file:///Users/someone/handbook.pdf>)",
}), "Word and LibreOffice take the fragment of a local link for part of the file name and refuse the link, so the local links lose it -- and the web link keeps its own, where a fragment names a section of the page and belongs to the address.");
}
[Test]
public void AReaderWhichFollowsAPageIsToldIt()
{
IList<Source> sources = [new("Handbook (Page 266)", "file:///Users/someone/handbook.pdf#page=266", SourceOrigin.RAG)];
Assert.Multiple(() =>
{
Assert.That(EntriesOf(sources.ToMarkdown()).Single(), Does.EndWith("handbook.pdf#page=266>)"), "A browser and a PDF reader open the document where the passage is, so they are told the page.");
Assert.That(EntriesOf(sources.ToExportMarkdown()).Single(), Does.EndWith("handbook.pdf#page=266>)"), "The clipboard and every text format keep it as well; only the two office formats ask for it to be dropped.");
});
}
[Test]
public void AKnownPageRidesInTheLinkOfASource()
{
var location = LocationOf("file:///Users/someone/My%20Documents/Gr%C3%B6%C3%9Fere%20%C3%9Cbersicht.pdf#page=12");
Assert.Multiple(() =>
{
Assert.That(location.Path, Does.EndWith("Größere Übersicht.pdf").And.Contains("My Documents"), "The percent-encoding of the link is undone, so the program is handed the name the file really has.");
Assert.That(location.PageNumber, Is.EqualTo(12), "This is the page the passage was found on, and the page the document is opened at.");
});
}
[Test]
public void APathOfAWindowsMachineComesBackAsOne()
{
var location = LocationOf("file:///C:/Users/someone/Documents/handbook.pdf#page=3");
Assert.Multiple(() =>
{
Assert.That(location.Path, Is.EqualTo(@"C:\Users\someone\Documents\handbook.pdf"), "A drive letter and backslashes are what a program on Windows is handed -- and what the link was made from there.");
Assert.That(location.PageNumber, Is.EqualTo(3));
});
}
[Test]
public void AChatFromBeforeThisReleaseKeepsItsDocumentAndLosesOnlyItsPage()
{
var location = LocationOf("file:///Users/someone/handbook.pdf#chunk=3");
Assert.Multiple(() =>
{
Assert.That(location.Path, Does.EndWith("handbook.pdf"), "Such a source still names its document, so the click still opens it.");
Assert.That(location.PageNumber, Is.Null, "A chunk is not a page: no program can be sent to one, so the document opens on its first page.");
});
}
[Test]
public void ALinkWithoutAFragmentNamesNoPage()
{
Assert.That(LocationOf("file:///Users/someone/handbook.pdf").PageNumber, Is.Null);
}
[Test]
public void APageWhichIsNoPageIsReadAsNone()
{
Assert.Multiple(() =>
{
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=0").PageNumber, Is.Null, "Pages are counted from one, so a zero is not a page.");
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=-2").PageNumber, Is.Null);
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#page=twelve").PageNumber, Is.Null);
Assert.That(LocationOf("file:///Users/someone/handbook.pdf#chunk=3&page=12").PageNumber, Is.EqualTo(12), "A link which already carried a fragment gets the page appended with an ampersand, and it is found there too.");
});
}
[Test]
public void AWebSourceNamesNoDocumentAtAll()
{
// The fragment reads like a page on purpose: what decides is the scheme, not the fragment.
ISource source = new Source("Article", "https://example.org/article#page=12", SourceOrigin.LLM);
Assert.That(source.TryGetDocumentLocation(out _), Is.False, "A web source is opened by the browser and has no path to hand to a program.");
}
/// <summary>
/// Reads where the link of a source points, and fails the test when it points nowhere.
/// </summary>
/// <param name="url">The link of the source.</param>
/// <returns>The document and the page the link names.</returns>
private static SourceDocumentLocation LocationOf(string url)
{
ISource source = new Source("Handbook", url, SourceOrigin.RAG);
Assert.That(source.TryGetDocumentLocation(out var location), Is.True, "This link names a file, so a location is what it has.");
return location;
}
/// <summary>
/// Reads the entries of a source list, without the headings above them.
/// </summary>

View File

@ -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.");
}
}

View File

@ -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}'");

View File

@ -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]

View File

@ -86,8 +86,6 @@ pub struct QdrantEdgeStoragePoint {
pub creation_utc: String,
pub last_write_utc: String,
pub embedded_at_utc: String,
pub confidence_level: String,
pub confidence_level_rank: i32,
}
#[derive(Deserialize)]
@ -159,8 +157,6 @@ pub struct QdrantEdgeSearchResult {
pub creation_utc: String,
pub last_write_utc: String,
pub embedded_at_utc: String,
pub confidence_level: String,
pub confidence_level_rank: i32,
}
#[derive(Clone, Serialize)]
@ -758,8 +754,6 @@ fn to_qdrant_edge_point(point: QdrantEdgeStoragePoint) -> QdrantEdgeResult<qdran
"creation_utc": point.creation_utc,
"last_write_utc": point.last_write_utc,
"embedded_at_utc": point.embedded_at_utc,
"confidence_level": point.confidence_level,
"confidence_level_rank": point.confidence_level_rank,
}),
)
.into())
@ -787,8 +781,6 @@ fn to_qdrant_edge_search_result(point: ScoredPoint) -> QdrantEdgeSearchResult {
creation_utc: payload_string(&payload, "creation_utc"),
last_write_utc: payload_string(&payload, "last_write_utc"),
embedded_at_utc: payload_string(&payload, "embedded_at_utc"),
confidence_level: payload_string(&payload, "confidence_level"),
confidence_level_rank: payload_i32(&payload, "confidence_level_rank").unwrap_or_default(),
}
}

View File

@ -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))