Skip to content

AI scenarios

Conversations

Bit.BlazorUI.AI Scripted transport

A sidebar over a conversation store, grouped by recency, with new, switch, and delete - and a reply that keeps streaming in a thread you switched away from. Then a conversation written out as JSON and read back.

Live demo

Send a message to start a thread, press New for another one, and switch between them - even while a reply is still arriving.

\n**Threads over a conversation store**: ```razor @implements IAsyncDisposable
@if (session is not null) { }
``` ```csharp // One live session per thread, so a reply keeps streaming while another thread is open. private readonly Dictionary sessions = new(StringComparer.Ordinal); private IReadOnlyList summaries = []; private BitAiChatSession? session; private readonly string[] suggestions = ["Plan a weekend in Lisbon", "Draft a release note"]; [Inject] private BitAiChatSessionFactory SessionFactory { get; set; } = default!; [Inject] private IBitAiConversationStore Store { get; set; } = default!; protected override async Task OnInitializedAsync() { summaries = await Store.ListAsync(); if (summaries.Count > 0 && await Store.LoadAsync(summaries[0].ConversationId) is BitAiConversation latest) { Open(latest); } else { Open(new BitAiConversation()); } } private Task NewConversationAsync() { // Reuse an untouched thread rather than stacking empty ones. if (session?.Conversation.Messages.Count == 0) return Task.CompletedTask; Open(new BitAiConversation()); return Task.CompletedTask; } private async Task SelectAsync(BitAiConversationSummary summary) { if (summary.ConversationId == session?.Conversation.ConversationId) return; if (await Store.LoadAsync(summary.ConversationId) is BitAiConversation conversation) { Open(conversation); } } private async Task DeleteAsync(BitAiConversationSummary summary) { await Store.DeleteAsync(summary.ConversationId); if (sessions.Remove(summary.ConversationId, out var removed)) { Detach(removed); await removed.DisposeAsync(); } summaries = await Store.ListAsync(); if (session?.Conversation.ConversationId != summary.ConversationId) return; session = null; if (summaries.Count > 0 && await Store.LoadAsync(summaries[0].ConversationId) is BitAiConversation next) { Open(next); } else { Open(new BitAiConversation()); } } private void Open(BitAiConversation conversation) { if (sessions.TryGetValue(conversation.ConversationId, out var existing) is false) { existing = SessionFactory.Create(conversation); conversation.MessageAdded += OnMessageAdded; conversation.Changed += OnChanged; existing.TurnCompleted += OnTurnCompleted; sessions[conversation.ConversationId] = existing; } session = existing; } private void Detach(BitAiChatSession closing) { closing.Conversation.MessageAdded -= OnMessageAdded; closing.Conversation.Changed -= OnChanged; closing.TurnCompleted -= OnTurnCompleted; } // A thread is saved when a message arrives, so an untouched one never reaches the list. private void OnMessageAdded(BitAiConversation conversation, BitAiMessage message) => _ = InvokeAsync(() => SaveAsync(conversation)); // The reply is still empty when it arrives, so the thread is saved again once its turn ends: a store over a database // keeps what it was handed, not the live conversation, and the list's preview and order come from what it keeps. private void OnTurnCompleted(BitAiMessage reply) => _ = InvokeAsync(async () => { // The thread is looked up among the live ones: it need not be the open one, and a deleted one is not saved again. var owner = sessions.Values.FirstOrDefault(s => s.Conversation.FindMessage(reply.MessageId) is not null); if (owner is null) return; await SaveAsync(owner.Conversation); }); private async Task SaveAsync(BitAiConversation conversation) { await Store.SaveAsync(conversation); summaries = await Store.ListAsync(); StateHasChanged(); } private void OnChanged(BitAiConversation conversation) => _ = InvokeAsync(async () => { if (conversation.Messages.Count == 0) return; summaries = await Store.ListAsync(); StateHasChanged(); }); public async ValueTask DisposeAsync() { foreach (var open in sessions.Values) { Detach(open); await open.DisposeAsync(); } sessions.Clear(); } ``` `Program.cs`: ```csharp builder.Services.AddBitBlazorUIAIServices(ai => { ai.UseTransport(AiDemoTransports.Echo()); // Scoped, so on Blazor Server each visitor gets their own threads rather than everyone's. // A real app implements IBitAiConversationStore over its own database. ai.UseConversationStore(); }); ```

How it works

A BitAiConversation is a plain observable object, and a BitAiChatSession drives one of them. Switching threads means pointing the chat at a different session, not tearing anything down: each thread keeps its own live session here, so a reply started in one keeps streaming while another is open, and is there when you come back.

Persistence is an interface. This page saves through IBitAiConversationStore, registered for the site as the in-memory store with ai.UseConversationStore<BitAiInMemoryConversationStore>(); a real application implements it over its own database. It is scoped, so on Blazor Server each visitor gets their own threads rather than everyone's.

The store's four methods are the whole contract: ListAsync returns the BitAiConversationSummarys the sidebar draws (id, title, last change, a preview, the message count), LoadAsync one conversation in full, SaveAsync creates or replaces one, and DeleteAsync removes one. A loaded conversation is an IBitAiConversation; BitAiConversationSerializer.ToConversation(loaded) turns it into the BitAiConversation a session drives, returning it as it is when it already is one.

