流式传输

Alpha

流式传输目前处于 alpha 阶段:暂时还不建议用于生产部署,而且 API 可能会 发生变化。我们预计它会在未来几周内进入 beta 阶段。如果你在这之前就需要可用于生产环境的能力, 可以查看 AI Toolkit 客户端包中的流式传输。

AI 编辑会从服务器流式传输,因此当模型生成时,它们会实时显示在文档中。编辑在你的服务器上运行,并通过实时连接应用到 Tiptap Cloud 文档,因此每个协作者都会看到它发生。

需要协作

流式传输会将编辑应用到一个实时的 Tiptap Cloud 文档,并通过 协作 将其同步到编辑器,因此这两者都必不可少。与 非流式工具不同,这里没有内联文档模式。如果你还没有使用 Tiptap Cloud 协作,那么你将需要它才能从服务器流式传输编辑。

查看 GitHub 上的源代码。

技术栈

  • 一个客户端 React 应用程序
  • 一个服务器应用程序
  • 一个 AI 代理框架,例如 Vercel AI SDK
  • OpenAI 模型
  • Server AI Toolkit

Server AI Toolkit 可与任何 AI 代理框架和 AI 模型配合使用。它可与任何前端框架和后端编程语言一起使用。本指南使用一个简单的 Node.js 服务器端点,该端点接收一个 Request 对象并返回一个 Response。

在继续之前,请按照安装指南设置 Server AI Toolkit。

工作原理

  1. 你的服务器获取 tiptapEdit 工具定义并读取文档,就像它处理一个非流式 agent时一样。
  2. 当模型生成工具调用时,你的服务器会将原始输入增量作为 NDJSON start、delta 和 end 消息转发到 POST /v4/ai/toolkit/stream-tool。
  3. 之后由 Server AI Toolkit 为你应用编辑:随着转发的增量到达,它会通过实时连接逐步更新 Tiptap Cloud 文档。你无需自行应用任何内容;文档更新会在服务器端随着增量流入而自动发生。
  4. 客户端通过 协作 渲染这些变更。编辑器本身不会调用任何流式方法,因此这些编辑会作为文档更新到达。

流式编辑

适用于大多数 AI 后端

此示例使用 Vercel 的 AI SDK,但你可以将 stream-tool 接入大多数 AI 后端架构。你只需把模型的工具调用输入转发为 NDJSON 增量即可。

在服务端,向 stream-tool 打开一个 双工 NDJSON 请求,并用模型的工具调用流来驱动它。双工请求允许你逐步向上游流式传输请求体,这样你就可以在模型生成每个工具调用增量时立即转发,而不是一次性发送整个输入。

stream-tool 请求和响应都使用 NDJSON:每行一个 JSON 对象。你会发送三种消息类型:start 用于打开流,delta 用于 AI 生成的每个工具输入片段,end 用于输入完成时。

安装依赖

在你的服务端应用中安装这些依赖:

npm install ai zod
import {
  createUIMessageStreamResponse,
  gateway,
  generateId,
  isStepCount,
  streamText,
  tool,
  toUIMessageStream,
  type UIMessage,
} from 'ai'
import { z } from 'zod'
import { getAuthHeaders } from './server-ai-toolkit/get-auth-headers'
import { streamToolFetch } from './server-ai-toolkit/stream-tool-fetch'

// Server AI Toolkit API 基础 URL
const apiBaseUrl = process.env.TIPTAP_CLOUD_AI_API_URL || 'https://api.tiptap.dev'

