建议

接续 AI 代理聊天机器人指南

本指南是接续AI 代理聊天机器人指南。 请先阅读该指南。

AI 工具包让你可以在漂亮的差异 UI 中展示 AI 生成的更改,方便用户预览并接受或拒绝这些更改。

查看 GitHub 上的源码

首先,配置 reviewOptions 参数。将模式设置为 'preview',以在更改应用之前显示预览。

// 在 useChat 钩子内部
const result = toolkit.executeTool({
  toolName,
  input,
  reviewOptions: { mode: 'preview' },
})

执行工具后,多个建议会显示在编辑器内。每条建议都代表 AI 模型对文档所做的更改。

你可以在用户审核建议时继续对话。在每次调用工具后立即调用 addToolOutput,并同时显示审核 UI。

修改代码,使聊天机器人在显示审核 UI 的同时继续运行:

const [reviewState, setReviewState] = useState({
  // 是否显示审核 UI
  isReviewing: false,
  // 工具调用结果数据
  tool: '',
  toolCallId: '',
  output: null as unknown,
  // 从用户操作中收集的反馈事件
  userFeedback: [] as SuggestionFeedbackEvent[],
})

const { messages, sendMessage, addToolOutput } = useChat({
  transport: new DefaultChatTransport({ api: '/api/chat' }),
  sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
  async onToolCall({ toolCall }) {
    if (!editor) return

    const { toolName, input, toolCallId } = toolCall

    // 使用 AI 工具包执行工具
    const toolkit = getAiToolkit(editor)
    const result = toolkit.executeTool({
      toolName,
      input,
      reviewOptions: {
        mode: 'preview',
      },
    })

    // 立即继续对话
    addToolOutput({ tool: toolName, toolCallId, output: result.output })

    // 如果工具调用修改了文档,也显示审核 UI
    if (result.docChanged) {
      setReviewState({
        isReviewing: true,
        tool: toolName,
        toolCallId,
        output: result.output,
        userFeedback: [],
      })
    }
  },
})

样式建议

创建一个 CSS 文件(app/suggestions.css)将建议样式化为红色/绿色。

/* 将删除的文本突出显示为红色 */
.tiptap-ai-suggestion,
.tiptap-ai-suggestion > * {
  background-color: oklch(80.8% 0.114 19.571);
  color: oklch(0.396 0.141 25.723);
}

/* 较浅的背景色用于更改组(子更改带有更明显的高亮) */
.tiptap-ai-suggestion.tiptap-ai-suggestion--change-group,
.tiptap-ai-suggestion.tiptap-ai-suggestion--change-group > *:not(.tiptap-ai-suggestion-sub-change) {
  background-color: oklch(0.936 0.032 17.717);
}

/* 突出显示内联组内的子更改 */
.tiptap-ai-suggestion-sub-change {
  background-color: oklch(80.8% 0.114 19.571);
}

/* 将插入文本突出显示为绿色 */
.tiptap-ai-suggestion-diff,
.tiptap-ai-suggestion-diff > * {
  background-color: oklch(87.1% 0.15 154.449);
}

/* 较浅背景色用于差异更改组(子更改带有更明显的高亮) */
.tiptap-ai-suggestion-diff.tiptap-ai-suggestion-diff--change-group,
.tiptap-ai-suggestion-diff.tiptap-ai-suggestion-diff--change-group
  > *:not(.tiptap-ai-suggestion-diff-sub-change) {
  background-color: oklch(0.962 0.044 156.743);
}

/* 在替换差异部件中突出子更改 */
.tiptap-ai-suggestion-diff-sub-change {
  background-color: oklch(87.1% 0.15 154.449);
}

/* 正确渲染表格行插入 */
.tiptap-ai-suggestion-diff:has(tr) {
  display: contents;
}

.tiptap-ai-suggestion-diff:has(tr) td,
.tiptap-ai-suggestion-diff:has(tr) th {
  background-color: oklch(87.1% 0.15 154.449);
}

在应用中导入样式表:

// app/page.tsx
import './suggestions.css'

接受/拒绝所有建议

然后,在 React 组件中显示按钮以拒绝/接受这些更改:

