Improved the tool selection by showing unavailable tools on request (#1116)

This commit is contained in:
Thorsten Sommer authored and GitHub committed 2026-10-27 09:45:57 +01:00
1 parent 04546c25ac
commit 1a23479f33
9 files changed
+369 -62

No files matched your search

@@ -1,3 +1,4 @@
@using AIStudio.Tools.ToolCallingSystem
@inherits MSGComponentBase
<div class="d-flex">
@@ -36,61 +37,49 @@
@if (this.SupportsTools && this.catalog.Count > 0)
{
@*
The striping sits on this wrapper: the rows share their parent with the
introduction and the occasional alert, which would shift the parity.
*@
<div class="tool-selection-rows">
@foreach (var item in this.catalog)
{
var isSelected = this.SelectedToolIds.Contains(item.Id);
var warningText = this.GetWarningText(item);
<div class="tool-selection-row">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
@*
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-1 py-0 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="1">
@*
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.
var itemsShownFirst = this.GetItemsShownFirst();
var unavailableItems = this.GetUnavailableItems();
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
box and would otherwise swallow the clicks landing in that strip.
*@
<MudCheckBox T="bool" Size="Size.Small" Dense="@true" Color="Color.Primary" Value="@isSelected" ReadOnly="@true" Disabled="@this.IsRowDisabled(item)" Style="pointer-events: none;" />
<MudIcon Icon="@item.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.Description">
<MudText Typo="Typo.body1">@item.DisplayName</MudText>
</MudTooltip>
</MudStack>
</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Small" OnClick="@(async () => await this.OpenSettings(item))" />
</MudStack>
@if (!string.IsNullOrWhiteSpace(warningText))
@*
Each list has a wrapper of its own, which gives its rows their compact padding:
the rows share their parent with nothing else, neither the introduction nor the
button between both lists.
*@
@if (itemsShownFirst.Count > 0)
{
<div class="tool-selection-rows">
@foreach (var item in itemsShownFirst)
{
@this.RenderRow(item)
}
</div>
}
else
{
<MudText Typo="Typo.body1">@T("No tool can be selected right now.")</MudText>
}
@if (unavailableItems.Count > 0)
{
<MudButton StartIcon="@(this.showUnavailableTools ? Icons.Material.Filled.ExpandLess : Icons.Material.Filled.ExpandMore)"
Size="Size.Small"
Variant="Variant.Text"
Class="mt-1"
OnClick="@this.ToggleUnavailableTools">
@this.GetUnavailableToolsButtonText(unavailableItems)
</MudButton>
<MudCollapse Expanded="@this.showUnavailableTools">
<div class="tool-selection-rows">
@foreach (var item in unavailableItems)
{
@*
A caption renders as an inline span by default, and an inline element
cannot be justified. The div makes it a block of its own.
*@
<MudJustifiedText Typo="Typo.caption" HtmlTag="div" Color="Color.Warning" Class="ml-2">@warningText</MudJustifiedText>
@this.RenderRow(item)
}
</div>
}
</div>
<MudJustifiedText Typo="Typo.caption" HtmlTag="div" Class="mt-2 ml-2">
@T("You can switch off tools you never need in the app settings, under Tool Settings. They then no longer appear here.")
</MudJustifiedText>
</MudCollapse>
}
}
</MudCardContent>
<MudCardActions>
@@ -99,4 +88,58 @@
</MudCardActions>
</MudCard>
</MudPopover>
</div>
</div>
@code {
/// <summary>
/// One row of the selection, in either list.
/// </summary>
/// <remarks>
/// Both lists show the same row, so it is written once. It stays in this component, which keeps
/// the translations of its texts: a component of its own would give each of them a new key.
/// </remarks>
private RenderFragment RenderRow(ToolCatalogItem item) => @<div class="tool-selection-row">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Spacing="1">
@*
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-1 py-0 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="1">
@*
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
box and would otherwise swallow the clicks landing in that strip.
*@
<MudCheckBox T="bool" Size="Size.Small" Dense="@true" Color="Color.Primary" Value="@this.SelectedToolIds.Contains(item.Id)" ReadOnly="@true" Disabled="@this.IsRowDisabled(item)" Style="pointer-events: none;" />
<MudIcon Icon="@item.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.Description">
<MudText Typo="Typo.body1">@item.DisplayName</MudText>
</MudTooltip>
</MudStack>
</MudButton>
<MudIconButton Icon="@Icons.Material.Filled.Settings" Size="Size.Small" OnClick="@(async () => await this.OpenSettings(item))" />
</MudStack>
@if (this.GetWarningText(item) is var warningText && !string.IsNullOrWhiteSpace(warningText))
{
@*
A caption renders as an inline span by default, and an inline element
cannot be justified. The div makes it a block of its own.
*@
<MudJustifiedText Typo="Typo.caption" HtmlTag="div" Color="Color.Warning" Class="ml-2">@warningText</MudJustifiedText>
}
</div>;
}
@@ -45,6 +45,7 @@ public partial class ToolSelection : MSGComponentBase
private IDialogService DialogService { get; init; } = null!;
private bool showSelection;
private bool showUnavailableTools;
private IReadOnlyList<ToolCatalogItem> catalog = [];
protected override void OnParametersSet()
@@ -77,22 +78,72 @@ public partial class ToolSelection : MSGComponentBase
{
this.showSelection = !this.showSelection;
if (this.showSelection)
{
this.showUnavailableTools = false;
this.catalog = await this.ToolRegistry.GetCatalogAsync(this.Component);
}
}
private void Hide() => this.showSelection = false;
private void ToggleUnavailableTools() => this.showUnavailableTools = !this.showUnavailableTools;
/// <summary>
/// Whether the selection keeps the tools which cannot be selected right now apart, behind a button.
/// </summary>
/// <remarks>
/// Not while a response is running: no tool can be switched then, and the list shows the current
/// selection as a whole, as the note above it says.
/// </remarks>
private bool KeepsUnavailableToolsApart => !this.Disabled;
/// <summary>
/// The tools the selection lists first: those the user can select right now, or all of them while a response is running.
/// </summary>
private List<ToolCatalogItem> GetItemsShownFirst() => this.KeepsUnavailableToolsApart
? [..this.catalog.Where(this.IsSelectable)]
: [..this.catalog];
/// <summary>
/// The tools which cannot be selected right now, selected ones included, which the selection lists behind a button.
/// </summary>
private List<ToolCatalogItem> GetUnavailableItems() => this.KeepsUnavailableToolsApart
? [..this.catalog.Where(item => !this.IsSelectable(item))]
: [];
/// <summary>
/// The text of the button which shows or hides the tools that cannot be selected right now.
/// </summary>
/// <remarks>
/// A selected tool may end up behind the button, for example, when the user switches to a provider
/// with less confidence. The text then says so, because the selection would otherwise seem to have
/// lost it.
/// </remarks>
private string GetUnavailableToolsButtonText(IReadOnlyCollection<ToolCatalogItem> unavailableItems)
{
if (this.showUnavailableTools)
return T("Hide unavailable tools");
var selectedCount = unavailableItems.Count(item => this.SelectedToolIds.Contains(item.Id));
if (unavailableItems.Count == 1)
return selectedCount == 0 ? T("Show 1 unavailable tool") : T("Show 1 unavailable tool, which is selected");
return selectedCount == 0
? string.Format(T("Show {0} unavailable tools"), unavailableItems.Count.CompactCount())
: string.Format(T("Show {0} unavailable tools, {1} of them selected"), unavailableItems.Count.CompactCount(), selectedCount.CompactCount());
}
/// <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,
/// the chat read a mailbox which keeps the tool back, a response is running, or the model
/// cannot call tools in the first place.
/// tool itself cannot be selected right now, see ToolSelectionRules.IsSelectable, 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.IsBlockedByOutboundDataRestriction(item) || this.Disabled || !this.SupportsTools;
private bool IsRowDisabled(ToolCatalogItem item) => !this.IsSelectable(item) || this.Disabled || !this.SupportsTools;
private bool IsSelectable(ToolCatalogItem item) => ToolSelectionRules.IsSelectable(item, this.ProviderConfidence, this.RequiredOutboundDataRestriction.Restriction);
/// <summary>
/// Switches a tool when the user clicks anywhere in its row.
@@ -134,11 +185,7 @@ public partial class ToolSelection : MSGComponentBase
/// <summary>
/// Whether the mailboxes the chat read from keep every tool of this entry back.
/// </summary>
/// <remarks>
/// Only then is there nothing left to select. Should a mailbox keep back some tools of a
/// collection, the others still run, and the request leaves out the rest on its own.
/// </remarks>
private bool IsBlockedByOutboundDataRestriction(ToolCatalogItem item) => item.Tools.All(tool => !ToolSelectionRules.IsOutboundDataAllowed(this.RequiredOutboundDataRestriction.Restriction, tool.Implementation));
private bool IsBlockedByOutboundDataRestriction(ToolCatalogItem item) => !ToolSelectionRules.IsOutboundDataAllowed(this.RequiredOutboundDataRestriction.Restriction, item);
private string? GetProviderConfidenceHint(ToolCatalogItem item)
{