export async function handleStreamEditRequest(request: Request): Promise<Response> {
  const { messages, editorContext, documentId } = await request.json()
  // 单次编辑:任务就是最新的用户消息。
  const lastUser = (messages as UIMessage[]).filter((m) => m.role === 'user').at(-1)
  const task =
    lastUser?.parts
      .filter((p) => p.type === 'text')
      .map((p) => (p as { text: string }).text)
      .join(' ') ?? ''

  // 1. 获取 tiptapEdit 工具定义并读取文档。
  //    这些步骤与非流式流程一致。请参阅 REST API 参考。
  const { tools, systemPrompt } = await fetch(`${apiBaseUrl}/v4/ai/toolkit/fetch-tools`, {
    method: 'POST',
    headers: await getAuthHeaders(),
    body: JSON.stringify({ editorContext, tools: { tiptapEdit: true } }),
  }).then((res) => res.json())
  const tiptapEdit = tools.find((t) => t.name === 'tiptapEdit')

  const read = await fetch(`${apiBaseUrl}/v4/ai/toolkit/execute-tool`, {
    method: 'POST',
    headers: await getAuthHeaders(documentId),
    body: JSON.stringify({
      tool: { name: 'readDocument', input: {} },
      document: { type: 'cloud', id: documentId },
      editorContext,
    }),
  }).then((res) => res.json())

  // 2. 打开到 stream-tool 的 duplex NDJSON 请求。每条消息占一行。
  const encoder = new TextEncoder()
  let controller: ReadableStreamDefaultController<Uint8Array>
  const body = new ReadableStream<Uint8Array>({ start: (c) => (controller = c) })
  const send = (message: object) =>
    controller.enqueue(encoder.encode(`${JSON.stringify(message)}\n`))

  // `streamToolFetch` 通过 HTTP/2 发送请求,因此 duplex 上传
  // 可以在反向代理下正常工作(HTTP/1.1 代理会在响应开始后清空
  // 请求体,从而截断流)。请参阅演示源码了解该辅助函数。
  // 在流式传输请求体时,始终需要 `duplex: 'half'`。
  const upstream = streamToolFetch(`${apiBaseUrl}/v4/ai/toolkit/stream-tool`, {
    method: 'POST',
    headers: { ...(await getAuthHeaders(documentId)), 'Content-Type': 'application/x-ndjson' },
    body,
    duplex: 'half',
  })

  // 3. 驱动模型。将其工具输入作为 start / delta / end 消息转发。
  const result = streamText({
    model: gateway('openai/gpt-5.6-luna'),
    instructions: `${systemPrompt}\n\nMake the edit with tiptapEdit, then reply with one short sentence confirming what you changed.`,
    prompt: JSON.stringify({ content: read.tool.output, task }),
    tools: {
      tiptapEdit: tool({
        description: tiptapEdit.description,
        inputSchema: z.fromJSONSchema(tiptapEdit.inputSchema),
        strict: false,
        onInputStart: () =>
          send({
            version: 1,
            type: 'start',
            editorContext,
            document: { type: 'cloud', id: documentId },
            tool: { name: 'tiptapEdit' },
            user: 'ai-assistant',
          }),
        onInputDelta: ({ inputTextDelta }) =>
          send({ version: 1, type: 'delta', argsTextDelta: inputTextDelta }),
        onInputAvailable: () => {
          send({ version: 1, type: 'end' })
          controller.close()
        },
        // 等待 AI 服务端完成应用编辑,然后返回一个
        // 结果,这样聊天轮次就能完成。
        execute: async () => {
          const res = await upstream
          await res.text()
          return { ok: res.ok }
        },
      }),
    },
    // 第 0 步:强制执行编辑。第 1 步:禁止使用工具,让模型为聊天
    // 撰写确认句。
    stopWhen: isStepCount(2),
    prepareStep: ({ stepNumber }) => ({
      toolChoice: stepNumber === 0 ? 'required' : 'none',
    }),
  })

  // 编辑内容通过 Y.Doc 同步到编辑器;此流用于驱动聊天。
  return createUIMessageStreamResponse({
    stream: toUIMessageStream({
      stream: result.stream,
      originalMessages: messages,
      generateMessageId: generateId,
    }),
  })
}

请参阅 REST API 参考 了解完整的请求和响应消息结构,并参阅 演示源码 获取带错误处理、可用于生产环境的路由示例。

客户端设置

安装依赖

在你的客户端应用中安装以下依赖:

npm install @ai-sdk/react @tiptap/extension-collaboration @tiptap/react @tiptap/starter-kit @tiptap-pro/provider @tiptap/ai-toolkit @tiptap/y-tiptap ai yjs

在客户端中,添加 Collaboration 和 ServerAiToolkit 扩展,使用 TiptapCollabProvider 连接文档,并通过 AI SDK 的 useChat 钩子驱动聊天(与其他服务端演示相同)。流式编辑内容会通过 Collaboration 到达编辑器;useChat 会显示模型的回复。

