探索 Tiptap V3 的最新功能

将内容流式写入编辑器

Available in Start plan

streamContent 命令是一个低级 API,用于将内容流式写入编辑器。它支持追加内容,也支持替换指定范围内的内容。当你需要将诸如大型语言模型(LLM)响应这样的大量数据流式写入编辑器时,这个命令非常有用。

高级集成

该命令适用于需要从 URL 或响应体中向编辑器流式写入内容的高级集成场景。

参数

range:可以是单个插入位置,也可以是一个对象,指定需替换的范围,包含 fromto 属性。
callback:一个异步函数,接收一个写入函数,用于向编辑器流式写入内容。

callback 参数说明

getWritableStream:返回一个可写流对象,可以用它来分块写入数据到编辑器。
write:一个函数,接收一个对象,包含以下属性:

  • partial:要插入到编辑器的内容。
  • transform:一个可选函数,接收一个对象,包含以下属性:
    • buffer:流的累积内容。
    • partial:当前的部分内容。
    • editor:编辑器实例。
    • defaultTransform:默认的转换函数。该函数接收累积内容作为输入,并将其插入编辑器。
  • appendToChain:一个可选函数,用于将命令追加到链中。

使用示例

使用 write API

此示例演示如何从 URL 获取大型数据流,并逐块流式写入编辑器,体现了 streamContent 命令的灵活性。

editor.commands.streamContent({ from: 0, to: 10 }, async ({ write }) => {
  const response = await fetch('https://example.com/stream')
  const reader = response.body?.getReader()
  const decoder = new TextDecoder('utf-8')

  if (!reader) {
    throw new Error('无法从响应体获取读取器。')
  }

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    const chunk = decoder.decode(value, { stream: true })
    write({ partial: chunk })
  }
})

使用 getWritableStream API

此示例演示另一种使用可写流对象的方式,将数据块写入编辑器。

editor.commands.streamContent({ from: 0, to: 10 }, async ({ getWritableStream }) => {
  const response = await fetch('https://example.com/stream')
  // 直接将响应体内容传输到编辑器
  await response.body?.pipeTo(getWritableStream())
})

使用内容变换

你也可以利用 transform 函数,在内容流入编辑器前进行转换。此示例演示如何在流式写入编辑器前对内容进行转换。

editor.commands.streamContent({ from: 0, to: 10 }, async ({ write }) => {
  const response = await fetch('https://example.com/stream')
  const reader = response.body?.getReader()
  const decoder = new TextDecoder('utf-8')

  if (!reader) {
    throw new Error('无法从响应体获取读取器。')
  }

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    const chunk = decoder.decode(value, { stream: true })

    write({
      partial: transformedChunk,
      transform: ({ buffer, partial, editor, defaultTransform }) => {
        // 使用默认转换函数将整个缓冲区内容转换为大写字母并插入编辑器
        return defaultTransform(buffer.toUpperCase())
      },
    })
  }
})

使用场景: 从 URL 解析 Markdown 内容并流式写入编辑器。

import { marked } from 'marked'

editor.commands.streamContent({ from: 0, to: 10 }, async ({ write }) => {
  const response = await fetch('https://example.com/stream')
  const reader = response.body?.getReader()
  const decoder = new TextDecoder('utf-8')

  if (!reader) {
    throw new Error('无法从响应体获取读取器。')
  }

  while (true) {
    const { done, value } = await reader.read()
    if (done) break

    const chunk = decoder.decode(value, { stream: true })

    write({
      partial: chunk,
      transform: ({ buffer, partial, editor, defaultTransform }) => {
        // 解析 Markdown 内容为 HTML 字符串,并插入编辑器
        return defaultTransform(marked.parse(buffer))
      },
    })
  }
})

使用 appendToChain 选项

appendToChain 函数允许在执行命令链之前追加命令。此示例演示如何在执行之前向命令链添加命令。

import { selectionToInsertionEnd } from '@tiptap/core'

editor.commands.streamContent({ from: 0, to: 10 }, async ({ write }) => {
  write({
    partial: token,
    appendToChain: (chain) =>
      chain
        // 将选择移动到插入内容的末尾
        .command(({ tr }) => {
          selectionToInsertionEnd(tr, tr.steps.length - 1, -1)
          return true
        })
        // 滚动编辑器视图至插入内容末尾
        .scrollIntoView(),
  })
})

使用 streamContentrespondInline 选项

默认情况下,respondInlinetrue。插入块级内容时,有时你可能想将内容作为同级节点插入,而不是直接作为块插入。你可以使用 respondInline 选项,将内容插入到与 from 位置相同的层级。

editor.commands.setContent('<p>123</p>')
editor.commands.streamContent(
  4,
  async ({ write }) => {
    await new Promise((resolve) => {
      setTimeout(() => resolve(), 10)
    })
    write({ partial: '<p>hello ' })
    await new Promise((resolve) => {
      setTimeout(() => resolve(), 10)
    })
    write({ partial: 'world</p><p>ok</p>' })
  },
  { respondInline: true },
)
// 输出: <p>123hello world</p><p>ok</p>
// 与 `respondInline` 为 `false` 时的输出不同:<p>123</p><p>hello work</p><p>ok</p>

技术细节

以下是 streamContent 命令的完整 TypeScript 定义:

declare module '@tiptap/core' {
  interface Commands<ReturnType> {
    streamContent: {
      streamContent: (
        /**
         * 插入内容的位置。
         */
        position: number | Range,
        /**
         * 向编辑器写入内容的回调函数。
         */
        callback: (options: StreamContentAPI) => Promise<any>,
        /**
         * 传递给 `insertContentAt` 命令的选项。
         */
        options?: {
          parseOptions?: NonNullable<
            Parameters<RawCommands['insertContentAt']>['2']
          >['parseOptions']
          /**
           * 这会将内容插入到与 `from` 位置相同的层级。
           * 实际上,这会将内容作为 `from` 位置节点的同级节点插入。
           * @default true
           */
          respondInline?: boolean
        },
      ) => ReturnType
    }
  }
}

type StreamContentAPI = {
  /**
   * 写入内容到编辑器的函数。
   */
  write: (ctx: {
    /**
     * 流的部分内容,用于插入。
     */
    partial: string
    /**
     * 允许在插入编辑器之前转换内容的函数。
     * 必须返回 Prosemirror 的 `Fragment` 或 `Node`。
     */
    transform?: (ctx: {
      /**
       * 累积的流内容。
       */
      buffer: string
      /**
       * 当前部分内容。
       */
      partial: string
      editor: Editor
      /**
       * 默认的转换函数。
       */
      defaultTransform: (
        /**
         * 作为 HTML 字符串插入的内容。
         * @default ctx.buffer
         */
        htmlString?: string,
      ) => Fragment
    }) => Fragment | Node | Node[]
    /**
     * 允许在执行之前将命令追加到命令链。
     */
    appendToChain?: (chain: ChainedCommands) => ChainedCommands
  }) => {
    /**
     * 当前写入的缓冲区。
     */
    buffer: string
    /**
     * 插入内容的起始位置。
     */
    from: number
    /**
     * 插入内容的结束位置。
     */
    to: number
  }
  /**
   * 可写流,用于向编辑器写入内容。
   * @example fetch('https://example.com/stream').then(response => response.body.pipeTo(ctx.getWritableStream()))
   */
  getWritableStream: () => WritableStream
}