完整的 Next.js + Notion API + AI 实战教程,展示如何构建 AI 增强的生产力应用;社区热度极高(316 条互动)。
本文将构建一个 Next.js 应用,该应用与 Notion API 集成以管理用户数据库,并支持使用 CopilotKit 与这些数据进行交互。
读完本文后,你将学会如何:
搭建 Server Actions 以获取用户的 Notion 数据库。
使用 CopilotKit 实现聊天功能,以查询数据库。
使用 CopilotKit,直接在聊天中编辑用户的 Notion 数据库。
下面展示了 CopilotKit 在 Notion 数据库中执行操作的效果:
此外,我们还将探索如何在 Next.js 项目中实现环境变量的类型安全。👀
CopilotKit 是领先的开源框架,用于将可投入生产环境的 AI 驱动型 Copilot 集成到应用中。它提供了功能丰富的 SDK,支持多种 AI Copilot 使用场景,包括上下文感知、Copilot 操作和生成式 UI。
这意味着你可以专注于定义 Copilot 的角色,而不必深陷于从零开始构建 Copilot 的技术细节,也无须处理复杂的集成工作。
不妨看看 CopilotKit 的 GitHub ⭐️
首先,使用以下命令初始化一个 Next.js 项目:
ℹ️ 你可以使用任何自己喜欢的包管理器。这里我将使用 npm。
npx create-next-app@latest copilotkit-with-notion-api --typescript --tailwind --eslint --app --use-npm
进入项目目录:
cd copilotkit-with-notion-api
我们需要安装多个依赖。运行以下命令,安装项目所需的全部依赖:
npm install @copilotkit/react-core @copilotkit/react-ui @copilotkit/runtime @notionhq/client @t3-oss/env-nextjs openai zod
为了获得更好的编码体验,请安装以下开发依赖:
npm i --save-dev prettier-plugin-organize-imports prettier-plugin-package prettier-plugin-tailwindcss
现在 Prettier 已经安装完成,接下来按照我们的偏好进行配置。在项目根目录创建一个 .prettierrc 文件,并添加以下设置:
// 👇 .prettierrc
{
"arrowParens": "avoid",
"printWidth": 80,
"semi": false,
"singleQuote": true,
"jsxSingleQuote": true,
"trailingComma": "all",
"proseWrap": "always",
"tabWidth": 2,
"plugins": [
"prettier-plugin-tailwindcss",
"prettier-plugin-organize-imports",
"prettier-plugin-package"
]
}
你可以根据自己的偏好自由调整这些规则。
为了使用一套开箱即用的 UI 组件,我们将采用 shadcn/ui。运行以下命令,使用默认设置完成初始化:
npx shadcn@latest init -d
在管理环境变量时,我们将不局限于常规的 .env 配置,而是使用 TypeScript 实现类型安全。这样可以确保在所有必需变量均未正确定义的情况下,应用无法运行。
为此,我们将使用 @t3-oss/env-nextjs 库和 zod Schema 验证库。
创建一个新文件 lib/env.ts,并添加以下代码:
// 👇 lib/env.ts
import { createEnv } from '@t3-oss/env-nextjs'
import { z } from 'zod'
export const env = createEnv({
/*
* Serverside Environment variables, not available on the client.
* Will throw if you access these variables on the client.
*/
server: {
NOTION_SECRET_API_KEY: z.string().min(1),
NOTION_DB_ID: z.string().min(1),
OPENAI_API_KEY: z.string().min(1),
},
/*
* Environment variables available on the client (and server).
*
* 💡 You'll get type errors if these are not prefixed with NEXT_PUBLIC_.
*/
client: {},
/*
* Due to how Next.js bundles environment variables on Edge and Client,
* we need to manually destructure them to make sure all are included in bundle.
*
* 💡 You'll get type errors if not all variables from `server` & `client` are included here.
*/
runtimeEnv: {
NOTION_SECRET_API_KEY: process.env.NOTION_SECRET_API_KEY,
NOTION_DB_ID: process.env.NOTION_DB_ID,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
},
})
这种方式会在运行时验证环境变量。如果任何必需变量缺失或无效,应用将直接启动失败。
你可能已经猜到了,所有这些环境变量都必须在 .env 文件中定义。其中包括 Notion API 密钥,以及最重要的 OpenAI API 密钥。
现在,只需在 .env 文件中填入你的 OpenAI API Key:
OPENAI_API_KEY=<YOUR-OPENAI-API-KEY>
要使用 Notion API,我们首先需要创建一个 Notion 集成。
访问 notion.so/my-integrations 并创建一个新集成。填写名称,选择工作区,并记下 Internal Integration Secret。
更新该集成的功能权限,使其包含 Update 和 Insert 权限。
将该密钥添加到 .env 文件中:
NOTION_SECRET_API_KEY=<YOUR-SECRET-HERE>
在 Notion 中创建一个新数据库,或者使用现有数据库。在本教程中,我们将使用一个包含三列的示例数据库:name、link 和 dueDate。
你可以根据自己的具体需求自定义这些列。
要获取 Notion Database ID,请查看数据库的 URL。ID 是位于 Notion 域名与 ?v= 查询参数之间的字符串。
将该 ID 添加到 .env 文件中:
NOTION_DB_ID=<YOUR-DB-ID-HERE>
进入数据库,单击右上角的菜单按钮,然后分配之前创建的集成。
完成这些设置后,你的应用就可以使用 Notion API 访问和管理 Notion 数据库了。✨
目前一切顺利。现在,让我们集成 CopilotKit——它是应用的灵魂,使我们能够与 Notion 数据库进行交互。
首先,在 lib/ 目录中创建一个 constants.ts 文件,用于集中管理与数据库结构和 API 端点相关的常量:
// 👇 lib/constants.ts
export const NOTION_DB_PROPERTY_LINK = 'link'
export const NOTION_DB_PROPERTY_NAME = 'name'
export const NOTION_DB_PROPERTY_DUE_DATE = 'dueDate'
export const COPILOTKIT_API_ENDPOINT = '/api/copilotkit'
请更新数据库列名,使其与你的配置一致。COPILOTKIT_API_ENDPOINT 常量定义了 CopilotKit 请求所使用的端点。
接下来,在 /app/api/copilotkit 目录中创建一个 route.ts 文件:
// 👇 app/api/copilotkit/route.ts
import { COPILOTKIT_API_ENDPOINT } from '@/lib/constants'
import { env } from '@/lib/env'
import {
CopilotRuntime,
OpenAIAdapter,
copilotRuntimeNextJSAppRouterEndpoint,
} from '@copilotkit/runtime'
import { NextRequest } from 'next/server'
import OpenAI from 'openai'
const openai = new OpenAI({ apiKey: env.OPENAI_API_KEY })
// Here, we are using GPT-3.5-turbo OpenAI model instead of the default `gpt-4o`
const serviceAdapter = new OpenAIAdapter({ openai, model: 'gpt-3.5-turbo' })
const runtime = new CopilotRuntime()
export const POST = async (req: NextRequest) => {
const { handleRequest } = copilotRuntimeNextJSAppRouterEndpoint({
runtime,
serviceAdapter,
endpoint: COPILOTKIT_API_ENDPOINT,
})
return handleRequest(req)
}
该文件配置了一个用于处理 CopilotKit 请求的 POST 路由。它使用 OpenAI GPT-3.5-turbo 模型,并定义了用于处理请求的运行时环境。
POST 函数负责监听请求,通过自定义 runtime 和 service adapter 对其进行处理,然后经由 CopilotKit 端点返回响应,以处理 AI 生成的回复。
要将 CopilotKit 集成到应用中,请使用 CopilotKit Provider 包裹应用。同时,添加预构建的 Copilot 弹窗,以便立即获得可用的 UI 功能。
按照以下内容更新 layout.tsx:
// 👇 app/layout.tsx
import { COPILOTKIT_API_ENDPOINT } from '@/lib/constants'
import { CopilotKit } from '@copilotkit/react-core'
import { CopilotPopup } from '@copilotkit/react-ui'
import '@copilotkit/react-ui/styles.css'
// ...Rest of the code
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang='en'>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
<CopilotKit runtimeUrl={COPILOTKIT_API_ENDPOINT}>
<main>{children}</main>
<CopilotPopup instructions='You are assisting the user as best as you can. Answer the best way possible given the user notion database information.' />
</CopilotKit>
</body>
</html>
)
}
首先,我们只需导入所需模块和自定义样式,让 Copilot 弹窗拥有美观的外观。
我们使用 <CopilotKit /> Provider 包裹应用,并将 COPILOTKIT_API_ENDPOINT 常量作为 runtimeUrl 传入;该常量的值就是 /api/copilotkit 端点。
此时,你应该已经能在应用右下角看到一个小型聊天弹窗。它的默认样式也已经相当美观。
这就是我们在应用中设置 CopilotKit 所需的全部内容了。🥂 现在,剩下的就是提供应用的上下文,使其能够实时读取我们的数据并对我们进行指导。
现在,所有前期准备工作已经完成,是时候实施我们应用的核心功能了。
我们将从定义数据类型开始,逐步构建从 Notion 数据库获取和操作数据的功能。
在 types/ 目录中创建一个文件 types/notion.ts,用来定义 Notion 数据库数据的结构:
// 👇 types/notion.ts
import {
NOTION_DB_PROPERTY_DUE_DATE,
NOTION_DB_PROPERTY_LINK,
NOTION_DB_PROPERTY_NAME,
} from '@/lib/constants'
export type TResponse = {
success: boolean
error: Error | null
}
export type TRow = {
id: string
properties: {
[NOTION_DB_PROPERTY_NAME]: {
id: string
title: { text: { content: string } }[]
}
[NOTION_DB_PROPERTY_LINK]: { id: string; url: string }
[NOTION_DB_PROPERTY_DUE_DATE]: {
id: string
type: 'date'
date?: { start: string; end: string }
}
}
}
export type TRowDetails = {
id: string
[NOTION_DB_PROPERTY_NAME]: string
[NOTION_DB_PROPERTY_LINK]: string
[NOTION_DB_PROPERTY_DUE_DATE]: {
start: string
end: string
}
}
首先,我们定义了 TResponse 类型,它将用于服务端操作中定义函数的返回类型。然后是 TRow 和 TRowDetails 类型,它们基本上保存 Notion 数据库中每一行数据的类型定义。
TRow 类型定义用来匹配从 Notion API 返回的数据。TRowDetails 类型是我创建的自定义类型,只保存我计划在 UI 中的每一行显示的数据。
💡 根据你的 Notion 数据库结构调整这些类型中的属性。
创建 lib/actions.ts 用来定义与 Notion 数据库交互的服务端操作:
// 👇 lib/actions.ts
'use server'
import { env } from '@/lib/env'
import { TResponse } from '@/types/notion'
import { Client } from '@notionhq/client'
import { QueryDatabaseResponse } from '@notionhq/client/build/src/api-endpoints'
const notion = new Client({
auth: env.NOTION_SECRET_API_KEY,
})
export const fetchNotionDB = async (): Promise<
QueryDatabaseResponse | TResponse
> => {
try {
const dbQuery = await notion.databases.query({
database_id: env.NOTION_DB_ID,
})
return dbQuery
} catch (error) {
return {
success: false,
error: error as Error,
} as TResponse
}
}
fetchNotionDB 函数被标记为服务端操作('use server'),确保无论从何处调用,它都仅在服务器上运行。
首先,我们通过传递数据库 ID 来创建一个 Notion 客户端实例。然后在 fetchNotionDB 函数中,我们使用 Notion API 查询 Notion 数据库并返回响应。
现在,让我们更新 app/page.tsx 文件来获取和呈现 Notion 数据库数据:
// 👇 app/page.tsx
import { NotionTable } from '@/components/notion-table'
import { fetchNotionDB } from '@/lib/actions'
import {
NOTION_DB_PROPERTY_DUE_DATE,
NOTION_DB_PROPERTY_LINK,
NOTION_DB_PROPERTY_NAME,
} from '@/lib/constants'
import { isErrorResponse } from '@/lib/utils'
import { TRow } from '@/types/notion'
export default async function Home() {
const response = await fetchNotionDB()
if (isErrorResponse(response)) {
return (
<div className='mt-10 text-center text-rose-500'>
Failed to fetch data. Please try again later.
</div>
)
}
const dbRows = response.results.map(row => ({
id: row.id,
// @ts-expect-error properties field definitely exists in each row.
properties: row.properties || {},
})) as TRow[]
const formattedDBRows = dbRows.map(({ id, properties }) => {
const name =
properties?.[NOTION_DB_PROPERTY_NAME]?.title?.[0]?.text?.content || ''
const link = properties?.[NOTION_DB_PROPERTY_LINK]?.url || ''
const dueDate = properties?.[NOTION_DB_PROPERTY_DUE_DATE]?.date || {
start: '',
end: '',
}
return {
id,
[NOTION_DB_PROPERTY_NAME]: name,
[NOTION_DB_PROPERTY_LINK]: link,
[NOTION_DB_PROPERTY_DUE_DATE]: dueDate,
}
})
return (
<div className='mt-8 flex justify-center'>
<div className='w-full max-w-4xl'>
<NotionTable initialTableData={formattedDBRows} />
</div>
</div>
)
}
这里,我们首先使用 fetchNotionDB 函数获取用户的 Notion 数据库信息。然后,我们检查函数返回数据的类型,确保它是 QueryDatabaseResponse 类型。如果不是,我们只需显示一条错误消息并返回。
💡 属性赋值顶部的注释用于抑制建议行可能是另一种类型且可能没有 properties 字段的错误。但是,查询数据库总是为每一行返回一个 properties 字段。
接下来,我们获取 Notion 数据库的所有行并将其强制转换为 TRow[]。由于它包含一些与我们无关的数据,我们将其格式化为 formattedDBRows 变量作为 TRowDetails[]。
最后,我们将这些获取的数据传递给 <NotionTable /> 组件,该组件负责在 UI 中显示表格。
注意我们还没有实现 isErrorResponse 函数。添加一个工具函数来判断响应是否为错误。如下更新 lib/utils.ts:
// 👇 lib/utils.ts
import { TResponse } from '@/types/notion'
import { QueryDatabaseResponse } from '@notionhq/client/build/src/api-endpoints'
// ...Rest of the code
export function isErrorResponse(
data: QueryDatabaseResponse | TResponse,
): data is TResponse {
if (
typeof data === 'object' &&
data !== null &&
'success' in data &&
typeof (data as TResponse).success === 'boolean'
) {
return (data as TResponse).success === false
}
return false
}
这个函数检查数据是否为 TResponse 类型,以及 success 字段是否设置为 false。如果是,我们返回 false,否则返回 true。
现在,我们需要实现 <NotionTable /> 组件。首先在此之前,让我们添加一些将要使用的 shadcn/ui 组件。
安装必需的 UI 组件:
npx shadcn@latest add sonner table
通过将 Sonner toast 通知提供程序添加到 app/layout.tsx 来启用它:
// 👇 app/layout.tsx
import { Toaster } from '@/components/ui/sonner'
// ...Rest of the code
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode
}>) {
return (
<html lang='en'>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{/* ...Rest of the code */}
<main>{children}</main>
<Toaster />
{/* ...Rest of the code */}
</body>
</html>
)
}
创建 NotionTable 组件来呈现获取的数据。这个组件也将包含大部分 AI 工作。
添加文件 components/notion-table.tsx,代码如下:
// 👇 components/notion-table.tsx
'use client'
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { updateNotionDBRowLink, updateNotionDBRowTitle } from '@/lib/actions'
import { TRowDetails } from '@/types/notion'
import { useCopilotAction, useCopilotReadable } from '@copilotkit/react-core'
import Link from 'next/link'
import { useState } from 'react'
import { toast } from 'sonner'
interface NotionTableProps {
initialTableData: TRowDetails[]
}
export const NotionTable = ({ initialTableData }: NotionTableProps) => {
const [tableData, setTableData] = useState<TRowDetails[]>(initialTableData)
return (
<Table className='rounded-sm shadow-sm'>
<TableCaption className='py-4'>Notion Database</TableCaption>
<TableHeader className='bg-zinc-100'>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Link</TableHead>
<TableHead className='text-right'>Due Date</TableHead>
</TableRow>
</TableHeader>
{initialTableData.length === 0 ? (
<p className='text-center text-zinc-500'>No data found.</p>
) : (
<TableBody>
{tableData.map((dbRow, i) => (
<TableRow key={`${dbRow.name}-${dbRow.id}-${i}`}>
<TableCell className='font-medium'>
{dbRow.name ? (
<span>{dbRow.name}</span>
) : (
<span className='text-zinc-500'>Unnamed</span>
)}
</TableCell>
<TableCell>
{dbRow.link ? (
<Link
href={dbRow.link}
aria-label={`Link for ${dbRow.name || 'Unnamed'}`}
target='_blank'
className='underline underline-offset-4'
>
{dbRow.link}
</Link>
) : (
<span className='text-zinc-500'>No Link</span>
)}
</TableCell>
<TableCell className='text-right'>
{dbRow.dueDate.start ? (
<span>{dbRow.dueDate.start}</span>
) : (
<span className='text-zinc-500'>No Due Date</span>
)}
{dbRow.dueDate.end ? ` - ${dbRow.dueDate.end}` : null}
</TableCell>
</TableRow>
))}
</TableBody>
)}
</Table>
)
}
为了让 AI 理解并与我们的应用程序交互,我们将利用 CopilotKit 中的几个钩子。
具体来说,我们将使用 useCopilotReadable 和 useCopilotAction 钩子为 AI 提供上下文以及在我们的应用程序中执行操作的能力。
在 notion-table.tsx 中,添加 useCopilotReadable 钩子使 AI 能够感知应用程序的状态:
// ...Rest of the code
useCopilotReadable({
description:
'All the rows in our notion database which holds the information for all the meetings I need to attend.',
value: tableData,
})
// ...Rest of the code
通过添加这个钩子,CopilotKit 能够实时了解 tableData 状态,允许 AI 回答有关 Notion 数据库的查询。
接下来,我们实现 AI 修改数据的能力。在同一文件中的 useCopilotReadable 下方添加以下钩子,以允许更新行详情:
// 👇 components/notion-table.tsx
// ...Rest of the code
useCopilotAction({
name: 'updateRowName',
description:
'Update the title of the row (index starts from 0) in the notion database.',
parameters: [
{
name: 'index',
description: 'Index of the row to update.',
required: true,
},
{
name: 'newTitle',
description: 'New title for the row.',
required: true,
},
],
handler: async ({ index, newTitle }) => {
const parsedIndex = parseInt(index, 10)
if (isNaN(parsedIndex)) throw new Error('Invalid index')
const { success } = await updateNotionDBRowTitle({
tableRowId: tableData[parsedIndex].id,
tableRowNewTitle: newTitle,
})
if (!success) return toast.error('Could not update the notion DB')
toast.success('Successfully updated the notion DB')
setTableData(prevData => {
const updatedTableData = [...prevData]
if (parsedIndex >= 0 && parsedIndex < updatedTableData.length) {
updatedTableData[parsedIndex].name = newTitle
}
return updatedTableData
})
},
})
useCopilotAction({
name: 'updateRowLink',
description:
'Update the link of the row (index starts from 0) in the notion database.',
parameters: [
{
name: 'index',
description: 'Index of the row to update.',
required: true,
},
{
name: 'newLink',
description: 'New link to the row.',
required: true,
},
],
handler: async ({ index, newLink }) => {
const parsedIndex = parseInt(index, 10)
if (isNaN(parsedIndex)) throw new Error('Invalid index')
const { success } = await updateNotionDBRowLink({
tableRowId: tableData[parsedIndex].id,
tableRowNewLink: newLink,
})
if (!success) return toast.error('Could not update the notion DB')
toast.success('Successfully updated the notion DB')
setTableData(prevData => {
const updatedTableData = [...prevData]
if (parsedIndex >= 0 && parsedIndex < updatedTableData.length) {
updatedTableData[parsedIndex].link = newLink
}
return updatedTableData
})
},
})
// ...Rest of the code
每个 useCopilotAction 钩子包含:
name:操作的标识符。
description:操作的清晰说明。
parameters:操作所需的输入。
handler:执行操作的函数,修改数据库并相应地更新用户界面。
我们还没有编写 updateNotionDBRowTitle 和 updateNotionDBRowLink 函数。在 lib/actions.ts 文件中,定义与 Notion API 交互的辅助函数:
// 👇 lib/actions.ts
'use server'
import {
NOTION_DB_PROPERTY_LINK,
NOTION_DB_PROPERTY_NAME,
} from '@/lib/constants'
// ...Rest of the code
export const updateNotionDBRowTitle = async ({
tableRowId,
tableRowNewTitle,
}: {
tableRowId: string
tableRowNewTitle: string
}): Promise<TResponse> => {
try {
await notion.pages.update({
page_id: tableRowId,
properties: {
[NOTION_DB_PROPERTY_NAME]: {
title: [{ text: { content: tableRowNewTitle } }],
},
},
})
return { success: true, error: null } as TResponse
} catch (error) {
return { success: false, error: error as Error } as TResponse
}
}
export const updateNotionDBRowLink = async ({
tableRowId,
tableRowNewLink,
}: {
tableRowId: string
tableRowNewLink: string
}): Promise<TResponse> => {
try {
await notion.pages.update({
page_id: tableRowId,
properties: {
[NOTION_DB_PROPERTY_LINK]: {
url: tableRowNewLink,
},
},
})
return { success: true, error: null } as TResponse
} catch (error) {
return { success: false, error: error as Error } as TResponse
}
}
这两个函数都使用 Notion API 更新数据库中某一行(或"页面")的特定字段,返回成功或错误响应。
这就是我们今天在应用程序中将实现的全部内容。你也可以使用删除数据库中某一行的函数。请随意探索 Notion API 文档并实现