Skip to content

[Blazor] Add Components.AI rich text rendering - #68324

Open
javiercn wants to merge 4 commits into
javiercn/components-ai-01-chatfrom
javiercn-components-ai-02-rich-text
Open

[Blazor] Add Components.AI rich text rendering#68324
javiercn wants to merge 4 commits into
javiercn/components-ai-01-chatfrom
javiercn-components-ai-02-rich-text

Conversation

@javiercn

@javiercn javiercn commented Aug 10, 2026

Copy link
Copy Markdown
Member

Overview

This is position 2 in the native Components.AI stack tracked by #68340 and depends on #68323. It adds protocol-neutral structured rich text to the streaming chat layer, then proves the same formatted response in the broad AIApp product surface and in Agentic Chat across the separate DojoClient → AGUI.Client 0.0.5 → HTTP/SSE → AGUI.Server 0.0.5 → AGUIDojoApi boundary. The cross-cutting constraint is that Markdown and AG-UI remain outside Microsoft.AspNetCore.Components.AI; the product receives complete structured snapshots and renders them without knowing their source format.

Design

// src/Components/AI/src/Blocks/RichText/RichTextContent.cs
// A provider supplies a complete text/tree snapshot. Copying the top-level list prevents the
// caller from changing membership after publication; the pipeline can replace one snapshot atomically.
public class RichTextContent : AIContent
{
    public RichTextContent(string text, IReadOnlyList<RichTextNode> nodes)
    {
        ArgumentNullException.ThrowIfNull(text);
        ArgumentNullException.ThrowIfNull(nodes);

        Text = text;
        Nodes = [.. nodes];
    }

    public string Text { get; }
    public IReadOnlyList<RichTextNode> Nodes { get; }
}
// src/Components/AI/src/Blocks/RichText/RichTextNode.cs
// One recursive contract covers block and inline structure; consumers build trees without a Markdown dependency.
public abstract class RichTextNode
{
    private List<RichTextNode>? _children;

    public IReadOnlyList<RichTextNode> Children =>
        _children ?? (IReadOnlyList<RichTextNode>)Array.Empty<RichTextNode>();

    public void AddChild(RichTextNode child)
    {
        ArgumentNullException.ThrowIfNull(child);
        _children ??= new();
        _children.Add(child);
    }
}

The node hierarchy is compressed into four behavioral equivalence classes rather than separate rendering APIs: text/inline (Text, emphasis, strong, strikethrough, inline code, links/images and references), block flow (paragraph, heading, quote, code block, lists, breaks), tabular (table/row/cell plus per-column alignment), and metadata/fallback (definitions, footnotes, encoded HTML source). Each leaf carries only its distinct metadata; traversal and nesting remain on RichTextNode.

Two design decisions keep the layer sound and scoped. First, streamed structure is a complete snapshot, not a partially mutated shared tree; renderers never observe half-applied updates. Second, Markdown parsing lives only in DojoClient. Adding Markdig/AG-UI semantics to the shipping product was deliberately avoided, so any provider can map its own structured format into the same node contract.

Implementation

// src/Components/AI/src/Pipeline/RichTextContentHandler.cs
// The first snapshot emits one RichContentBlock; later snapshots replace its text and tree in place.
// Example: heading-only snapshot → heading + paragraph + list snapshot, still one streaming message block.
public override BlockMappingResult<RichContentBlock> Handle(
    BlockMappingContext context, RichContentBlock state)
{
    RichTextContent? snapshot = null;
    foreach (var content in context.UnhandledContents)
    {
        if (content is RichTextContent richText)
        {
            snapshot = richText;
            context.MarkHandled(content);
        }
    }

    if (snapshot is null)
    {
        return state.Id.Length > 0
            ? BlockMappingResult<RichContentBlock>.Complete()
            : BlockMappingResult<RichContentBlock>.Pass();
    }

    state.ReplaceContent(snapshot.Text, snapshot.Nodes);
    if (state.Id.Length == 0)
    {
        state.Id = context.Update.MessageId ?? Guid.NewGuid().ToString("N");
        return BlockMappingResult<RichContentBlock>.Emit(state, state);
    }

    return BlockMappingResult<RichContentBlock>.Update(state);
}

The plain-text fallback now maps paragraphs into ParagraphNode + TextNode, so old TextContent behavior and new structured snapshots converge on one renderer. MessageListContext recursively renders the four node classes above: one representative switch handles ordinary semantic elements, list/table branches add their metadata, links/images pass through an allowlist (http, https, relative paths, and mailto for links), and HtmlNode uses AddContent so source such as <mark>…</mark> is displayed encoded rather than executed.

