多文档 AI 代理

AI 代理聊天机器人指南的变体

本指南是AI 代理聊天机器人指南的变体。请先阅读该指南。

构建一个可以一次编辑多个 Tiptap 文档的 AI 代理聊天机器人。

查看GitHub 上的源码

技术栈

项目概述

在本指南中,我们将创建一个可以编辑多个文档的 AI 聊天机器人。我们将通过创建自定义工具来创建、删除和切换文档。然后,将它们与 Tiptap AI Toolkit 中的工具结合起来,使 AI 能够编辑文档内容。

该项目包含两个文件:

  • app/api/chat/route.ts:处理 AI 代理请求的 API 端点。
  • app/page.tsx:渲染 Tiptap 编辑器、文档切换器和简单聊天 UI 的 React 组件。

安装

创建一个 Next.js 项目:

npx create-next-app@latest multi-document-ai-agent

安装核心 Tiptap 包和针对 OpenAI 的 Vercel AI SDK

npm install @tiptap/react @tiptap/starter-kit ai @ai-sdk/react @ai-sdk/openai

安装 Tiptap AI Toolkit 和针对 Vercel AI SDK 的工具定义。

专业版包

AI Toolkit 是专业版包。安装之前,请按照私有注册表指南设置对私有 NPM 注册表的访问权限。

npm install @tiptap-pro/ai-toolkit @tiptap-pro/ai-toolkit-ai-sdk

服务器设置

我们将定义一个 API 端点,使用 Vercel AI SDK 调用 OpenAI 模型。如果您的后端不是 TypeScript,请参阅非 TypeScript 后端指南

引入 Tiptap AI Toolkit 的工具定义...toolDefinitions()),并将它们与下面定义的自定义工具合并:

  • createDocument:创建新文档
  • listDocuments:列出所有文档
  • setActiveDocument:切换到特定文档
  • deleteDocument:删除文档
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai'
import { toolDefinitions } from '@tiptap-pro/ai-toolkit-ai-sdk'
import { createAgentUIStreamResponse, ToolLoopAgent, tool, UIMessage } from 'ai'
import { z } from 'zod'

export async function POST(req: Request) {
  const { messages }: { messages: UIMessage[] } = await req.json()

  const agent = new ToolLoopAgent({
    model: openai('gpt-5.4-mini'),
    instructions: `You are an assistant that can edit rich text documents. 
    You have access to multiple documents and can switch between them. 
    At any point in time, the "active document" is the document that is open in the editor.
    When you call the tools to read and edit the document, they will read and edit the active document.
    To read and edit another document, you should use the tools to switch to that document and then read and edit it.
    Before making any edits, you should always list the documents and see which is the active document.
    `,
    tools: {
      ...toolDefinitions(),
      createDocument: tool({
        description: '创建新文档',
        inputSchema: z.object({
          documentName: z.string(),
          content: z.string().describe('The HTML content of the document'),
        }),
      }),
      listDocuments: tool({
        description:
          '查看所有可访问文档列表,并查看哪个是活动文档',
        inputSchema: z.object({}),
      }),
      setActiveDocument: tool({
        description: '切换到特定文档,使其成为活动文档',
        inputSchema: z.object({
          documentName: z.string(),
        }),
      }),
      deleteDocument: tool({
        description: '删除文档',
        inputSchema: z.object({
          documentName: z.string(),
        }),
      }),
    },
  })

  return createAgentUIStreamResponse({
    agent,
    uiMessages: messages,
  })
}

客户端设置

创建一个客户端 React 组件,用于渲染 Tiptap 编辑器、文档切换器和简单聊天 UI。

它使用 Vercel AI SDK 的 useChat hook 调用 API 端点并管理聊天对话。当 AI 模型输出工具调用时,有两种执行方式:

  • 如果是我们定义的自定义工具,直接处理。
  • 否则,使用 Tiptap AI Toolkit 的 executeTool 方法执行工具。
// app/page.tsx
'use client'

