Review changes (as a summary)

Continuation from the AI agent chatbot guide

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

After the AI agent finishes its work, show a summary of all the changes.

Note

This guide is a variation of the Review changes guide where, instead of reviewing changes every time the AI edits the document, the user reviews all of them at the end.

To implement this feature, we'll use the AI Toolkit's capability to compare documents in real time.

See the source code on GitHub.

When the user sends a message to the AI, call the startComparingDocuments method. This will start comparing the documents before and after the AI made changes.

// inside the onSubmit handler
const toolkit = getAiToolkit(editor)
toolkit.startComparingDocuments()

When the AI modifies the document, the changes are displayed as suggestions inside the editor.

Style suggestions

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

/* Highlight inserted text in green */
.tiptap-ai-suggestion,
.tiptap-ai-suggestion > * {
  background-color: oklch(87.1% 0.15 154.449);
}
.tiptap-ai-suggestion--selected,
.tiptap-ai-suggestion--selected > * {
  background-color: oklch(79.2% 0.209 151.711);
}

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

Accept/reject all changes

To accept all changes, call stopComparingDocuments. This will hide the diff view and stop the real-time document comparison.

const toolkit = getAiToolkit(editor)

toolkit.stopComparingDocuments()

To reject all changes, call the rejectAllSuggestions method. This will reset the editor content to how it was before the AI made changes. The method returns AI feedback that you can collect and include in the next user message. Then, call stopComparingDocuments to stop the real-time document comparison.

const result = toolkit.rejectAllSuggestions()
// Collect feedback events to include in next user message
const userFeedback = result.aiFeedback.events
toolkit.stopComparingDocuments()

Accept/reject individual changes

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

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

The acceptSuggestion and rejectSuggestion methods return AI feedback that you can collect and send to the AI to improve future suggestions.

toolkit.startComparingDocuments({
  displayOptions: {
    renderDecorations(options) {
      return [
        ...options.defaultRenderDecorations(),

        // Accept button
        Decoration.widget(options.range.to, () => {
          const element = document.createElement('button')
          element.textContent = 'Accept'
          element.addEventListener('click', () => {
            const result = toolkit.acceptSuggestion(options.suggestion.id)
            // Collect feedback events
            setReviewState((prev) => ({
              ...prev,
              userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
            }))
            if (toolkit.getSuggestions().length === 0) {
              stopComparing()
            }
          })
          return element
        }),

        // Reject button
        Decoration.widget(options.range.to, () => {
          const element = document.createElement('button')
          element.textContent = 'Reject'
          element.addEventListener('click', () => {
            const result = toolkit.rejectSuggestion(options.suggestion.id)
            // Collect feedback events
            setReviewState((prev) => ({
              ...prev,
              userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
            }))
            if (toolkit.getSuggestions().length === 0) {
              stopComparing()
            }
          })
          return element
        }),
      ]
    },
  },
})

Full demo code

// 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 { 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>`,
  })

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

      const { toolName, input, toolCallId } = toolCall

      // Reset feedback events when a new tool call starts
      setReviewState((prev) => ({ ...prev, userFeedback: [] }))

      // Use the AI Toolkit to execute the tool
      const toolkit = getAiToolkit(editor)
      const result = toolkit.executeTool({
        toolName,
        input,
      })

      addToolOutput({ tool: toolName, toolCallId, output: result.output })
    },
  })

  const [input, setInput] = useState('Replace the last paragraph with a short story about Tiptap')
  const [reviewState, setReviewState] = useState({
    isComparing: false,
    userFeedback: [] as SuggestionFeedbackEvent[],
  })

  if (!editor) return null

  const toolkit = getAiToolkit(editor)

  function stopComparing() {
    toolkit.stopComparingDocuments()
    setReviewState((prev) => ({ ...prev, isComparing: false }))
  }

  const showReviewUI = reviewState.isComparing && status === 'ready'

  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>
      ))}
      {!showReviewUI && (
        <form
          onSubmit={(e) => {
            e.preventDefault()

            if (reviewState.isComparing) return

            // Build message text with feedback if available
            let messageText = input.trim()
            if (reviewState.userFeedback.length > 0) {
              const feedbackOutput = JSON.stringify(reviewState.userFeedback)
              messageText += `\n\n<user_feedback>${feedbackOutput}</user_feedback>`
            }

            toolkit.startComparingDocuments({
              displayOptions: {
                renderDecorations(options) {
                  return [
                    ...options.defaultRenderDecorations(),

                    // Accept button
                    Decoration.widget(options.range.to, () => {
                      const element = document.createElement('button')
                      element.textContent = 'Accept'
                      element.addEventListener('click', () => {
                        const result = toolkit.acceptSuggestion(options.suggestion.id)
                        setReviewState((prev) => ({
                          ...prev,
                          userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
                        }))
                        if (toolkit.getSuggestions().length === 0) {
                          stopComparing()
                        }
                      })
                      return element
                    }),

                    // Reject button
                    Decoration.widget(options.range.to, () => {
                      const element = document.createElement('button')
                      element.textContent = 'Reject'
                      element.addEventListener('click', () => {
                        const result = toolkit.rejectSuggestion(options.suggestion.id)
                        setReviewState((prev) => ({
                          ...prev,
                          userFeedback: [...prev.userFeedback, ...result.aiFeedback.events],
                        }))
                        if (toolkit.getSuggestions().length === 0) {
                          stopComparing()
                        }
                      })
                      return element
                    }),
                  ]
                },
              },
            })
            setReviewState((prev) => ({ ...prev, isComparing: true }))

            if (messageText) {
              sendMessage({ text: messageText })
              setInput('')
              setReviewState((prev) => ({ ...prev, userFeedback: [] }))
            }
          }}
        >
          <input
            disabled={reviewState.isComparing}
            value={input}
            onChange={(e) => setInput(e.target.value)}
          />
        </form>
      )}
      {showReviewUI && (
        <div>
          <h2>Reviewing Changes</h2>
          <button
            onClick={() => {
              const result = toolkit.acceptAllSuggestions()
              // Collect all feedback events
              const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]
              setReviewState({
                isComparing: false,
                userFeedback,
              })
              toolkit.stopComparingDocuments()
            }}
          >
            Accept all
          </button>
          <button
            onClick={() => {
              const result = toolkit.rejectAllSuggestions()
              // Collect all feedback events
              const userFeedback = [...reviewState.userFeedback, ...result.aiFeedback.events]
              setReviewState({
                isComparing: false,
                userFeedback,
              })
              toolkit.stopComparingDocuments()
            }}
          >
            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 all AI-generated changes after the AI finishes its work:

See the source code on GitHub.

Next steps

Learn more about the AI Toolkit's capability to compare documents in real time.