# AI 流式接入

把陆续收到的 Markdown 显示为同一条消息，并处理结束、取消与失败。

AI 回复通常分多次到达。应用负责接收网络数据并累计文本，`QuireMarkdown` 负责把当前已经收到的 Markdown 显示出来。它不发起模型请求，也不决定请求何时结束。

## 1. 把消息交给阅读组件

沿用[快速开始](/guide/getting-started)的组件导入。`message.id` 是同一条回复的稳定标识，`message.text` 是应用累计的文本：

```vue
<QuireMarkdown
  :content="message.text"
  :identity="message.id"
  :status="message.receiving ? 'receiving' : 'complete'"
  @error="reportReadingError"
/>
```

每次传入已收到的**完整原文**。同一消息的前缀追加复用解析会话；全文替换、清空、identity
变化和 options 对象替换会重建会话并释放旧资源。`status` 为 `receiving`、`complete`、
`stopped` 或 `error`，只表示传输状态；停止不丢字，也不提前冻结块或补写语法结束标记。
后到的链接、脚注定义仍可更新前文。外部网络请求由应用取消。

## 2. 观察追加和停止的效果

点击“开始流式演示”，观察标题、链接和脚注陆续出现；点击“停止”会保留已收到的文字。停止网络请求仍由应用负责。

### reading/stream.vue

```vue
<script setup lang="ts">
import { computed, onBeforeUnmount, ref } from "vue";
import { QuireMarkdown, type QuireMarkdownProps } from "@quirekit/incremark/vue";
import { QuireButton, QuireTypography } from "@quirekit/ui/vue";
import type { ExampleLocale } from "../shared/types";
import { readingSample } from "./sample";

const props = withDefaults(defineProps<{ /** Tutorial language. */ locale?: ExampleLocale }>(), { locale: "zh-CN" });
const content = ref(readingSample(props.locale)), identity = ref(0);
const status = ref<NonNullable<QuireMarkdownProps["status"]>>("complete");
const labels = computed(() => props.locale === "zh-CN"
  ? { start: "开始流式演示", stop: "停止", load: "一次性加载", reading: "流式阅读预览", receiving: "正在接收", stopped: "已停止", complete: "已完成" }
  : { start: "Start streaming", stop: "Stop", load: "Load all", reading: "Streaming reading preview", receiving: "Receiving", stopped: "Stopped", complete: "Complete" });
let timer: ReturnType<typeof setTimeout> | undefined;

/** Cancels the producer independently of QuireMarkdown's parser lifecycle. */
function cancel(): void { if (timer !== undefined) clearTimeout(timer); timer = undefined; }
/** Supplies the authoritative full text after each simulated network chunk; no parser APIs are needed. */
function start(): void {
  cancel(); identity.value++; content.value = ""; status.value = "receiving";
  const source = readingSample(props.locale);
  /** Delivers the next 20 UTF-16 code units, including arbitrary syntax boundaries. */
  function append(): void {
    content.value = source.slice(0, content.value.length + 20);
    if (content.value.length < source.length) timer = setTimeout(append, 30);
    else { timer = undefined; status.value = "complete"; }
  }
  append();
}
/** Retains the exact received prefix when the user stops delivery. */
function stop(): void { cancel(); status.value = "stopped"; }
/** Replaces the message with its complete source and a new resource identity. */
function load(): void { cancel(); identity.value++; content.value = readingSample(props.locale); status.value = "complete"; }
onBeforeUnmount(cancel);
</script>
<template>
  <div class="grid gap-4 p-qk-surface">
    <div class="flex flex-wrap items-center gap-2">
      <QuireButton size="sm" variant="outline" @click="start">{{ labels.start }}</QuireButton>
      <QuireButton size="sm" variant="ghost" :disabled="status !== 'receiving'" @click="stop">{{ labels.stop }}</QuireButton>
      <QuireButton size="sm" variant="ghost" @click="load">{{ labels.load }}</QuireButton>
      <QuireTypography as="span" variant="helper">{{ status === 'receiving' ? labels.receiving : status === 'stopped' ? labels.stopped : labels.complete }}</QuireTypography>
    </div>
    <section :aria-label="labels.reading"><QuireMarkdown :content="content" :identity="String(identity)" :status="status" :locale="locale" /></section>
  </div>
</template>

```

### shared/types.ts

```ts
/** Example host language; independent of the documentation router. */
export type ExampleLocale = "zh-CN" | "en";

```

### reading/sample.ts

````ts
import type { ExampleLocale } from "../shared/types";

/** @param locale - Tutorial language. @returns One source with late definitions, repeated footnotes and an incrementally displayed code block. */
export function readingSample(locale: ExampleLocale): string {
  const chinese = locale === "zh-CN";
  return [chinese ? "## 流式 Markdown" : "## Streaming Markdown",
    chinese ? "先收到正文和[文档][api]引用，定义会在末尾到达。这里重复引用同一脚注[^note]、[^note]。"
      : "The [documentation][api] reference arrives before its definition. This footnote is used twice[^note], [^note].",
    ...Array.from({ length: 6 }, (_, index) => chinese
      ? `第 ${index + 1} 段保留 **强调**、中文与 emoji 🙂。已收到的正文继续可读，后置定义到达时前面的链接会更新。`
      : `Paragraph ${index + 1} keeps **emphasis** and emoji 🙂. Existing text stays readable while late definitions update earlier links.`),
    "```ts\nconst message = 'hello'\nconsole.log(message)\n```",
    chinese ? "[^note]: 脚注正文也使用[文档][api]链接。" : "[^note]: The footnote also links to the [documentation][api].",
    "[api]: https://example.com/docs", "[api]: https://example.com/ignored",
  ].join("\n\n");
}

````

## 服务端首屏

默认 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`，并在结束时返回全文。消息列表、请求状态和错误提示仍放在应用中。

### recipes/response.ts

```ts
/**
 * 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 必须跨块保留解码状态。
