Added tool calling support (#731)

Co-authored-by: krut_ni <nils.kruthoff@dlr.de>
Co-authored-by: Thorsten Sommer <SommerEngineering@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-09-04 15:48:07 +02:00
1 parent b00c3f9ab3
commit 4d8d30e15e
266 files changed
+11186 -740

No files matched your search

@@ -33,18 +33,27 @@
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="@state.AuditColor">
@state.AuditLabel
</MudChip>
@if (!string.IsNullOrWhiteSpace(state.SourceLabel))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Filled" Color="@state.SourceColor" Icon="@state.SourceIcon">
@state.SourceLabel
</MudChip>
}
@if (!string.IsNullOrWhiteSpace(state.AvailabilityLabel))
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="@state.AvailabilityColor" Icon="@state.AvailabilityIcon">
@state.AvailabilityLabel
</MudChip>
}
@if (this.PluginToolIds.Count > 0)
{
<MudChip T="string" Size="Size.Small" Variant="Variant.Outlined" Color="Color.Info" Icon="@Icons.Material.Filled.Handyman">
@this.GetToolCountLabel()
</MudChip>
}
</div>
<MudText Typo="Typo.body2" Class="mud-text-secondary">
@state.Headline
@@ -135,6 +144,15 @@
</td>
<td><MudText Typo="Typo.body2">@state.SourceLabel</MudText></td>
</tr>
@if (this.PluginToolIds.Count > 0)
{
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Tools")</b></MudText>
</td>
<td><code style="font-size: 0.8rem;">@string.Join(", ", this.PluginToolIds)</code></td>
</tr>
}
<tr>
<td>
<MudText Typo="Typo.body2"><b>@T("Current hash")</b></MudText>
@@ -21,6 +21,17 @@ public partial class AssistantPluginSecurityCard : MSGComponentBase
? new PluginAssistantSecurityState()
: PluginAssistantSecurityResolver.Resolve(this.SettingsManager, this.Plugin);
/// <summary>
/// The tools this plugin runs with, either in its assistant or in the chat it launches.
/// </summary>
/// <remarks>
/// Tools are a capability, not a detail: an assistant allowed to search the web or read a page
/// can carry what a user typed out of the app. Whoever decides whether to enable this plugin
/// should see that beforehand, which is why the count sits in the header next to the audit
/// level and the tools themselves are named in the details.
/// </remarks>
private IReadOnlyList<string> PluginToolIds => this.Plugin?.AssistantToolIds ?? this.Plugin?.ChatLaunchConfiguration?.ToolIds ?? [];
private CultureInfo currentCultureInfo = CultureInfo.InvariantCulture;
private bool showSecurityCard;
private bool showDetails;
@@ -126,6 +137,10 @@ public partial class AssistantPluginSecurityCard : MSGComponentBase
: this.FormatFileTimestamp(auditedAt.Value.ToLocalTime().DateTime);
}
private string GetToolCountLabel() => this.PluginToolIds.Count is 1
? this.T("Uses 1 tool")
: string.Format(this.T("Uses {0} tools"), this.PluginToolIds.Count);
private string GetAuditProviderLabel()
{
var providerName = this.SecurityState.Audit?.AuditProviderName;
@@ -124,6 +124,11 @@
<MudDivider Vertical="true" Style="height: 24px; align-self: center;"/>
<ProfileSelection MarginLeft="" CurrentProfile="@this.currentProfile" CurrentProfileChanged="@this.ProfileWasChanged" Disabled="@(!this.currentChatTemplate.AllowProfileUsage)" DisabledText="@T("Profile usage is disabled according to your chat template settings.")"/>
@if (this.SettingsManager.AreToolsEnabled())
{
<ToolSelection Component="Components.CHAT" LLMProvider="@this.Provider" SelectedToolIds="@this.selectedToolIds" SelectedToolIdsChanged="@this.SelectedToolIdsChanged" Disabled="@this.IsCurrentChatStreaming" />
}
@if (PreviewFeatures.PRE_RAG_2024.IsEnabled(this.SettingsManager))
{
@@ -3,6 +3,7 @@ using AIStudio.Dialogs;
using AIStudio.Provider;
using AIStudio.Settings;
using AIStudio.Settings.DataModel;
using AIStudio.Tools.ToolCallingSystem;
using AIStudio.Tools.AIJobs;
using AIStudio.Tools.Media;
using AIStudio.Tools.Services;
@@ -48,6 +49,9 @@ public partial class ChatComponent : MSGComponentBase
[Inject]
private ILogger<ChatComponent> Logger { get; set; } = null!;
[Inject]
private ToolRegistry ToolRegistry { get; set; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
@@ -76,6 +80,7 @@ public partial class ChatComponent : MSGComponentBase
private bool mustLoadChat;
private LoadChat loadChat;
private bool autoSaveEnabled;
private HashSet<string> selectedToolIds = [];
private bool previousInputForbidden = true;
private Guid lastSeenChatId = Guid.Empty;
private AIStudio.Settings.Provider lastSeenProvider = AIStudio.Settings.Provider.NONE;
@@ -113,7 +118,7 @@ public partial class ChatComponent : MSGComponentBase
protected override async Task OnInitializedAsync()
{
this.MediaTranscriptionService.StateChanged += this.OnMediaImportStateChanged;
// Apply the filters for the message bus:
this.ApplyFilters([], [ Event.HAS_CHAT_UNSAVED_CHANGES, Event.RESET_CHAT_STATE, Event.CHAT_STREAMING_DONE, Event.AI_JOB_CHANGED, Event.AI_JOB_FINISHED, Event.CHAT_GENERATION_CHANGED, Event.WORKSPACE_RENAMED, Event.CONFIGURATION_CHANGED ]);
@@ -128,6 +133,7 @@ public partial class ChatComponent : MSGComponentBase
this.currentChatTemplate = this.SettingsManager.GetPreselectedChatTemplate(Tools.Components.CHAT);
if (!this.ComposerState.HasUserDraft && !this.ComposerState.HasComposerContent)
this.ComposerState.ApplyTemplate(this.currentChatTemplate);
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
this.lastAppliedStandardDataSourceOptions = this.SettingsManager.ConfigurationData.Chat.PreselectedDataSourceOptions.CreateCopy();
@@ -149,6 +155,7 @@ public partial class ChatComponent : MSGComponentBase
// Use chat thread sent by the user:
this.ChatThread = deferredRequest.ChatThread;
this.ChatThread.IncludeDateTime = true;
this.ApplyToolSelectionOfLoadedChat();
//
// Apply the chat template of the incoming chat to the composer. Like everywhere else,
@@ -332,6 +339,7 @@ public partial class ChatComponent : MSGComponentBase
await this.ChatThreadChanged.InvokeAsync(this.ChatThread);
this.Logger.LogInformation($"The chat '{this.ChatThread!.ChatId}' with title '{this.ChatThread.Name}' ({this.ChatThread.Blocks.Count} messages) was loaded successfully.");
this.ApplyToolSelectionOfLoadedChat();
await this.SyncWorkspaceHeaderWithChatThreadAsync();
await this.SelectProviderWhenLoadingChat();
}
@@ -617,9 +625,8 @@ public partial class ChatComponent : MSGComponentBase
{
var previousProvider = this.Provider;
var previousChatTemplate = this.currentChatTemplate;
var chatProviderId = this.ChatThread?.SelectedProvider;
this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(chatProviderId);
this.Provider = this.SettingsManager.GetChatProviderForLoadedChat(this.Provider.Id);
if (this.Provider != previousProvider)
await this.ProviderChanged.InvokeAsync(this.Provider);
@@ -756,6 +763,7 @@ public partial class ChatComponent : MSGComponentBase
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
@@ -778,6 +786,8 @@ public partial class ChatComponent : MSGComponentBase
if (this.MediaTranscriptionService.IsBusy(this.CurrentMediaImportOwner))
return;
await this.RefreshProviderSelectionFromConfigurationAsync();
if (!this.IsProviderSelected)
return;
@@ -798,6 +808,7 @@ public partial class ChatComponent : MSGComponentBase
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
@@ -899,15 +910,18 @@ public partial class ChatComponent : MSGComponentBase
}
this.Logger.LogDebug($"Start processing user input using provider '{this.Provider.InstanceName}' with model '{this.Provider.Model}'.");
this.StateHasChanged();
this.ChatThread!.RuntimeComponent = Tools.Components.CHAT;
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.ChatThread.RuntimeSelectedToolIds = this.ToolRegistry.FilterToolIdsForProvider(this.Provider, this.selectedToolIds);
await this.AIJobService.TryStartChatGenerationAsync(new ChatGenerationRequest
{
ChatThread = this.ChatThread!,
ChatThread = this.ChatThread,
AIText = aiText,
LastUserPrompt = lastUserPrompt,
ProviderSettings = this.Provider,
IsForeground = true,
});
await this.SyncForegroundChatAsync();
this.StateHasChanged();
}
@@ -917,6 +931,37 @@ public partial class ChatComponent : MSGComponentBase
if (this.ChatThread is not null)
await this.AIJobService.CancelChatGenerationAsync(this.ChatThread.ChatId);
}
/// <summary>
/// Takes over the tool selection of the chat that was just loaded or handed to this component.
/// </summary>
/// <remarks>
/// A thread without a selection means the chat defaults: that is a chat saved before tools
/// existed, as well as one a launcher opened without naming any. Both want what the settings
/// preselect. Every path that puts a thread into this component has to come through here, or
/// the footer would keep showing the tools of the chat before it.
/// </remarks>
private void ApplyToolSelectionOfLoadedChat() =>
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.ChatThread?.SelectedToolIds ?? this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
private Task SelectedToolIdsChanged(HashSet<string> updatedToolIds)
{
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
//
// The thread keeps the selection so that reopening the chat tomorrow brings the same tools
// back. What is stored is what the user chose, not what the current provider is allowed to
// run: filtering here would quietly drop a tool for good the moment the user switches to a
// provider with less confidence.
//
if (this.ChatThread is not null)
{
this.ChatThread.SelectedToolIds = [..this.selectedToolIds];
this.hasUnsavedChanges = true;
}
return Task.CompletedTask;
}
private async Task SaveThread()
{
@@ -980,6 +1025,7 @@ public partial class ChatComponent : MSGComponentBase
//
this.hasUnsavedChanges = false;
this.ComposerState.Clear();
this.selectedToolIds = ToolSelectionRules.NormalizeSelection(this.SettingsManager.GetDefaultToolIds(Tools.Components.CHAT));
this.RefreshCurrentProfileAndChatTemplate();
//
@@ -1029,6 +1075,7 @@ public partial class ChatComponent : MSGComponentBase
SelectedProvider = this.Provider.Id,
SelectedProfile = this.currentProfile.Id,
SelectedChatTemplate = this.currentChatTemplate.Id,
SelectedToolIds = [..this.selectedToolIds],
SystemPrompt = SystemPrompts.DEFAULT,
WorkspaceId = this.currentWorkspaceId,
ChatId = Guid.NewGuid(),
@@ -1104,6 +1151,7 @@ public partial class ChatComponent : MSGComponentBase
await this.SyncWorkspaceHeaderWithChatThreadAsync();
await this.SyncForegroundChatAsync();
this.dataSourceSelectionComponent?.ChangeOptionWithoutSaving(this.ChatThread.DataSourceOptions, this.ChatThread.AISelectedDataSources);
this.ApplyToolSelectionOfLoadedChat();
}
else
{
@@ -1123,6 +1171,19 @@ public partial class ChatComponent : MSGComponentBase
this.StateHasChanged();
}
private async Task RefreshProviderSelectionFromConfigurationAsync()
{
var updatedProvider = this.SettingsManager.GetPreselectedProvider(Tools.Components.CHAT, this.Provider.Id);
var providerChanged = updatedProvider != this.Provider;
if (providerChanged)
this.Provider = updatedProvider;
if (!providerChanged)
return;
await this.ProviderChanged.InvokeAsync(this.Provider);
}
private async Task ResetState()
{
@@ -1275,6 +1336,7 @@ public partial class ChatComponent : MSGComponentBase
this.StateHasChanged();
}
break;
}
}
@@ -1312,4 +1374,4 @@ public partial class ChatComponent : MSGComponentBase
}
#endregion
}
}
@@ -28,11 +28,26 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBaseCore
[Parameter]
public Action<HashSet<TData>> SelectionUpdate { get; set; } = _ => { };
/// <summary>
/// An asynchronous action that is called when the selection changes.
/// </summary>
[Parameter]
public Func<HashSet<TData>, Task> SelectionUpdateAsync { get; set; } = _ => Task.CompletedTask;
/// <summary>
/// Determines whether a specific item is locked by a configuration plugin.
/// </summary>
[Parameter]
public Func<TData, bool> IsItemLocked { get; set; } = _ => false;
[Parameter]
public string? EmptySelectionText { get; set; }
[Parameter]
public string? SingleSelectionText { get; set; }
[Parameter]
public string? MultipleSelectionText { get; set; }
#region Overrides of ConfigurationBase
@@ -49,11 +64,12 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBaseCore
private async Task OptionChanged(IEnumerable<TData?>? updatedValues)
{
if(updatedValues is null)
this.SelectionUpdate([]);
else
this.SelectionUpdate(updatedValues.Where(n => n is not null).ToHashSet()!);
// OfType drops the nulls and gives back the non-nullable element type in one step, which
// Where cannot: it keeps the nullable type no matter what the predicate proves.
var selection = updatedValues is null ? [] : updatedValues.OfType<TData>().ToHashSet();
this.SelectionUpdate(selection);
await this.SelectionUpdateAsync(selection);
await this.SettingsManager.StoreSettings();
await this.InformAboutChange();
}
@@ -61,12 +77,12 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBaseCore
private string GetMultiSelectionText(List<TData?>? selectedValues)
{
if(selectedValues is null || selectedValues.Count == 0)
return T("No preview features selected.");
return this.EmptySelectionText ?? T("No items selected.");
if(selectedValues.Count == 1)
return T("You have selected 1 preview feature.");
return this.SingleSelectionText ?? T("You have selected 1 item.");
return string.Format(T("You have selected {0} preview features."), selectedValues.Count);
return string.Format(this.MultipleSelectionText ?? T("You have selected {0} items."), selectedValues.Count);
}
private bool IsLockedValue(TData value) => this.IsItemLocked(value);
@@ -76,4 +92,4 @@ public partial class ConfigurationMultiSelect<TData> : ConfigurationBaseCore
"This feature is managed by your organization and has therefore been disabled.",
typeof(ConfigurationBase).Namespace,
nameof(ConfigurationBase));
}
}
@@ -36,9 +36,11 @@
<MudSelectItem T="string" Value="@chatTemplate.Id">@chatTemplate.GetSafeName()</MudSelectItem>
}
</MudSelect>
<MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Source">
<MudSelect T="string" Label="@T("Data sources (Optional)")" MultiSelection="@true" SelectedValues="@this.DataSourceIds" SelectedValuesChanged="@this.SetDataSourceIds" MultiSelectionTextFunc="@this.GetSelectedDataSourceText" Variant="Variant.Outlined" Margin="Margin.Dense" Class="mb-3 rounded-lg" Adornment="Adornment.Start" AdornmentIcon="@Icons.Material.Filled.Source">
@foreach (var dataSource in this.SettingsManager.ConfigurationData.DataSources)
{
<MudSelectItem T="string" Value="@dataSource.Id">@dataSource.Name</MudSelectItem>
}
</MudSelect>
</MudSelect>
<ToolSelectionField Component="Components.CHAT" SelectedToolIds="@this.ToolIds" SelectedToolIdsChanged="@this.SetToolIds" Label="@T("Tools (Optional)")" Help="@T("These tools are preselected when the chat opens. Users can change the selection in the chat, and every tool has to meet the confidence requirements of the provider in use.")"/>
@@ -61,6 +61,19 @@ public partial class DirectChatLauncherForm : MSGComponentBase
[Parameter]
public EventCallback<IEnumerable<string>> DataSourceIdsChanged { get; set; }
/// <summary>
/// The tools preselected for the chat. An empty selection keeps the normal chat defaults.
/// </summary>
/// <remarks>
/// A preselection, not a limit: the user can switch tools in the chat as usual. What a tool
/// may actually do is decided there, by the confidence of the provider in use.
/// </remarks>
[Parameter]
public HashSet<string> ToolIds { get; set; } = [];
[Parameter]
public EventCallback<HashSet<string>> ToolIdsChanged { get; set; }
/// <summary>
/// Validates the workspace name. The hosts differ here: the Builder requires a name only while
/// its launcher switch is on, whereas the settings dialog always requires one.
@@ -135,6 +148,12 @@ public partial class DirectChatLauncherForm : MSGComponentBase
await this.DataSourceIdsChanged.InvokeAsync(selectedDataSourceIds);
}
private async Task SetToolIds(HashSet<string> toolIds)
{
this.ToolIds = toolIds;
await this.ToolIdsChanged.InvokeAsync(toolIds);
}
private string GetSelectedDataSourceText(List<string?>? selectedValues)
{
if (selectedValues is null || selectedValues.Count == 0)
@@ -0,0 +1,24 @@
<MudTreeView T="JsonTreeNode"
Items="@this.items"
ReadOnly="true"
Hover="true"
Dense="true"
ExpandOnClick="true">
<ItemTemplate Context="item">
@if (item.Value is { } node)
{
<MudTreeViewItem T="JsonTreeNode"
Icon="@node.Icon"
Value="@item.Value"
Expanded="@item.Expanded"
CanExpand="@node.Expandable"
Items="@item.Children">
<BodyContent>
<MudText Typo="Typo.body2" Style="font-family: monospace; white-space: pre-wrap; overflow-wrap: anywhere;">
@node.Text
</MudText>
</BodyContent>
</MudTreeViewItem>
}
</ItemTemplate>
</MudTreeView>
@@ -0,0 +1,73 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class JsonTreeView : ComponentBase
{
[Parameter]
public JsonNode? Value { get; set; }
private IReadOnlyCollection<TreeItemData<JsonTreeNode>> items = [];
protected override void OnParametersSet()
{
this.items = [CreateTreeItem("$", this.Value)];
}
private static TreeItemData<JsonTreeNode> CreateTreeItem(string label, JsonNode? value)
{
var children = CreateChildren(value);
return new TreeItemData<JsonTreeNode>
{
Expanded = false,
Expandable = children.Count > 0,
Value = new JsonTreeNode
{
Text = $"{label}: {FormatValue(value)}",
Icon = GetIcon(value),
Expandable = children.Count > 0,
},
Children = children,
};
}
private static List<TreeItemData<JsonTreeNode>> CreateChildren(JsonNode? value) => value switch
{
JsonObject jsonObject => jsonObject
.Select(property => CreateTreeItem(JsonSerializer.Serialize(property.Key), property.Value))
.ToList(),
JsonArray jsonArray => jsonArray
.Select((item, index) => CreateTreeItem($"[{index}]", item))
.ToList(),
_ => [],
};
private static string FormatValue(JsonNode? value) => value switch
{
JsonObject jsonObject when jsonObject.Count == 0 => "{}",
JsonObject => "{...}",
JsonArray jsonArray when jsonArray.Count == 0 => "[]",
JsonArray => "[...]",
null => "null",
_ => value.ToJsonString(),
};
private static string GetIcon(JsonNode? value) => value switch
{
JsonObject => Icons.Material.Filled.DataObject,
JsonArray => Icons.Material.Filled.DataArray,
_ => Icons.Material.Filled.Code,
};
private sealed class JsonTreeNode
{
public string Text { get; init; } = string.Empty;
public string Icon { get; init; } = string.Empty;
public bool Expandable { get; init; }
}
}
@@ -0,0 +1,26 @@
@inherits MSGComponentBase
@if (this.NeedsToolCallingProvider)
{
@* Nothing runs at all, so the other two would only add noise: *@
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="@this.Class">
@T("Tools were selected for this run, but the chosen model cannot use tools. It runs without them. Please choose a model which supports tools.")
</MudAlert>
}
else
{
@* Two independent reasons a tool stays out of reach, so both may show at once: *@
@if (this.ToolsNeedingConfiguration.Count > 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="@this.Class">
@(string.Format(T("Some tools selected for this run are not fully configured and stay unused: {0}. Please complete their settings."), string.Join(", ", this.ToolsNeedingConfiguration)))
</MudAlert>
}
@if (this.ToolsBeyondProviderConfidence.Count > 0)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Outlined" Dense="@true" Class="@this.Class">
@(string.Format(T("Not all tools selected for this run can be used with the chosen AI provider: {0}. Please choose a provider with a higher confidence level to use all of them."), string.Join(", ", this.ToolsBeyondProviderConfidence)))
</MudAlert>
}
}
@@ -0,0 +1,111 @@
using AIStudio.Provider;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Says when tools an assistant was told to use cannot reach the selected provider.
/// </summary>
/// <remarks>
/// Whoever named these tools — a document analysis policy, an assistant plugin — did so without
/// knowing which provider the user would pick. The user cannot switch a blocked tool on either,
/// because there is no selection to switch. Saying nothing would let the run quietly proceed
/// without them, which is why this belongs next to the provider selection: choosing another
/// provider is what resolves it.
/// </remarks>
public partial class ManagedToolsWarning : MSGComponentBase
{
[Parameter]
public AIStudio.Tools.Components Component { get; set; } = AIStudio.Tools.Components.CHAT;
/// <summary>
/// The tools of this run, as named by the assistant's own rules.
/// </summary>
[Parameter]
public IReadOnlySet<string> ToolIds { get; set; } = new HashSet<string>();
[Parameter]
public AIStudio.Settings.Provider ProviderSettings { get; set; } = AIStudio.Settings.Provider.NONE;
[Parameter]
public string Class { get; set; } = "mb-3";
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
private IReadOnlyList<ToolCatalogItem> availableTools = [];
/// <summary>
/// Whether this run expects tools while the selected provider cannot call any.
/// </summary>
private bool NeedsToolCallingProvider => this.ToolIds.Count > 0 && this.SettingsManager.AreToolsEnabled() && !this.ProviderSettings.GetToolCallingAvailability().IsAvailable;
/// <summary>
/// The tools of this run whose settings are incomplete, so they cannot run at all.
/// </summary>
/// <remarks>
/// Unlike the confidence case, no provider resolves this: the tool itself is missing something,
/// such as the web search without a server address. Tools an organization switched off are left
/// out, because completing their settings would not bring them back either.
/// </remarks>
private IReadOnlyList<string> ToolsNeedingConfiguration
{
get
{
if (this.ToolIds.Count is 0 || !this.SettingsManager.AreToolsEnabled())
return [];
return this.availableTools
.Where(x => this.ToolIds.Contains(x.Definition.Id) && x.IsActive && !x.ConfigurationState.IsConfigured)
.Select(x => x.Implementation.GetDisplayName())
.ToList();
}
}
/// <summary>
/// The tools of this run which the selected provider is not trusted enough to receive.
/// </summary>
/// <remarks>
/// Tools switched off in the settings are not counted: choosing another provider would not
/// bring them back, so naming them here would send the user after the wrong fix.
/// </remarks>
private IReadOnlyList<string> ToolsBeyondProviderConfidence
{
get
{
if (this.ToolIds.Count is 0 || !this.SettingsManager.AreToolsEnabled())
return [];
var providerConfidence = this.ProviderSettings == AIStudio.Settings.Provider.NONE
? ConfidenceLevel.NONE
: this.ProviderSettings.UsedLLMProvider.GetConfidence(this.SettingsManager).Level;
return this.availableTools
.Where(x => this.ToolIds.Contains(x.Definition.Id) && x.IsActive)
.Where(x => !ToolSelectionRules.IsProviderConfidenceAllowed(providerConfidence, x.MinimumProviderConfidence))
.Select(x => x.Implementation.GetDisplayName())
.ToList();
}
}
protected override async Task OnInitializedAsync()
{
this.availableTools = await this.ToolRegistry.GetCatalogAsync(this.Component);
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
await base.OnInitializedAsync();
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED:
this.availableTools = await this.ToolRegistry.GetCatalogAsync(this.Component);
await this.InvokeAsync(this.StateHasChanged);
break;
}
}
}
@@ -17,6 +17,4 @@ public partial class MudTextList : ComponentBase
public string Class { get; set; } = string.Empty;
private string Classes => $"mud-text-list {this.Class}";
}
public readonly record struct TextItem(string Header, string Text);
}
@@ -1,6 +1,7 @@
using AIStudio.Agents;
using AIStudio.Chat;
using AIStudio.Tools.Security;
using AIStudio.Tools.Web;
using Microsoft.AspNetCore.Components;
@@ -8,9 +9,21 @@ namespace AIStudio.Components;
public partial class ReadWebContent : MSGComponentBase
{
/// <summary>
/// How long loading one page may take.
/// </summary>
/// <remarks>
/// The user is watching a progress indicator while this runs, so it is shorter than what the
/// tools allow themselves for a page fetched in the background.
/// </remarks>
private const int TIMEOUT_SECONDS = 60;
[Inject]
private HTMLParser HTMLParser { get; init; } = null!;
private WebPageRetrievalService WebPageRetrievalService { get; init; } = null!;
[Inject]
private ILogger<ReadWebContent> Logger { get; init; } = null!;
[Inject]
private AgentTextContentCleaner AgentTextContentCleaner { get; init; } = null!;
@@ -85,12 +98,23 @@ public partial class ReadWebContent : MSGComponentBase
{
this.processStep = this.process[ReadWebContentSteps.LOADING];
this.StateHasChanged();
var html = await this.HTMLParser.LoadWebContentHTML(new Uri(this.providedURL));
//
// The same retrieval the read web page tool uses, so a page is fetched and read one
// way throughout AI Studio. The difference is the target policy: here the user typed
// the URL, so their own network is not off limits.
//
var retrievedPage = await this.WebPageRetrievalService.RetrieveAsync(
new Uri(this.providedURL),
new WebPageRetrievalOptions
{
TimeoutSeconds = TIMEOUT_SECONDS,
TargetChosenByUser = true,
});
this.processStep = this.process[ReadWebContentSteps.PARSING];
this.StateHasChanged();
markdown = this.HTMLParser.ParseToMarkdown(html);
markdown = retrievedPage.ExtractedPage.Markdown;
markdown = await this.PromptInjectionGuardService.SanitizeAsync(markdown, PromptInjectionSource.WebContent(this.providedURL));
if (this.PreselectContentCleanerAgent && this.providerSettings != AIStudio.Settings.Provider.NONE)
@@ -125,7 +149,7 @@ public partial class ReadWebContent : MSGComponentBase
this.StateHasChanged();
}
}
catch
catch (Exception exception)
{
if (this.AgentIsRunning)
{
@@ -134,6 +158,14 @@ public partial class ReadWebContent : MSGComponentBase
await this.AgentIsRunningChanged.InvokeAsync(this.AgentIsRunning);
this.StateHasChanged();
}
//
// Say why nothing was loaded. An empty text field looks like a page without content,
// and the reasons a page cannot be read are things the user can act on: a link to a
// PDF rather than a page, a host that does not answer, a server refusing the request.
//
this.Logger.LogWarning(exception, "Could not load the web content from '{ProvidedUrl}'.", this.providedURL);
await this.MessageBus.SendError(new(Icons.Material.Filled.CloudOff, string.Format(this.T("The content of '{0}' could not be loaded: {1}"), this.providedURL, exception.Message)));
}
this.Content = markdown;
@@ -28,7 +28,7 @@
var availablePreviewFeatures = ConfigurationSelectDataFactory.GetPreviewFeaturesData(this.SettingsManager).ToList();
if (availablePreviewFeatures.Count > 0)
{
<ConfigurationMultiSelect OptionDescription="@T("Select preview features")" SelectedValues="@this.GetSelectedPreviewFeatures" Data="@availablePreviewFeatures" SelectionUpdate="@this.UpdateEnabledPreviewFeatures" OptionHelp="@T("Which preview features would you like to enable?")" IsItemLocked="@this.IsPluginContributedPreviewFeature" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) && meta.IsLocked"/>
<ConfigurationMultiSelect OptionDescription="@T("Select preview features")" SelectedValues="@this.GetSelectedPreviewFeatures" Data="@availablePreviewFeatures" SelectionUpdate="@this.UpdateEnabledPreviewFeatures" OptionHelp="@T("Which preview features would you like to enable?")" IsItemLocked="@this.IsPluginContributedPreviewFeature" IsLocked="() => ManagedConfiguration.TryGet(x => x.App, x => x.EnabledPreviewFeatures, out var meta) && meta.IsLocked" EmptySelectionText="@T("No preview features selected.")" SingleSelectionText="@T("You have selected 1 preview feature.")" MultipleSelectionText="@T("You have selected {0} preview features.")"/>
}
}
@@ -0,0 +1,60 @@
@inherits SettingsPanelBase
<ExpansionPanel HeaderIcon="@Icons.Material.Filled.Build" HeaderText="@T("Tool Settings")">
<MudText Typo="Typo.body1" Class="mb-4">
@T("Configure global settings for each tool.")
</MudText>
<MudTable Items="@this.items" Hover="@true" Dense="@true">
<HeaderContent>
<MudTh>@T("Icon")</MudTh>
<MudTh>@T("Name")</MudTh>
<MudTh>@T("Description")</MudTh>
<MudTh>@T("Minimum provider confidence")</MudTh>
<MudTh>@T("Status")</MudTh>
<MudTh>@T("Settings")</MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>
<MudIcon Icon="@context.Implementation.Icon" Color="Color.Info" />
</MudTd>
<MudTd>
<MudText Typo="Typo.body1">@context.Implementation.GetDisplayName()</MudText>
</MudTd>
<MudTd>
<MudText Typo="Typo.body2">@context.Implementation.GetDescription()</MudText>
</MudTd>
<MudTd>
<MudMenu StartIcon="@Icons.Material.Filled.Security" EndIcon="@Icons.Material.Filled.KeyboardArrowDown" Label="@this.GetCurrentConfidenceLevelName(context)" Variant="Variant.Filled" Style="@this.SetCurrentConfidenceLevelColorStyle(context)" Disabled="@this.IsToolConfidenceManaged()">
@foreach (var confidenceLevel in this.GetSelectableConfidenceLevels())
{
<MudMenuItem OnClick="@(async () => await this.ChangeMinimumProviderConfidence(context, confidenceLevel))">
@this.GetConfidenceLevelName(confidenceLevel)
</MudMenuItem>
}
</MudMenu>
</MudTd>
<MudTd>
@if (!context.IsActive)
{
<MudTooltip Text="@T("This tool has been disabled by your organization.")">
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Error" />
</MudTooltip>
}
else if (context.ConfigurationState.IsConfigured)
{
<MudIcon Icon="@Icons.Material.Filled.CheckCircle" Color="Color.Success" />
}
else
{
<MudTooltip Text="@this.GetConfigurationTooltip(context)">
<MudIcon Icon="@Icons.Material.Filled.Warning" Color="Color.Warning" />
</MudTooltip>
}
</MudTd>
<MudTd>
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@(async () => await this.OpenSettings(context.Definition.Id))" />
</MudTd>
</RowTemplate>
</MudTable>
</ExpansionPanel>
@@ -0,0 +1,89 @@
using AIStudio.Provider;
using AIStudio.Dialogs.Settings;
using AIStudio.Settings;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components.Settings;
public partial class SettingsPanelTools : SettingsPanelBase
{
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
private IReadOnlyList<ToolCatalogItem> items = [];
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
this.items = await this.ToolRegistry.GetCatalogAsync(this.ToolRegistry.GetAllDefinitions());
await base.OnInitializedAsync();
}
private async Task OpenSettings(string toolId)
{
var parameters = new DialogParameters<ToolSettingsDialog>
{
{ x => x.ToolId, toolId },
};
var dialog = await this.DialogService.ShowAsync<ToolSettingsDialog>(null, parameters, Dialogs.DialogOptions.FULLSCREEN);
await dialog.Result;
this.items = await this.ToolRegistry.GetCatalogAsync(this.ToolRegistry.GetAllDefinitions());
this.StateHasChanged();
}
private string GetConfigurationTooltip(ToolCatalogItem item) => item.ConfigurationState.MissingRequiredFields.Count switch
{
_ when !string.IsNullOrWhiteSpace(item.ConfigurationState.Message) => item.ConfigurationState.Message,
0 => this.T("This tool still needs to be configured."),
_ => string.Format(this.T("Missing required settings: {0}"), string.Join(", ", item.ConfigurationState.MissingRequiredFields.Select(fieldName => this.GetFieldDisplayName(item, fieldName))))
};
private string GetFieldDisplayName(ToolCatalogItem item, string fieldName)
{
var fieldDefinition = item.Definition.SettingsSchema.Properties.GetValueOrDefault(fieldName);
if (fieldDefinition is null)
return fieldName;
return item.Implementation.GetSettingsFieldLabel(fieldName, fieldDefinition);
}
private IEnumerable<ConfidenceLevel> GetSelectableConfidenceLevels() =>
Enum.GetValues<ConfidenceLevel>().OrderBy(x => x).Where(x => x is not ConfidenceLevel.UNKNOWN);
private string GetCurrentConfidenceLevelName(ToolCatalogItem item) => this.GetConfidenceLevelName(GetMinimumProviderConfidence(item));
private string GetConfidenceLevelName(ConfidenceLevel confidenceLevel) => confidenceLevel is ConfidenceLevel.NONE
? this.T("No minimum confidence level chosen")
: confidenceLevel.GetName();
private string SetCurrentConfidenceLevelColorStyle(ToolCatalogItem item) =>
$"background-color: {GetMinimumProviderConfidence(item).GetColor(this.SettingsManager)};";
private bool IsToolConfidenceManaged() =>
ManagedConfiguration.TryGet(x => x.Tools, x => x.MinimumProviderConfidenceByToolId, out var meta) && meta.IsLocked;
// The catalog already carries the resolved level, so there is nothing to look up again:
private static ConfidenceLevel GetMinimumProviderConfidence(ToolCatalogItem item) => item.MinimumProviderConfidence;
private async Task ChangeMinimumProviderConfidence(ToolCatalogItem item, ConfidenceLevel confidenceLevel)
{
this.SettingsManager.SetMinimumProviderConfidenceForTool(item.Definition.Id, confidenceLevel, item.Definition.MinimumProviderConfidence);
await this.SettingsManager.StoreSettings();
this.items = await this.ToolRegistry.GetCatalogAsync(this.ToolRegistry.GetAllDefinitions());
await this.MessageBus.SendMessage<bool>(this, Event.CONFIGURATION_CHANGED);
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED:
this.items = await this.ToolRegistry.GetCatalogAsync(this.ToolRegistry.GetAllDefinitions());
await this.InvokeAsync(this.StateHasChanged);
break;
}
}
}
@@ -0,0 +1,3 @@
namespace AIStudio.Components;
public readonly record struct TextItem(string Header, string Text);
@@ -0,0 +1,10 @@
@inherits MSGComponentBase
@if (this.availableTools.Count > 0)
{
@if (this.Component is not Components.CHAT && this.IncludeVisibilityToggle)
{
<ConfigurationOption OptionDescription="@T("Show tool selection in this assistant?")" LabelOn="@T("Tool selection is visible")" LabelOff="@T("Tool selection is hidden")" State="@(() => this.SettingsManager.IsToolSelectionVisible(this.Component))" StateUpdate="@(value => this.SettingsManager.SetToolSelectionVisibility(this.Component, value))" />
}
<ConfigurationMultiSelect TData="string" OptionDescription="@this.OptionTitle" SelectedValues="@this.GetSelectedValues" Data="@this.availableTools" SelectionUpdate="@this.UpdateSelection" OptionHelp="@this.OptionHelp" Disabled="@(() => this.AreDefaultToolsDisabled)" IsItemLocked="@this.IsToolDisabled" EmptySelectionText="@T("No tools selected.")" SingleSelectionText="@T("You have selected 1 tool.")" MultipleSelectionText="@T("You have selected {0} tools.")" />
}
@@ -0,0 +1,54 @@
using AIStudio.Settings;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class ToolDefaultsConfiguration : MSGComponentBase
{
[Parameter]
public AIStudio.Tools.Components Component { get; set; } = AIStudio.Tools.Components.CHAT;
[Parameter]
public bool IncludeVisibilityToggle { get; set; } = true;
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
private List<ConfigurationSelectData<string>> availableTools = [];
private string OptionTitle => this.Component is AIStudio.Tools.Components.CHAT ? this.T("Default tools for chat") : this.T("Default tools for this assistant");
private string OptionHelp => this.Component is AIStudio.Tools.Components.CHAT
? this.T("Choose which tools should be preselected for new chats.")
: this.T("Choose which tools should be preselected for new runs of this assistant.");
/// <summary>
/// Whether preselecting tools is pointless right now.
/// </summary>
/// <remarks>
/// Only where the toggle above decides whether the user ever sees a tool selection: a hidden
/// selection makes its defaults meaningless. Without that toggle the assistant reaches its
/// tools some other way — from a form field of its own, for instance — and the defaults do
/// apply.
/// </remarks>
private bool AreDefaultToolsDisabled =>
this.IncludeVisibilityToggle &&
this.Component is not AIStudio.Tools.Components.CHAT &&
!this.SettingsManager.IsToolSelectionVisible(this.Component);
private bool IsToolDisabled(string toolId) => !this.SettingsManager.IsToolActive(toolId);
protected override async Task OnInitializedAsync()
{
this.availableTools = (await this.ToolRegistry.GetCatalogAsync(this.Component))
.Select(x => new ConfigurationSelectData<string>(x.Implementation.GetDisplayName(), x.Definition.Id))
.ToList();
await base.OnInitializedAsync();
}
private HashSet<string> GetSelectedValues() => this.SettingsManager.GetDefaultToolIds(this.Component);
private void UpdateSelection(HashSet<string> values) => this.SettingsManager.ConfigurationData.Tools.DefaultToolIdsByComponent[this.Component.ToString()] = [..ToolSelectionRules.NormalizeSelection(values)];
}
@@ -0,0 +1,97 @@
@inherits MSGComponentBase
<div class="d-flex">
<MudTooltip Text="@this.ToolButtonTooltip" Placement="Placement.Top">
<MudIconButton Icon="@Icons.Material.Filled.Build" Class="@this.PopoverButtonClasses" OnClick="@this.ToggleSelection"/>
</MudTooltip>
<MudPopover Open="@this.showSelection" AnchorOrigin="Origin.TopLeft" TransformOrigin="Origin.BottomLeft" DropShadow="@true" Class="border-solid border-4 rounded-lg">
<MudCard>
<MudCardHeader>
<CardHeaderContent>
<MudStack Row="true" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h5">@T("Tool Selection")</MudText>
<MudSpacer />
</MudStack>
</CardHeaderContent>
</MudCardHeader>
<MudCardContent Style="min-width: 28em; max-height: 60vh; max-width: 48vw; overflow: auto;">
<MudText Typo="Typo.body1" Class="mb-3">
@T("Tools allow the LLM to perform targeted additional actions such as web searches or reading web pages.")
</MudText>
@if (!this.SupportsTools)
{
<MudText Typo="Typo.body1">@this.UnsupportedToolsMessage</MudText>
}
else if (this.Disabled)
{
<MudAlert Dense="@true" Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-3">
@T("Tool changes are locked while a response is running. Your current selection is shown below and applies again from the next message once the run is finished.")
</MudAlert>
}
else if (this.catalog.Count == 0)
{
<MudText Typo="Typo.body1">@T("No tools are available in this context.")</MudText>
}
@if (this.SupportsTools && this.catalog.Count > 0)
{
@foreach (var item in this.catalog)
{
var isSelected = this.SelectedToolIds.Contains(item.Definition.Id);
var isConfigured = item.ConfigurationState.IsConfigured;
var providerConfidenceHint = this.GetProviderConfidenceHint(item);
<MudPaper Class="pa-2 mb-2 border rounded-lg">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween">
@*
Everything but the settings button switches the tool, so aiming for the
small switch is optional. The button spans that part of the row, which
keeps the settings button outside of it without any event plumbing.
*@
<MudButton Variant="Variant.Text" Color="Color.Default" Class="px-2 py-1 justify-start"
Style="min-width:auto; text-transform:none; flex-grow:1;"
Disabled="@this.IsRowDisabled(item)" OnClick="@(async () => await this.ToggleToolFromRow(item))">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@*
The switch 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.
*@
<MudSwitch T="bool" 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)
{
<MudTooltip Text="@T("This tool has been disabled by your organization.")">
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Error" Size="Size.Small" />
</MudTooltip>
}
<MudTooltip Text="@item.Implementation.GetDescription()">
<MudText Typo="Typo.body1">@item.Implementation.GetDisplayName()</MudText>
</MudTooltip>
</MudStack>
</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Settings" OnClick="@(async () => await this.OpenSettings(item.Definition.Id))" />
</MudStack>
@if (!isConfigured)
{
<MudText Typo="Typo.caption" Color="Color.Warning">@(string.IsNullOrWhiteSpace(item.ConfigurationState.Message) ? T("Required settings are missing. Configure this tool before enabling it.") : item.ConfigurationState.Message)</MudText>
}
@if (!item.IsActive)
{
<MudText Typo="Typo.caption" Color="Color.Warning">@T("This tool has been disabled by your organization.")</MudText>
}
@if (!string.IsNullOrWhiteSpace(providerConfidenceHint))
{
<MudText Typo="Typo.caption" Color="Color.Warning">@providerConfidenceHint</MudText>
}
</MudPaper>
}
}
</MudCardContent>
<MudCardActions>
<MudSpacer />
<MudButton Variant="Variant.Filled" OnClick="@this.Hide">@T("Close")</MudButton>
</MudCardActions>
</MudCard>
</MudPopover>
</div>
@@ -0,0 +1,155 @@
using AIStudio.Dialogs.Settings;
using AIStudio.Provider;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
public partial class ToolSelection : MSGComponentBase
{
[Parameter]
public AIStudio.Tools.Components Component { get; set; } = AIStudio.Tools.Components.CHAT;
[Parameter]
public required AIStudio.Settings.Provider LLMProvider { get; set; }
[Parameter]
public HashSet<string> SelectedToolIds { get; set; } = [];
[Parameter]
public EventCallback<HashSet<string>> SelectedToolIdsChanged { get; set; }
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public string PopoverButtonClasses { get; set; } = string.Empty;
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
[Inject]
private IDialogService DialogService { get; init; } = null!;
private bool showSelection;
private IReadOnlyList<ToolCatalogItem> catalog = [];
protected override void OnParametersSet()
{
this.SelectedToolIds = ToolSelectionRules.NormalizeSelection(this.SelectedToolIds);
base.OnParametersSet();
}
protected override async Task OnInitializedAsync()
{
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
await base.OnInitializedAsync();
}
private ToolCallingAvailability ToolCallingAvailability => this.LLMProvider.GetToolCallingAvailability();
private bool SupportsTools => this.ToolCallingAvailability.IsAvailable;
private string ToolButtonTooltip => this.SupportsTools
? this.T("Select tools")
: this.UnsupportedToolsMessage;
private string UnsupportedToolsMessage => this.ToolCallingAvailability.Message;
private ConfidenceLevel ProviderConfidence => this.LLMProvider == AIStudio.Settings.Provider.NONE
? ConfidenceLevel.NONE
: this.LLMProvider.UsedLLMProvider.GetConfidence(this.SettingsManager).Level;
private async Task ToggleSelection()
{
this.showSelection = !this.showSelection;
if (this.showSelection)
this.catalog = await this.ToolRegistry.GetCatalogAsync(this.Component);
}
private void Hide() => this.showSelection = false;
/// <summary>
/// Whether this tool can be switched at all right now.
/// </summary>
/// <remarks>
/// The switch and the row click share this, so both agree on when a tool is out of reach: the
/// organization disabled it, it is not configured, the provider lacks the confidence it needs,
/// a response is running, or the model cannot call tools in the first place.
/// </remarks>
private bool IsRowDisabled(ToolCatalogItem item) => !item.IsActive || !item.ConfigurationState.IsConfigured || this.IsBlockedByProviderConfidence(item) ||
this.Disabled || !this.SupportsTools;
/// <summary>
/// Switches a tool when the user clicks anywhere in its row.
/// </summary>
/// <remarks>
/// Hitting the switch itself is needless precision work, so the text, the icon, and the empty
/// space count as well. Only the settings button is left out, because it sits outside the
/// button that spans the rest of the row.
/// </remarks>
private async Task ToggleToolFromRow(ToolCatalogItem item)
{
if (this.IsRowDisabled(item))
return;
await this.ChangeSelection(item.Definition.Id, !this.SelectedToolIds.Contains(item.Definition.Id));
}
private async Task ChangeSelection(string toolId, bool isSelected)
{
if (isSelected && !this.SettingsManager.IsToolActive(toolId))
return;
var updated = new HashSet<string>(this.SelectedToolIds, StringComparer.Ordinal);
if (isSelected)
updated.Add(toolId);
else
updated.Remove(toolId);
updated = ToolSelectionRules.NormalizeSelection(updated);
this.SelectedToolIds = updated;
await this.SelectedToolIdsChanged.InvokeAsync(updated);
}
// The catalog already carries the resolved level, so there is nothing to look up again:
private static ConfidenceLevel GetMinimumProviderConfidence(ToolCatalogItem item) => item.MinimumProviderConfidence;
private bool IsBlockedByProviderConfidence(ToolCatalogItem item) => !ToolSelectionRules.IsProviderConfidenceAllowed(this.ProviderConfidence, GetMinimumProviderConfidence(item));
private string? GetProviderConfidenceHint(ToolCatalogItem item)
{
if (!this.IsBlockedByProviderConfidence(item))
return null;
return string.Format(
this.T("This tool requires provider confidence {0}. The selected provider has {1}."),
GetMinimumProviderConfidence(item).GetName(),
this.ProviderConfidence.GetName());
}
private async Task OpenSettings(string toolId)
{
var parameters = new DialogParameters<ToolSettingsDialog>
{
{ x => x.ToolId, toolId },
};
var dialog = await this.DialogService.ShowAsync<ToolSettingsDialog>(null, parameters, Dialogs.DialogOptions.FULLSCREEN);
await dialog.Result;
this.catalog = await this.ToolRegistry.GetCatalogAsync(this.Component);
this.StateHasChanged();
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED when this.showSelection:
this.catalog = await this.ToolRegistry.GetCatalogAsync(this.Component);
await this.InvokeAsync(this.StateHasChanged);
break;
}
}
}
@@ -0,0 +1,16 @@
@inherits MSGComponentBase
@if (this.availableTools.Count > 0)
{
<ConfigurationMultiSelect TData="string"
OptionDescription="@this.Label"
OptionHelp="@this.Help"
Data="@this.availableTools"
SelectedValues="@(() => this.SelectedToolIds)"
SelectionUpdateAsync="@this.OptionChangedAsync"
Disabled="@(() => this.ReadOnly || this.Disabled)"
IsItemLocked="@this.IsToolLocked"
EmptySelectionText="@T("No tools selected")"
SingleSelectionText="@T("1 tool selected")"
MultipleSelectionText="@T("{0} tools selected")"/>
}
@@ -0,0 +1,83 @@
using AIStudio.Settings;
using AIStudio.Tools.ToolCallingSystem;
using Microsoft.AspNetCore.Components;
namespace AIStudio.Components;
/// <summary>
/// Picks the tools of a run as an ordinary form field, next to the settings they belong to.
/// </summary>
/// <remarks>
/// The counterpart to the tool selection in the footer, which floats above a whole chat or
/// assistant. Where the tools belong to one specific setting — the instructions of a batch job,
/// say — they are easier to grasp right there, and a read-only field is the honest way to show
/// tools somebody else decided on.
/// </remarks>
public partial class ToolSelectionField : MSGComponentBase
{
[Parameter]
public AIStudio.Tools.Components Component { get; set; } = AIStudio.Tools.Components.CHAT;
[Parameter]
public HashSet<string> SelectedToolIds { get; set; } = [];
[Parameter]
public EventCallback<HashSet<string>> SelectedToolIdsChanged { get; set; }
/// <summary>
/// Shows the tools without letting the user change them.
/// </summary>
/// <remarks>
/// For tools that were decided elsewhere, such as by a document analysis policy. The user
/// still gets to see what the run will do.
/// </remarks>
[Parameter]
public bool ReadOnly { get; set; }
[Parameter]
public bool Disabled { get; set; }
[Parameter]
public string Label { get; set; } = string.Empty;
[Parameter]
public string Help { get; set; } = string.Empty;
[Inject]
private ToolRegistry ToolRegistry { get; init; } = null!;
private List<ConfigurationSelectData<string>> availableTools = [];
protected override async Task OnInitializedAsync()
{
this.availableTools = (await this.ToolRegistry.GetCatalogAsync(this.Component))
.Select(x => new ConfigurationSelectData<string>(x.Implementation.GetDisplayName(), x.Definition.Id))
.ToList();
this.ApplyFilters([], [ Event.CONFIGURATION_CHANGED ]);
await base.OnInitializedAsync();
}
private bool IsToolLocked(string toolId) => !this.SettingsManager.IsToolActive(toolId);
private async Task OptionChangedAsync(HashSet<string> updatedToolIds)
{
this.SelectedToolIds = ToolSelectionRules.NormalizeSelection(updatedToolIds);
await this.SelectedToolIdsChanged.InvokeAsync(this.SelectedToolIds);
}
protected override async Task ProcessIncomingMessage<T>(ComponentBase? sendingComponent, Event triggeredEvent, T? data) where T : default
{
switch (triggeredEvent)
{
case Event.CONFIGURATION_CHANGED:
this.availableTools = (await this.ToolRegistry.GetCatalogAsync(this.Component))
.Select(x => new ConfigurationSelectData<string>(x.Implementation.GetDisplayName(), x.Definition.Id))
.ToList();
await this.InvokeAsync(this.StateHasChanged);
break;
}
}
}