// src/Components/AI/testassets/DojoClient/Formatting/FormattedChatClient.cs
// The real AGUIChatClient remains the inner client. Only TextContent entries are replaced;
// tool/state content stays in the original update and at its original relative position.
await foreach (var update in base.GetStreamingResponseAsync(
    messages,
    options,
    cancellationToken).ConfigureAwait(false))
{
    // (collect text chunks and remove only TextContent entries — omitted)
    if (firstTextIndex >= 0)
    {
        foreach (var chunk in chunks)
        {
            text.Append(chunk);
        }

        var snapshot = text.ToString();
        update.Contents.Insert(
            firstTextIndex,
            new RichTextContent(snapshot, MarkdownRichTextParser.Parse(snapshot)));
    }

    yield return update;
}

MarkdownRichTextParser covers the Dojo formatting equivalence classes once: block dispatch handles code fences, headings/thematic breaks, quotes, lists, then paragraphs; inline dispatch handles images, links, strong/strike/emphasis, inline code, and remaining text. This parser is a test-asset adapter, not product policy.

// src/Components/AI/testassets/DojoClient.E2E.Tests/Tests/AgenticChatRichTextScenarioTests.cs
// Acceptance proof: launch two processes, override only the API's model, and route the browser to DojoClient.
var api = await StartServerAsync<AGUIDojoApiAssembly>(TestRoot.Servers, options =>
{
    options.ConfigureServices<DojoModelOverrides>(
        nameof(DojoModelOverrides.AgenticChatRichText));
});
var ui = await StartServerAsync<global::DojoClient.Components.App>(
    TestRoot.Servers,
    options => options.EnvironmentVariables["AGUI_DOJO_API_URL"] = api.AppUrl);

// The first checkpoint proves partial structured rendering; release then proves the completed list/link.
await Expect(assistant.Locator("h2")).ToHaveTextAsync("Blazor components");
await Expect(assistant.Locator("li")).ToHaveCountAsync(0);
await checkpoints.ReleaseAsync(prompt, "structure");
await Expect(assistant.Locator("li")).ToHaveCountAsync(2);

Review guidance: read the snapshot contract/handler, renderer safety branches, and FormattedChatClient content preservation closely. Spot-check one leaf from each node equivalence class, the focused AIApp matrix, and the generated two-checkpoint recording. Acceptance criteria: the same response renders headings, inline formatting, links, and lists in AIApp and DojoClient; DojoClient still performs a real POST/SSE exchange with AGUIDojoApi; unsafe URLs do not become active links and HTML source is encoded.

Outcome

Validation class Result
Dependency-aware builds (Microsoft.AspNetCore.Components.AI.Tests, AIApp.E2E.Tests, DojoClient.E2E.Tests) Passed, 0 warnings / 0 errors
Product unit tests 70/70 passed
AIApp RichTextTests 1/1 passed
DojoClient AgenticChatRichTextScenarioTests 1/1 passed over the real dual-host HTTP/SSE path

The resulting layer preserves plain-text chat, adds atomic structured streaming and safe default rendering, and establishes formatted Agentic Chat without introducing later stack concerns such as client tools, server tools, approval flow, or shared/predictive state.

@javiercn
javiercn requested a review from a team as a code owner August 10, 2026 16:25
Model formatted assistant output as a protocol-neutral RichTextNode hierarchy and map complete streaming snapshots into RichContentBlock instances. Replacing snapshots atomically keeps renderers from observing partially mutated trees while plain text continues to map to paragraph and text nodes.

Render the structured nodes through the existing message-list fallback with safe URL handling and encoded HTML source, so consumers can preserve formatting without coupling the product to AG-UI or a Markdown implementation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Decorate the real AGUIChatClient with a streaming formatter that accumulates text received over HTTP/SSE and emits complete RichTextContent snapshots. The wrapper leaves non-text AG-UI content untouched, preserving the DojoClient-to-AGUI.Client and AGUIDojoApi-to-AGUI.Server boundary for later tool scenarios.

Parse the Agentic Chat Markdown into protocol-neutral nodes in DojoClient and make the credential-free API fixture stream headings, emphasis, lists, and inline code for manual use.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Cover structured snapshot replacement, plain-text paragraph mapping, node metadata, recursive rendering, URL filtering, and HTML encoding in the product tests. The focused AIApp page renders a broad node matrix without AG-UI dependencies so product behavior is independently reviewable.

Add an Agentic Chat rich-text browser test that still launches DojoClient and AGUIDojoApi separately, overrides only the API model, and asserts Markdown formatting after the response crosses AGUI.Server, HTTP/SSE, and AGUI.Client. The generated recording is intentionally deferred to the next commit.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Record the two deterministic model checkpoints used by the Agentic Chat rich-text browser test: the initial heading and inline formatting, followed by the linked rendering-mode list. The model remains the only replaced service, so replay still exercises the real AG-UI request and SSE response path.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

Copilot-Session: 323b0ef8-7905-4041-8388-a08586c0bd34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant