用 Next.js 打造 AI 实时 PDF 编辑器
这次让我们使用 Next.js 实现实时 PDF 编辑,并使接入 AI 辅助改进内容,同时用 Playwright 生成 PDF。
具体实现
Playwright 支持将网页转为 PDF,我们只需要按照 PDF 的规范绘制页面,并且支持内容可编辑,然后将内容传到后端使用 Playwright 渲染即可。

1.数据和绘制
首先实现 PDF 的数据结构,举个简单的例子:
// pdf.json
{
"cover": {
"title": "PDF标题",
"slogan": "PDF由仙人提供"
},
"parts": [
{
"name": "Part1",
"content": "Part1的内容..."
}
]
}
将它保存为状态,这里推荐使用 Valtio。
// store.ts
import { proxy } from "valtio"
// 将 json 转为 object
const pdf_json = JSON.parse("...")
// 使用 valtio 代理
export const atomPDF = proxy({
pdf: pdf_json
})
将内容绘制出来:
// 文件路径:app/pdf/page.tsx
// 对应页面路由为:/pdf
export default function PDF() {
// valtio 必须用快照才能响应式更新 UI
const { pdf } = useSnapshot(atomPDF)
return (
// A4 纸大小对应网页的 794px × 1123px(标准96dpi)
<div className="w-[768px]">
{/*封面,高 1123px 占满一页*/}
<div className="w-full h-[1123px]">
{pdf.cover.title}
{pdf.cover.slogan}
</div>
{/*内容*/}
<div className="w-full">
{pdf.parts.map((part) => (
// 自动分页:break-inside-avoid
<div className="break-inside-avoid">{ part.content }</div>
))}
</div>
</div>
)
}
2.将网页渲染为 PDF
我们使用 Next.js 写一个 Server Action,在服务端引入 Playwright,加载我们的网页并渲染为 PDF。
// action.ts
"use server"
import {chromium} from "playwright"
// 调用可以生成 PDF
export async function actionMakePDF(pdf: PDFData) {
// 创建浏览器实例
let browser = await chromium.launch({})
const page = await browser.newPage()
// 打开我们写好的页面
await page.goto("http://localhost:3000/pdf")
// 将页面渲染为 PDF
await page.pdf({
path: "./out/test.pdf", // 输出路径
format: "A4",
printBackground: true,
margin: { bottom: "30px" },
})
// 关闭浏览器
await browser.close()
}
然后在前端调用,就能在 out/test.pdf 路径找到刚生成的 PDF 文件了:
await actionMakePDF(pdf)
3.实现编辑
Valtio 支持直接赋值改变状态,现在则我们只需要修改 atomPDF 变量就能改变页面内容:
// 修改标题
let title = atomPDF.cover.title
// 修改第一个 Part 的内容
atomPDF.parts[0].content = "新的内容"
但是为了方便复杂的字段结构,我们应该改为使用 path 来读取、修改状态,这里推荐 lodash,你也可以用 eval()。
// 将字段路径抽离出来
const path = ["cover", "title"]
const path2 = ["parts", 0, "content"]
// 使用 lodash 库的 get/set 函数操作对象字段
get(atomPDF, path) // 读取
set(atomPDF, path2, "新的内容") // 修改
现在我们将 path 抽离了出来。
让我们创建一个组件,通过绑定 path 来展示、编辑指定字段的内容,并能且自动更新状态。
export default function EditableText({ path }) {
// 直接用 atomPDF 不会触发界面更新,要用快照
const { pdf } = useSnapshot(atomPDF)
return (
// 或者用 <div contenteditable="true"/>
<textarea
value={
get(pdf, path) // 显示内容绑定
}
onChange={(event) => {
// 值改变,更新状态
set(atomPDF, path, event.target.value)
}}
/>
)
}
现在将 div 替换成 EditableText,就能实现编辑了。
<div className="w-[768px]">
{/*封面,高 1123px 占满一页*/}
<div className="w-full h-[1123px]">
{/*替换这里*/}
<EditableText path={["cover","title"]}/>
<EditableText path={["cover","slogan"]}/>
</div>
{/*内容*/}
<div className="w-full">
{pdf.parts.map((part, index) => (
// 替换这里
<EditableText path={["cover","parts", index, "content"]}/>
))}
</div>
</div>
修改样式,在聚焦时增加外框高亮,就能变成这样:

4.接入 AI
CopilotKit 是一个 React Agent 框架,可以方便的接入 AI 模型(OpenAI 格式接口),或者LangGraph,MCP等服务。支持前后端交互,共享状态,互相调用。配置过程就省略了,参考官网。
// 导入默认样式
import "@copilotkit/react-ui/styles.css";
// 这里的 api/copilotkit 需要在 Next.js 中配置对应的接口
// 主要是配置模型和 Key 等操作
<CopilotKit runtimeUrl={"/api/copilotkit"}>
{/*聊天框*/}
<CopilotChat />
</CopilotKit>
我这里演示一个通过Hook创建一个Action,可以把它理解为一个可被AI调用的函数,它的作用是让 AI 将内容写入到文档中。
useCopilotAction({
name: "write_into_doc", // Action 名称
description: "将内容写入文档", // 能力描述,给AI的提示
parameters: [ // 参数列表
{
name: "content", // 参数名
type: "string", // 类型
description: "文档内容", // 参数描述,给AI的提示
},
],
// 当工具被 AI 调用时会回调这个函数,参数由 AI 自动填入
handler: async ({ content }) => {
// 将 AI 返回的内容写入 Part1
pdf.parts[0].content = content
},
// 展示在信息流的组件,可以让用户交互、确认
renderAndWaitForResponse: Agree,
})
现在在 CopilotKit 聊天框中对 AI 说:把内容:“xxx” 写入文档,你就会看到文档指定内容变成了xxx,大功告成。