diff --git a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua index ad76c293..93a41d56 100644 --- a/app/MindWork AI Studio/Assistants/I18N/allTexts.lua +++ b/app/MindWork AI Studio/Assistants/I18N/allTexts.lua @@ -3907,6 +3907,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." @@ -11578,9 +11581,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 +11593,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." diff --git a/app/MindWork AI Studio/Chat/ContentText.cs b/app/MindWork AI Studio/Chat/ContentText.cs index d86424cb..661dcc95 100644 --- a/app/MindWork AI Studio/Chat/ContentText.cs +++ b/app/MindWork AI Studio/Chat/ContentText.cs @@ -55,6 +55,49 @@ public sealed class ContentText : IContent [JsonIgnore] public ToolRuntimeStatus ToolRuntimeStatus { get; set; } = new(); + /// + /// What the tool conversation of the running request adds to it, as far as it has got. + /// + /// + /// 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.

+ /// 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.

+ /// 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. + ///
+ [JsonIgnore] + public IReadOnlyList PendingToolConversation { get; set; } = []; + + /// + /// Clears what the previous run of the tools left behind. + /// + /// + /// 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. + /// + public void BeginToolRun() + { + this.ToolInvocations.Clear(); + this.PendingToolConversation = []; + } + + /// + /// Says that no request is running anymore. + /// + /// + /// 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. + /// + public void EndToolRun() => this.PendingToolConversation = []; + /// public async Task 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(); } diff --git a/app/MindWork AI Studio/Chat/ConversationParts.cs b/app/MindWork AI Studio/Chat/ConversationParts.cs index 3c6e8dc1..f3683d26 100644 --- a/app/MindWork AI Studio/Chat/ConversationParts.cs +++ b/app/MindWork AI Studio/Chat/ConversationParts.cs @@ -1,3 +1,7 @@ +using System.Text.Json; + +using AIStudio.Tools.ToolCallingSystem; + namespace AIStudio.Chat; /// @@ -6,9 +10,15 @@ namespace AIStudio.Chat; /// /// 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. /// public sealed record ConversationParts { @@ -23,13 +33,17 @@ public sealed record ConversationParts public IReadOnlyList Texts { get; init; } = []; /// - /// The texts which are still being written. + /// The texts which belong to this moment alone. /// /// /// 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. /// public IReadOnlyList GrowingTexts { get; init; } = []; @@ -48,7 +62,9 @@ public sealed record ConversationParts /// /// /// 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. /// /// The conversation so far, or null when there is none yet. /// @@ -59,8 +75,12 @@ public sealed record ConversationParts /// What stands in the composer. /// What is attached to the composer. /// Whether the model takes images at all. When it does not, none are sent. + /// + /// The tools the model may call, filtered for the provider the same way they are before + /// sending, or null when there are none. + /// /// The parts of the conversation. - public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable? draftAttachments, bool imagesAreSent) + public static ConversationParts Of(ChatThread? thread, string systemPrompt, string draft, IEnumerable? draftAttachments, bool imagesAreSent, IEnumerable? toolDefinitions) { var texts = new List(); var growing = new List(); @@ -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 }; } + /// + /// What one tool costs the request it is offered in. + /// + /// + /// 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. + /// + /// The tool as it was declared. + /// The text to count for it. + 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}"; + } + /// /// Puts attachments into the two groups they are counted in. /// diff --git a/app/MindWork AI Studio/Components/ChatComponent.razor.cs b/app/MindWork AI Studio/Components/ChatComponent.razor.cs index 9ffe4b5b..a11730ba 100644 --- a/app/MindWork AI Studio/Components/ChatComponent.razor.cs +++ b/app/MindWork AI Studio/Components/ChatComponent.razor.cs @@ -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. /// /// The thread to build the prompt for. + /// The tools whose policy the prompt states. /// The system prompt as it would be sent. - 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 toolDefinitions) => thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text; - return thread.BuildSystemPrompt(this.SettingsManager, toolDefinitions).Text; - } + /// + /// The tools the next request would offer the model. + /// + /// + /// 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. + /// + /// The definitions of the selected tools. + private IReadOnlyList GetRunnableToolDefinitions() => this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds) + .Select(this.ToolRegistry.GetDefinition) + .Where(definition => definition is not null) + .Select(definition => definition!) + .ToList(); /// /// The thread a new chat starts with, as the selections made so far decide it. diff --git a/app/MindWork AI Studio/Components/DataSourceSelection.razor b/app/MindWork AI Studio/Components/DataSourceSelection.razor index dfb1ba36..de7ae518 100644 --- a/app/MindWork AI Studio/Components/DataSourceSelection.razor +++ b/app/MindWork AI Studio/Components/DataSourceSelection.razor @@ -7,11 +7,11 @@ @if (this.PopoverTriggerMode is PopoverTriggerMode.ICON) { - + } else { - + @T("Select data") } @@ -19,13 +19,13 @@ - + - @T("Data Source Selection") + @@ -33,7 +33,7 @@ - + @if (this.waitingForDataSources) { @@ -42,7 +42,7 @@ } else if (this.SettingsManager.ConfigurationData.DataSources.Count == 0) { - + @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).") @@ -57,51 +57,51 @@ } else if (this.showDataSourceSelection) { - + @if (this.areDataSourcesEnabled) { - + @if (this.SettingsManager.ConfigurationData.AgentRetrievalContextValidation.EnableRetrievalContextValidation) { - + } @switch (this.aiBasedSourceSelection) { case true when this.availableDataSources.Count == 0: - + @T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.") break; case true when this.DataSourcesAISelected.Count == 0: - + @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.") break; case false when this.availableDataSources.Count == 0: - + @T("Your data sources cannot be used with the selected providers due to data privacy or confidence-level requirements, or they are currently unavailable.") break; case false: - - + + @foreach (var source in this.availableDataSources) { - + @source.Name @if (source is IInternalDataSource internalSource) { - + } @@ -113,20 +113,20 @@ case true: - - + + @foreach (var source in this.availableDataSources) { - + @source.Name @if (source is IInternalDataSource internalSource) { - + } @@ -134,21 +134,21 @@ } - - + + @foreach (var source in this.DataSourcesAISelected) { - + @source.DataSource.Name @if (source.DataSource is IInternalDataSource internalSource) { - + } @@ -165,11 +165,24 @@ break; } + + @if (!this.aiBasedSourceSelection && this.GetUnavailablePreselectedDataSources().Count > 0) + { + + @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:") + +
    + @foreach (var source in this.GetUnavailablePreselectedDataSources()) + { +
  • @source.Name
  • + } +