import { useChat } from '@ai-sdk/react'
import { Collaboration } from '@tiptap/extension-collaboration'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { ServerAiToolkit, getEditorContext } from '@tiptap/ai-toolkit'
import { DefaultChatTransport } from 'ai'
import { useRef } from 'react'

const editor = useEditor({
  immediatelyRender: false,
  extensions: [
    StarterKit.configure({ undoRedo: false }),
    Collaboration.configure({ document: doc }),
    ServerAiToolkit,
  ],
})

// 将 editorContext 放在 ref 中,以避免 AI SDK 的陈旧闭包问题(vercel/ai#7819)。
const editorContextRef = useRef(editor ? getEditorContext(editor) : null)
editorContextRef.current = editor ? getEditorContext(editor) : null

const { messages, sendMessage, status } = useChat({
  transport: new DefaultChatTransport({
    api: '/api/stream-edit',
    body: () => ({
      editorContext: editorContextRef.current,
      documentId: 'your-document-id',
    }),
  }),
})

流式传输时淡入文本

流式文本会逐个 token 写入文档。可选的 AiInsertReveal 扩展会在文本到达时将其淡入,使编辑内容平滑地显示,而不是突然出现。它是一种仅影响视图的装饰:不会添加标记,也不会触碰文档,因此不会影响接受/拒绝操作或持久化;你自己的输入和其他协作者的输入也不会淡入。

它建立在上面的客户端设置之上:它会显示通过 Collaboration 到达的文本,因此需要先完成该设置。从 streaming-reveal 子路径导入它,并将其添加到 ServerAiToolkit 之后。它从 @tiptap/y-tiptap 读取协作位置,如果该依赖尚未安装,也请一并安装。

将你的协作提供程序传递给它。提供程序的 awareness 用于区分 AI 与其他协作者;没有它就不会显示任何淡入效果。

import { ServerAiToolkit, getEditorContext } from '@tiptap/ai-toolkit'
import { AiInsertReveal } from '@tiptap/ai-toolkit/streaming-reveal'

const editor = useEditor({
  immediatelyRender: false,
  extensions: [
    StarterKit.configure({ undoRedo: false }),
    Collaboration.configure({ document: doc }),
    ServerAiToolkit,
    AiInsertReveal.configure({ provider }),
  ],
})

CSS 样式

该扩展会为每个刚完成流式传输的文本段添加 ai-insert-reveal 类,并设置其 animation-delay,使淡入效果在逐 token 重新渲染时从正确的位置继续。扩展不附带 CSS;请为该类添加动画以定义效果。该类会应用在行内元素上,因此请使用适用于行内元素的属性,例如 opacity、color 或 filter。

.tiptap .ai-insert-reveal {
  animation: ai-insert-reveal 380ms ease-out both;
}

@keyframes ai-insert-reveal {
  from {
    opacity: 0.35;
  }
  to {
    opacity: 1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .tiptap .ai-insert-reveal {
    animation: none;
  }
}

配置

选项默认值描述
providernull你的协作提供程序。必需:其 awareness 用于识别 AI;没有它就不会显示淡入效果。
className'ai-insert-reveal'应用于每个流式文本段的类。修改它可以运行其他效果或避免类名冲突。
durationMs550每个文本段保留其装饰的时长。请将其设置为不小于 CSS 动画时长,以便淡入效果能够完成。
AiInsertReveal.configure({ provider, durationMs: 700 })

以跟踪更改形式流式传输

为了让用户审阅流式编辑而不是直接应用它们,请在 start 消息中添加 reviewOptions。这样,编辑内容会以跟踪更改建议的形式出现,实时输入到文档中,用户可以接受或拒绝。

send({
  version: 1,
  type: 'start',
  editorContext,
  document: { type: 'cloud', id: documentId },
  tool: { name: 'tiptapEdit' },
  user: 'ai-assistant',
  reviewOptions: { mode: 'trackedChanges' },
})

在客户端添加 Tracked Changes 扩展,以渲染这些建议并接受或拒绝它们。请参阅 Use with Tracked Changes 指南 和 Tracked Changes 流式演示。

最终结果

结果是一个简单但精致的 AI 聊天机器人应用:

请查看 GitHub 上的源代码。

下一步