StreamMark 入门:从 NuGet 安装到 Avalonia 原生流式 Markdown 渲染

在 Avalonia 中显示 Markdown,最简单的需求是把一段字符串渲染出来。但如果字符串来自 AI,一次只到达几个字,还会包含代码、表格、公式和图表,接入方式就需要多考虑一步:如何持续追加内容,以及如何组织越来越长的对话。

StreamMark 是面向这类场景的 Avalonia 原生 Markdown 渲染库,不需要 WebView。本文从安装 NuGet 开始,先完成单篇文档显示,再接入流式输出和可选扩展。

本文由 AI 辅助整理,并基于项目源码和发布包核对。使用截至 2026 年 10 月 19 日已发布的 0.1.0-alpha.22,对应 .NET 10 与 Avalonia 12.1.3。它仍是预览版本,建议固定版本再接入。下文的 C# 接入 API 已通过发布包编译验证;配图是 TKCode 中实际集成 StreamMark 的 Headless + Skia 渲染截图,用于展示效果,并非下面最小程序的运行截图。

图 1:原生界面中的混合 Markdown 内容。公式、语法高亮和图表分别需要对应扩展包。

1. 准备 Avalonia 项目

准备 .NET 10 SDK。如果还没有 Avalonia 项目,可以先安装模板,再创建一个基础桌面项目:

bash 复制代码
dotnet new install Avalonia.Templates --version 12.1.3
dotnet new avalonia -n StreamMarkStarter
cd StreamMarkStarter

已有项目可以跳过创建步骤。确认目标框架为 net10.0,Avalonia 相关包统一使用 12.1.3,包括 Avalonia.Desktop、Avalonia.Themes.Fluent 及模板中启用的其他 Avalonia 包。不要只升级其中一个包,留下混用的大版本。

StreamMark 当前面向桌面应用。这里的示例使用 Fluent 主题,不要求安装 AtomUI。

2. 从 NuGet 安装 StreamMark

只显示普通 Markdown,先安装这一个包即可:

bash 复制代码
dotnet add package TKCode.StreamMark.Avalonia --version 0.1.0-alpha.22

注意两个名称的区别:NuGet 包名带有 TKCode. 前缀,但程序集和 C# 命名空间仍是 StreamMark.*。例如代码中使用的是 using StreamMark.Avalonia;。

需要更丰富的内容时,再安装对应扩展:

bash 复制代码
dotnet add package TKCode.StreamMark.Highlighting.TextMate --version 0.1.0-alpha.22
dotnet add package TKCode.StreamMark.RaTeX --version 0.1.0-alpha.22
dotnet add package TKCode.StreamMark.Mermaid --version 0.1.0-alpha.22
包 用途
TKCode.StreamMark.Avalonia Markdown 控件、默认主题;自动依赖 Core
TKCode.StreamMark.Highlighting.TextMate 代码语法高亮
TKCode.StreamMark.RaTeX LaTeX 公式
TKCode.StreamMark.Mermaid Mermaid 图表
TKCode.StreamMark.Theme.AtomUI 已使用 AtomUI 的宿主应用进行主题映射

这里显式指定预览版本,不依赖 NuGet 客户端默认是否搜索预发行版。所有 StreamMark 扩展建议使用相同版本。

3. 在 App 中注册主题

打开 App.axaml,保留模板原有的 Fluent 主题,并在后面加入 StreamMark 主题:

xml 复制代码
<Application xmlns="https://github.com/avaloniaui"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:sm="clr-namespace:StreamMark.Avalonia.Themes;assembly=StreamMark.Avalonia"
             x:Class="StreamMarkStarter.App"
             RequestedThemeVariant="Dark">
    <Application.Styles>
        <FluentTheme />
        <sm:StreamMarkTheme />
    </Application.Styles>
</Application>

x:Class 请与自己的项目命名空间保持一致。也可以在 App.Initialize() 中用 C# 注册,但不要和 XAML 重复添加:

csharp 复制代码
Styles.Add(new StreamMark.Avalonia.Themes.StreamMarkTheme());

这里设置深色仅为演示;可以改为 Light 或使用系统主题。Fluent 等宿主主题提供常规控件模板,StreamMark 主题提供 Markdown 内容使用的资源,两者职责不同。

4. 显示第一篇 Markdown

将模板的 MainWindow.axaml 简化为:

xml 复制代码
<Window xmlns="https://github.com/avaloniaui"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        x:Class="StreamMarkStarter.MainWindow"
        Title="StreamMark 入门"
        Width="960" Height="720" />

然后修改 MainWindow.axaml.cs。这一版只依赖基础包:

csharp 复制代码
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using StreamMark.Avalonia;
using StreamMark.Avalonia.Extensions;

namespace StreamMarkStarter;

public partial class MainWindow : Window
{
    private readonly MarkdownServices _services = new();
    private readonly MarkdownView _view = new();

