AI 代理聊天机器人

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

查看 GitHub 上的源码

技术栈

安装

创建一个 Next.js 项目:

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

Pro 包

Server AI Toolkit 和 Collaboration 是 pro 包。在安装之前,你需要先设置 访问 Tiptap 的私有 NPM 注册表

安装核心 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 jsonwebtoken

安装 Server AI Toolkit 包:

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

安装 jsonwebtoken 的 TypeScript 类型:

npm install --save-dev @types/jsonwebtoken

API 端点

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

环境变量

先完成授权设置

在创建辅助函数之前,请先按照 授权指南 设置环境变量和认证辅助函数 (createJwtTokengetAuthHeaders)。

你还需要一个 OpenAI API 密钥;如果使用 Tiptap Cloud 协作,还需要为 Document Server 配置 TIPTAP_CLOUD_SECRET。请将这些变量与 授权指南 中的变量一起添加到你的 .env 文件中:

# .env(除了 Server AI Toolkit 变量之外)
TIPTAP_CLOUD_SECRET=your-tiptap-cloud-document-server-secret
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(schemaAwarenessData: unknown): Promise<
  {
    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}/v3/ai/toolkit/tools`, {
    method: 'POST',
    headers: getAuthHeaders(),
    body: JSON.stringify({
      schemaAwarenessData,
    }),
  })

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

  return responseData.tools
}

获取 schema 识别提示

Schema 识别 是 Server AI Toolkit 让你的 LLM 理解文档结构的能力。使用此函数,你可以生成该识别内容的格式化字符串,包含在提示中以向模型描述文档。

// lib/server-ai-toolkit/get-schema-awareness-prompt.ts
import { getAuthHeaders } from './get-auth-headers'

/**
 * 从 Server AI Toolkit API 获取 schema 识别提示
 */
export async function getSchemaAwarenessPrompt(schemaAwarenessData: unknown): Promise<string> {
  const apiBaseUrl = process.env.TIPTAP_CLOUD_AI_API_URL || 'https://api.tiptap.dev'

  const response = await fetch(`${apiBaseUrl}/v3/ai/toolkit/schema-awareness-prompt`, {
    method: 'POST',
    headers: getAuthHeaders(),
    body: JSON.stringify({
      schemaAwarenessData,
    }),
  })

  if (!response.ok) {
    throw new Error(`获取 schema 识别提示失败:${response.statusText}`)
  }

  const result: { prompt: string } = await response.json()
  return result.prompt
}

使用自定义节点?

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

执行工具

此函数通过 Server AI Toolkit API 执行指定工具。它将工具名称、输入参数、文档 ID 和 schema 识别数据发送给 API。服务器会借助 JWT 中的凭证自动从 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,
  schemaAwarenessData: unknown,
  options: { sessionId?: string } = {},
): Promise<{ output: unknown; docChanged: boolean; sessionId: string }> {
  const apiBaseUrl = process.env.TIPTAP_CLOUD_AI_API_URL || 'https://api.tiptap.dev'

  const response = await fetch(`${apiBaseUrl}/v3/ai/toolkit/execute-tool`, {
    method: 'POST',
    headers: getAuthHeaders(),
    body: JSON.stringify({
      toolName,
      input,
      sessionId: options.sessionId,
      experimental_documentOptions: { documentId },
      schemaAwarenessData,
    }),
  })

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

  return response.json()
}

在聊天轮次之间持久化会话

将最新的 sessionId 存储在对话历史中,并在后续的工具调用中重用它。这样服务器就能拒绝过时的编辑,而不会覆盖更新的用户更改。

可以使用一个小型辅助函数,例如 lib/server-ai-toolkit/session-id.ts, 从对话历史中读取最新的 sessionId

现在创建主 API 路由:

// app/api/server-ai-agent-chatbot/route.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 { getSchemaAwarenessPrompt } from '@/lib/server-ai-toolkit/get-schema-awareness-prompt'
import { getToolDefinitions } from '@/lib/server-ai-toolkit/get-tool-definitions'
import {
  getSessionIdFromConversationHistory,
  type ServerAiToolkitMessage,
} from '@/lib/server-ai-toolkit/session-id'

export async function POST(req: Request) {
  const {
    messages,
    schemaAwarenessData,
    documentId,
  }: {
    messages: ServerAiToolkitMessage[]
    schemaAwarenessData: unknown
    documentId: string
  } = await req.json()
  let sessionId = getSessionIdFromConversationHistory(messages)

  // 从 Server AI Toolkit API 获取工具定义
  const toolDefinitions = await getToolDefinitions(schemaAwarenessData)

  // 从 Server AI Toolkit API 获取 schema 识别提示
  const schemaAwarenessPrompt = await getSchemaAwarenessPrompt(schemaAwarenessData)

  // 将 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, schemaAwarenessData, {
              sessionId,
            })
            sessionId = result.sessionId
            return result.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: `You are an assistant that can edit rich text documents.
