用时间锚点管理故事世界状态:Nova Story 的架构设计

TL;DR — Nova Story 最核心的设计,不是“在写作工具旁边接了一个 LLM”,而是把小说项目本身建模成一个带世界状态的本地工作区:正文树负责承载创作结构,时间锚点负责切分故事状态,辅助资料按时间层叠加成快照,底层再用 bare Git repo + SQLite virtual workdir 管理当前态和历史态。这样一来,分支、提交、语义化 diff、以及项目感知 AI,才真正建立在同一套数据模型之上。


项目地址:Nova Story

一、我为什么没有把小说项目继续建模成“一组文档”

很多写作工具的默认前提,其实都一样:

小说项目 = 若干文档 + 若干目录。

这个前提在短篇写作里通常没问题,但只要进入长篇、系列或者世界观比较重的作品,它就会越来越吃力。

因为作者真正维护的东西,从来都不只是文档。

作者维护的是一个不断变化的故事世界

  • 某个角色在前期还活着,后期已经死亡;
  • 某个国家在前几章还是稳定秩序,后几章已经崩解;
  • 某条设定在“大战前”成立,到“大战后”可能就已经失效;
  • 某段正文依赖的是当时的世界状态,而不是项目里所有资料的全集。

如果这些变化没有被显式建模,写作过程就会越来越依赖作者的脑内记忆。人会混淆,AI 更会混淆。最后常见的问题就会出现:

  • 把已经废弃的设定继续当成有效事实;
  • 把战前资料带进战后章节;
  • 把“当前正在写的部分”与“整个项目的历史信息”混成一团。

Nova Story 从一开始想解决的,就不是“再做一个 Markdown 编辑器”,而是这个更底层的问题:

能不能把小说创作过程建模成一个可分支、可提交、可按故事状态切换上下文的本地工作区?

而这套架构的中心,就是我最后引入的概念:时间锚点(timeline point)


二、时间锚点到底是什么

时间锚点不是“章节编号”,也不是“剧情大纲目录”。

它更接近一个我在架构层刻意定义的概念:

故事世界状态发生重大变化的断面。

比如一部作品里,可能会有这样的时间锚点:

  • 原点:故事开始前,世界仍处于初始状态;
  • 锚点 A:王都陷落;
  • 锚点 B:主角失忆;
  • 锚点 C:新秩序建立。

这些锚点并不需要和章节一一对应。

一个锚点可以跨越多个章节、多个场景,因为它表达的不是“写到哪一章”,而是:

  • 这个故事世界当前处于什么状态;
  • 哪些设定仍然成立;
  • 哪些辅助资料在这个时刻对作者和 AI 是可见的。

这件事看起来只是概念设计,实际上决定了整个系统后面的存储结构、diff 方式,甚至 AI 的上下文注入方式。

2.1 为什么我不想把时间轴做成“章节标签”

如果时间线只是章节标签,那么它最终只会变成另一种目录结构。

但我真正想表达的是:

  • 一个世界状态可以对应多个章节;
  • 一个章节也不一定意味着世界状态变化;
  • 创作时需要切换的,不是“打开哪篇文档”,而是“当前处于哪个故事断面”。

所以在 Nova Story 里,时间锚点承担的是上下文切分机制,而不是排版辅助工具。

图 1:Nova Story 的核心领域模型



graph TD
  T0[Origin 原点] --> T1[锚点 A 王都陷落]
  T1 --> T2[锚点 B 主角失忆]
  T2 --> T3[锚点 C 新秩序建立]

  C1[正文节点 章节一] -.锚定.-> T1
  C2[正文节点 章节二] -.锚定.-> T2
  C3[正文节点 章节三] -.锚定.-> T3

  A0[辅助资料 Origin] --> A1[辅助资料在 T1 的增量]
  A1 --> A2[辅助资料在 T2 的增量]
  A2 --> A3[辅助资料在 T3 的增量]

这张图最重要的不是“箭头怎么连”,而是三件事:

  1. 正文节点锚定到时间点,而不是直接挂在一个平铺文档列表里;
  2. 辅助资料不是一份静态全量树,而是沿着时间线不断叠加的快照;
  3. 同一个时间锚点可以服务多个正文节点,因为它表达的是世界状态,而不是章节编号。

三、我把创作工作区拆成了三套核心数据

围绕时间锚点,Nova Story 的工作区最终稳定成了三套核心数据结构。

3.1 正文树:创作结构的承载体

正文在系统里不是一篇大文档,而是一棵树。

每个节点都可以代表一个章节、场景或者片段,节点之间有明确的:

  • 父子关系;
  • 同级顺序;
  • 标题;
  • 正文内容;
  • 锚定时间点。

这么做的原因很现实:

  • 长篇创作天然是结构化的;
  • 节点级移动、重排、拆分,比整篇文档编辑更接近真实写作过程;
  • 只有把内容做成结构化节点,后面的语义化 diff 和 AI 操作才有基础。

3.2 时间线:故事状态的有序链

时间线本身不是一个简单数组,而是一条有顺序关系的链。

每个时间点记录:

  • 唯一 ID;
  • 标签;
  • 描述;
  • 前一个时间点。

也就是说,系统不仅知道“有哪些时间点”,还知道它们的相对顺序。这让“重排时间锚点”变成了一个一等能力,而不是需要全量重写数据的特殊操作。

3.3 辅助资料:随时间演化的参考快照

辅助资料包括人物设定、组织关系、世界观说明、地点资料、规则笔记等。

但在 Nova Story 里,它们并不是一份简单的文件树,而是:

在原点与各个时间锚点上逐层叠加出来的可见快照。

这意味着系统回答的问题不再是:

  • “项目里有没有这份资料?”

而变成了:

  • “在当前时间断面下,这份资料是否可见、是否仍然成立?”

这就是我为什么说,时间锚点在这里不是 UI 概念,而是数据模型的中心。


四、底层为什么我最终选了 Git,而不是再造一套数据库

如果只看业务需求,最直观的做法其实是:

  • 用一套数据库存项目元数据;
  • 用一套表结构存正文树;
  • 再用一套表存时间线和辅助资料;
  • 需要历史时自己做 version table。

但我最后没有这么做。

Nova Story 的底层选择是:

  • 一个 bare Git repository 作为对象和历史内核;
  • 一个 SQLite virtual workdir 作为当前分支的可编辑工作区。

4.1 我想要的,不只是“能保存”,而是天然带历史语义

小说创作天然需要这些能力:

  • 分支:尝试不同剧情走向;
  • 提交:记录阶段性版本;
  • 历史:回看某个决策之前的状态;
  • 对比:知道这次到底改了什么;
  • 回滚:撤回错误修改。

如果这些能力本来就是系统核心,那最直接的方式其实就是:

不要把 Git 当成附属功能,而是把它当成存储内核。

4.2 当前态和历史态在这套架构里是分开的

在 Nova Story 里:

  • Git 对象库负责保存历史树、提交、refs;
  • VirtualWorkdir 负责保存当前分支的工作区状态;
  • 分支再通过 branch-map.json 映射到独立的 workdir key。

这比直接把一堆真实文件 checkout 到磁盘上更适合本地创作工作区,因为我真正需要的是:

  • 可编辑的当前态;
  • 可提交的快照;
  • 可追溯的历史;
  • 而不是一套必须和物理工作目录强绑定的文件布局。

4.3 连项目元数据和 AI chat,我也尽量放进同一存储模型里

Nova Story 里一些“看起来不像 Git”的数据,也没有额外再引入独立数据库,而是直接放进了自定义 refs:

  • 项目元数据:refs/novel-evolver/meta
  • AI 聊天记录:refs/novel-evolver/chats

这样做的好处是,项目相关状态尽量被收敛到了同一类存储语义之下,而不是散落在多个相互独立的数据系统里。

4.4 先把这件事说清楚:Nova Story 不是“把 Git UI 化”

这里很容易产生一个误解:既然底层是 Git,那 Nova Story 是不是本质上只是一个给作者用的 Git 客户端皮肤?

我的答案是否定的。

更准确的说法应该是:

我不是把原生 Git 工作流直接暴露给作者,而是把 Git 当成内部状态机,再在上面建立一层面向创作的领域模型。

作者在界面里看到的是:

  • 项目;
  • 分支;
  • workspace;
  • 提交;
  • 正文树;
  • 时间锚点;
  • 辅助资料快照。

而不是:

  • .git 目录;
  • detached HEAD;
  • staging area;
  • rebase / stash / cherry-pick 这一类底层概念。

也就是说,Git 在这里承担的是历史对象存储与版本演化引擎,但 Nova Story 对作者暴露的是一套更贴近创作心智模型的控制面。

这也是我为什么坚持用 bare repo + virtual workdir 这套组合,而不是直接让系统操作一个真实 checkout 出来的工作目录。

图 2:Nova Story 的底层存储分层



graph TD
  UI[React 工作区 / 项目工作台 / AI 面板] --> RPC[RPC 与 Chat API]
  RPC --> DOMAIN[Workspace / Projects / AI Domain]

  DOMAIN --> REPO[Bare Git Repository]
  DOMAIN --> WD[SQLite VirtualWorkdir]

  REPO --> META[refs/novel-evolver/meta]
  REPO --> CHATS[refs/novel-evolver/chats]
  REPO --> COMMITS[commits / trees / refs heads]

  WD --> STATE[index.jsonl]
  WD --> TL[timeline.jsonl]
  WD --> MS[manuscript/*.md]
  WD --> AUX[aux/origin + aux/timeline/*]

这张图里最值得注意的是中间那一层分工:

  • Git 负责历史和对象持久化;
  • VirtualWorkdir 负责当前可编辑状态;
  • 领域层负责把这两者翻译成“正文树 / 时间线 / 辅助资料”这样的创作语义。

也正因为有这层分工,UI 和 AI 才不需要直接理解底层对象树,它们只需要理解领域模型。

为了把这个结构说得更具体一点,我现在的仓库外围组织大致长这样:

1
2
3
4
5
6
7
<projectId>.git/
HEAD
refs/heads/<branch>
refs/novel-evolver/meta
refs/novel-evolver/chats
branch-map.json
workdir.db

4.5 bare Git repo 里到底放了什么

先说最重要的一点:这里的仓库是 bare repo

也就是说,它本身不直接提供一个给作者编辑的真实工作目录。它负责的是:

  • 保存 commit / tree / blob;
  • 维护 refs/heads/* 这样的分支引用;
  • 保存项目级自定义 refs;
  • 作为所有工作区的共同对象库。

在这套结构里:

  • refs/heads/<branch> 指向真正的提交历史;
  • refs/novel-evolver/meta 用来保存项目元数据;
  • refs/novel-evolver/chats 用来保存 AI 对话相关文件;
  • branch-map.json 负责把分支名映射到一个不依赖分支名的 workdirKey
  • workdir.db 则是当前可编辑状态所在的 SQLite virtual workdir 存储。

这里还有一个我觉得挺有意思的工程细节:

metachats 这两个自定义 ref,并不是普通业务表,也不是另一套数据库,而是直接挂了一棵树,用来存像 project.jsonchat-list.jsonmessages/<chatId>.jsonl 这样的文件。这让“项目附属状态”也尽量复用了 Git 的树对象语义。

4.6 真正可编辑的工作区,其实是 SQLite 里的一个虚拟文件系统

作者真正编辑的,不是 bare repo 本身,而是某个分支对应的 VirtualWorkdir

从实现上看,这个 workdir 对外表现得像一个文件系统:

  • 可以 readFile / writeFile
  • 可以 mkdir / delete
  • 可以列目录;
  • 可以对当前状态做 diff;
  • 可以把当前状态写成一棵 Git tree。

但这些操作的落点并不是磁盘上一堆散落的真实文件,而是 workdir.db 里的 SQLite 持久层。

我之所以选择这条路,而不是给每个分支都建一个真实目录,主要是因为它能同时满足几件事:

  1. 当前态是可编辑的;
  2. 历史态仍然由 Git 对象库统一管理;
  3. 分支切换和 checkout 不需要真的在磁盘上搬运大量文件;
  4. 多个分支可以共享同一个对象库,但各自维护独立的当前工作区。

4.7 工作区里的文件为什么长这样

写到这里,工作区内部的文件布局其实就能解释清楚了:

1
2
3
4
5
index.jsonl
timeline.jsonl
manuscript/<nodeId>.md
aux/origin/**
aux/timeline/<pointId>/**

这里我没有把全部状态粗暴地塞进一个大 JSON,而是拆成了几类更贴近语义的文件:

  • index.jsonl:正文树的结构索引,记录 id / parentId / title / anchorTimelinePointId
  • manuscript/<nodeId>.md:每个正文节点自己的正文内容;
  • timeline.jsonl:时间锚点链;
  • aux/originaux/timeline/<pointId>:辅助资料的原点层和时间增量层。

这样做有几个好处:

  • 正文结构变化和正文内容变化可以分开处理;
  • 单节点正文修改不会把整棵树重写成一个巨大的 blob;
  • diff 时更容易判断“这是结构变化,还是正文内容变化”;
  • AI 读取某个正文节点或某个时间点时,也更容易只装配自己真正需要的那部分上下文。

4.8 一次 commit 和一次 checkout,内部到底发生了什么

如果把整个过程再说“工程一点”,一条分支上的一次编辑大概是这样流动的:

1
2
3
4
5
6
编辑正文/时间线/辅助资料
-> 写入 VirtualWorkdir 当前文件视图
-> 需要提交时,把当前 workdir 写成 Git tree
-> 用这个 tree 创建 commit
-> 更新 refs/heads/<branch>
-> 再把 workdir reset 到这个新 tree

换成更接近实现的伪代码,大致就是:

1
2
3
4
const treeHash = workdir.writeTree()
const commitHash = repo.createCommit(treeHash, parents, message, author)
repo.updateRef(`refs/heads/${branchName}`, commitHash)
workdir.reset(treeHash)

而 checkout 到某个历史提交时,逻辑也不是在真实文件夹里执行 git checkout,而是:

  • 找到目标 commit 的 tree;
  • 把对应分支的 VirtualWorkdir 基线切到这棵 tree;
  • 然后让领域层重新从 workdir 里读出正文树、时间线和辅助资料。

这件事的结果是:

Git 负责“历史上发生过什么”,VirtualWorkdir 负责“我现在正在编辑什么”。

这两个问题在 Nova Story 里是被明确拆开的,而不是混在一起的。


五、辅助资料为什么必须做成 overlay,而不是“当前唯一真相”

时间锚点真正发挥作用的地方,是辅助资料。

我没有把它设计成“一棵始终只有当前状态的文件树”,而是设计成了一个分层叠加模型

  • aux/origin:故事开始前的初始设定;
  • aux/timeline/<pointId>:某个时间点新增或修改的资料层。

当系统要读取某个时间点的辅助资料快照时,不是直接打开一个目录,而是会按顺序把这些层叠起来。

5.1 为什么这里我用的是 overlay 思路

因为我真正想表达的不是“文件覆盖”,而是:

  • 哪份资料从什么时候开始成立;
  • 哪份资料从什么时候开始失效;
  • 同一路径在不同时间点下看到的内容能不能不同。

如果只是简单覆盖,就很难回答“历史上这份资料什么时候被改掉了”。

如果直接保留多份独立快照,又会让写入、diff 和回滚的成本迅速膨胀。

overlay 处在一个刚好合适的位置上:

  • 存储的是增量;
  • 读取时再构造快照;
  • 时间断面的概念天然成立。

5.2 whiteout 是这套模型里很关键但容易被忽略的一环

删除在这套模型里不能只是“物理删掉文件”。

因为一旦真的物理删除,历史就会被破坏。某份资料也就失去了“它曾经存在过,但从某个时间点开始失效”的表达能力。

所以 Nova Story 在辅助资料层里用了 whiteout 语义。

它表达的是:

不是“这个文件从来不存在”,而是“从这个时间点开始,这个路径不再可见”。

这是一个非常重要的差别。

前者描述的是物理存储,后者描述的是故事世界状态。

而 Nova Story 想保存的,恰恰是后者。

5.3 这样一来,AI 读到的也不再是“资料全集”

当我把当前时间点切到某个锚点时,系统实际做的是:

1
2
目标时间点 = T2
可见快照 = Origin 层 + T1 层 + T2 层 - whiteout 删除项

这件事的价值在 AI 参与写作时会被进一步放大。

因为模型拿到的上下文不再是“项目里所有可能相关的设定”,而是当前这个故事断面下真正可见、真正成立的设定

这比单纯做一个全量知识库检索要更贴近创作过程。对作者来说,这减少了信息污染;对 AI 来说,这减少了把错误世界状态带进当前章节的概率。


六、为什么我还专门做了语义化 diff 和 revert

如果系统底层已经是 Git,那一个很自然的问题就是:

直接展示 Git diff 不就够了吗?

答案是:对机器够,对作者不够。

Git 能非常准确地告诉我:

  • index.jsonl 变了;
  • timeline.jsonl 变了;
  • 某个 manuscript/<id>.md 变了;
  • 某个 aux/timeline/<pointId>/... 路径变了。

但作者真正关心的不是这些底层文件名,而是:

  • 哪个章节被移动了;
  • 哪个章节的锚定时间点变了;
  • 哪个时间锚点被重排了;
  • 哪份辅助资料从哪个时间点开始被删除了。

所以在 Git 之上,我又做了一层领域语义翻译。

6.1 语义化 diff 不是替代 Git,而是把 Git 翻译回创作语言

在 Nova Story 里,工作区状态会被拆成三个 area:

area 底层变化 面向作者的语义
content index.jsonl / manuscript/*.md 章节新增、删除、重排、改标题、改正文、改锚点
timeline timeline.jsonl 时间点新增、删除、改名、改描述、改顺序
aux aux/origin / aux/timeline/* 某份辅助资料在哪个时间点新增、修改、删除或失效

也就是说,Git 负责给我“真实差异”,领域层再把它解释成“创作差异”。

6.2 这样做之后,单项 revert 才真正成立

只有当系统知道“这是章节顺序变化”而不是“某个 JSONL 行改了”,它才能有意义地支持:

  • 撤回单个正文节点的修改;
  • 撤回某个时间点的改动;
  • 撤回某条辅助资料路径在当前工作区里的变更。

这一步对我来说非常重要,因为我不想让作者面对的是一个抽象的版本控制系统,而是一个能用创作语言解释变化的工作区。

换句话说,Git 在底层是事实来源,但作者最终看到的必须是领域语义。


七、AI 在这套架构里不是外挂,而是运行时的一部分

很多“AI 写作工具”的典型形态,其实都差不多:

  • 左边一个编辑器;
  • 右边一个聊天框;
  • 中间靠 prompt 拼接上下文。

Nova Story 不是这个思路。

在我的设计里,AI 不是拿到一大段字符串然后自由发挥,而是运行在这套项目模型之上。它能读取和操作的对象,天然就是:

  • 正文树;
  • 时间线;
  • 当前时间点可见的辅助资料;
  • 当前编辑器上下文。

7.1 上下文不是猜的,而是由编辑器状态显式注入的

AI 运行时会拿到当前编辑器的真实上下文,例如:

  • 当前正在编辑的正文节点;
  • 当前打开的辅助资料路径;
  • 当前时间锚点;
  • 当前时间锚点标签。

这意味着模型不再需要靠 prompt 去猜“我现在到底在写什么”,而是直接拿到当前工作区的事实状态。

7.2 AI 也不是直接写文件,而是通过工具操作领域对象

Nova Story 给 AI 注册的是一组项目工具,而不是一堆裸文件接口。它可以:

  • 读取或修改正文节点;
  • 创建、修改、移动时间锚点;
  • 在当前时间断面下读写辅助资料;
  • 在必须的时候向作者发起结构化追问。

这件事的意义在于,AI 修改的不是“某个路径下的某个文本文件”,而是正文树、时间线和辅助资料快照本身

7.3 工具结果还会直接变成 UI 刷新事件

在实现上,AI 工具调用完成后,服务端不会只是返回一段文本,而是会根据工具结果发出工作区刷新事件,让前端知道:

  • 应该刷新正文树;
  • 还是刷新时间线;
  • 还是刷新当前辅助资料;
  • 是否需要自动切换到新的时间点或新创建的节点。

7.4 如果把 AI 上下文注入过程说得再工程一点

这部分如果只写成“系统会把编辑器上下文传给模型”,其实还不够具体。

Nova Story 真正做的事情,接近下面这条流水线:

1
2
3
4
5
6
当前可见聊天分支
+ 系统提示词
+ 当前编辑器快照
+ 用户通过 @ 引用的 prompt 快照
+ 当前请求允许使用的工具集合
-> 组装成 model messages

其中最关键的是“当前编辑器快照”。它不是一段模糊描述,而是一份结构化状态,里面至少会包含:

  • 当前 workspaceId;
  • 当前激活的正文节点 ID 与标题;
  • 当前激活的辅助资料路径;
  • 当前时间锚点 ID;
  • 当前时间锚点标签。

服务端在真正调用模型前,会把这份快照整理成一条额外的上下文消息。也就是说,模型最终收到的并不是“请根据当前章节继续写”,而更接近:

1
当前编辑器:正文节点 id=...;时间锚点 id=...,label=...

如果用户在输入框里通过 @ 引用了全局 Prompt,这些引用也不会只保留成纯文本,而是会先固化成一份 snapshot,再一起注入到本轮模型消息里。这样做的目的,是避免 Prompt 在后续被改名或改内容后,影响已经发生过的对话语义。

7.5 还有一个很关键的细节:时间锚点切换可以在同一轮工具调用里生效

如果只是把“当前时间点”作为只读上下文传进去,那模型一旦调用 set_current_timeline,后续工具读取还是会看到旧状态。这显然不对。

所以在实现里,我给工具运行时维护了一份可更新的上下文快照。

这意味着同一轮推理里可以出现这样的链路:

1
2
3
4
先调用 set_current_timeline 切到某个时间点
-> 运行时上下文立即更新
-> 紧接着调用 read_file / write_file
-> 这些工具已经基于新的时间断面工作

这件事很小,但我觉得它特别能体现“AI 不是外挂聊天框,而是工作区运行时的一部分”。因为模型不是在回合之间被动等前端切状态,而是在同一轮工具执行里就真正改变了自己之后看到的项目上下文。

图 3:AI 与工作区的数据流闭环



sequenceDiagram
  participant U as 作者
  participant UI as 编辑器 / AI 面板
  participant API as Chat API
  participant M as 模型
  participant T as Assistant Tools
  participant W as Workspace Domain

  U->>UI: 发送写作或修改请求
  UI->>API: 携带当前编辑器上下文
  API->>M: 注入系统提示词 + 项目上下文
  M->>T: 调用正文/时间线/辅助资料工具
  T->>W: 读写领域对象
  W-->>T: 返回结构化结果
  T-->>API: 工具输出 + 刷新事件
  API-->>UI: 流式文本 + workspace refresh event
  UI->>UI: 局部 refetch 并更新选中状态

这张图真正想说明的是:

  • AI 不再是旁边悬着的聊天框;
  • 它已经进入了工作区运行时;
  • 它的写入结果会回到领域层,再驱动 UI 状态同步。

顺带一提,我连聊天本身都做了分叉路径支持。原因也很简单:创作探索本身就是分叉的,AI 对话也不例外。


八、为什么前端和服务层在这个项目里刻意保持得比较薄

Nova Story 的技术栈本身并不复杂:

  • 服务端入口用 Bun.serve()
  • 通过 RPC 暴露项目、工作区和配置能力;
  • 单独提供 chat 接口承接流式 AI 输出;
  • 前端用 React 组织项目列表、工作台、工作区和 AI 设置;
  • 主编辑区统一用 CodeMirror;
  • AI 输入区用 Lexical 支持 @prompt mention。

这些技术选型当然重要,但在这个项目里,我刻意不让它们成为架构主角。

因为我越来越明确地觉得:

Nova Story 的决定性复杂度,不在前端框架,也不在 HTTP 层,而在领域模型是否成立。

如果“正文树 + 时间锚点 + 辅助资料 overlay”这套模型是对的,那么:

  • UI 只是它的呈现层;
  • RPC 只是它的分发层;
  • AI 只是它的运行时扩展。

反过来,如果这套模型本身不成立,那么前端再漂亮、AI 再聪明,也只是在一套不稳定的数据基础上叠能力。


九、这套架构目前让我最满意的,和我最克制的地方

回头看现在这版 Nova Story,我最满意的地方其实不是“功能已经很多”,而是这几个关键前提已经被立住了:

  1. 小说项目不再只是文档集合,而是有世界状态的工作区;
  2. 时间锚点已经进入数据模型,而不是停留在 UI 概念;
  3. Git、工作区、语义化 diff 和 AI 已经被放进同一条架构主线上。

同时,我也很清楚这套系统现在仍然有一些刻意保守的地方。

比如:

  • 当前历史视图仍然更偏单分支工作流,而不是完整的多泳道 Git 图谱;
  • 一些更重的版本控制操作还没有全部开放到 UI;
  • 更丰富的协作能力也还在后面。

但我现在反而觉得,这种克制是必要的。

因为在这种系统里,最不应该着急追求的,是“功能面铺满”。最应该先做对的,是数据模型和状态边界。

从工程验证上看,这条路线目前也已经具备了比较稳的基础:

  • bun test:361 个测试通过;
  • bunx tsc --noEmit:通过;
  • bun run lint:通过;
  • bun run build:通过。

对一个状态层级比较多、涉及版本控制和 AI 写入的本地系统来说,这种可验证性不是锦上添花,而是能不能继续演进的前提。


十、最后总结一下:时间锚点不是附加功能,而是架构的中心

如果只用一句话总结这篇文章,我会这样描述 Nova Story:

它不是把小说写作当成“编辑一堆 Markdown 文件”,而是把它建模成“一个可分支、可提交、可按故事时间切换上下文的本地创作系统”。

在这套系统里,时间锚点承担的不是装饰性的标签功能,而是整个世界状态模型的中心:

  • 正文节点通过它获得明确的世界状态坐标;
  • 辅助资料通过它变成可按时间切换的参考快照;
  • AI 通过它拿到正确的上下文边界;
  • diff 和 revert 也因此可以提升到创作语义层。

对我来说,这也是 Nova Story 和“普通写作工具 + 聊天框”最本质的区别。

不是先做一个 AI,再想办法把小说塞进去;
而是先把小说项目建模成一个有世界状态的系统,再让 AI 在这套系统上工作。

如果后面我还要继续扩展它,我也大概率不会先从 UI 特效或者 prompt 技巧开始,而是继续沿着这条主线往下走:

  • 更清晰的状态边界;
  • 更强的版本控制表达;
  • 更可靠的项目感知 AI。

因为对小说创作来说,真正重要的从来不是“我现在打开了哪篇文档”,而是:

我现在所写的这一段,处于哪个故事世界状态之中。