Review changes

Continuation from the AI agent chatbot guide

This guide continues the AI agent chatbot guide. Read it first.

The AI Toolkit lets you show AI-generated changes in a beautiful diff UI, so your users can review and accept or reject them.

First, configure the reviewOptions parameter. Set the mode to 'preview' to show a preview of the changes before they are applied.

// inside the useChat hook
const result = toolkit.executeTool({
  toolName,
  input
  currentChunk: currentChunk.current,
  reviewOptions: { mode: 'preview' },
})

After executing the tool, several suggestions will be displayed inside the editor. Each suggestion represents a change that the AI model wants to make to the document.

Before continuing the conversation, the AI agent has to wait for the user to review the changes. So if the tool call modifies the document, then addToolResult should not be called immediately, and instead a review UI should be displayed.

Edit the code so that the chatbot stops and waits for the user to review the changes:

const [reviewState, setReviewState] = useState({
  // Whether to display a review UI
  isReviewing: false,
  // Data for the tool call result
  tool: '',
  toolCallId: '',
  output: '',
})

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

    const { toolName, input, toolCallId } = toolCall

    // Use the AI Toolkit to execute the tool
    const toolkit = getAiToolkit(editor)
    const result = toolkit.executeTool({
      toolName,
      input,
      currentChunk: currentChunk.current,
      reviewOptions: {
        mode: 'preview',
      },
    })

    currentChunk.current = result.currentChunk

    // If the tool call modifies the document, stop and display a review UI
    if (result.docChanged) {
      // Show the review UI
      setReviewState({
        isReviewing: true,
        tool: toolName,
        toolCallId,
        output: result.output,
      })
    } else {
      // Continue the conversation
      addToolResult({ tool: toolName, toolCallId, output: result.output })
    }
  },
})

Style suggestions

Create a CSS file (app/suggestions.css) to style suggestions in red/green.

/* Highlight deleted text in red */
.tiptap-ai-suggestion,
.tiptap-ai-suggestion > * {
  background-color: oklch(80.8% 0.114 19.571);
}
.tiptap-ai-suggestion--selected,
.tiptap-ai-suggestion--selected > * {
  background-color: oklch(70.4% 0.191 22.216);
}
/* Highlight inserted text in green */
.tiptap-ai-suggestion-diff,
.tiptap-ai-suggestion-diff > * {
  background-color: oklch(87.1% 0.15 154.449);
}
.tiptap-ai-suggestion-diff--selected,
.tiptap-ai-suggestion-diff--selected > * {
  background-color: oklch(79.2% 0.209 151.711);
}

Import the stylesheet in your app:

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

Accept/reject all suggestions

Then, in the React component, display a button to reject/accept the changes:

{
  reviewState.isReviewing && (
    <div>
      <h2>Reviewing Changes</h2>
      <button
        onClick={() => {
          const toolkit = getAiToolkit(editor)
          toolkit.applyAllSuggestions()
          addToolResult(reviewState)
          return setReviewState({
            ...reviewState,
            isReviewing: false,
          })
        }}
      >
        Accept all
      </button>
      <button
        onClick={() => {
          const toolkit = getAiToolkit(editor)
          toolkit.setSuggestions([])
          addToolResult({
            ...reviewState,
            output:
              'The changes were rejected. Ask the user why, and what you can do to improve them.',
          })
          return setReviewState({
            ...reviewState,
            isReviewing: false,
          })
        }}
      >
        Reject all
      </button>
    </div>
  )
}

Accept/reject individual suggestions

You can also display buttons or a popover over a suggestion, with actions to accept or reject it.

To render custom elements in the UI, set the reviewOptions.displayOptions parameter. There, you can set the renderDecorations option. It's a function that returns a list of ProseMirror decorations.

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

          // Accept button
          Decoration.widget(options.range.to, () => {
            const element = document.createElement('button')
            element.textContent = 'Accept'
            element.addEventListener('click', () => {
              toolkit.applySuggestion(options.suggestion.id)
            })
            return element
          }),

          // Reject button
          Decoration.widget(options.range.to, () => {
            const element = document.createElement('button')
            element.textContent = 'Reject'
            element.addEventListener('click', () => {
              toolkit.removeSuggestion(options.suggestion.id)
            })
            return element
          }),
        ]
      },
    },
  },
})