{
  reviewState.isReviewing && (
    <div>
      <h2>审核更改</h2>
      <button
        onClick={() => {
          const toolkit = getAiToolkit(editor)
          const result = toolkit.acceptAllSuggestions()
          // 合并所有反馈事件(之前的 + 新的)
          const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]
          let output = reviewState.output

          // 如果有未接受的更改,附加反馈到工具输出
          if (userFeedback.length > 0 && userFeedback.some((event) => !event.accepted)) {
            const outputString =
              typeof output === 'string' ? output : JSON.stringify(output)
            output = `${outputString}\n\n<user_feedback>\n${JSON.stringify(userFeedback)}\n</user_feedback>`
          }

          addToolOutput({
            tool: reviewState.tool,
            toolCallId: reviewState.toolCallId,
            output,
          })
          // 重置反馈事件并关闭审核 UI
          setReviewState({
            ...reviewState,
            isReviewing: false,
            userFeedback: [],
          })
        }}
      >
        全部接受
      </button>
      <button
        onClick={() => {
          const toolkit = getAiToolkit(editor)
          const result = toolkit.rejectAllSuggestions()
          // 合并所有反馈事件(之前的 + 新的)
          const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]
          // 将拒绝信息和反馈放进 XML 标签中
          const rejectionMessage =
            'Some changes you made were rejected by the user. Ask the user why, and what you can do to improve them.'
          const outputWithFeedback = `${rejectionMessage}\n\n<user_feedback>\n${JSON.stringify(userFeedback)}\n</user_feedback>`
          addToolOutput({
            tool: reviewState.tool,
            toolCallId: reviewState.toolCallId,
            output: outputWithFeedback,
          })
          // 重置反馈事件并关闭审核 UI
          setReviewState({
            ...reviewState,
            isReviewing: false,
            userFeedback: [],
          })
        }}
      >
        全部拒绝
      </button>
    </div>
  )
}

收集 AI 反馈

当用户接受或拒绝建议时,AI 工具包会提供反馈,你可以将其发送回 AI,帮助它从用户偏好中学习。acceptSuggestionacceptAllSuggestionsrejectSuggestionrejectAllSuggestions 等方法都会返回包含更改信息的 aiFeedback 属性。

const result = toolkit.acceptSuggestion('suggestion-1')
console.log(result.aiFeedback.events)

如上例所示,反馈事件被收集在 reviewState.userFeedback 数组中,然后在用户接受或拒绝所有更改时以 XML 标签包裹的形式发送给 AI。这使得 AI 能够理解哪些更改被接受或拒绝,从而改进未来的建议。

接受/拒绝单条建议

你还可以在建议上显示按钮或弹出框,添加接受或拒绝操作。

要在 UI 中渲染自定义元素,设置 reviewOptions.displayOptions 参数。在这里你可以设置 renderDecorations 选项。它是一个返回 ProseMirror decorations 列表的函数。

const result = toolkit.executeTool({
  toolName,
  input,
  reviewOptions: {
    mode: 'preview',
    displayOptions: {
      renderDecorations(options) {
        return [
          ...options.defaultRenderDecorations(),

          // 接受按钮
          Decoration.widget(options.range.to, () => {
            const element = document.createElement('button')
            element.textContent = '接受'
            element.addEventListener('click', () => {
              const result = toolkit.acceptSuggestion(options.suggestion.id)
              // 使用函数式更新收集反馈事件
              setReviewState((prev) => ({
                ...prev,
                userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
              }))
            })
            return element
          }),

          // 拒绝按钮
          Decoration.widget(options.range.to, () => {
            const element = document.createElement('button')
            element.textContent = '拒绝'
            element.addEventListener('click', () => {
              const result = toolkit.rejectSuggestion(options.suggestion.id)
              // 使用函数式更新收集反馈事件
              setReviewState((prev) => ({
                ...prev,
                userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
              }))
            })
            return element
          }),
        ]
      },
    },
  },
})

完整演示代码

// app/page.tsx
'use client'

import { useChat } from '@ai-sdk/react'
import { Decoration } from '@tiptap/pm/view'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { AiToolkit, getAiToolkit, type SuggestionFeedbackEvent } from '@tiptap-pro/ai-toolkit'
import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai'
import { useRef, useState } from 'react'
import './suggestions.css'

