AI 代理聊天机器人

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

查看 GitHub 上的源码

技术栈

安装

创建一个 Next.js 项目:

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/server-ai-toolkit

API 端点

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

环境变量

先完成授权设置

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

你还需要一个 OpenAI API 密钥;如果使用 Tiptap Cloud 协作,还需要为文档服务器配置 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(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,
  }
}

使用自定义节点?

如果你的编辑器包含自定义节点或 marks,请使用 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()
}

现在创建主 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 { getToolDefinitions } from '@/lib/server-ai-toolkit/get-tool-definitions'

export async function POST(req: Request) {
  const {
    messages,
    editorContext,
    documentId,
  }: {
    messages: unknown[]
    editorContext: unknown
    documentId: string
  } = await req.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: `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.

${prompt}`,
    tools,
  })

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

客户端设置

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

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

// app/actions.ts
'use server'

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 getCollabConfig(
  userId: string,
  documentName: string,
): Promise<{ token: string; appId: string }> {
  if (!TIPTAP_PRIVATE_KEY || !TIPTAP_ENVIRONMENT_ID) {
    throw new Error(
      '必须设置 TIPTAP_PRIVATE_KEY 和 TIPTAP_ENVIRONMENT_ID 环境变量',
    )
  }

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

  const privateKey = await importPKCS8(TIPTAP_PRIVATE_KEY, 'ES256')

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

  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, getEditorContext } from '@tiptap/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 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 上的源码

替代方案:直接提供文档

Instead of using Tiptap Cloud documents, you can provide and manage the document yourself by passing an inline document field in the execute-tool request body.

采用这种方式时,你需要在每次工具执行前获取文档,并在 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 文档中。此外,变更无法关联到某个特定用户,也没有与 版本历史 的内置集成。

更多信息请参阅 REST API 参考 中关于 document 字段的说明。

下一步