+ } } }
- + @T("Close") @@ -187,7 +200,7 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE) @if (!string.IsNullOrWhiteSpace(this.ConfigurationHeaderMessage)) { - + @this.ConfigurationHeaderMessage } @@ -198,19 +211,19 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE) - + @foreach (var source in this.availableDataSources) { - + @source.Name @if (source is IInternalDataSource internalSource) { - + } @@ -220,4 +233,4 @@ else if (this.SelectionMode is DataSourceSelectionMode.CONFIGURATION_MODE) } -} +} \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs b/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs index 9ad90e26..18ddf6a8 100644 --- a/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs +++ b/app/MindWork AI Studio/Components/DataSourceSelection.razor.cs @@ -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(); } + + /// + /// Collects the preselected data sources which the filters removed. + /// + /// + /// 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. + /// + /// The unusable preselected data sources, or an empty list when there are none. + private IReadOnlyList 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? 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(); } diff --git a/app/MindWork AI Studio/Components/DataSourceSelection.razor.css b/app/MindWork AI Studio/Components/DataSourceSelection.razor.css new file mode 100644 index 00000000..73c446c8 --- /dev/null +++ b/app/MindWork AI Studio/Components/DataSourceSelection.razor.css @@ -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; +} diff --git a/app/MindWork AI Studio/Components/MudTextSwitch.razor b/app/MindWork AI Studio/Components/MudTextSwitch.razor index 353ac8b8..7f9c65ce 100644 --- a/app/MindWork AI Studio/Components/MudTextSwitch.razor +++ b/app/MindWork AI Studio/Components/MudTextSwitch.razor @@ -1,5 +1,5 @@ - - + + @(this.Value ? this.LabelOn : this.LabelOff) \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/MudTextSwitch.razor.cs b/app/MindWork AI Studio/Components/MudTextSwitch.razor.cs index 2bce2c27..e1ca7f7a 100644 --- a/app/MindWork AI Studio/Components/MudTextSwitch.razor.cs +++ b/app/MindWork AI Studio/Components/MudTextSwitch.razor.cs @@ -27,4 +27,19 @@ public partial class MudTextSwitch : ComponentBase [Parameter] public string LabelOff { get; set; } = string.Empty; + + /// + /// Whether to render this switch in its compact form. + /// + /// + /// 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. + /// + [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; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/PreviewBeta.razor b/app/MindWork AI Studio/Components/PreviewBeta.razor index 5494f51a..9cd66969 100644 --- a/app/MindWork AI Studio/Components/PreviewBeta.razor +++ b/app/MindWork AI Studio/Components/PreviewBeta.razor @@ -1,7 +1,7 @@ @inherits MSGComponentBase - + @T("Beta") diff --git a/app/MindWork AI Studio/Components/PreviewBeta.razor.cs b/app/MindWork AI Studio/Components/PreviewBeta.razor.cs index d73a9c53..b06089cd 100644 --- a/app/MindWork AI Studio/Components/PreviewBeta.razor.cs +++ b/app/MindWork AI Studio/Components/PreviewBeta.razor.cs @@ -7,5 +7,16 @@ public partial class PreviewBeta : MSGComponentBase [Parameter] public bool ApplyInnerScrollingFix { get; set; } + /// + /// Additional class names for the chip itself, separated by space. + /// + /// + /// 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. + /// + [Parameter] + public string ChipClass { get; set; } = "mb-3"; + private string Classes => this.ApplyInnerScrollingFix ? "InnerScrollingFix" : string.Empty; } \ No newline at end of file diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor index 0ed3b81d..31db52cb 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelEmbeddings.razor @@ -93,8 +93,6 @@ } - - @T("Add Embedding") - + } diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor index 26383ee2..7fd0d9da 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelProviders.razor @@ -78,5 +78,5 @@ } - + diff --git a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor index 67fbf767..178d7185 100644 --- a/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor +++ b/app/MindWork AI Studio/Components/Settings/SettingsPanelTranscription.razor @@ -83,8 +83,6 @@ } - - @T("Add transcription provider") - + } diff --git a/app/MindWork AI Studio/Components/ToolSelection.razor b/app/MindWork AI Studio/Components/ToolSelection.razor index 439c77b0..8763cc42 100644 --- a/app/MindWork AI Studio/Components/ToolSelection.razor +++ b/app/MindWork AI Studio/Components/ToolSelection.razor @@ -58,11 +58,15 @@ Disabled="@this.IsRowDisabled(item)" OnClick="@(async () => await this.ToggleToolFromRow(item))"> @* - 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. *@ - + @if (!item.IsActive) { diff --git a/app/MindWork AI Studio/Plugins/configuration/plugin.lua b/app/MindWork AI Studio/Plugins/configuration/plugin.lua index e3d5fd75..e183f41d 100644 --- a/app/MindWork AI Studio/Plugins/configuration/plugin.lua +++ b/app/MindWork AI Studio/Plugins/configuration/plugin.lua @@ -385,9 +385,16 @@ CONFIG["SETTINGS"] = {} -- A short notification is still shown when this setting is disabled. -- CONFIG["SETTINGS"]["DataApp.ShowPromptInjectionAlert"] = true --- Configure the user permission to add providers: +-- Configure the master permission to add providers. When set to false, the add +-- buttons stay visible but are disabled regardless of the provider-specific settings. -- CONFIG["SETTINGS"]["DataApp.AllowUserToAddProvider"] = false +-- Fine-tune the permission to add each provider type. These settings only allow +-- adding providers while DataApp.AllowUserToAddProvider is also true. +-- CONFIG["SETTINGS"]["DataApp.AllowUserToAddLLMProvider"] = false +-- CONFIG["SETTINGS"]["DataApp.AllowUserToAddEmbeddingProvider"] = false +-- CONFIG["SETTINGS"]["DataApp.AllowUserToAddTranscriptionProvider"] = false + -- Configure the user permission to import plugin archives from disk. -- When set to false, the import button on the plugins page stays visible but is disabled. -- CONFIG["SETTINGS"]["DataApp.AllowUserToImportPlugins"] = false diff --git a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua index 2d05b223..3fcacd9c 100644 --- a/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/de-de-43065dbc-78d0-45b7-92be-f14c2926e2dc/plugin.lua @@ -3354,7 +3354,7 @@ UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3267850764"] = "Das ausgewählte -- We could load models from '{0}', but the provider did not return any usable text models. UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T3378120620"] = "Wir konnten Modelle von '{0}' laden, aber der Anbieter hat keine verwendbaren Textmodelle zurückgegeben." --- Ihre Datenquellen konnten nicht verwendet werden. Diese Antwort wurde ohne sie erstellt. +-- Your data sources could not be used. This answer was created without them. UI_TEXT_CONTENT["AISTUDIO::CHAT::CONTENTTEXT::T373499115"] = "Ihre Datenquellen konnten nicht verwendet werden. Diese Antwort wurde ohne sie erstellt." -- The local image file does not exist. Skipping the image. @@ -3909,6 +3909,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." @@ -10473,7 +10476,7 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3267850764"] = "Das aus -- We could load models from '{0}', but the provider did not return any usable text models. UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T3378120620"] = "Wir konnten Modelle von „{0}“ laden, aber der Anbieter hat keine verwendbaren Textmodelle zurückgegeben." --- Ihre Datenquellen konnten nicht verwendet werden. Diese Antwort wurde ohne sie erstellt. +-- Your data sources could not be used. This answer was created without them. UI_TEXT_CONTENT["AISTUDIO::TOOLS::AIJOBS::AIJOBSERVICE::T373499115"] = "Ihre Datenquellen konnten nicht verwendet werden. Diese Antwort wurde ohne sie erstellt." -- Software Development @@ -11586,8 +11589,8 @@ UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T -- 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"] = "Dies ist der Standard-Erweiterungsprozess, bei dem alle abgerufenen Kontexte verwendet werden, um den Chatverlauf zu ergänzen." --- Die Prüfung, welche Textpassagen zu Ihrer Frage passen, ist fehlgeschlagen. Für diese Antwort werden alle gefundenen Textpassagen verwendet. -UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T392269104"] = "Die Prüfung, welche Textpassagen zu Ihrer Frage passen, ist fehlgeschlagen. Für diese Antwort werden alle gefundenen Textpassagen verwendet." +-- The check of which passages fit your question failed. This answer uses all passages that were found. +UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::AUGMENTATIONPROCESSES::AUGMENTATIONONE::T392269104"] = "Die Prüfung, welche Textstellen zu Ihrer Frage passen, ist fehlgeschlagen. Diese Antwort verwendet alle gefundenen Textstellen." -- Automatic AI data source selection with heuristik source reduction UI_TEXT_CONTENT["AISTUDIO::TOOLS::RAG::DATASOURCESELECTIONPROCESSES::AGENTICSRCSELWITHDYNHEUR::T2339257645"] = "Automatische Auswahl der Datenquellen mittels KI und mit heuristischer Datenquellen-Reduktion" diff --git a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua index 33cc1326..fc875155 100644 --- a/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua +++ b/app/MindWork AI Studio/Plugins/languages/en-us-97dfb1ba-50c4-4440-8dfa-6575daf543c8/plugin.lua @@ -3909,6 +3909,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." diff --git a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs index 9843b362..32be87e3 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/AnthropicToolCallingAdapter.cs @@ -19,9 +19,13 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList internalMessages = []; private readonly List pendingToolResults = []; + private readonly List recordedRequestTexts = []; private readonly List tools = runnableTools.Select(x => ProviderToolAdapters.ToAnthropicTool(x.Definition)).ToList(); private AnthropicResponse? lastResponse; + /// + public IReadOnlyList RecordedRequestTexts => this.recordedRequestTexts; + /// public async Task ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default) { @@ -76,13 +80,30 @@ public sealed class AnthropicToolCallingAdapter(Model chatModel, IList - 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); + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs index cab47ab9..71138df9 100644 --- a/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs +++ b/app/MindWork AI Studio/Provider/Anthropic/ProviderAnthropic.cs @@ -81,7 +81,7 @@ public sealed class ProviderAnthropic() : BaseProvider(LLMProviders.ANTHROPIC, n var toolRegistry = Program.SERVICE_PROVIDER.GetService(); var toolExecutor = Program.SERVICE_PROVIDER.GetService(); 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 diff --git a/app/MindWork AI Studio/Provider/BaseProvider.cs b/app/MindWork AI Studio/Provider/BaseProvider.cs index 2c15830c..76541bbb 100644 --- a/app/MindWork AI Studio/Provider/BaseProvider.cs +++ b/app/MindWork AI Studio/Provider/BaseProvider.cs @@ -1270,7 +1270,7 @@ public abstract class BaseProvider : IProvider, ISecretId var toolRegistry = Program.SERVICE_PROVIDER.GetService(); var toolExecutor = Program.SERVICE_PROVIDER.GetService(); 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) diff --git a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs index 594efc43..5c34b4f0 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ChatCompletionToolCallingAdapter.cs @@ -22,9 +22,13 @@ public sealed class ChatCompletionToolCallingAdapter( : IToolCallingProviderAdapter where TRequest : ChatCompletionAPIRequest { private readonly List internalMessages = []; + private readonly List recordedRequestTexts = []; private ChatCompletionResponseMessage? lastResponseMessage; private List lastToolCalls = []; + /// + public IReadOnlyList RecordedRequestTexts => this.recordedRequestTexts; + /// public async Task ExecuteRoundAsync(string? finalResponseInstruction, bool includeTools, CancellationToken token = default) { @@ -79,23 +83,55 @@ public sealed class ChatCompletionToolCallingAdapter( } /// - 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}"); + } /// /// /// Chat Completions has no error flag on a tool message, so a failure travels in the content /// like any other result. /// - 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); + } + + /// + /// Notes one piece of text as part of what the next round sends. + /// + /// + /// 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. + /// + private void Record(string? text) + { + if (!string.IsNullOrWhiteSpace(text)) + this.recordedRequestTexts.Add(text); + } /// /// Normalizes the tool calls of one response. diff --git a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs index 7de4e08f..92c9d959 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ProviderOpenAI.cs @@ -176,7 +176,7 @@ public sealed class ProviderOpenAI() : BaseProvider(LLMProviders.OPEN_AI, new Ur var toolExecutor = Program.SERVICE_PROVIDER.GetService(); 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 ? [] diff --git a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs index 01486557..a308ce75 100644 --- a/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs +++ b/app/MindWork AI Studio/Provider/OpenAI/ResponsesToolCallingAdapter.cs @@ -16,8 +16,12 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b Func> executeRequestAsync) : IToolCallingProviderAdapter { private readonly List internalItems = []; + private readonly List recordedRequestTexts = []; private ResponsesResponse? lastResponse; + /// + public IReadOnlyList RecordedRequestTexts => this.recordedRequestTexts; + /// /// The tools offered to the model: the provider-native ones plus our local functions. /// @@ -77,7 +81,17 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList 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()); + } } /// @@ -85,11 +99,17 @@ public sealed class ResponsesToolCallingAdapter(Model chatModel, IList b /// The Responses API has no error flag on a function call output, so a failure travels in the /// output like any other result. /// - 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 BuildEffectiveProviderTools(IList providerTools, IReadOnlyList<(ToolDefinition Definition, IToolImplementation Implementation)> runnableTools) { diff --git a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs index 75771266..055992ce 100644 --- a/app/MindWork AI Studio/Settings/DataModel/DataApp.cs +++ b/app/MindWork AI Studio/Settings/DataModel/DataApp.cs @@ -154,6 +154,21 @@ public sealed class DataApp(Expression>? configSelection = n /// public bool AllowUserToAddProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddProvider, true); + /// + /// Should the user be allowed to add LLM providers? + /// + public bool AllowUserToAddLLMProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddLLMProvider, true); + + /// + /// Should the user be allowed to add embedding providers? + /// + public bool AllowUserToAddEmbeddingProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddEmbeddingProvider, true); + + /// + /// Should the user be allowed to add transcription providers? + /// + public bool AllowUserToAddTranscriptionProvider { get; set; } = ManagedConfiguration.Register(configSelection, n => n.AllowUserToAddTranscriptionProvider, true); + /// /// Should the user be allowed to import plugin archives from disk? /// diff --git a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs index e48747da..de20d7ec 100644 --- a/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs +++ b/app/MindWork AI Studio/Tools/AIJobs/AIJobService.cs @@ -28,6 +28,15 @@ public sealed class AIJobService(SettingsManager settingsManager, MessageBus mes public DateTimeOffset LastCheckpoint { get; set; } + /// + /// When the chat was last told that something happened which was not a streamed chunk. + /// + /// + /// 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. + /// + 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; } + /// + /// Says that the answer of a chat has moved without a chunk having arrived. + /// + /// + /// 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. + /// + /// The chat whose answer moved. + 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 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); diff --git a/app/MindWork AI Studio/Tools/HTMLParser.cs b/app/MindWork AI Studio/Tools/HTMLParser.cs index a5095830..9e2ba1ad 100644 --- a/app/MindWork AI Studio/Tools/HTMLParser.cs +++ b/app/MindWork AI Studio/Tools/HTMLParser.cs @@ -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; /// - /// The HTML to Markdown converter, built once from a fixed configuration. + /// The fixed configuration every HTML to Markdown conversion runs with. /// /// - /// 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. /// - private static readonly Converter MARKDOWN_CONVERTER = new(new Config + private static readonly Config MARKDOWN_CONFIG = new() { UnknownTags = Config.UnknownTagsOption.Bypass, RemoveComments = true, SmartHrefHandling = true, - }); + }; + + /// + /// The converters not currently in use, kept so that the reflection in their constructor does + /// not run for every page. + /// + /// + /// 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.