    public MainWindow()
    {
        InitializeComponent();

        // 先设置服务,再设置 Markdown。
        MarkdownServices.SetServices(_view, _services);
        _view.Markdown = """
            # Hello StreamMark

            这是运行在 **Avalonia 原生控件** 中的 Markdown。

            - 支持标题、段落和列表
            - 可以选择、复制文字
            - 接下来接入流式输出

            | 项目 | 状态 |
            | --- | --- |
            | NuGet 安装 | 完成 |
            | Avalonia 接入 | 完成 |
            """;

        Content = new ScrollViewer
        {
            Content = new Border
            {
                MaxWidth = 820,
                Padding = new Thickness(28),
                HorizontalAlignment = HorizontalAlignment.Stretch,
                Child = _view
            }
        };
    }
}

运行:

bash 复制代码
dotnet run

此时可以看到标题、列表和表格。MarkdownView 的高度会随文档增长,因此单篇文档通常由外层 ScrollViewer 提供滚动;MaxWidth 是为了控制阅读宽度。

5. 加入代码高亮、公式和 Mermaid

安装扩展包后,还需要注册插件。把上面的 _services 字段替换为:

csharp 复制代码
private readonly MarkdownServices _services = new()
{
    Plugins =
    [
        new StreamMark.Highlighting.TextMate.TextMatePlugin(),
        new StreamMark.RaTeX.RaTeXPlugin(),
        new StreamMark.Mermaid.MermaidPlugin()
    ]
};

然后把 _view.Markdown 替换为下面的内容:

csharp 复制代码
_view.Markdown = """
    ## 代码高亮

    ```csharp
    public record Message(string Role, string Content);
    ```

    ## 数学公式

    行内公式:$E = mc^2$

    $$
    \mathrm{Attention}(Q,K,V)
    = \mathrm{softmax}\left(\frac{QK^T}{\sqrt{d_k}}\right)V
    $$

    ## Mermaid 流程图

    ```mermaid
    flowchart LR
        A[网络文本增量] --> B[MarkdownStream]
        B --> C[增量解析]
        C --> D[Avalonia 原生界面]
    ```
    """;

C# 原始字符串 """ 不需要把 LaTeX 的反斜杠再转义一遍。这里的公式是 Markdown 内容,交给 RaTeX 插件处理。

图 2:TKCode 中通过 StreamMark 显示的 Mermaid 图表。默认配色会随宿主的明暗主题变化。

几个接入细节:

  • 插件应在服务第一次使用前配置好。不要在已经渲染文档后再往同一个服务的插件列表追加配置。
  • 将服务设置在具体 MarkdownView 上最容易理解;多个子控件共用配置时,也可以设置到 Window 或共同祖先上。
  • 使用插件时,手动创建的流式文档、会话条目要使用同一个 services.Profile,避免视图启用了插件,但文档仍使用默认解析配置。

RaTeX 的 NuGet 包携带六个桌面 RID 对应的原生库(Windows、Linux、macOS,各含 x64/arm64)。正常 NuGet 接入不要求自己安装 Rust,也不要求手动编译原生库。只有从源码构建或定制原生部分时,才需要走仓库的原生构建流程。

6. 把静态显示改成流式输出

不要在每个网络分片到达时都执行 _view.Markdown += chunk。该属性适合加载完整文档;流式回答应创建一次 MarkdownStream,后续只追加新增文本。

在同一个 MainWindow 类中加入:

csharp 复制代码
private async Task PlayDemoAsync(CancellationToken cancellationToken)
{
    var stream = new MarkdownStream(profile: _services.Profile);
    _view.Stream = stream;

    string[] chunks =
    [
        "# 流式回答\n\n",
        "你好,",
        "**StreamMark**!\n\n",
        "- 原生渲染\n",
        "- 增量更新\n"
    ];

    try
    {
        foreach (var chunk in chunks)
        {
            cancellationToken.ThrowIfCancellationRequested();
            stream.Push(chunk);
            await Task.Delay(80, cancellationToken);
        }
    }
    finally
    {
        // 即使取消,也结束当前流,保留已经收到的内容。
        stream.Complete();
    }
}

为便于运行演示,在构造函数末尾添加以下代码,让窗口打开后自动播放一次;这是对真实网络返回的模拟,不会调用模型 API:

csharp 复制代码
Opened += async (_, _) => await PlayDemoAsync(CancellationToken.None);

示例需要 System.Threading 和 System.Threading.Tasks 命名空间;常见 .NET 模板启用 ImplicitUsings 后已自动导入。正式应用可以换成按钮触发,并在事件处理器中处理取消和错误。

真实模型接入时,只需把 chunks 换成 SDK 返回的文本增量 。一轮回答创建一个流,按顺序 Push(),结束后 Complete()。如果 SDK 返回的是累计全文,先计算新增后缀,不能把整篇内容重复 Push。

Push() 和 Complete() 可以从后台线程调用;设置 _view.Stream、添加控件和更新会话条目仍要在 UI 线程进行。示例从 Avalonia 窗口事件进入,因此控件赋值在 UI 线程。

