AI 代理聊天机器人

构建一个简单的 AI 代理聊天机器人,它可以读取和编辑 Tiptap 文档。

查看 GitHub 上的源码

技术栈

  • 一个客户端 React 应用程序
  • 一个服务器应用程序
  • 一个 AI 代理框架,例如 Vercel AI SDK
  • OpenAI 模型
  • Server AI Toolkit

Server AI Toolkit 可与任何 AI 代理框架和 AI 模型配合使用。可将其用于任何前端框架和后端编程语言。本指南使用一个简单的 Node.js 服务器端点,它接收一个 Request 对象并返回一个 Response

在继续之前,请按照 安装指南 配置 Server AI Toolkit。

设置演示应用

npx create-next-app@latest server-ai-agent-chatbot

安装核心 Tiptap 包、协作扩展,以及用于 OpenAI 的 Vercel AI SDK

npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-collaboration @tiptap-pro/provider ai @ai-sdk/react @ai-sdk/openai zod uuid yjs jose

安装 Server AI Toolkit 包:

npm install @tiptap/ai-toolkit

Server API 端点

创建一个 API 端点,使用 Vercel AI SDK 调用 OpenAI 模型。从 Server AI Toolkit API 获取工具定义,并通过 API 执行工具。

安装依赖

在你的服务器应用中安装这些依赖:

npm install ai @ai-sdk/openai jose zod

环境变量

先设置授权

在创建辅助函数之前,请先按照 授权指南 配置你的环境变量和身份验证辅助函数 (createJwtTokengetAuthHeaders)。

你还需要一个 OpenAI API 密钥;如果使用 Tiptap Cloud 协作,还需要提供包含你的 Document Server ID 的 TIPTAP_CLOUD_DOCUMENT_SERVER_ID。将它们添加到你的 .env 文件中,并与 授权指南 中的变量一起配置:

# .env(除了 Server AI Toolkit 变量之外)
TIPTAP_CLOUD_DOCUMENT_SERVER_ID=document-server-id
OPENAI_API_KEY=your-openai-key # AI SDK 会自动获取它

辅助函数

创建几个辅助函数以支持你的端点并与 Server AI Toolkit 交互:

获取工具定义

此函数会从 Server AI Toolkit API 获取提示词和可用的工具定义。

// lib/server-ai-toolkit/get-tool-definitions.ts
import type z from 'zod'
import { getAuthHeaders } from './get-auth-headers'

/**
 * 从 Server AI Toolkit API 获取工具定义
 */
export async function getToolDefinitions(editorContext: unknown): Promise<{
  prompt: string
  tools: {
    name: string
    description: string
    inputSchema: z.core.JSONSchema.JSONSchema
  }[]
}> {
  const apiBaseUrl = process.env.TIPTAP_CLOUD_AI_API_URL || 'https://api.tiptap.dev'

  const response = await fetch(`${apiBaseUrl}/v4/ai/toolkit/fetch-tools`, {
    method: 'POST',
    headers: await getAuthHeaders(),
    body: JSON.stringify({
      editorContext,
      tools: {
        tiptapRead: true,
        tiptapEdit: true,
      },
    }),
  })

  if (!response.ok) {
    throw new Error(`获取工具失败:${response.statusText}`)
  }
  const responseData = await response.json()

  return {
    prompt: responseData.systemPrompt,
    tools: responseData.tools,
  }
}

使用自定义节点?

如果你的编辑器包含自定义节点或标记,请使用 addJsonSchemaAwareness 进行配置。请参见 自定义节点指南

执行工具

此函数通过 Server AI Toolkit API 执行工具。它会将工具名称、输入参数、文档 ID 和编辑器上下文数据发送到 API。当 JWT 包含该文档的 Documents:Write 权限时,服务器会自动获取并保存 Tiptap Cloud 文档。

// lib/server-ai-toolkit/execute-tool.ts
import { getAuthHeaders } from './get-auth-headers'

/**
 * 通过 Server AI Toolkit API 执行工具
 */
