用 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,大功告成。