默认存在平滑输出队列,因此调用 Complete() 不代表屏幕上的文字已经同步全部出现。需要知道平滑队列也结束时,可以监听 stream.Completed;需要立即展示当前积压时,可以调用 stream.Flush()。

7. 长对话使用 TranscriptView

MarkdownView 是单文档控件,本身不提供虚拟化。聊天记录越来越长时,建议用 TranscriptView,让它组织消息、虚拟化显示行,并处理跨消息选区和滚动。

下面的代码可放在窗口构造函数中,替换原来的单文档 ScrollViewer 内容设置:

csharp 复制代码
var transcript = new StreamMark.Avalonia.Transcript.TranscriptView
{
    MaxContentWidth = 820
};
MarkdownServices.SetServices(transcript, _services);

// 用户消息可以是任意 Avalonia 控件。
transcript.Entries.Add(
    new StreamMark.Avalonia.Transcript.ContentEntry(
        new TextBlock { Text = "请介绍 StreamMark", Margin = new Thickness(16) }));

// 历史消息:用匹配插件的 Profile 创建静态条目。
transcript.Entries.Add(
    StreamMark.Avalonia.Transcript.MarkdownEntry.FromText(
        "这是一条 **历史回答**。", _services.Profile));

// 新回答:创建一次,持续追加。
var reply = new MarkdownStream(profile: _services.Profile);
transcript.Entries.Add(
    new StreamMark.Avalonia.Transcript.MarkdownEntry(reply));

Content = transcript;
reply.Push("## 新回答\n\n");
reply.Push("下面可以接入真实模型的文本增量。");
reply.Complete();

不要再把 TranscriptView 放入一个让它无限增高的外层纵向 ScrollViewer;保持窗口或 Grid 给它有限的可视高度。需要主动跳到底部时调用 transcript.ScrollToEnd(),而不是每次收到分片都强制滚到底部,打断用户阅读历史。

图 3:TKCode 中的 StreamMark 内容展示。diff、任务列表和 details 可以放在同一篇 Markdown 中;截图里的任务状态是预置示例。

8. 主题、链接和图片的常见问题

文字颜色异常或背景不协调。 先确认注册了 StreamMarkTheme,并检查宿主的明暗主题。StreamMark 的 Markdown 资源使用 Md.* 键;需要整体设计系统映射时,再考虑主题包。已经使用 AtomUI 的应用可以安装 TKCode.StreamMark.Theme.AtomUI,在 StreamMark 默认主题与宿主 AtomUI 主题就绪后加入 AtomUIMarkdownTheme。普通 Fluent 项目不必为了 Markdown 额外引入 AtomUI。

明明安装了扩展,却没有渲染效果。 安装包、注册插件、创建匹配 Profile 的文档是三件事。先确认服务已经设置到正确控件,再确认 MarkdownStream(profile: _services.Profile) 或 MarkdownEntry.FromText(text, _services.Profile) 使用了同一套配置。

长文档滑动时控件越来越多。 单个 MarkdownView 不等于虚拟化会话。多轮聊天请改用 TranscriptView;虚拟化减少视图实例和布局开销,并不会消除历史字符串和消息模型本身占用的内存。

发布后公式无法加载原生库。 按实际平台指定 RID,例如 dotnet publish -c Release -r osx-arm64。检查发布输出包含对应平台的 RaTeX 原生资产,不要把 x64 与 arm64 混用。Linux 上还应检查系统依赖与发行版环境。

图片路径或链接行为不符合预期。 入门时先使用可访问的 HTTPS 图片地址,减少相对路径解析问题。链接由 StreamMark 的链接策略约束;默认允许的外部协议为 HTTP、HTTPS 和 mailto,应用可通过 LinkClicked 接入自己的打开方式,并处理 RequiresConfirmation。不要不加判断地把任意 URL 交给系统启动。

Markdown 里能否执行脚本。 StreamMark 支持一部分 HTML 和白名单组件,但不是完整浏览器。script、style、iframe 等未知元素不会执行,应按原始源码展示。网页 CSS、JavaScript 插件不能直接照搬到原生 Markdown 控件里。

9. 推荐的接入顺序

先让基础包和默认主题显示一段静态 Markdown;再注册自己真正需要的扩展;接着通过 MarkdownStream 对接网络文本增量;最后按会话长度选择 TranscriptView,补上取消、错误提示、链接处理和图片策略。

本文示例只覆盖渲染接入,不包含模型认证、工具执行或会话持久化。StreamMark 负责把内容呈现在原生界面中,Agent 或聊天应用的业务流程仍由宿主管理。

参考入口:

验证说明:本文的服务注册、MarkdownView、MarkdownStream、TranscriptView 和三类扩展插件 API,已使用 NuGet 0.1.0-alpha.22 与 Avalonia 12.1.3 在 .NET 10 下编译检查,结果为 0 警告、0 错误。配图来自已有真实组件渲染;本文未对所有操作系统进行运行测试,也未进行性能对比测试。