+ /// 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. + ///

+ /// The pool holds no more converters than are ever converting at once, which is a handful. + ///
+ private static readonly ConcurrentBag CONVERTER_POOL = []; /// /// Loads a web page. @@ -238,5 +260,21 @@ public sealed class HTMLParser /// /// The HTML content to parse. /// The converted Markdown content. - public static string ParseToMarkdown(string html) => MARKDOWN_CONVERTER.Convert(html); + /// + /// 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. + /// + 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; + } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs index f253116f..45158b00 100644 --- a/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs +++ b/app/MindWork AI Studio/Tools/PluginSystem/PluginConfiguration.cs @@ -241,6 +241,15 @@ public sealed class PluginConfiguration(bool isInternal, LuaState state, PluginT // Config: allow the user to add providers? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddProvider, this.Id, settingsTable, dryRun); + // Config: allow the user to add LLM providers? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddLLMProvider, this.Id, settingsTable, dryRun); + + // Config: allow the user to add embedding providers? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddEmbeddingProvider, this.Id, settingsTable, dryRun); + + // Config: allow the user to add transcription providers? + ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToAddTranscriptionProvider, this.Id, settingsTable, dryRun); + // Config: allow the user to import plugin archives? ManagedConfiguration.TryProcessConfiguration(x => x.App, x => x.AllowUserToImportPlugins, this.Id, settingsTable, dryRun); diff --git a/app/MindWork AI Studio/Tools/RAG/AugmentationProcesses/AugmentationOne.cs b/app/MindWork AI Studio/Tools/RAG/AugmentationProcesses/AugmentationOne.cs index 97ebb1b5..a3553856 100644 --- a/app/MindWork AI Studio/Tools/RAG/AugmentationProcesses/AugmentationOne.cs +++ b/app/MindWork AI Studio/Tools/RAG/AugmentationProcesses/AugmentationOne.cs @@ -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."))); } } diff --git a/app/MindWork AI Studio/Tools/RAG/RAGProcesses/AISrcSelWithRetCtxVal.cs b/app/MindWork AI Studio/Tools/RAG/RAGProcesses/AISrcSelWithRetCtxVal.cs index a93f28c6..b7958a8e 100644 --- a/app/MindWork AI Studio/Tools/RAG/RAGProcesses/AISrcSelWithRetCtxVal.cs +++ b/app/MindWork AI Studio/Tools/RAG/RAGProcesses/AISrcSelWithRetCtxVal.cs @@ -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 { diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/IToolCallingProviderAdapter.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/IToolCallingProviderAdapter.cs index d4edc16f..2d941ab0 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/IToolCallingProviderAdapter.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/IToolCallingProviderAdapter.cs @@ -49,4 +49,19 @@ public interface IToolCallingProviderAdapter /// the others carry the failure in the content, which is where it has to be legible anyway. /// public void RecordToolResult(string callId, string content, bool isError = false); + + /// + /// The texts which everything recorded so far adds to the request of every following round. + /// + /// + /// 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.

