返回首页

AI 小说编辑器架构拆解:从富文本工作台到上下文感知的流式生成

发布于 2026-08-10 · AI 工具链 · 12 分钟

文章目录

做一个“能调用大模型的文本框”并不难,难的是做一个可以长期写小说的编辑器。小说不是一次性提示词的产物,而是一组持续变化的结构化数据:作品、卷、章节、人物、世界观、历史摘要和当前大纲彼此关联;与此同时,编辑器还要处理自动保存、目录拖拽、流式生成、异常回滚和模型兼容。

我在 AI Novel Copilot 中尝试把这些问题放进一套完整的前后端架构。它目前仍处于脚手架和功能验证阶段,但核心链路已经打通:三栏创作工作台、无限层级章节树、富文本自动保存、世界观上下文选择,以及 AI 正文的 SSE 流式生成。

这篇文章不讨论“怎样写出一个按钮”,而是从系统边界出发,解释每一层为什么存在、数据如何流动,以及这套方案继续扩大后会遇到什么。

先把编辑器当成创作系统#

工作台采用三栏结构:左侧管理小说和章节树,中间承载章节大纲与 Tiptap 富文本编辑器,右侧管理世界设定和 AI 上下文。用户看到的是一个页面,背后却同时运行着三种状态:

  • 界面临时状态:当前选中的作品、章节、展开节点、生成进度。
  • 可恢复的本地设置:模型地址、API Key、模型名、温度和最大 Token 数。
  • 服务端业务数据:作品、章节正文、目录顺序、摘要和世界观设定。

如果把它们全部塞进 React 组件,任意一次目录操作都会牵动整个页面。项目因此把视图、客户端状态、业务 API 和持久化拆开,让每一层只处理自己能确认的事实。

┌──────────────────────── Next.js / React ────────────────────────┐
│  章节树 ChapterTree   │  TiptapEditor   │  RightSidebar         │
└───────────────┬────────────────┬──────────────────┬─────────────┘
                │                │                  │
         ┌──────▼────────────────▼──────────────────▼──────┐
         │           Zustand editor-store                 │
         │  选择状态 / 乐观更新 / 保存快照 / 本地模型配置   │
         └──────┬──────────────────────────────┬───────────┘
                │ JSON API                     │ SSE
         ┌──────▼──────────────────────────────▼───────────┐
         │               Hono Worker                      │
         │  参数校验 / 业务路由 / 上下文组装 / 流代理       │
         └──────┬──────────────────────────────┬───────────┘
                │ Drizzle ORM                  │ OpenAI-compatible API
         ┌──────▼──────────┐           ┌───────▼───────────┐
         │ Cloudflare D1   │           │ 大模型服务         │
         └─────────────────┘           └───────────────────┘

这个分层的关键不是技术栈数量,而是两条路径互不混淆:普通编辑走可确认的 CRUD 与保存流程,AI 生成走长连接式的流式流程。两者最终都回到同一个章节正文状态。

前端:编辑状态不是数据库副本#

前端使用 Next.js App Router、React 和 TypeScript,Zustand 则承担工作台的领域状态。Store 中不仅保存 novelschapters 和当前选择,也保存每个章节最近一次成功落库的正文快照。

这个快照解决了自动保存最容易忽略的问题:编辑器每次触发 onChange,并不代表都要请求后端。工作台在正文变化后等待 900ms,再比较当前 HTML 与已保存快照;没有差异就不发送请求,有差异才进入 saving 状态。成功后更新快照,失败则保留正文并向界面暴露错误。

Tiptap onChange


更新 Zustand 中的章节正文

      ├── 900ms 内继续输入 ── 重置计时器


当前正文 === 保存快照? ── 是 ── 结束
      │ 否

PATCH 章节正文 ── 成功 ── 更新快照与保存时间

      └────────── 失败 ── 保留本地内容并显示错误

目录树的复杂度更高。章节既可以是正文,也可以是卷或分组节点;parentId 允许无限层级,orderIndex 决定同级顺序。拖拽时,前端先计算新树并立即渲染,再把归一化后的节点顺序提交给后端。如果请求失败,Store 恢复操作前的完整数组。这种乐观更新让拖拽保持跟手,同时给网络失败留下明确的回滚点。

删除也不是简单过滤一个节点。它需要先收集全部后代,清理对应的正文快照,重新选择仍存在的章节,再归一化兄弟节点顺序;服务端失败时,章节树、当前选择和快照必须一起恢复。也正因为这些状态必须原子变化,集中式 Store 比散落在组件里的多个 useState 更合适。

Tiptap:AI 和人工共享同一份文档#

正文编辑器使用 Tiptap。它提供结构化富文本能力,又保留了扩展选区菜单、命令和自定义节点的空间。当前章节正文以 HTML 保存,人工输入和 AI 生成最终都写入同一个 content 字段,避免维护“AI 草稿”和“正式正文”两套容易失同步的数据。

AI 返回的是纯文本 Token。工作台先累积文本,对特殊字符做 HTML 转义,再按段落转换为 <p><br />,最后更新当前章节。这样可以阻断模型输出直接成为不受信任 HTML,同时让用户在生成过程中看到正文持续进入编辑器。

当前实现每收到一个 Token 都会重新构造整段 HTML,适合验证链路,但不是最终形态。下一步更合理的做法是通过 Tiptap 自定义命令在光标位置增量插入内容,并把一次生成包装成单个事务,从而改善长文本性能、撤销体验和选区保持。

后端:Worker 同时是业务层和 AI 网关#

后端使用 Hono 运行在 Cloudflare Worker。它并非只把请求转发给模型,而是承担四项工作:

职责 实现方式 解决的问题
输入边界 Zod 校验作品 ID、大纲、模型参数和选中上下文 阻止无效数据进入数据库和模型请求
数据访问 Drizzle ORM 访问 D1 保持查询类型与表结构一致
上下文工程 查询作品、世界设定和前文摘要并组装系统提示词 让模型知道“正在写哪本书”
流式代理 读取上游数据流并转换为标准 SSE 事件 隐藏模型供应商差异,简化前端

将 AI 请求放在 Worker 而不是浏览器还有一个重要原因:浏览器只需要理解本项目的 /api/ai/generate 协议,不必理解每个上游服务的响应细节。Worker 统一写出 tokendoneerror 事件,也可以在未来集中加入鉴权、速率限制、用量统计和审计。

上下文工程:先检索,再拼提示词#

生成正文时,前端不会把整个数据库发给模型,而是提交 novelId、当前大纲、被用户启用的设定 ID,以及最近几个章节 ID。Worker 再用这些 ID 做有边界的查询:

  1. 根据 novelId 读取书名和简介。
  2. 只读取属于这本书、且被显式选中的世界设定。
  3. 只读取最近章节的标题与摘要,不直接塞入完整正文。
  4. 将写作约束、作品信息、背景设定、前文历史和本章任务组装为系统提示词。
  5. 把本章大纲作为用户消息发送给兼容 OpenAI Chat Completions 的模型。

这是一种轻量的检索增强:不需要向量数据库,也能控制上下文规模,并避免把无关设定送入每次请求。章节摘要是其中最重要的压缩层。长篇小说的完整正文很快会超过上下文窗口,而摘要可以让模型保留事件因果、人物状态和叙事连续性。

用户选中的设定 ID ─┐
最近章节 ID ────────┼─► D1 查询 ─► 上下文组装 ─► 模型请求
小说 ID + 本章大纲 ─┘                              │

编辑器 ◄─ Zustand ◄─ token 事件 ◄─ Hono SSE ◄─ 上游流

这里还做了一层兼容处理:部分 OpenAI 兼容服务并不完整支持 temperaturemax_tokens。当上游以 400 拒绝相关参数时,Worker 会用只包含模型、消息和 stream 的最小请求体重试。这个降级逻辑比让用户猜测供应商差异更可靠。