export async function executeTool(
  toolName: string,
  input: unknown,
  documentId: string,
  editorContext: unknown,
): Promise<{
  tool: { name: string; output: unknown }
  docChanged: boolean
  document: object | null
}> {
  const apiBaseUrl = process.env.TIPTAP_CLOUD_AI_API_URL || 'https://api.tiptap.dev'

  const response = await fetch(`${apiBaseUrl}/v4/ai/toolkit/execute-tool`, {
    method: 'POST',
    headers: await getAuthHeaders(documentId),
    body: JSON.stringify({
      editorContext,
      document: {
        type: 'cloud',
        id: documentId,
      },
      user: 'ai-assistant',
      tool: {
        name: toolName,
        input,
      },
    }),
  })

  if (!response.ok) {
    throw new Error(`工具执行失败:${response.statusText}`)
  }

  return response.json()
}

创建 POST /api/server-ai-agent-chatbot 端点:

// server/server-ai-agent-chatbot.ts
import { openai } from '@ai-sdk/openai'
import { createAgentUIStreamResponse, ToolLoopAgent, tool } from 'ai'
import z from 'zod'
import { executeTool } from '@/lib/server-ai-toolkit/execute-tool'
import { getToolDefinitions } from '@/lib/server-ai-toolkit/get-tool-definitions'

export async function handleChatRequest(request: Request): Promise<Response> {
  const {
    messages,
    editorContext,
    documentId,
  }: {
    messages: unknown[]
    editorContext: unknown
    documentId: string
  } = await request.json()

  // 从 Server AI Toolkit API 获取提示词和工具定义
  const { prompt, tools: toolDefinitions } = await getToolDefinitions(editorContext)

  // 将 API 工具定义转换为 AI SDK 工具格式
  const tools = Object.fromEntries(
    toolDefinitions.map((toolDef) => [
      toolDef.name,
      tool({
        description: toolDef.description,
        inputSchema: z.fromJSONSchema(toolDef.inputSchema),
        execute: async (input) => {
          try {
            const result = await executeTool(toolDef.name, input, documentId, editorContext)
            return result.tool.output
          } catch (error) {
            console.error(`执行工具 ${toolDef.name} 失败:`, error)
            return {
              error: error instanceof Error ? error.message : '未知错误',
            }
          }
        },
      }),
    ]),
  )

  const agent = new ToolLoopAgent({
    model: openai('gpt-5.4-mini'),
    instructions: `你是一个可以编辑富文本文档的助手。
在你的回复中,请简洁并直奔主题。不过,你在文档中生成的内容不需要简洁直白:相反,它应尽可能贴合用户的请求。
在调用任何工具之前,请先用一句话或更少的话,简要说明你打算做什么,作为对任务的高层概述,就像人类作者会描述的那样。
规则:在你的回复中,不要提供任何工具调用的细节。
规则:在你的回复中,不要提供文档 HTML 内容的任何细节。

${prompt}`,
    tools,
  })

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

客户端设置

安装依赖

在你的客户端应用中安装这些依赖:

npm install @ai-sdk/react @tiptap/extension-collaboration @tiptap/react @tiptap/starter-kit @tiptap-pro/provider @tiptap/ai-toolkit ai uuid yjs

创建一个客户端 React 组件,用于渲染支持协作的 Tiptap 编辑器以及一个简单的聊天界面。该组件利用 Vercel AI SDK 中的 useChat hook 来调用 API 端点并管理聊天对话。

首先,创建一个用于生成 Tiptap Cloud 协作 JWT 的 API 端点。此端点会授权客户端访问 Tiptap Cloud 协作服务中的特定文档。

创建 POST /api/collaboration-token 端点。

// server/collaboration-token.ts
import { SignJWT, importPKCS8 } from 'jose'

const TIPTAP_PRIVATE_KEY = process.env.TIPTAP_PRIVATE_KEY
const TIPTAP_ENVIRONMENT_ID = process.env.TIPTAP_ENVIRONMENT_ID
const TIPTAP_CLOUD_DOCUMENT_SERVER_ID = process.env.TIPTAP_CLOUD_DOCUMENT_SERVER_ID

export async function handleCollaborationTokenRequest(request: Request): Promise<Response> {
  const { documentId, userId } = await request.json()
  const privateKey = await importPKCS8(TIPTAP_PRIVATE_KEY, 'ES256')

  const token = await new SignJWT({
    permissions: [{ action: 'Documents:Write', resource: documentId }],
  })
    .setProtectedHeader({ alg: 'ES256' })
    .setIssuer(TIPTAP_ENVIRONMENT_ID)
    .setSubject(userId)
    .setAudience(['Documents'])
    .setExpirationTime('1h')
    .sign(privateKey)

  return Response.json({ token, appId: TIPTAP_CLOUD_DOCUMENT_SERVER_ID })
}

现在创建主页面组件:

// components/AiChatbot.tsx
import { useChat } from '@ai-sdk/react'
import { Collaboration } from '@tiptap/extension-collaboration'
import { EditorContent, useEditor } from '@tiptap/react'
import StarterKit from '@tiptap/starter-kit'
import { TiptapCollabProvider } from '@tiptap-pro/provider'
import { ServerAiToolkit, getEditorContext } from '@tiptap/ai-toolkit'
import { DefaultChatTransport } from 'ai'
import { useEffect, useState } from 'react'
import { v4 as uuid } from 'uuid'
import * as Y from 'yjs'

export function Page() {
  const [doc] = useState(() => new Y.Doc())
  const [documentId] = useState(() => `server-ai-agent-chatbot/${uuid()}`)

  const editor = useEditor({
    immediatelyRender: false,
    extensions: [StarterKit, Collaboration.configure({ document: doc }), ServerAiToolkit],
  })

  useEffect(() => {
    let provider: TiptapCollabProvider | undefined

    const connect = async () => {
      const response = await fetch('/api/collaboration-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ documentId, userId }),
      })

      if (!response.ok) {
        throw new Error('获取协作凭证失败')
      }

      const { appId, token } = await response.json()
      provider = new TiptapCollabProvider({
        appId,
        name: documentId,
        token,
        document: doc,
        user: 'user-1',
      })
    }

    void connect().catch(console.error)

    return () => provider?.destroy()
  }, [doc, documentId, userId])

  const editorContext = editor ? getEditorContext(editor) : null

  const { messages, sendMessage } = useChat({
    transport: new DefaultChatTransport({
      api: '/api/server-ai-agent-chatbot',
      body: { documentId },
    }),
  })

  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 />
          <div className="mt-2 whitespace-pre-wrap">
            {messages?.map((message) =>
              message.parts
                .filter((p) => p.type === 'text')
                .map((p) => p.text)
                .join('\n') || '加载中...'
            )}
          </div>
        </div>
      ))}
      <form
        onSubmit={(e) => {
          e.preventDefault()
          if (input.trim()) {
            sendMessage({ text: input }, { body: { editorContext } })
            setInput('')
          }
        }}
      >
        <input value={input} onChange={(e) => setInput(e.target.value)} />
        <button type="submit">发送</button>
      </form>
    </div>
  )
}

