Skip to content

AI scenarios

Basic chat

Bit.BlazorUI.AI Scripted transport

A working chat in three lines of markup. The session comes from the registered transport, and everything else is default behaviour.

Live demo

Send anything, or pick a starter prompt: the transport registered for the whole site echoes it back a few characters at a time.

\n**A chat over the registered transport**: ```razor @implements IAsyncDisposable
``` ```csharp private BitAiChatSession? session; private readonly string[] suggestions = [ "What can these components do?", "Show me streaming", "Explain render modes", ]; [Inject] private BitAiChatSessionFactory SessionFactory { get; set; } = default!; // A session over the transport registered with AddBitBlazorUIAIServices. protected override void OnInitialized() => session = SessionFactory.Create(); public ValueTask DisposeAsync() => session?.DisposeAsync() ?? ValueTask.CompletedTask; ``` `Program.cs`: ```csharp builder.Services.AddBitBlazorUIAIServices(ai => { ai.UseTransport(AiDemoTransports.Echo()); }); ``` `AiDemoTransports.cs`: ```csharp // The smallest useful transport: it streams the prompt back. A real one wraps a provider's SDK and // turns its stream into the same events. public static IBitAiTransport Echo() => new AiScriptedTransport((request, token) => AiScript.Stream(token, async yielder => { var said = AiScript.LastUserMessage(request); var text = said?.GetText() ?? string.Empty; var files = said?.Attachments.Count ?? 0; // A message can carry files and no text at all, and an echo of nothing would read as no answer. var reply = text.Length > 0 ? $"You said: {text}" : files == 1 ? "You sent 1 file." : $"You sent {files} files."; await yielder(new BitAiMessageStartedEvent()); await foreach (var evt in AiScript.Write(reply, 3, 12, token)) await yielder(evt); await yielder(AiScript.Usage(request, reply)); await yielder(new BitAiMessageCompletedEvent()); })); ``` `AiScriptedTransport.cs`: ```csharp using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading.Channels; // The demo's helpers, which every scripted transport of the AI scenarios is written with: a transport built from a // delegate, and the few steps a script repeats. An app's own transport implements IBitAiTransport directly instead // (see EchoTransport.cs). public sealed class AiScriptedTransport(Func> script) : IBitAiTransport { public IAsyncEnumerable StreamAsync(BitAiRequest request, CancellationToken cancellationToken = default) => script(request, cancellationToken); } public static class AiScript { // Streams text a few characters at a time, the way a model does. public static async IAsyncEnumerable Write(string text, int chunk = 4, int delayMs = 14, [EnumeratorCancellation] CancellationToken cancellationToken = default) { for (var i = 0; i < text.Length; i += chunk) { await Task.Delay(delayMs, cancellationToken); yield return new BitAiTextDeltaEvent(text.Substring(i, Math.Min(chunk, text.Length - i))); } } // Streams reasoning, a word at a time. public static async IAsyncEnumerable Think(string text, int delayMs = 10, [EnumeratorCancellation] CancellationToken cancellationToken = default) { foreach (var word in text.Split(' ')) { await Task.Delay(delayMs, cancellationToken); yield return new BitAiReasoningDeltaEvent(word + " "); } } public static Task Pause(int milliseconds, CancellationToken cancellationToken) => Task.Delay(milliseconds, cancellationToken); public static IBitAiMessage? LastUserMessage(BitAiRequest request) => request.Messages.LastOrDefault(m => m.Role.IsUser); public static string LastUserText(BitAiRequest request) => LastUserMessage(request)?.GetText() ?? string.Empty; // Whether a tool returned since the latest user message: the session ran it, and the model should now answer. public static bool HasToolResultThisTurn(BitAiRequest request) => ToolOutcomeThisTurn(request) is not null; // The outcome of the latest tool call that returned since the latest user message. A call the user declined comes // back as a failure. public static BitAiToolOutcome? ToolOutcomeThisTurn(BitAiRequest request) { for (var i = request.Messages.Count - 1; i >= 0; i--) { var message = request.Messages[i]; if (message.Role.IsUser) return null; if (message.Parts.OfType().LastOrDefault(c => c.Result is not null) is { } call) return call.Result; } return null; } // Plausible token usage: about four characters a token, priced at 3 and 15 USD a million. public static BitAiUsageUpdatedEvent Usage(BitAiRequest request, string output) { var input = request.Messages.Sum(m => m.GetText().Length) / 4; var generated = output.Length / 4; return new BitAiUsageUpdatedEvent(new BitAiTokenUsage { InputTokens = input, OutputTokens = generated, Cost = (input * 3m + generated * 15m) / 1_000_000m, Currency = "USD", }); } // Cuts a long payload into slices, the way a model streams it. public static IEnumerable Slice(string value, int size) { for (var i = 0; i < value.Length; i += size) yield return value.Substring(i, Math.Min(size, value.Length - i)); } // A JSON literal as a value that outlives its document. public static JsonElement Json(string json) { using var document = JsonDocument.Parse(json); return document.RootElement.Clone(); } // Turns a script that awaits a callback per event into an event stream, so a script reads top to bottom. public static async IAsyncEnumerable Stream([EnumeratorCancellation] CancellationToken token, Func, Task> body) { var channel = Channel.CreateUnbounded(); var producer = Task.Run(async () => { try { await body(evt => channel.Writer.WriteAsync(evt, token).AsTask()); channel.Writer.TryComplete(); } catch (Exception exception) { channel.Writer.TryComplete(exception); } }, token); await foreach (var evt in channel.Reader.ReadAllAsync(token)) yield return evt; await producer; } } ``` `EchoTransport.cs`: ```csharp using System.Runtime.CompilerServices; // The same echo as a class of its own, without the demo's helpers - the shape of the transport an app writes, around // its provider's SDK instead of a loop. Register it with ai.UseTransport(). public sealed class EchoTransport : IBitAiTransport { public async IAsyncEnumerable StreamAsync( BitAiRequest request, [EnumeratorCancellation] CancellationToken cancellationToken = default) { // The whole conversation is in request.Messages, oldest first; the echo only reads the newest user message. var said = request.Messages.LastOrDefault(m => m.Role.IsUser); var reply = $"You said: {said?.GetText()}"; yield return new BitAiMessageStartedEvent(); // Honouring the token is what makes the stop button stop the stream. for (var i = 0; i < reply.Length; i += 3) { await Task.Delay(12, cancellationToken); yield return new BitAiTextDeltaEvent(reply.Substring(i, Math.Min(3, reply.Length - i))); } yield return new BitAiUsageUpdatedEvent(new BitAiTokenUsage { InputTokens = request.Messages.Sum(m => m.GetText().Length) / 4, OutputTokens = reply.Length / 4, }); yield return new BitAiMessageCompletedEvent(); } } ```