数据模型:用自引用关系承载章节树#

D1 中的核心表是 NovelsWorldSettingsChaptersUserSettingsChapters 通过 parent_id 自引用,因此卷、分组与章节可以共享同一棵树;删除作品时,章节和设定通过外键级联删除;删除父章节时,当前约束会把直接子节点的 parent_id 设为空。

这套模型足以支撑当前编辑器,但“删除子树”不能只依赖外键。业务层仍要显式确认所有后代,并决定是整体删除、提升子节点,还是拒绝存在内容的破坏性操作。前端已经按整棵子树处理,后端也必须保持同样的语义,否则刷新后会出现界面与数据库不一致。

另一个需要谨慎处理的字段是 UserSettings.apiKey。当前表结构可以保存 API 配置,但生产环境不应把第三方密钥以明文形式长期存入普通业务表。更稳妥的方案是让用户选择仅在本地保存,或使用服务端加密、密钥轮换和访问审计;如果平台提供自有模型额度,则完全不向客户端暴露供应商密钥。

两种一致性策略#

这套系统有两种不同的一致性要求:

  • 目录操作追求交互即时性,因此采用“本地先变更,服务端失败则回滚”的乐观策略。
  • 正文编辑追求不丢字,因此采用“本地始终保留,防抖提交,成功后更新快照”的保守策略。

AI 生成介于两者之间。Token 必须立即可见,但只有收到完成事件后才适合自动保存。若流在中途失败,当前实现会保留已经生成的部分,让作者决定继续编辑还是重新生成。相比自动清空,这更符合创作工具的风险偏好:网络失败不应该抹掉已经出现的文字。

从脚手架到可用产品,还缺什么#

当前仓库已经证明架构链路可行,但要进入多人或正式创作场景,还需要补齐几层能力:

  • 为请求增加用户身份、作品权限和速率限制。
  • 为章节写入增加版本号或 ETag,防止多标签页覆盖。
  • 建立章节历史和生成记录,让 AI 操作可以撤销与追溯。
  • 将摘要生成纳入保存流程,并允许作者手动校正摘要。
  • 对世界设定做结构化分类和冲突检测,而不只是文本拼接。
  • 用 Tiptap 扩展实现选区扩写、改写、润色和续写,并保持单次撤销语义。
  • 为 SSE 增加主动取消、超时、断线恢复和用量回传。
  • 把 API Key 从普通持久化路径中移出,建立明确的密钥安全策略。

其中最值得优先做的不是增加更多写作按钮,而是版本历史、并发保护和可取消生成。它们决定编辑器是否敢承载真正的长篇作品。

结语#

AI 小说编辑器的核心并不是“让模型写字”,而是维护一套可解释的创作上下文,并让人工编辑、数据库和模型输出在同一份章节文档上稳定协作。

AI Novel Copilot 选择了一条相对克制的路线:Next.js 负责交互工作台,Zustand 管理客户端领域状态,Tiptap 管理文档,Hono Worker 守住业务与模型边界,D1 和 Drizzle 承担结构化持久化,SSE 则把生成过程送回作者眼前。每个组件都不新奇,但它们在正确的边界上组合起来,才构成一个真正可继续演进的小说创作系统。

完整代码与后续进展可以在 GitHub 仓库 查看。

继续阅读

AI 工具链 · 2026-08-10 · 14 分钟

从 PDF 到可信回答:MindLink AI 的 RAG 全链路

拆解 MindLink AI 如何完成 PDF 解析、文本分块、Embedding、PGVector 检索、来源回传与流式回答,并分析这套 RAG 架构的取舍和演进方向。

阅读全文 →

AI 工具链 · 2026-05-23 · 4 分钟

AI 内容导出器:把 AI 回复变成可编辑、可分享、可归档的成品

小工具将 AI 输出快速预览并导出为 Word、PDF、PNG 或 JPG,降低人工排版成本。

阅读全文 →