A new thread is saved when its first message arrives (BitAiConversation.MessageAdded), and again as each reply completes (BitAiChatSession.TurnCompleted), since a store over a database keeps the reply as it was handed - empty, had it been saved only as it arrived. New reuses a thread nobody has written in yet, so pressing it twice does not leave an empty conversation in the list.

Saved and restored

Write a message or two and press Save: the thread is written out as JSON. Keep writing, then press Restore: the thread is read back from that JSON into a session of its own.

\n**A conversation as JSON**: ```razor @implements IAsyncDisposable
@if (session is not null) { @* Keyed by the session: the restored thread keeps its conversation id, and is still a new chat. *@ }
``` ```csharp private BitAiChatSession? session; private string? json; private int savedCount; private readonly string[] suggestions = ["Plan a weekend in Lisbon"]; [Inject] private BitAiChatSessionFactory SessionFactory { get; set; } = default!; protected override void OnInitialized() => session = SessionFactory.Create(); // The whole thread - messages, parts, tool calls, artifacts, agent runs - as the JSON a store would keep. private void Save() { if (session is null) return; json = BitAiConversationSerializer.ToJson(session.Conversation); savedCount = session.Conversation.Messages.Count; } // The JSON read back into a live conversation, and a new session to drive it; the old one only has to stop. private async Task RestoreAsync() { if (json is null) return; var previous = session; session = SessionFactory.Create(BitAiConversationSerializer.FromJson(json)); if (previous is not null) await previous.DisposeAsync(); } public ValueTask DisposeAsync() => session?.DisposeAsync() ?? ValueTask.CompletedTask; ``` `FileConversationStore.cs`: ```csharp // A conversation store over a folder of JSON files: the serializer writes a conversation out and reads it back. // Registered in place of the in-memory one with ai.UseConversationStore(). public sealed class FileConversationStore : IBitAiConversationStore { private readonly string folder = Path.Combine(AppContext.BaseDirectory, "conversations"); public async ValueTask> ListAsync(CancellationToken cancellationToken = default) { if (Directory.Exists(folder) is false) return []; var summaries = new List(); foreach (var file in Directory.EnumerateFiles(folder, "*.json")) { // A snapshot is plain data: enough for the list, without building the live conversation. var snapshot = BitAiConversationSerializer.SnapshotFromJson(await File.ReadAllTextAsync(file, cancellationToken)); summaries.Add(new BitAiConversationSummary(snapshot.ConversationId, snapshot.Title, snapshot.UpdatedAt, MessageCount: snapshot.Messages.Count)); } return [.. summaries.OrderByDescending(s => s.UpdatedAt)]; } public async ValueTask LoadAsync(string conversationId, CancellationToken cancellationToken = default) { var path = PathOf(conversationId); if (File.Exists(path) is false) return null; return BitAiConversationSerializer.FromJson(await File.ReadAllTextAsync(path, cancellationToken)); } public async ValueTask SaveAsync(IBitAiConversation conversation, CancellationToken cancellationToken = default) { Directory.CreateDirectory(folder); var snapshot = BitAiConversationSerializer.ToSnapshot(conversation); await File.WriteAllTextAsync(PathOf(conversation.ConversationId), BitAiConversationSerializer.ToJson(snapshot), cancellationToken); } public ValueTask DeleteAsync(string conversationId, CancellationToken cancellationToken = default) { File.Delete(PathOf(conversationId)); return ValueTask.CompletedTask; } // The ids the package generates (conv_...) are letters, digits and an underscore, safe as file names. private string PathOf(string conversationId) => Path.Combine(folder, $"{conversationId}.json"); } ```

Persisting a conversation

A store over a database keeps data, not live objects, and BitAiConversationSerializer is what turns one into the other. ToSnapshot(conversation) takes a BitAiConversationSnapshot - plain records of the conversation, its messages (BitAiMessageSnapshot) and their parts (BitAiPartSnapshot), its artifacts and their versions, its agent runs, under a SchemaVersion - and ToJson(snapshot) writes it; SnapshotFromJson(json) and ToConversation(snapshot) go back, and ToJson(conversation) / FromJson(json) do both steps at once, as the demo above does. A reply that was still streaming when it was saved comes back cancelled, and a tool call that never finished likewise: nothing will ever finish them.

An attachment is kept by reference (Url) and with its inline Data; a ContentFactory, like an error's Exception, cannot be written and is dropped. The JSON is written through the package's source-generated metadata, so it is safe to trim. A BitAiConversationSerializerOptions adapts it: SnapshotPart and RestorePart persist a part type of the app's own (without them a BitAiCustomJsonPart keeps its JSON, but any other part of the app's keeps only its text, and comes back as a BitAiCustomJsonPart), and Json takes settings that know the app's types, for values the app keeps in the Extensions or Metadata dictionaries (CreateJsonOptions() plus the app's JsonSerializerContext). The FileConversationStore.cs tab is a store over a folder of JSON files built that way.

Components used

  • BitAiConversationList - the sidebar: today, yesterday, the previous seven and thirty days, then by month.
  • BitAiChat - the open thread, keyed by its conversation so switching starts it afresh.
  • BitAiCodeBlockView - the saved JSON, with its copy button.

Feedback

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