+ /// 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. + ///
+ public IReadOnlyList RecordedRequestTexts { get; } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs index e9c897eb..c169dba7 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoop.cs @@ -114,6 +114,7 @@ public sealed class ToolCallingLoop(ILogger 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 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 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 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 diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoopContext.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoopContext.cs index 49a1b19a..79b4ee9b 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoopContext.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/Harness/ToolCallingLoopContext.cs @@ -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); + } + + /// + /// Hands the conversation the adapter has accumulated to the assistant message. + /// + /// + /// 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. + /// + /// The adapter of this run, which knows what it has recorded. + public async Task PublishPendingToolConversationAsync(IToolCallingProviderAdapter adapter) + { + if (this.CurrentAssistantContent is null) + return; + + this.CurrentAssistantContent.PendingToolConversation = [..adapter.RecordedRequestTexts]; + await this.AnnounceAsync(this.CurrentAssistantContent); } /// @@ -72,7 +91,7 @@ public sealed class ToolCallingLoopContext ToolNames = [.. toolNames], }; - await this.CurrentAssistantContent.StreamingEvent(); + await this.AnnounceAsync(this.CurrentAssistantContent); } /// @@ -88,6 +107,32 @@ public sealed class ToolCallingLoopContext return; this.CurrentAssistantContent.ToolRuntimeStatus = new(); - await this.CurrentAssistantContent.StreamingEvent(); + await this.AnnounceAsync(this.CurrentAssistantContent); + } + + /// + /// Says that something about the running answer has changed. + /// + /// + /// 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.