文档状态管理

此实现传入了 document: { type: 'cloud', id: documentId },因此 Server AI Toolkit 会自动从 Tiptap Cloud 获取并保存文档。客户端使用 Tiptap Collaboration 实时同步更改。

最终结果

配合额外的 CSS 样式,效果是一个简单但精致的 AI 聊天机器人应用,使用 Server AI Toolkit 进行文档编辑:

查看 GitHub 上的源码

替代方案:直接提供文档

不使用 Tiptap Cloud 文档时,你可以通过在 execute-tool 请求体中传入内联的 document 字段,自己提供并管理文档。

采用这种方式时,你需要在每次工具执行前获取文档,并在 docChangedtrue 时将更新后的文档保存回去。

import { loadDocument, saveDocument } from './db'

const document = await loadDocument()
const response = await fetch(`${apiBaseUrl}/v4/ai/toolkit/execute-tool`, {
  method: 'POST',
  headers: await getAuthHeaders(),
  body: JSON.stringify({
    editorContext,
    document: {
      type: 'inline',
      content: document,
    },
    tool: {
      name: toolName,
      input,
    },
  }),
})

if (!response.ok) {
  throw new Error(`工具执行失败:${response.statusText}`)
}

const body = await response.json()

// 如果工具执行修改了文档,则存储该文档
if (body.docChanged && body.document) {
  await saveDocument(body.document)
}

tiptapRead 工具可以修改文档

tiptapRead 工具可以对文档进行编辑,以便为后续的读取操作做准备。 因此,如果 docChanged 属性为 true,你应该在执行 tiptapRead 工具后更新文档。

document 字段中以内联方式提供文档有几个限制。首先,不支持 Tiptap 评论,因为它们存储在 Tiptap Cloud 文档中。此外,变更无法关联到某个特定用户,也没有与 版本历史 的内置集成。

有关 document 字段的更多信息,请参阅 REST API 参考

下一步