Swift Markdown Kit · DOCS

流式与会话

流式与会话

如果你在基于大模型做聊天界面,这一页是重点。下面写到的都是默认行为,除非 另有说明。

完整接入

一个 MarkdownChatRenderView 渲染整段会话 —— 整屏一个 WKWebView,不是每条 消息一个。把消息数组交给它,剩下的 diff 由它完成:

import SwiftUI
import SwiftMarkdownKit

@MainActor
@Observable
final class ConversationModel {
    var messages: [MarkdownChatMessage] = []

    func send(_ prompt: String, using client: MyStreamingClient) async {
        messages.append(MarkdownChatMessage(role: .user, markdown: prompt))

        let replyID = UUID().uuidString
        messages.append(
            MarkdownChatMessage(id: replyID, role: .assistant, markdown: "", status: .streaming)
        )

        for await token in client.stream(prompt) {
            guard let index = messages.firstIndex(where: { $0.id == replyID }) else { return }
            messages[index].markdown += token
        }

        if let index = messages.firstIndex(where: { $0.id == replyID }) {
            messages[index].status = .completed
        }
    }
}

struct ConversationScreen: View {
    @State private var model = ConversationModel()

    var body: some View {
        MarkdownChatRenderView(messages: model.messages)
    }
}

往 markdown 上追加就够了。视图会识别出这条消息是「变长了」而不是「变了」, 只把增量流进已有的气泡 —— 不会重新解析、也不会整条重绘,所以渲染一整条回答的 总成本与它的长度成正比。

流结束时把 status 置为 .completed。这是停止「正在输入」状态、收起推理折叠的 信号。

用 UIKit 驱动

UIKit,或者任何你更想「推」而不是「diff」的场景,直接调用视图控制器:

import UIKit
import SwiftMarkdownKit

final class ConversationViewController: UIViewController {
    private let chat = MarkdownChatRenderViewController()

    override func viewDidLoad() {
        super.viewDidLoad()

        addChild(chat)
        chat.view.frame = view.bounds
        chat.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        view.addSubview(chat.view)
        chat.didMove(toParent: self)
    }

    func begin(replyTo prompt: String) {
        chat.appendMessage(MarkdownChatMessage(role: .user, markdown: prompt))
        chat.appendMessage(
            MarkdownChatMessage(id: "reply", role: .assistant, markdown: "", status: .streaming)
        )
    }

    func receive(_ token: String) {
        chat.appendChunk(token, to: "reply")
    }

    func finish() {
        chat.finishMessage("reply")
    }
}

appendChunk(_:to:isFinal:) 是热路径,每个 token 都调用它是安全的。SDK 会把 约一帧内的 token 攒成一批再送进 WebView,所以每秒 40 个 token 的模型只产生几次 跨进程往返,而不是 40 次。文字出现的节奏不变 —— 想关掉这个行为见 chunkCoalescingInterval。

推理内容

推理模型下发思维链只有两种形态,SDK 都能处理,你不需要区分。

写在正文里 —— 思考内容混在 content 流中,用 <think> … </think> 包裹, DeepSeek-R1、Qwen、GLM 以及绝大多数本地部署的模型都是这样。照常传进去即可:

chat.appendChunk("<think>先把题目看清楚</think>", to: "reply")
chat.appendChunk("答案是 42。", to: "reply")

标记被拆成两个 chunk(先到 <thi、再到 nk>)也能正确识别。

独立通道 —— 服务商用单独的 SSE 字段下发推理(DeepSeek、Qwen、GLM 用 reasoning_content,OpenRouter 用 reasoning)。送到另一个入口:

for await event in client.streamEvents(prompt) {
    switch event {
    case .reasoning(let text): chat.appendReasoningChunk(text, to: "reply")
    case .content(let text):   chat.appendChunk(text, to: "reply")
    }
}

如果你是整条消息下发而不是按 chunk 追加,同样的文本放进 MarkdownChatMessage.reasoning:

func receive(reasoning text: String, for replyID: String, in messages: inout [MarkdownChatMessage]) {
    guard let index = messages.firstIndex(where: { $0.id == replyID }) else { return }
    messages[index].reasoning += text
}

两种方式都会落到消息上方的折叠面板:模型思考时展开并逐字流出,正文第一个字 出现的瞬间收起成一行。读者自己点开或收起之后,自动收起不再覆盖这个选择。

复制消息复制的是正文,不含推理内容。

var options = MarkdownChatRenderOptions()
options.reasoning.thinkingLabel = "思考中…"
options.reasoning.completedLabel = "已深度思考"
options.reasoning.durationLabelFormat = "已深度思考(用时 {seconds} 秒)"
options.reasoning.isExpandedByDefault = false
配置项默认值作用
isEnabledtrue关闭后推理内容被丢弃,行内标记按原文显示。
parsesInlineTagstrue只用 appendReasoningChunk 下发推理时可以关掉。
tagsthink、thinking、thought、reasoning、reason正文流中被识别为推理标记的标签名。
collapsesWhenAnswerBeginstrue正文开始时自动收起。
isExpandedByDefaultfalse已完成的消息是否默认展开推理。

finishReasoning(for:) 可以提前收起,setReasoningExpanded(_:for:) 让你用 自己的 UI 控制展开状态。两者在 MarkdownChatRenderProxy 上同样可用。

文字如何出现

options.streamingPresentation = .smooth      // 默认
options.streamingPresentation = .immediate

.smooth 按动画帧节奏逐字上屏,网络 chunk 再不均匀,打字效果也是均匀的。 .immediate 则是来一个显示一个。

没有空行的长内容 —— 一张宽表格、上百行代码块 —— 否则每帧都要重新解析。两种 策略可以兜住:

options.renderOptions.longBlockStreaming = .throttled   // 默认:继续显示,但降低重绘频率
options.renderOptions.longBlockStreaming = .deferred    // 块完成前不重绘,完成后一次画出
options.renderOptions.longBlockThreshold = 512          // 超过多少字符才启用

没写完的 Markdown 会被兜住而不是暴露出来:右花括号还没流到的公式保持正文颜色, 不会闪成 KaTeX 的错误红;半截的代码围栏也不会「这一帧打开、下一帧关闭」。

滚动

options.autoScrollBehavior = .nearBottom   // 默认
options.autoScrollBehavior = .always
options.autoScrollBehavior = .never
options.preservesUserScroll = true
options.bottomThreshold = 96

.nearBottom 只在读者本来就接近底部时才跟随新 token,所以往上翻看历史不会被 强行拉回去。

想自己控制滚动 —— 比如做一个悬浮的「回到最新」按钮 —— 持有 proxy 并监听 viewport:

struct ConversationScreen: View {
    @State private var proxy = MarkdownChatRenderProxy()
    @State private var isNearBottom = true
    let messages: [MarkdownChatMessage]

    var body: some View {
        MarkdownChatRenderView(messages: messages, proxy: proxy) { event in
            if case .viewportChanged(let viewport) = event {
                isNearBottom = viewport.isNearBottom
            }
        }
        .overlay(alignment: .bottom) {
            if !isNearBottom {
                Button("回到最新") { proxy.scrollToBottom() }
            }
        }
    }
}

消息操作

复制、重试、编辑会渲染成每条消息下方的无障碍工具条。

options.messageActions.assistantActions = [.copy, .retry]
options.messageActions.userActions = [.copy, .edit]

复制默认开启,且由 SDK 端到端完成。重试和编辑默认不开,原因值得明说:SDK 无法 重新发起你的请求,也无法写进你的输入框。所以,把某个操作列进数组,就等于你 承诺会处理它 —— 一个点了没反应的按钮,看起来是做完了,其实没有。

MarkdownChatRenderView(messages: messages, options: options) { event in
    guard case .messageAction(let action) = event else { return }
    switch action.action {
    case .retry: regenerate(messageID: action.messageID)
    case .edit:  moveToComposer(messageID: action.messageID)
    case .copy:  break   // 已经复制完了,这里只是回执
    @unknown default: break
    }
}

Debug 构建下,如果发出的操作没人处理,控制台会打印一行说明被忽略了什么。