export default function Page() {
  const editor = useEditor({
    immediatelyRender: false,
    extensions: [StarterKit, AiToolkit],
    content: `<h1>AI 代理演示</h1><p>请 AI 优化这段内容。</p>`,
  })

  const [reviewState, setReviewState] = useState({
    // 是否显示审核 UI
    isReviewing: false,
    // 工具调用结果数据
    tool: '',
    toolCallId: '',
    output: null as unknown,
    // 从用户操作中收集的反馈事件
    userFeedback: [] as SuggestionFeedbackEvent[],
  })

  const acceptButtonRef = useRef<HTMLButtonElement>(null)
  const rejectButtonRef = useRef<HTMLButtonElement>(null)

  const { messages, sendMessage, addToolOutput } = useChat({
    transport: new DefaultChatTransport({ api: '/api/chat' }),
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
    async onToolCall({ toolCall }) {
      if (!editor) return

      const { toolName, input, toolCallId } = toolCall

      // 使用 AI 工具包执行工具
      const toolkit = getAiToolkit(editor)
      const result = toolkit.executeTool({
        toolName,
        input,
        reviewOptions: {
          mode: 'preview',
          displayOptions: {
            renderDecorations(options) {
              return [
                ...options.defaultRenderDecorations(),

                // 接受按钮
                Decoration.widget(options.range.to, () => {
                  const element = document.createElement('button')
                  element.textContent = '接受'
                  element.addEventListener('click', () => {
                    const result = toolkit.acceptSuggestion(options.suggestion.id)
                    // 使用函数式更新收集反馈事件
                    setReviewState((prev) => ({
                      ...prev,
                      userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
                    }))
                    if (toolkit.getSuggestions().length === 0) {
                      acceptButtonRef.current?.click()
                    }
                  })
                  return element
                }),

                // 拒绝按钮
                Decoration.widget(options.range.to, () => {
                  const element = document.createElement('button')
                  element.textContent = '拒绝'
                  element.addEventListener('click', () => {
                    const result = toolkit.rejectSuggestion(options.suggestion.id)
                    // 使用函数式更新收集反馈事件
                    setReviewState((prev) => ({
                      ...prev,
                      userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
                    }))
                    if (toolkit.getSuggestions().length === 0) {
                      rejectButtonRef.current?.click()
                    }
                  })
                  return element
                }),
              ]
            },
          },
        },
      })

      // 立即继续对话
      addToolOutput({ tool: toolName, toolCallId, output: result.output })

      // 如果工具调用修改了文档,也显示审核 UI
      if (result.docChanged) {
        setReviewState({
          isReviewing: true,
          tool: toolName,
          toolCallId,
          output: result.output,
          userFeedback: [],
        })
      }
    },
  })

  const [input, setInput] = useState('用一个关于 Tiptap 的短故事替换最后一段')

  if (!editor) return null

  return (
    <div>
      <EditorContent editor={editor} />
      {messages?.map((message) => (
        <div key={message.id} style={{ whiteSpace: 'pre-wrap' }}>
          <strong>{message.role}</strong>
          <br />
          {message.parts
            .filter((p) => p.type === 'text')
            .map((p) => p.text)
            .join('\n')}
        </div>
      ))}
      <form
        onSubmit={(e) => {
          e.preventDefault()
          if (input.trim()) {
            sendMessage({ text: input })
            setInput('')
          }
        }}
      >
        <input value={input} onChange={(e) => setInput(e.target.value)} />
      </form>
      {reviewState.isReviewing && (
        <div>
          <h2>审核更改</h2>
          <button
            ref={acceptButtonRef}
            onClick={() => {
              const toolkit = getAiToolkit(editor)
              const result = toolkit.acceptAllSuggestions()
              // 合并所有反馈事件(之前的 + 新的)
              const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]

              // 如果有未接受的更改,附加反馈到工具输出
              if (userFeedback.length > 0 && userFeedback.some((event) => !event.accepted)) {
                sendMessage({
                  text: `\n\n<user_feedback>\n${JSON.stringify(userFeedback)}\n</user_feedback>`,
                })
              }

              addToolOutput({
                tool: reviewState.tool,
                toolCallId: reviewState.toolCallId,
                output: reviewState.output,
              })
              // 重置反馈事件并关闭审核 UI
              setReviewState({
                ...reviewState,
                isReviewing: false,
                userFeedback: [],
              })
            }}
          >
            全部接受
          </button>
          <button
            ref={rejectButtonRef}
            onClick={() => {
              const toolkit = getAiToolkit(editor)
              const result = toolkit.rejectAllSuggestions()
              // 合并所有反馈事件(之前的 + 新的)
              const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]
              // 将拒绝信息和反馈放进 XML 标签中
              const rejectionMessage =
                'Some changes you made were rejected by the user. Ask the user why, and what you can do to improve them.'
              const outputWithFeedback = `${rejectionMessage}\n\n<user_feedback>\n${JSON.stringify(userFeedback)}\n</user_feedback>`
              sendMessage({ text: outputWithFeedback })
              // 重置反馈事件并关闭审核 UI
              setReviewState({
                ...reviewState,
                isReviewing: false,
                userFeedback: [],
              })
            }}
          >
            全部拒绝
          </button>
        </div>
      )}
    </div>
  )
}

最终效果

通过额外的 CSS 样式,结果是一个简单但精致的 AI 聊天机器人应用,用户可以审核 AI 生成的更改:

查看 GitHub 上的源码

接下来的步骤

高级审核选项

reviewOptions 参数让你可以完全控制审核流程和 UI。

使用 reviewOptions.mode 参数,你可以控制何时将更改应用到文档。

  • preview:在更改被应用前显示预览。用户接受之前,更改不会应用到文档。
  • review:立即应用更改,然后再进行审核。

完整的 reviewOptions 参数参考,请见 executeTool 方法的 API 参考

你可以将本指南与 Streaming 结合使用,以便在建议被审核时,流式传输操作并保持助手的响应性。

自定义建议的显示方式

在审核更改时,AI Toolkit 会将更改的预览显示为 建议

你可以通过设置 reviewOptions.displayOptions 参数来自定义这些建议的显示方式。你甚至可以在其中 渲染 React 组件