Skip to content

AI scenarios

Custom tool UI

Bit.BlazorUI.AI Scripted transport

One tool rendered by an application component instead of the default card, everywhere that tool appears.

Live demo

Search the documentation: search_docs reports its progress, then its hits, in a card of this site's own.

\n**A tool drawn by the app**: ```razor @implements IAsyncDisposable
``` ```csharp private BitAiChatSession? session; private readonly string[] suggestions = ["Find the docs on render modes"]; [Inject] private IBitAiToolRegistry Tools { get; set; } = default!; protected override void OnInitialized() => session = new BitAiChatSession( AiDemoToolTransports.CallsTool( "search_docs", """{"query":"render modes"}""", "Two passages matched. The first covers choosing a mode per component; the second covers what happens when a circuit is lost."), tools: Tools); public ValueTask DisposeAsync() => session?.DisposeAsync() ?? ValueTask.CompletedTask; ``` `Program.cs`: ```csharp builder.Services.AddBitBlazorUIAIServices(ai => { ai.UseTransport(AiDemoTransports.Echo()); ai.AddTool(BitAiTool.Create( "search_docs", "Search the product documentation and return matching passages.", AiDemoJsonContext.Default.SearchArgs, async (args, invocation, token) => { // Reported to the card while the tool runs. invocation.Progress.Report(new BitAiToolProgress(0.2, "Querying the index")); await Task.Delay(500, token); invocation.Progress.Report(new BitAiToolProgress(0.7, "Ranking results")); await Task.Delay(500, token); var source = new BitAiCitationSource("docs-render-modes", "Render modes") { Kind = BitAiSourceKind.Document, SiteName = "Product documentation", Url = "https://example.com/docs/render-modes", }; // The value the card reads, the text the model reads, and the source the reply lists. return BitAiToolOutcome.Success(SearchHits(args.Query), AiDemoJsonContext.Default.SearchResult) with { Text = $"2 passages matched “{args.Query}”.", Citations = [new BitAiCitation(BitAiId.New("cit"), source)], }; }, definition => definition with { Title = "Search docs", Icon = BitIconName.Search })); // Wherever search_docs is called, SearchResultCard draws it instead of the default card. ai.ConfigureRenderers(renderers => renderers.RegisterTool("search_docs")); }); static SearchResult SearchHits(string query) => new(query, [ new SearchHit("Render modes", 0.94), new SearchHit("Circuit lifetime", 0.81), ]); ``` `SearchResultCard.razor`: ```razor @using System.Globalization @using System.Text.Json
@(Call.Status is BitAiToolCallStatus.Completed ? "Searched the documentation" : "Searching the documentation") @if (Call.Duration is { } duration) { }
@if (Query is { Length: > 0 } query) {
@query
} @if (Hits is { } hits) {
    @foreach (var hit in hits.EnumerateArray()) {
  • @(hit.TryGetProperty("title", out var title) ? title.GetString() : null)
  • }
} else if (Call.Progress is { } progress) { }
@code { // Handed over by the registry, which renders the component with the call as its Call parameter. [Parameter, EditorRequired] public BitAiToolCallPart Call { get; set; } = default!; // Checked rather than assumed: a custom renderer is handed data a model produced. private JsonElement? Hits => Call.Result?.Value is { } value && value.ValueKind is JsonValueKind.Object && value.TryGetProperty("hits", out var hits) && hits.ValueKind is JsonValueKind.Array ? hits : null; private string? Query => Call.DisplayArguments is { } args && args.ValueKind is JsonValueKind.Object && args.TryGetProperty("query", out var value) ? value.GetString() : null; } ``` `AiDemoJsonContext.cs`: ```csharp public sealed record SearchArgs(string Query); public sealed record SearchResult(string Query, IReadOnlyList Hits); public sealed record SearchHit(string Title, double Score); // The web defaults write "hits", "title" and "score", which is what SearchResultCard reads back. [JsonSourceGenerationOptions(JsonSerializerDefaults.Web)] [JsonSerializable(typeof(SearchArgs))] [JsonSerializable(typeof(SearchResult))] public partial class AiDemoJsonContext : JsonSerializerContext { } ``` `AiDemoToolTransports.cs`: ```csharp // AiScriptedTransport and AiScript are the demo's helpers - see the Basic chat scenario. public static IBitAiTransport CallsTool(string toolName, string arguments, string answer, string? declinedAnswer = null) => new AiScriptedTransport((request, token) => AiScript.Stream(token, async yielder => { var outcome = AiScript.ToolOutcomeThisTurn(request); await yielder(new BitAiMessageStartedEvent()); // First round trip: ask for the tool, streaming its arguments, and stop; the session runs it. if (outcome is null) { await yielder(new BitAiStatusEvent("Deciding what to look up")); await AiScript.Pause(500, token); var callId = BitAiId.NewToolCallId(); await yielder(new BitAiToolCallStartedEvent(callId, toolName)); foreach (var slice in AiScript.Slice(arguments, 6)) { await AiScript.Pause(40, token); await yielder(new BitAiToolCallDeltaEvent(callId, slice)); } await yielder(new BitAiToolCallCompletedEvent(callId)); await yielder(new BitAiMessageCompletedEvent(BitAiFinishReason.ToolCalls)); return; } // Second round trip: the result is on the request. var reply = outcome.IsSuccess ? answer : declinedAnswer ?? $"I did not run {toolName}, so nothing was changed."; await foreach (var evt in AiScript.Write(reply, 4, 12, token)) await yielder(evt); await yielder(AiScript.Usage(request, reply)); await yielder(new BitAiMessageCompletedEvent()); })); ```

How it works

search_docs is rendered by SearchResultCard, an ordinary component of this site. BitAiRendererRegistry maps a tool name to a renderer (renderers.RegisterTool<SearchResultCard>("search_docs")), so the substitution applies wherever that tool is called without touching the message list. The component is handed the call as its Call parameter and re-renders as the call changes: its progress reports while the tool runs, its result once it has returned. RegisterTool(name, context => ...) takes a render fragment instead, handed a BitAiToolCallContext (the call, its message and the session).

The transport asks for search_docs as for any tool (see Tool calling), and the session runs it. The tool is declared with the BitAiTool.Create<TArgs>(name, description, argumentsTypeInfo, (args, invocation, token) => ...) overload (see Declaring a tool), whose handler is handed the BitAiToolInvocation and returns the whole outcome. It reports where it is through invocation.Progress - a BitAiToolProgress with a Fraction and a Message, which lands on the call as Call.Progress - and returns a full BitAiToolOutcome: the value the card reads (Call.Result.Value), the Text the model reads, and the Citations of what it searched, which the session adds to the reply's sources. The card reads the arguments through Call.DisplayArguments, which holds them while they still stream, and the time the call took through Call.Duration.

A custom renderer is handed data a model produced, so the shape it wants is a hope, not a guarantee: the card checks the payload before it reads it, and a renderer that throws anyway is contained by the boundary around it rather than taking the conversation down. The same registry takes renderers for message part kinds, artifact kinds, structured output schemas, code languages and citations.

Components used

Feedback

Found a mistake, a gap, or something that could be clearer? Every page and every component is one click from its source.