How it works

BitAiChatSessionFactory creates a BitAiChatSession over the IBitAiTransport registered with AddBitBlazorUIAIServices, and the session drives a BitAiConversation. BitAiChat renders that conversation - the transcript, the composer, the empty state with its starter prompts and the scroll-to-latest button - and sends what the user writes through the session. The transport here is twenty lines of scripted C#; a real one wraps a provider's SDK, and nothing on the UI side changes.

Sending a message is one turn. The session appends the user message (BitAiChatSession.SubmitAsync), opens an empty assistant message, and calls the transport's one method, StreamAsync(BitAiRequest, CancellationToken). The BitAiRequest carries the conversation so far (Messages, oldest first, each an IBitAiMessage with its parts and attachments), its ConversationId, the session's generation settings (Options), the definitions of the tools the model may call (Tools) and the scoped Services. The transport answers with a stream of BitAiEvents, which the session applies to the assistant message as they arrive. This echo sends the smallest useful sequence:

  1. BitAiMessageStartedEvent - the reply has begun, so the thinking indicator can show at once.
  2. BitAiTextDeltaEvent, three characters at a time - each one appended to the reply's text part.
  3. BitAiUsageUpdatedEvent - the tokens it used, shown under the reply (the site turns ShowUsage on).
  4. BitAiMessageCompletedEvent - the reply is finished, and the message actions appear under it.

Only the text deltas are required: the session opens the message itself on the first one, and closes it when the stream ends. The stop button cancels the token the transport was handed, so a transport that honours it promptly stops at once and keeps what it had written; one that fails reports it with a BitAiErrorEvent rather than throwing (see Errors and retry). The AiScriptedTransport.cs tab holds the helpers every scenario's script is written with (AiScriptedTransport, AiScript); the EchoTransport.cs tab is the same echo as a class of its own, without them - the shape of the class an app writes.

Press Enter to send, Shift and Enter for a newline. The composer grows as you type and stops at twelve rows; while a reply streams, its send button becomes a stop button. The chat takes the height of its container, so give that container one.

Registration

AddBitBlazorUIAIServices registers the package (and the core services it builds on, so it replaces the AddBitBlazorUIServices line), then hands its callback a BitAiBuilder, which configures everything else in one statement:

  • UseTransport<TTransport>(), UseTransport(transport) or UseTransport(services => ...) - the transport the factory's sessions stream from: a scoped type, one shared instance, or one built from services.
  • AddTool(tool) and AddTool<TTool>() - the tools the model may call (Tool calling).
  • UseApprovalPolicy<TPolicy>() - which calls wait for a human decision (Tool approval).
  • AddModels(models, providers, defaultModelId) - the models the user can pick between (Models and usage).
  • UseConversationStore<TStore>() - where conversations are kept (Conversations).
  • ConfigureSessions(options => options with { ... }) - the BitAiChatSessionOptions every session of the factory starts with: the tool loop limit, text pacing, the timeout, the request options (Streaming, Parallel tools).
  • ConfigureUi(options => options with { ... }) - the presentation defaults of the components (Options & theming).
  • ConfigureRenderers(renderers => ...) - the app's own views for a tool, a structured schema, an artifact kind or a part kind (Custom tool UI, Forms).
  • ConfigureGenerativeUi(registry => ...) - the components model output may render (Generative UI).
  • Services - the service collection itself, for anything else the transport or the tools need.

Calling AddBitBlazorUIAIServices again with another callback adds to what an earlier call set up - a module of the app can register its own tools - since nothing is registered twice. Its trySingleton flag is for WebAssembly and Hybrid only: on Blazor Server the default approval policy remembers one user's "allow always", and a singleton would share it with every other user.

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.