从一段课堂录音开始:OpenNexus 如何长成一套本地 AI 知识工作台

一节课结束后,真正让人头疼的往往不是“有没有录音”,而是录音之后怎么办。

十几分钟甚至一两个小时的音频躺在文件夹里。想复习时,只能拖着进度条来回找;自动转写虽然省下了听写时间,却通常只留下一大段未经整理的文字;如果再让聊天模型做摘要,结果又很容易脱离原文,或者过几天便找不到它和哪段课程内容有关。

OpenNexus 就从这个很具体的问题出发:能不能把真实课程录音变成一份仍然可以核对、继续编辑、参与检索的笔记?进一步说,这份笔记能不能进入个人知识库,被 Agent 用来制定学习计划、拆分任务,又不迫使用户把整个资料库交给某个在线平台?

做到 0.5.2-alpha1 时,项目已经不再只是“带 AI 的 Markdown 编辑器”。它更像一套本地优先的知识工作台:Markdown Vault 是原始资料,SQLite 维护可重建的索引和运行状态,桌面 Host 管理文件、凭据和进程,AI Core 负责转写、检索、Agent 与模型适配,可选的 Sync Server 只处理同步。Skill、Plugin、主题和 MCP 则让能力可以继续生长。

这篇文章不是功能清单。我想沿着项目真正走过的路径,讲清楚它为什么这样设计,以及 49 个 Pull Request、44 条审阅评论和一连串失败用例,怎样一点点改变了实现。

说明:文中的测试数字是对应 PR 审阅时的历史快照,不是可以累加的“总测试数”。开发文档也记录了不同阶段的状态;涉及当前行为时,以 0.5.2-alpha1 的代码和发布说明为准。

笔记属于用户

OpenNexus 把 Markdown 文件和附件视为用户数据,而不是数据库的附属物。即使 AI Core 停止运行,用户仍然可以用其他编辑器打开 Vault。SQLite 保存的是结构化投影、全文索引、向量、会话、任务和运行记录;这些数据重要,但原则上可以重建。

这一区分直接决定了系统边界:

前端不直接拿模型密钥,也不直接操作本地数据库。Rust Host 负责桌面权限、系统对话框、凭据、文件系统和子进程监督;Python AI Core 作为独立进程运行,通过本机受控通道提供 RAG、Agent、媒体处理和导出服务。未配置同步服务器时,编辑、搜索、转写和 Agent 仍然可以在本机完成。

“本地优先”并不等于“绝不联网”。用户可以选择远程模型,也可以开启多设备同步。关键在于:联网是一项可见的、可撤销的配置,而不是应用的默认生存条件。

录音转笔记,难点不在转写按钮

最初很容易把这项功能理解成一条直线:上传音频,调用 ASR,保存文本。真实课程录音很快让这种想法失效。

课程录音里会有停顿、回头补充、口误、板书说明,也可能出现教师与学生交替发言。转写文本需要保留时间戳和片段,用户才能回听校对;说话人聚类只能表示“声音簇”,不能冒充真实身份识别;从转录稿生成知识点笔记时,还要保留原始稿,而不是让一次模型生成覆盖唯一证据。

现在的流程更接近下面这样:

本地音频链路使用固定版本的 ASR 与声纹组件,CPU 是默认路径,CUDA 是显式选装。这里有两个刻意保留的限制:第一,说话人编号不是身份判断;第二,没有参考转写或人工标注时,系统不会宣称一个看似精确的字错率或人数。

转录稿和知识点笔记是两份不同的材料。前者便于逐段核对,后者根据内容组织概念、步骤和例题。遇到算法课程,笔记可以附上代码;关系复杂时可以生成 Mermaid;涉及函数变化时可以使用 function-plot。工具只有在内容需要时才出现,不能为了“看起来像 AI”给每篇笔记塞一张图。

这个流程后来又补上两道保护。

一是本地限定。仅本地处理的媒体笔记会把 embedding_local_only 策略写进 frontmatter。它不是一次请求里的临时开关,而会随着 Markdown 留在 Vault 中,后续重建索引时仍然生效。开发审阅曾经发现,带注释的 YAML、BOM、未闭合 frontmatter 和普通分割线会让策略被误读。最终实现统一了解析边界:非法策略明确拒绝,普通 --- 仍可作为正文分割线,不能因为解析失败就悄悄走远程 Embedding。

二是安全更新。网络响应丢失后重复上传,不应产生两份附件和两项任务;用户已经改过生成的笔记时,新一轮转写也不能静默覆盖。为此,上传和任务使用幂等键,笔记更新保存正文摘要作为基线,发生冲突时要求用户选择保留现有内容或另建笔记。

这部分工作的价值不只在于“转得出来”,而在于它终于形成了闭环:原音频能回听,转录能修订,笔记能追溯,修改能索引,冲突也有去处。

Agent 不是另一个聊天框

OpenNexus 的第二条主线是个人规划 Agent。它面对的不是一句问答,而是一项会持续一段时间的目标,例如整理一门课的复习范围、把若干知识点拆成学习任务,或根据已有笔记安排阶段计划。

Agent Runtime 保存一次运行的状态、步骤、预算和事件。模型可以提出 Tool Call,但工具是否能执行取决于 Tool Registry、Skill 声明、Plugin 权限和用户授权。检索到的笔记只是上下文,不能从笔记正文里“读出”新的权限。

Trace 不是界面临时拼出来的动画。agent_runs 保存运行,agent_events 保存按序事件;前端按 run_id + sequence 去重和回放,SSE 中断后可以继续。一次模型调用、工具请求、权限等待、工具结果和错误都能在时间线上找到。

这项设计也经历过一次很典型的审阅。早期前端根据事件出现的先后,把工具节点挂到“最近一次模型调用”下面;真实后端却可能先发出 ModelCallCompleted,再执行工具,并通过 parent_model_call_id 表示归属。正常演示看不出问题,一旦并发或断线回放,Trace 就会建错树。修复后,节点关系完全依据稳定 ID,而不是对事件相邻顺序的猜测。

任务系统与 Agent 也不共用一套含糊的“正在运行”状态。取消、超时、最大步骤、Token Budget 和容量裁剪都有明确边界。压力测试专门验证了取消、故障注入、SSE 回放和任务列表,而不是只看一条成功轨迹。

检索系统真正要守住的是一致性

本地知识库采用全文与向量双路检索。课程名、代码标识符和术语交给 FTS5;自然语言和同义表达交给向量检索;两路候选通过 RRF 融合,再做轻量重排,最后返回带来源的 Block。

数据库的核心关系可以简化为:

第一轮检索 PR 的审阅几乎把实现推倒重来。folder 参数可以借助 .. 越过 Vault;名为 upsert 的向量写入其实只是普通 INSERT;修改正文后旧向量没有删除;固定取前 50 条再过滤,让第 51 条之后的合法结果永远消失;重建索引会先清空旧数据,中途失败便留下半成品。

这些问题有一个共同点:每个局部函数都“像是能用”,但系统状态并不可信。

修复没有停在输入校验。路径在字符串检查后还要 resolve,并确认仍位于 Vault;notes、blocks、FTS、向量和 index_meta 共用事务;更新时比较新旧 Block ID,删除失效向量;创建同名笔记返回冲突;删除文件则先改名为 tombstone,再提交数据库事务,失败时恢复。全文检索的过滤、计数与分页被下推到 SQL,而不是把候选上限从 50 改成 1000 后假装问题消失。

向量模型也不是可以随意替换的黑盒。不同模型、revision 和维度属于不同向量空间。项目把空间身份写进索引,普通笔记和仅本地笔记可以按策略分区,各自在自己的空间中检索,再融合名次,而不是直接比较来自两个模型的余弦分数。模型切换发生在异步推理期间时,请求会冻结自己的模型与设备快照,避免“旧模型生成的向量被登记成新模型”。

Skill、Plugin、MCP 和社区为什么要分开

扩展系统里最容易出现的误解,是把所有可安装内容都叫“插件”。OpenNexus 有意把它们拆成几类:

类型

解决的问题

是否带来新的可执行能力

Skill

告诉 Agent 如何完成一类工作,组合提示词、工具和检索策略

Plugin

注册工具、命令、设置项或导入导出能力

MCP Server

通过标准协议连接外部工具服务

Theme

改变工作区和 Markdown 的视觉表现

例如,“课程笔记重写”可以是 Skill:它规定如何读取转录、如何组织知识点、何时使用图表。真正读取某个外部系统的能力则应由 Plugin 或 MCP 提供。Skill 引用了不存在的工具时,安装状态会显示缺少依赖,而不是运行到一半才报一个模糊错误。

社区原型承担发现、版本、清单和审核信息的展示,但安装决定仍在桌面 Host。包会先暂存和校验,用户可以看到权限、依赖和可执行入口,再确认安装。扩展事务记录安装前后状态,失败能够回滚。秘密设置只保存凭据引用,不进入 Manifest、前端状态或 Agent Trace。

审阅对主题包也留下了很有用的一课。早期版本安装主题时会立刻把 CSS 注入页面,即使主题没有启用,body:root 规则也可能污染当前界面;保存插件设置时,响应返回还会错误地清除用户在等待期间的新编辑。修复后,只有当前主题的样式会挂载,预览运行在隔离环境中;设置保存使用提交快照和编辑版本判断,迟到响应不能覆盖新输入。

MCP 也没有被强行塞进 AI Core。开发阶段支持独立的 stdio、Streamable HTTP 和兼容 HTTP+SSE 连接管理;在生产环境里,未经沙箱和信任确认的 stdio 进程不能因为“配置成功”就自动获得运行资格。进程独立、环境变量裁剪和工具权限是必要条件,但它们不能冒充操作系统级沙箱。

同步只同步该同步的东西

Sync Server 是可选基础设施,不承载 RAG、Agent 或本地模型。桌面端保存文件身份、操作日志、outbox、同步游标和冲突状态;服务端使用 PostgreSQL 管理用户、设备、Vault、修订、对象和配额,大对象进入 S3 兼容存储。

密码、权限和本机路径不属于同步内容。可选同步范围可以包含布局、对话、已完成的 Agent 历史、Provider 通用参数和扩展安装清单,但不会同步 Provider 密钥;扩展在另一台设备上仍需要重新下载和授权。双边同时修改同一文件时,系统先预览冲突,不把“最后写入者获胜”包装成智能合并。

这也是为什么 Sync Server、主程序和社区原型在公开开发时可以分仓库维护,而发布镜像仍可保留单仓库:它们的部署边界不同,但一个演示版本需要从同一修订复现。

导出功能:一次次“差一点就好了”

如果要选一个最能代表项目工程过程的模块,我会选导出。

把 Markdown 导出成 HTML 看起来并不难。第一次审阅很快发现:链接只做了 HTML 转义,没有过滤 javascript:;图片 AST 字段映射错了;原始 HTML 降级时正文被静默丢弃;过期任务删了状态却没有删产物;渲染阻塞事件循环,运行中取消无法生效;项目约定的 function-plot 围栏也没有被识别。

随后加入 PDF、DOCX 和函数图像,新的边界又出现了:浮点刻度可能陷入死循环,复杂表达式可以占满线程,函数数量没有上限,引用块正文和嵌套列表顺序会丢失,列表里的链接和强调样式消失,等待渲染槽位的任务无法及时取消。

函数图进入 PDF 后,曲线超出绘图区、纵轴标签被旋转到画布外、渐近线被错误连接成竖线。为修渐近线加入的判断,又会误删一条正常但陡峭的连续曲线。

这些不是“改一个 if”就结束的零散 bug。最终方案把 Markdown 先转换成稳定的 Document AST,HTML、PDF、DOCX 分别消费同一语义结构;导出任务使用后台队列、取消和产物生命周期;链接协议、资源大小、函数数量、采样和组合复杂度都有预算;曲线使用自适应细分和裁剪,不能只靠固定采样点猜测是否连续。

PR #17 到 #43 之间多次出现“请求修改—补测试—复审—再发现边界”的循环。它看起来比一次合并慢,却换来了一个更重要的结果:导出不再是演示时偶尔成功的按钮,而是一条有错误码、资源上限、回归样例和保存路径的工程链路。

PR 审阅改变了哪些东西

历史上共有 49 个 PR,其中 31 个合并,18 个关闭但未直接合并;37 个 PR 留下讨论,共 44 条审阅或跟进评论。未合并不等于无效,很多分支承担了一轮修复或复审,最终由后续整合 PR 带入主线。

把所有审阅记录摊开后,可以看到几个反复出现的主题:

  • 成功响应之前发生的每一步,是否都在同一事务或补偿流程里;

  • 取消、断线、重试和进程重启后,状态是否仍然可解释;

  • API 接受的字段是否真的有语义,而不是“先收下以后再说”;

  • 前端展示的状态,是否来自真实后端事实;

  • 扩展、链接、路径和凭据是否在正确的信任边界内;

  • 性能优化有没有用真实长文、批量向量和并发任务复测;

  • 测试通过之后,是否还主动构造了失败路径。

审阅里最有价值的做法是故障注入。让向量删除故意失败,才能知道删除笔记会不会留下孤儿数据;让 index_meta 写入失败,才能发现主事务之外仍有部分提交;让旧请求晚于新编辑返回,才能复现界面状态被覆盖;把渐近点放在两个采样点之间,才能看到图像里那条并不存在的竖线。

到了首个完整 Alpha 候选的审阅,后端 935 项、前端 544 项、Sync Server 30 项测试通过,Rust 全目标测试、Clippy、TypeScript、Vite 和 Tauri Release 构建也完成。这个数字并不表示软件已经没有 bug。它更像一个阶段标记:项目终于有足够多的自动化约束,让后续修复不必每次从“希望没弄坏别处”开始。

性能优化不该从动画开关开始

长文卡顿最初很像一个前端问题,真正测量后却分成了几层:编辑器装饰计算、Mermaid 与代码高亮的加载、滚动定位、后台索引、向量结果传输,以及任务日志对主线程的影响。

项目采用按需加载、编辑器依赖分块、可视区相关计算、缓存高亮结果和后台索引,避免打开长文时把所有重工作同时挤到首屏。后来又修过一次“大批量本地向量传输”:模型本身并不慢,真正的压力来自跨进程搬运过大的结果。性能报告因此同时保存基线与修复后的数据,而不是只留下一个“已优化”的结论。

同样的原则也用于模型运行。CUDA 失败时只对初始化失败和显存不足做一次受控的 CPU 回退,普通模型错误不会无限重试;诊断记录请求设备、实际设备和耗时,但不把未知设备伪装成成功使用的 GPU。安装 CUDA 组件也独立于默认 CPU 环境,避免一次可选加速破坏基础可用性。

为什么 OpenNexus 不是“套壳调用模型”

如果只看界面,很多能力都能被描述成一个按钮:转写、生成笔记、聊天、规划、导出、同步。真正把这些按钮连成产品的是按钮背后的约束。

录音保留时间片段与修订;检索返回带引用的 Block;Agent 在 Tool、权限和 Trace 中运行;扩展经过暂存、审阅、授权和事务安装;同步依据文件身份、修订和冲突处理。模型可以更换,知识库的所有权不跟着 Provider 走。

这套做法的代价很明显:开发速度不会像拼接几个 API 那样快。一次看似简单的功能常常要补协议、状态机、错误码、恢复路径和测试。可它也带来一个更实际的好处:当桌面端、AI Core、模型或网络其中一层失败时,用户至少还能知道发生了什么,哪些数据已经写入,下一步应该重试、回滚还是换一种处理方式。

0.5.2-alpha1 之后,还欠哪些工作

Alpha 标签需要被认真对待。当前版本已经能完整演示课程录音转笔记、Agent 个人规划、扩展社区、主题、MCP、多格式导出和远程同步,但仍有几项工作不应被宣传文案掩盖:

  • 真实长录音、重叠语音和不同设备环境仍需要更广泛的质量样本;

  • 本地模型下载、CUDA 组件和磁盘占用需要更清晰的安装前预估;

  • 扩展生态在接纳不可信第三方包之前,还要继续强化签名、来源验证和平台沙箱;

  • 同步需要更多断网、时钟偏差、超大附件和多端冲突的长期运行验证;

  • PDF、DOCX、Mermaid 与函数图的组合文档仍应保留视觉回归样例;

  • 发布构建应由受控 CI 完成签名与校验,而不是把本地成功构建视为最终供应链保证。

下一阶段最值得做的,也许不是再加一排功能入口,而是继续减少“看起来成功、实际状态不确定”的时刻:让错误提示更接近用户动作,让诊断信息可以导出但不泄露隐私,让每个跨进程任务都能恢复,让扩展权限更容易看懂。

结语

OpenNexus 最初只是想把一段课程录音整理成笔记。做下去才发现,真正的问题不是生成一段文字,而是怎样让这段文字进入一个可信、可编辑、可检索、可规划、可迁移的个人知识系统。

这个项目最重要的变化,也许不是从 0.4.0 走到 0.5.2-alpha1,而是从“功能是否出现”转向“状态是否可信”。一篇笔记写到哪里,一次 Agent 为什么调用工具,一个扩展拿到了什么权限,一项同步是否产生冲突,一次导出为什么失败——这些问题能被回答,AI 才不是覆盖在笔记软件上的一层雾。

课堂结束以后,录音不必继续躺在文件夹里。它可以成为一份能校对的转录、一张可执行的学习计划、一组带来源的知识节点,也可以在需要时安全地去往另一台设备。OpenNexus 想做的,就是把这条路铺完整。


项目仓库:

本文基于项目历史开发文档、问题与修复复盘、当前源码,以及 Gitea 上 #1—#49 的 PR 审阅记录整理。为保护赛事公正与个人隐私,未引用人员姓名、学校信息、凭据、私有地址或分工表内容。

おとといは兎を見たの、昨日は鹿、今日はあなた