Files
AgenticCode/openspec/changes/archive/2026-04-05-enable-rich-text-display/design.md
local 7a5c22593a feat: enable rich text display for assistant messages
Add markdown-to-HTML rendering for assistant messages using Markdig with
HTML sanitization. Includes cached rendering to avoid lag during streaming,
styled markdown elements (code blocks, tables, lists, blockquotes) within
chat bubbles, and 18 unit tests covering rendering and XSS prevention.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 01:29:58 +01:00

58 lines
3.5 KiB
Markdown

## Context
Assistant messages from the LLM contain markdown formatting (bold, code blocks, lists, headings) but are currently rendered as plain text via `<MudText>@message.Content</MudText>`. Markdig 1.1.1 is already in the stack spec but not yet wired up. The Blazor WASM client needs a rendering pipeline that converts markdown to HTML and displays it safely inside chat bubbles.
## Goals / Non-Goals
**Goals:**
- Render assistant message markdown as formatted HTML (headings, bold, italic, code, lists, tables, links)
- Sanitize HTML output to prevent XSS from LLM-generated content
- Style rendered elements to look good inside MudBlazor chat bubbles
- Maintain streaming performance — re-render as tokens arrive without flicker
**Non-Goals:**
- Rendering user messages as markdown (they stay plain text)
- Syntax highlighting for code blocks (future enhancement)
- LaTeX/math rendering
- Image rendering from markdown
## Decisions
### D1: Use Markdig for markdown-to-HTML conversion
Markdig is already in the stack spec. It's the standard .NET markdown library, runs in WASM, and supports GFM (GitHub Flavored Markdown) extensions out of the box.
**Alternative**: Custom regex-based parsing — rejected, fragile and incomplete.
### D2: Render via `MarkupString` in Blazor
Blazor's `MarkupString` struct renders raw HTML in the component tree. We wrap the Markdig HTML output in `MarkupString` to display it. This replaces `@message.Content` with `@((MarkupString)renderedHtml)` for assistant messages only.
**Alternative**: Use a third-party Blazor markdown component — rejected, adds a dependency when Markdig + MarkupString achieves the same result with less coupling.
### D3: HTML sanitization via allowlist approach
LLM output is untrusted. After Markdig converts markdown to HTML, we strip any tags/attributes not on an allowlist. Allowed: `p, h1-h6, strong, em, code, pre, ul, ol, li, a (href only), table, thead, tbody, tr, th, td, br, blockquote`. This prevents script injection without a heavy sanitizer dependency.
**Alternative**: Use a NuGet sanitizer package (e.g., HtmlSanitizer) — viable but adds a dependency for a simple allowlist. If the allowlist proves insufficient, revisit.
### D4: Create a MarkdownService for the conversion pipeline
A `MarkdownService` class encapsulates the Markdig pipeline configuration, HTML sanitization, and caching. Registered as singleton in DI. This keeps the rendering logic out of the Razor component and makes it testable.
### D5: Incremental rendering during streaming
During streaming, the assistant message content grows token by token. Each time `StateHasChanged()` is called, the full content string is re-converted through Markdig. This is acceptable because:
- Markdig is fast (sub-millisecond for typical message sizes)
- Messages rarely exceed a few KB
- No DOM diffing overhead — Blazor handles this efficiently
If performance becomes an issue, we can cache the last-known-good HTML prefix and only re-render the delta.
## Risks / Trade-offs
- **[XSS from LLM output]** → Mitigated by HTML sanitization allowlist. The allowlist is conservative — only structural tags, no script/style/event handlers.
- **[Streaming flicker]** → Low risk. Blazor's diffing minimizes DOM updates. If observed, add debouncing to StateHasChanged calls.
- **[Markdig WASM size]** → Markdig adds ~200KB to the WASM bundle. Already accepted in stack spec.
- **[Incomplete markdown support]** → Markdig supports GFM by default. Edge cases (nested tables, complex HTML blocks) may render imperfectly but are rare in LLM output.