+ /// 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. + ///
+ /// The assistant message which changed. + 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(); + if (jobService is not null) + await jobService.NotifyChatActivityAsync(this.ChatThread.ChatId); } } \ No newline at end of file diff --git a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchResultRetrievalService.cs b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchResultRetrievalService.cs index c3a9f0ed..a8aab398 100644 --- a/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchResultRetrievalService.cs +++ b/app/MindWork AI Studio/Tools/ToolCallingSystem/ToolCallingImplementations/WebSearch/WebSearchResultRetrievalService.cs @@ -4,6 +4,8 @@ namespace AIStudio.Tools.ToolCallingSystem.ToolCallingImplementations.WebSearch; internal sealed class WebSearchResultRetrievalService(WebPageRetrievalService webPageRetrievalService) { + private static readonly ILogger LOGGER = Program.LOGGER_FACTORY.CreateLogger(); + private const int MAX_PARALLEL_RETRIEVALS = 4; /// @@ -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 diff --git a/app/MindWork AI Studio/Tools/Web/WebPageContentExtractor.cs b/app/MindWork AI Studio/Tools/Web/WebPageContentExtractor.cs index 2576f11c..c4ff6342 100644 --- a/app/MindWork AI Studio/Tools/Web/WebPageContentExtractor.cs +++ b/app/MindWork AI Studio/Tools/Web/WebPageContentExtractor.cs @@ -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 }; } + /// + /// Converts the readable part of the page to Markdown. + /// + /// + /// 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.

+ /// 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. + ///
+ 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; diff --git a/app/MindWork AI Studio/wwwroot/app.css b/app/MindWork AI Studio/wwwroot/app.css index 9cfd455d..13e53304 100644 --- a/app/MindWork AI Studio/wwwroot/app.css +++ b/app/MindWork AI Studio/wwwroot/app.css @@ -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; +} diff --git a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md index 85a6cdc2..7a519234 100644 --- a/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md +++ b/app/MindWork AI Studio/wwwroot/changelog/v26.9.1.md @@ -11,7 +11,7 @@ - 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. @@ -24,6 +24,7 @@ - Improved the app icon. The previous one was generated by an image model; the new one was created based on it and keeps the familiar green landscape with the chat bubble. Because it is now a vector drawing, it stays sharp everywhere it appears: in your taskbar or dock, in the window list, and on the start screen while AI Studio is loading. - Improved how AI Studio works out what a model can do. Every model family now stands on its own, together with the page it was read from, and our build refuses rules which contradict each other or name no source. That way, mistakes are caught before they ever reach you. - Improved the list of your attached files: every file now appears under the folder it came from, and each folder is named only once, no matter in which order you attached your files. +- Improved organization-wide provider management: IT departments can now separately prevent users from adding chat, transcription, or embedding providers. The existing master setting still overrides all three provider-specific settings. - Changed how provider trust and provider confidence work together. Marking a provider as trustworthy in a configuration no longer also satisfies a required confidence level: one says who runs the provider, the other how confidential it is. Organizations raise a provider's level in their own confidence scheme instead. This applies beyond local data sources, for example, when a model reads a page from your intranet. - Fixed the abilities AI Studio assumed for many models. We checked the families against their documentation: some models gained image input, reasoning, or tool calling, others lost an ability they never had. - Fixed model names that a provider writes in its own way not being recognized at all, such as the colon Ollama puts before the variant. Those models were treated as plain text models and lost every other ability. @@ -40,7 +41,8 @@ - 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. - 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. diff --git a/app/Tests/Chat/ConversationPartsTests.cs b/app/Tests/Chat/ConversationPartsTests.cs index de1d3452..8972be9a 100644 --- a/app/Tests/Chat/ConversationPartsTests.cs +++ b/app/Tests/Chat/ConversationPartsTests.cs @@ -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. /// [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, diff --git a/app/Tests/Tools/HTMLParserConcurrencyTests.cs b/app/Tests/Tools/HTMLParserConcurrencyTests.cs new file mode 100644 index 00000000..86600910 --- /dev/null +++ b/app/Tests/Tools/HTMLParserConcurrencyTests.cs @@ -0,0 +1,136 @@ +using System.Collections.Concurrent; +using AIStudio.Tools; + +namespace AIStudio.Tests.Tools; + +/// +/// Checks that converting several pages to Markdown at the same time keeps them apart. +/// +/// +/// 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.

+/// 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. +///
+[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(); + var failures = new ConcurrentBag(); + + // + // 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_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."); + }); + } + + /// + /// Converts the same page over and over, once every thread has arrived at the barrier. + /// + 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); + } + } + } + + /// + /// Builds a page out of the elements the reported stack traces named. + /// + /// + /// 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. + /// + private static string BuildPageHtml() + { + const string BLOCK = + """ +
+

An introduction to the topic at hand.

+
    +
  1. First item +
      +
    • Nested item +
        +
      1. Deeply nested item
      2. +
      3. Another one +
        • And one level deeper still
        +
      4. +
      +
    • +
    +
  2. +
  3. Second item
  4. +
+ + + + + + +
Column AColumn B

A cell holding a paragraph.

  • A cell holding a list
  • with two entries
+

A closing paragraph with bold and emphasized text.

+
+ """; + + return string.Concat(Enumerable.Repeat(BLOCK, 20)); + } + + /// + /// Everything one thread of this test needs, so that it is passed rather than captured. + /// + private sealed record ConversionRun(Barrier StartSignal, string Html, ConcurrentBag Results, ConcurrentBag Failures); +} \ No newline at end of file