AI 回复通常分多次到达。应用负责接收网络数据并累计文本,QuireMarkdown 负责把当前已经收到的 Markdown 显示出来。它不发起模型请求,也不决定请求何时结束。
1. 把消息交给阅读组件
沿用快速开始的组件导入。message.id 是同一条回复的稳定标识,message.text 是应用累计的文本:
<QuireMarkdown
:content="message.text"
:identity="message.id"
:status="message.receiving ? 'receiving' : 'complete'"
@error="reportReadingError"
/>
每次传入已收到的完整原文。同一消息的前缀追加复用解析会话;全文替换、清空、identity
变化和 options 对象替换会重建会话并释放旧资源。status 为 receiving、complete、
stopped 或 error,只表示传输状态;停止不丢字,也不提前冻结块或补写语法结束标记。
后到的链接、脚注定义仍可更新前文。外部网络请求由应用取消。
2. 观察追加和停止的效果
点击“开始流式演示”,观察标题、链接和脚注陆续出现;点击“停止”会保留已收到的文字。停止网络请求仍由应用负责。
服务端首屏
默认 SSR 直接生成标题、列表、链接等语义内容,客户端首帧使用相同解析策略。资源加载
只在客户端挂载后开始。需要推迟初次解析时使用 ssr="source":服务端与首帧显示转义
原文,挂载后解析。state 可接收已有快照,但不能与 content 同时提供。
3. 接收真实文本响应
下面的可复用函数只处理响应体为 原始 Markdown 文本 的 HTTP 流。 把应用的 AbortSignal 同时交给 fetch 和此函数,并用 onText 更新 message.text; 开始请求设 receiving,成功设 complete,主动取消设 stopped,其他失败设 error。 旧请求的回调应核对请求身份,不能覆盖已切换的消息;组件卸载时由宿主 abort。
recipes/response.ts 只负责从响应体读取和解码文本,readMarkdownResponse(response, signal, onText) 会把累计全文交给 onText,并在结束时返回全文。消息列表、请求状态和错误提示仍放在应用中。
/**
* Consume an HTTP response whose body is raw Markdown text, not SSE or provider JSON.
* @param response - Response returned by the application's fetch call.
* @param signal - The same AbortSignal passed to fetch by the host.
* @param onText - Receives the entire accepted Markdown prefix after each decoded chunk.
* @returns Complete source, preserving UTF-8 characters split across transport chunks.
*/
export async function readMarkdownResponse(response: Response, signal: AbortSignal, onText: (text: string) => void): Promise<string> {
signal.throwIfAborted();
if (!response.ok || !response.body) throw new Error(`markdown-response:${response.status}`);
const reader = response.body.getReader(), decoder = new TextDecoder();
let content = "";
/** Cancel producer reads; the component retains whatever the host has already accepted. */
const cancel = (): void => { void reader.cancel().catch(() => {}); };
signal.addEventListener("abort", cancel, { once: true });
try {
while (true) {
const chunk = await reader.read();
signal.throwIfAborted();
if (chunk.done) break;
content += decoder.decode(chunk.value, { stream: true });
onText(content);
}
const tail = decoder.decode();
if (tail) { content += tail; onText(content); }
return content;
} finally {
signal.removeEventListener("abort", cancel);
await reader.cancel().catch(() => {});
reader.releaseLock();
}
}
如果服务端使用 SSE 或 JSON 事件,先在宿主解码协议,只把文本 delta 追加为 Markdown。
不要把 data:、事件 JSON 或二进制 chunk 的边界直接当作 Markdown;TextDecoder 必须跨块保留解码状态。