Full demo code

// app/page.tsx
'use client'

import { useChat } from '@ai-sdk/react'
import { AiToolkit, getAiToolkit } from '@tiptap-pro/ai-toolkit'
import { Decoration } from '@tiptap/pm/view'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
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 Agent Demo</h1><p>Ask the AI to improve this.</p>`,
  })

  // Fixes issue: https://github.com/vercel/ai/issues/8148
  const editorRef = useRef(editor)
  editorRef.current = editor

  // The AI Agent reads the document in chunks. This variable keeps track of the current chunk
  // that the AI Agent is reading.
  const currentChunk = useRef(0)

  const [reviewState, setReviewState] = useState({
    // Whether to display the review UI
    isReviewing: false,
    // Data for the tool call result
    tool: '',
    toolCallId: '',
    output: '',
  })

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

      const { toolName, input, toolCallId } = toolCall

      // Use the AI Toolkit to execute the tool
      const toolkit = getAiToolkit(editor)
      const result = toolkit.executeTool({
        toolName,
        input,
        currentChunk: currentChunk.current,
        reviewOptions: {
          mode: 'preview',
          displayOptions: {
            renderDecorations(options) {
              return [
                ...options.defaultRenderDecorations(),

                // Accept button
                Decoration.widget(options.range.to, () => {
                  const element = document.createElement('button')
                  element.textContent = 'Accept'
                  element.addEventListener('click', () => {
                    toolkit.applySuggestion(options.suggestion.id)
                  })
                  return element
                }),

                // Reject button
                Decoration.widget(options.range.to, () => {
                  const element = document.createElement('button')
                  element.textContent = 'Reject'
                  element.addEventListener('click', () => {
                    toolkit.removeSuggestion(options.suggestion.id)
                  })
                  return element
                }),
              ]
            },
          },
        },
      })

      currentChunk.current = result.currentChunk

      // If the tool call modifies the document, halt the conversation and display the review UI
      if (result.docChanged) {
        // Show the review UI
        setReviewState({
          isReviewing: true,
          tool: toolName,
          toolCallId,
          output: result.output,
        })
      } else {
        // Continue the conversation
        addToolResult({ tool: toolName, toolCallId, output: result.output })
      }
    },
  })

  const [input, setInput] = useState('Replace the last paragraph with a short story about 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>
      ))}
      {!reviewState.isReviewing && (
        <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>Reviewing Changes</h2>
          <button
            onClick={() => {
              const toolkit = getAiToolkit(editor)
              toolkit.applyAllSuggestions()
              addToolResult(reviewState)
              return setReviewState({
                ...reviewState,
                isReviewing: false,
              })
            }}
          >
            Accept all
          </button>
          <button
            onClick={() => {
              const toolkit = getAiToolkit(editor)
              toolkit.setSuggestions([])
              addToolResult({
                ...reviewState,
                output:
                  'The changes were rejected. Ask the user why, and what you can do to improve them.',
              })
              return setReviewState({
                ...reviewState,
                isReviewing: false,
              })
            }}
          >
            Reject all
          </button>
        </div>
      )}
    </div>
  )
}

End result

With additional CSS styles, the result is a simple but polished AI chatbot application where users can review AI-generated changes:

See the source code on GitHub.

Advanced review options

The reviewOptions parameter gives you full control over the review workflow and the UI.

With the reviewOptions.mode parameter you can control when the changes are applied to the document.

  • preview: Show a preview of the changes before they are applied. Changes are not applied to the document until the user has accepted them.
  • review: Apply changes immediately, and review them after they are applied to the document.

See a complete reference of the reviewOptions parameter in the API reference for the executeTool method.

Popular AI agent chatbots like Cursor call multiple tools in a row and let the user review changes after the AI agent has finished all its work. To implement this type of workflow, you can use the primitives to compare documents in real time.