In your responses, be concise and to the point. However, the content you generate in the document does not need to be concise and to the point: instead, it should follow the user's request as closely as possible.
Before calling any tools, summarize you're going to do (in a sentence or less), as a high-level view of the task, as a human writer would describe it.
Rule: In your responses, do not provide any details of the tool calls.
Rule: In your responses, do not provide any details of the HTML content of the document.

${schemaAwarenessPrompt}`,
    tools,
  })

  return createAgentUIStreamResponse({
    agent,
    messageMetadata: ({ part }) =>
      part.type === 'finish' && sessionId ? { sessionId } : undefined,
    uiMessages: messages,
  })
}

客户端设置

创建一个客户端 React 组件,用于渲染支持协作功能的 Tiptap 编辑器和简单聊天界面。该组件使用 Vercel AI SDK 的 useChat 钩子 调用 API 端点并管理聊天对话。

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

// app/actions.ts
'use server'

import jwt from 'jsonwebtoken'

const TIPTAP_CLOUD_SECRET = process.env.TIPTAP_CLOUD_SECRET
const TIPTAP_CLOUD_DOCUMENT_SERVER_ID = process.env.TIPTAP_CLOUD_DOCUMENT_SERVER_ID

export async function getCollabConfig(
  userId: string,
  documentName: string,
): Promise<{ token: string; appId: string }> {
  if (!TIPTAP_CLOUD_SECRET) {
    throw new Error('TIPTAP_CLOUD_SECRET 环境变量未设置')
  }

  if (!TIPTAP_CLOUD_DOCUMENT_SERVER_ID) {
    throw new Error('TIPTAP_CLOUD_DOCUMENT_SERVER_ID 环境变量未设置')
  }

  const payload = {
    sub: userId,
    allowedDocumentNames: [documentName],
  }

  const token = jwt.sign(payload, TIPTAP_CLOUD_SECRET, { expiresIn: '1h' })

  return { token, appId: TIPTAP_CLOUD_DOCUMENT_SERVER_ID }
}

现在创建主页面组件:

// app/page.tsx
'use client'

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, getSchemaAwarenessData } from '@tiptap-pro/server-ai-toolkit'
import { DefaultChatTransport } from 'ai'
import { useEffect, useRef, useState } from 'react'
import { v4 as uuid } from 'uuid'
import * as Y from 'yjs'
import { getCollabConfig } from './actions'

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

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

  // 从服务器函数获取 JWT 和 appId
  useEffect(() => {
    const setupProvider = async () => {
      try {
        const { token, appId } = await getCollabConfig('user-1', documentId)

        const collabProvider = new TiptapCollabProvider({
          appId,
          name: documentId,
          token,
          document: doc,
          user: 'user-1',
          onOpen() {
            console.log('WebSocket 连接已打开。')
          },
          onConnect() {
            editor?.commands.setContent('Hello, world!')
          },
        })

        providerRef.current = collabProvider
      } catch (error) {
        console.error('设置协作失败:', error)
      }
    }

    setupProvider()

    return () => {
      if (providerRef.current) {
        providerRef.current.destroy()
        providerRef.current = null
      }
    }
  }, [documentId, doc, editor])

  const schemaAwarenessData = editor ? getSchemaAwarenessData(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: { schemaAwarenessData } })
            setInput('')
          }
        }}
      >
        <input value={input} onChange={(e) => setInput(e.target.value)} />
        <button type="submit">发送</button>
      </form>
    </div>
  )
}

文档状态管理

此实现使用 documentId 代替直接传递文档。文档状态通过 Tiptap Cloud 的 REST API 在服务器端管理,确保工具多次执行时状态一致。客户端则使用 Tiptap Collaboration 进行实时同步。

另一种方式:直接提供文档

你也可以不使用 Tiptap Cloud 文档,而是在 execute-tool 请求体中传递 document 字段(而不是 experimental_documentOptions),自行提供和管理文档。采用这种方式时,你需要在每次工具执行前获取文档,并在 result.docChangedtrue 时保存更新后的文档。请参阅 REST API 参考

最终效果

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

查看 GitHub 上的源码

后续步骤