import { DefaultChatTransport, lastAssistantMessageIsCompleteWithToolCalls } from 'ai'
import { useChat } from '@ai-sdk/react'
import { Editor, EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { useEffect, useRef, useState } from 'react'
import { AiToolkit, getAiToolkit } from '@tiptap-pro/ai-toolkit'

/**
 * 应用中可用的文档之一。
 * 每个文档都有名称和内容。
 */
interface Document {
  /** 文档名称/标题 */
  name: string
  /** 文档的 HTML 内容 */
  content: string
}

/**
 * 应用启动时的初始文档集合
 */
const initialDocuments: Document[] = [
  {
    name: 'Document 1',
    content: '<h1>Document 1</h1><p>这是第一个文档的内容。</p>',
  },
]

export default function Page() {
  // 当前活动文档的编辑器实例
  const editorRef = useRef<Editor | null>(null)
  // 文档列表
  const [documents, setDocuments] = useState(initialDocuments)
  // 活动文档名称
  const [activeDocumentName, setActiveDocumentName] = useState('Document 1')

  /**
   * 根据名称查找文档
   * @param documentName
   * @returns
   */
  const findDocument = (documentName: string) => {
    return documents.find((doc) => doc.name === documentName)
  }

  /**
   * 活动文档为当前编辑器中打开的文档
   */
  const activeDocument = findDocument(activeDocumentName)

  /**
   * 在切换到新文档前调用此函数,以保存活动文档内容
   */
  const saveActiveDocument = () => {
    const editor = editorRef.current
    if (!editor) return
    const content = editor.getHTML()
    setDocuments((documents) =>
      documents.map((doc) => (doc.name === activeDocumentName ? { ...doc, content } : doc)),
    )
  }

  /**
   * 创建新文档
   * @param documentName 新文档名称
   * @returns 操作结果信息
   */
  const createDocument = (documentName: string, content: string) => {
    saveActiveDocument()
    const existingDocument = findDocument(documentName)
    if (existingDocument) {
      setActiveDocumentName(documentName)
      return `文档已存在。活动文档现为 "${existingDocument.name}"`
    }
    const newDocument = {
      name: documentName,
      content: content,
    }
    setDocuments((documents) => [...documents, newDocument])
    setActiveDocumentName(documentName)
    return `文档已创建。活动文档现为 "${documentName}"`
  }

  /**
   * 删除文档
   * @param documentName 要删除的文档名称
   * @returns 操作结果信息
   */
  const deleteDocument = (documentName: string) => {
    if (documentName === activeDocumentName) {
      return `无法删除当前活动文档。活动文档为 "${activeDocumentName}"。请先切换到其他文档。`
    }
    const existingDocument = findDocument(documentName)
    if (!existingDocument) {
      return `文档不存在。活动文档仍是 "${activeDocumentName}"`
    }
    setDocuments((documents) => documents.filter((doc) => doc.name !== documentName))
    return `已删除文档 "${documentName}"。`
  }

  /**
   * 切换活动文档
   * @param documentName 目标文档名称
   * @returns 操作结果信息
   */
  const setActiveDocument = (documentName: string) => {
    const existingDocument = findDocument(documentName)
    if (!existingDocument) {
      return `文档不存在。`
    }
    saveActiveDocument()
    setActiveDocumentName(documentName)
    return `已切换到文档 "${documentName}"。`
  }

  /**
   * 获取所有文档及当前活动文档的格式化列表
   * @returns 包含文档及活动文档名称的字符串
   */
  const listDocuments = () => {
    return `文档列表:${documents
      .map((doc) => `"${doc.name}"`)
      .join(', ')}。当前活动文档是 "${activeDocumentName}"。`
  }

  /**
   * 处理来自 AI 代理的工具调用,将其路由到相应函数
   * @param toolCall 工具调用对象,包含工具名、输入等
   * @returns 执行结果,包含工具名、ID 和输出
   */
  const handleToolCall = (toolCall: { toolName: string; input: unknown }): string => {
    const editor = editorRef.current
    if (!editor) return ''

    const { toolName, input } = toolCall

    if (toolName === 'createDocument') {
      const createDocumentInput = input as {
        documentName: string
        content: string
      }
      return createDocument(
        createDocumentInput.documentName,
        createDocumentInput.content,
      )
    } else if (toolName === 'listDocuments') {
      return listDocuments()
    } else if (toolName === 'setActiveDocument') {
      return setActiveDocument((input as { documentName: string }).documentName)
    } else if (toolName === 'deleteDocument') {
      return deleteDocument((input as { documentName: string }).documentName)
    }

    // 使用 AI Toolkit 执行工具
    const toolkit = getAiToolkit(editor)
    const result = toolkit.executeTool({
      toolName,
      input,
    })

    return result.output
  }

  const { messages, sendMessage, addtoolOutput } = useChat({
    transport: new DefaultChatTransport({ api: '/api/multi-document' }),
    sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
    async onToolCall({ toolCall }) {
      const output = handleToolCall(toolCall)
      if (output) {
        addtoolOutput({
          tool: toolCall.toolName,
          toolCallId: toolCall.toolCallId,
          output: output as unknown as undefined,
        })
      }
    },
  })

  const [input, setInput] = useState(
    '创建两个文档,一个包含一首短诗,另一个包含一篇关于 Tiptap 的短故事'
  )

  return (
    <div>
      {documents.map((doc) => (
        <button
          key={doc.name}
          disabled={doc.name === activeDocumentName}
          onClick={() => setActiveDocument(doc.name)}
        >
          {doc.name}
        </button>
      ))}

      {activeDocument && (
        <EditorComponent
          key={activeDocument.name}
          initialContent={activeDocument.content}
          onEditorInitialized={(editor) => (editorRef.current = 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()
          sendMessage({ text: input })
          setInput('')
        }}
      >
        <input value={input} onChange={(e) => setInput(e.target.value)} />
      </form>
    </div>
  )
}

/**
 * 用于包裹支持 AI Toolkit 的 Tiptap 编辑器的组件
 * @param initialContent 编辑器的初始 HTML 内容
 * @param onEditorInitialized 编辑器初始化完成时的回调
 */
function EditorComponent({
  initialContent,
  onEditorInitialized,
}: {
  /** 编辑器的初始 HTML 内容 */
  initialContent: string
  /** 编辑器初始化完成时的回调 */
  onEditorInitialized: (editor: Editor) => void
}) {
  const editor = useEditor({
    immediatelyRender: false,
    extensions: [StarterKit, AiToolkit],
    content: initialContent,
  })

  useEffect(() => {
    if (editor) {
      onEditorInitialized(editor)
    }
  }, [editor, onEditorInitialized])

  return <EditorContent editor={editor} content={initialContent} />
}

最终效果

添加额外的 CSS 样式后,得到一个简单但美观的 AI 聊天机器人应用,能够编辑多个文档:

查看GitHub 上的源码

后续步骤

本指南中创建的多文档系统存储在内存中,未进行持久化。您可以调整系统,将文档存储在数据库或文件系统中,或将其保存为Tiptap Cloud中的协作文档。

文档结构可通过允许 AI 创建文件夹来组织文档以得到改进。

最后,多文档系统可以结合以下任一 AI Toolkit 功能: