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
| 配置项 | 默认值 | 作用 |
|---|---|---|
isEnabled | true | 关闭后推理内容被丢弃,行内标记按原文显示。 |
parsesInlineTags | true | 只用 appendReasoningChunk 下发推理时可以关掉。 |
tags | think、thinking、thought、reasoning、reason | 正文流中被识别为推理标记的标签名。 |
collapsesWhenAnswerBegins | true | 正文开始时自动收起。 |
isExpandedByDefault | false | 已完成的消息是否默认展开推理。 |
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 构建下,如果发出的操作没人处理,控制台会打印一行说明被忽略了什么。