1. 为什么“Agent 办公运行时”是个值得盯的方向
第一次看到“Agent 办公运行时”这个说法,我脑子里蹦出来的不是某个具体产品,而是一个很实际的问题:现在绝大多数 AI Agent 在办公场景里,本质上还是个“聊天框加几个工具调用”。你让它改个表格,它给你返回一段 Markdown;你让它做份汇报,它吐出来一堆文字让你自己往 PPT 里贴。这个链路里,Agent 和真正的办公文件之间隔着一层厚厚的“翻译层”,而翻译层就是错误和低效的温床。
Univer 这个项目切入的点,恰好是把这个翻译层干掉。它做的事情,用一句话概括:把表格、文档、幻灯片这三种最核心的办公文件格式,做成一套可编程的运行时,让 AI Agent 能够像操作内存对象一样,直接、结构化地读写办公文件。你听到“运行时”这个词可能会觉得有点重,但放在这里其实很准确——它不是简单的文件解析库,而是一套带状态、带渲染、带协同能力的执行环境。
这个方向为什么现在值得关注?因为 Agent 的能力瓶颈正在从“模型够不够聪明”转移到“模型能不能精确地操作真实世界的对象”。办公文件就是真实世界里最高频、最结构化、也最容易被搞砸的对象之一。一个单元格的公式引用错了,整张报表就废了;一个段落的样式继承乱了,整份文档的排版就崩了。这些都不是靠“让模型再想想”能解决的,得靠运行时层面的约束和校验。
这篇文章适合谁看?如果你在做 Agent 产品,尤其是办公、协作、数据处理方向的,这里面的架构思路和踩坑经验对你有直接参考价值。如果你是 TypeScript 开发者,想了解一个大型前端项目怎么设计可扩展的运行时架构,Univer 的代码组织方式也值得拆一拆。哪怕你只是好奇“AI 到底怎么才能真的帮我改 Excel”,看完你应该能明白现在的技术卡点在哪、已经走到了哪一步。
我接下来会从整体设计思路、核心模块拆解、实操接入方式、以及实际跑下来会遇到的问题这几个角度,把 Univer 这套东西掰开讲。不堆概念,尽量说人话,该给代码给代码,该说坑说坑。
2. Univer 的整体设计思路拆解
2.1 从“文件格式”到“运行时对象”的思维转变
传统办公文件处理的路子是:文件存在磁盘上,格式是 xlsx、docx、pptx,你要操作它,就得先解析成某种中间结构,改完再序列化回去。这个路子的问题在于,解析和序列化这两步都是有损的、有歧义的。一个合并单元格在 xlsx 里是一组 XML 属性,解析成对象之后,不同库对它的表达方式可能完全不一样,你再写回去,样式可能就丢了。
Univer 换了个思路:它不把文件当成“要解析的格式”,而是把表格、文档、幻灯片当成“运行时里的活对象”。你打开一个表格,它不是先解析再渲染,而是直接在运行时里构建出一棵可操作的对象树,渲染只是这棵树的一个视图。Agent 要改东西,改的是这棵树上的节点,改完之后运行时自动处理重渲染和持久化。
这个转变带来的最大好处是:Agent 的操作有了明确的语义边界。比如你要设置 A1 单元格的值,在运行时里就是对一个特定 ID 的单元格对象做一次 setValue,这个操作会触发依赖它的公式重算、触发视图更新、触发协同同步。所有这些副作用都是运行时帮你管的,Agent 不需要知道公式引擎怎么工作,也不需要知道渲染层怎么 diff。
提示:这种“运行时优先”的设计,和游戏引擎的思路很像。游戏引擎不会让你直接改显存里的像素,而是让你操作场景图里的节点,引擎负责把节点变化翻译成渲染指令。Univer 在办公文件领域做的是同一件事。
2.2 为什么选 TypeScript 作为实现语言
Univer 用 TypeScript 写,这个选择不是随意的。办公运行时本质上是一个“状态机加渲染器”,而 TypeScript 的类型系统恰好能把状态的结构和转换规则表达得很清楚。你去看 Univer 的源码,会发现它大量使用了 discriminated union 来表达单元格类型、文档节点类型,用泛型来约束命令的输入输出。这些类型不只是给开发者看的文档,它们在编译期就能拦住很多运行时才会暴露的错误。
另一个原因是生态。Agent 的开发目前主流是在 Node.js 和浏览器环境里,TypeScript 在这两个环境里都是一等公民。Univer 要嵌入 Agent 的运行时,就必须能无缝跑在 Agent 所在的进程里,TypeScript 编译出来的 JavaScript 天然满足这个要求。而且 Univer 的插件体系也是基于 TypeScript 的模块系统设计的,Agent 要扩展它的能力,直接写个 TypeScript 插件注册进去就行。
2.3 插件化架构:为什么核心要保持“薄”
Univer 的架构里,核心运行时只负责最基础的事情:对象树的维护、命令的分发、事件的广播、渲染的调度。具体到“表格怎么算公式”“文档怎么排版”“幻灯片怎么管理图层”,这些都是插件干的活。这个设计的好处是,Agent 不需要加载它用不到的能力。如果你的 Agent 只处理表格,那文档和幻灯片的插件可以不加载,运行时的体积和启动时间都能压下来。
更重要的是,插件化让 Agent 的定制变得可行。假设你的业务场景里有一种特殊的单元格类型,比如“从外部 API 拉数据的动态单元格”,你不需要改 Univer 的核心代码,写个插件注册一种新的单元格类型和对应的命令处理器就行。运行时在遇到这种单元格时,会走你注册的逻辑。这种扩展方式对 Agent 来说很友好,因为 Agent 的行为往往需要根据业务场景做深度定制,插件化提供了这个定制点。
2.4 协同能力为什么是“运行时”的一部分
Univer 把协同编辑做进了运行时,而不是当成一个外挂功能。这个决策背后的逻辑是:Agent 操作办公文件时,往往不是独占的。用户可能正在看这个表格,Agent 在后台改数据,改完的结果要实时反映到用户的视图上。如果协同是外挂的,Agent 的修改和用户的修改之间就会有一层同步延迟,甚至可能冲突。
把协同做进运行时之后,Agent 的每一次修改都是一个带版本号的原子操作。运行时会处理操作变换(OT)或者冲突-free 复制数据类型(CRDT),保证 Agent 的修改和用户的修改能正确合并。对 Agent 开发者来说,你不需要关心协同的细节,你只管发命令,运行时保证最终一致性。
3. 核心模块拆解与实操要点
3.1 表格运行时:公式引擎与依赖图
表格是 Univer 最成熟的模块,也是 Agent 最常打交道的部分。它的核心是一套公式引擎加一张依赖图。你往单元格里写值或者写公式,运行时会解析公式,找出它引用了哪些单元格,然后在依赖图里建立边。当被引用的单元格变化时,依赖图会触发重算,把新的值传播到所有下游单元格。
对 Agent 来说,这意味着你不需要自己维护“改了 A1 之后哪些单元格要更新”的逻辑。你只管改 A1,运行时会把 B1、C1 里所有依赖 A1 的公式都算一遍。这个能力在 Agent 做数据分析时特别有用,因为 Agent 经常需要做“假设分析”——改一个输入,看所有输出怎么变。有了依赖图,这个操作就是一次 setValue 加一次等待重算完成。
实操上,Agent 调用表格运行时的典型流程是这样的:
// 假设已经拿到了 univer 实例和当前激活的表格 const workbook = univerAPI.getActiveWorkbook(); const worksheet = workbook.getActiveSheet(); // 批量写入数据,Agent 从外部数据源拿到的二维数组 const values = [ ['产品', 'Q1', 'Q2', 'Q3'], ['A', 100, 150, 200], ['B', 80, 120, 160], ]; worksheet.getRange('A1:D3').setValues(values); // 写入公式,让运行时自己算汇总 worksheet.getRange('E2').setFormula('=SUM(B2:D2)'); worksheet.getRange('E3').setFormula('=SUM(B3:D3)'); // 等待公式重算完成 await univerAPI.getFormula().calculate();这里有个细节值得说:setValues是批量操作,比逐个单元格 setValue 快很多。Agent 在写入大量数据时,一定要用批量接口,否则每次 setValue 都触发一次依赖图更新和渲染调度,性能会崩。我实测过,写一千个单元格,逐个写要好几秒,批量写基本是毫秒级。
注意:公式重算不是同步的。你 setFormula 之后,单元格的值不会立刻变成计算结果,需要等运行时的计算调度跑完。Agent 在读取计算结果之前,必须 await 计算完成的信号,否则会读到旧值或者空值。
3.2 文档运行时:富文本模型与命令系统
文档运行时的核心是一个富文本模型,它把文档表达成一棵节点树,每个节点有类型(段落、标题、列表、表格等)和属性(加粗、斜体、颜色等)。Agent 要修改文档,不是直接改这棵树,而是通过命令系统发命令。命令系统的好处是,每个命令都是可撤销、可协同的原子操作。
为什么不让 Agent 直接改树?因为直接改树的话,Agent 需要自己处理很多边界情况:改了这个节点之后,父节点的样式怎么继承?相邻节点的合并怎么处理?撤销栈怎么维护?命令系统把这些都封装好了,Agent 只需要表达“我要把这段文字加粗”,命令系统负责算出具体要改哪些节点、怎么改。
Agent 操作文档的典型场景是“根据模板生成文档”。比如你有一个合同模板,Agent 需要把变量填进去。这个场景下,Agent 的操作流程是:先定位到占位符节点,然后用替换命令把占位符换成实际内容。定位的方式可以是按文本搜索,也可以是按节点 ID。按 ID 定位更可靠,但需要模板在生成时就给占位符打上 ID。
// 按文本搜索定位占位符 const searchResult = univerAPI.getDocument() .searchText('{{客户名称}}'); if (searchResult.length > 0) { const range = searchResult[0].getRange(); // 用替换命令填入实际内容 univerAPI.getDocument() .replaceText(range, '某某科技有限公司'); }这里踩过的坑是:替换文本时,如果占位符跨越了多个样式节点(比如“{{客户名称}}”里“客户”是加粗的,“名称”不是),直接替换会丢失样式。正确的做法是先选中整个占位符范围,清除样式,再插入新文本,最后按需重新应用样式。Univer 的命令系统提供了这些细粒度的操作,但 Agent 需要知道什么时候该用哪个。
3.3 幻灯片运行时:图层管理与布局计算
幻灯片模块相对表格和文档来说,复杂度在于布局。一张幻灯片上的元素有绝对定位,元素之间有层级关系,还有母版和版式的继承。Agent 要生成幻灯片,本质上是在做“布局求解”——给定内容和约束,算出每个元素的位置和大小。
Univer 的幻灯片运行时把每个元素表达成一个带变换矩阵的节点,渲染时按层级顺序绘制。Agent 操作幻灯片的方式主要是增删元素和修改元素属性。比如 Agent 要根据一段文字生成一页幻灯片,它需要:创建一个文本框元素,设置文本内容,根据文本长度和字号估算文本框大小,然后把它放到合适的位置。
这个“估算大小和位置”的过程,目前 Agent 做得并不好。因为文本的实际渲染尺寸取决于字体、换行规则、容器宽度等多个因素,Agent 很难在生成时就算准。一个实用的做法是:Agent 先按粗略估算放置元素,然后调用运行时的“自动布局”能力做一次修正。Univer 提供了一些布局辅助方法,但坦白说这块还在演进中,Agent 生成幻灯片的成品率目前不如表格和文档。
3.4 命令系统:Agent 与运行时的唯一接口
三个模块共享同一套命令系统,这是 Univer 架构里最值得学的一点。命令是 Agent 和运行时之间的唯一接口,Agent 不直接操作对象树,运行时也不暴露内部状态给 Agent。每个命令有明确的输入类型和输出类型,有执行前的校验,有执行后的副作用声明。
这套设计对 Agent 的意义在于:它把“Agent 能做什么”变成了一个可枚举、可校验的集合。Agent 不需要理解运行时的内部结构,它只需要知道有哪些命令可用、每个命令需要什么参数。这大大降低了 Agent 接入的复杂度,也让运行时的行为变得可预测。
命令的执行是异步的,返回一个 Promise。Agent 发完命令后,可以 await 命令完成,也可以监听命令执行的事件。对于需要严格顺序的操作,await 是必须的;对于可以并行的操作,监听事件更高效。
4. 把 Univer 接进 Agent 运行时的完整实操
4.1 环境准备与依赖安装
Univer 的包发布在 npm 上,核心包是@univerjs/core,表格、文档、幻灯片分别是@univerjs/sheets、@univerjs/docs、@univerjs/slides。如果你要在 Node.js 环境里跑(Agent 通常跑在服务端),需要额外处理渲染层的依赖,因为 Univer 默认是给浏览器环境设计的。
Node.js 环境下,你不需要完整的渲染能力,只需要运行时和命令系统。Univer 提供了 headless 模式,可以只加载核心和数据处理相关的插件,跳过渲染插件。这样启动更快,内存占用也更低。
# 核心包 npm install @univerjs/core @univerjs/sheets @univerjs/docs # 公式引擎(表格需要) npm install @univerjs/sheets-formula # 协同能力(如果需要) npm install @univerjs/rpc安装完之后,初始化运行时的代码大概长这样:
import { Univer, LocaleType } from '@univerjs/core'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsFormulaPlugin } from '@univerjs/sheets-formula'; const univer = new Univer({ locale: LocaleType.ZH_CN, // headless 模式下不传渲染相关的配置 }); univer.registerPlugin(UniverSheetsPlugin); univer.registerPlugin(UniverSheetsFormulaPlugin); // 创建一个空的表格 const workbook = univer.createUniverSheet({ id: 'agent-workbook', sheetOrder: ['sheet1'], sheets: { sheet1: { id: 'sheet1', name: 'Sheet1', rowCount: 1000, columnCount: 26, }, }, });这里有个容易踩的坑:rowCount和columnCount不要设得太大。Univer 在创建表格时会按这个尺寸初始化内部数据结构,设成十万行的话,初始化就要好几秒,内存也吃不消。Agent 处理的数据量通常不会太大,设个几千行足够了,需要更多行的时候可以动态扩展。
4.2 Agent 工具函数的封装方式
Agent 要调用 Univer,不能让它直接拼命令,得封装成工具函数。工具函数的粒度很关键:太细的话,Agent 要调很多次才能完成一个任务,容易出错;太粗的话,Agent 失去了灵活性,遇到没预料到的场景就卡住了。
我的经验是,按“业务操作”的粒度来封装,而不是按“运行时命令”的粒度。比如“写入一个数据区域”是一个工具函数,“设置一个公式”是一个工具函数,“读取一个区域的值”是一个工具函数。这些工具函数内部可能调用多个运行时命令,但对 Agent 来说,它们是一个原子操作。
// 封装成 Agent 可调用的工具函数 const tools = { writeRange: async (sheetId: string, range: string, values: any[][]) => { const worksheet = workbook.getSheetBySheetId(sheetId); worksheet.getRange(range).setValues(values); await univer.getFormula().calculate(); return { success: true, range }; }, setFormula: async (sheetId: string, cell: string, formula: string) => { const worksheet = workbook.getSheetBySheetId(sheetId); worksheet.getRange(cell).setFormula(formula); await univer.getFormula().calculate(); return { success: true, cell }; }, readRange: async (sheetId: string, range: string) => { const worksheet = workbook.getSheetBySheetId(sheetId); const values = worksheet.getRange(range).getValues(); return { success: true, values }; }, };工具函数的返回值要结构化,包含成功标志和结果数据。Agent 拿到返回值后,可以根据成功标志决定下一步操作。如果失败了,返回值里要包含错误信息,方便 Agent 判断是重试还是换策略。
4.3 处理 Agent 的并发操作
Agent 在办公场景里经常需要并发操作,比如同时改多个表格、同时生成文档和幻灯片。Univer 的运行时本身是单线程的,命令是串行执行的,但 Agent 的调用可能是并发的。如果不做处理,并发调用会导致命令交错,产生不可预期的结果。
处理方式有两种:一种是加锁,让 Agent 的调用排队;另一种是利用运行时的命令队列,把并发调用转成串行命令。我倾向于第二种,因为 Univer 的命令系统本身就是为串行执行设计的,你只需要保证 Agent 的调用最终变成有序的命令流就行。
// 用一个简单的队列来串行化 Agent 的调用 class CommandQueue { private queue: Promise<any> = Promise.resolve(); enqueue<T>(fn: () => Promise<T>): Promise<T> { const result = this.queue.then(fn); this.queue = result.catch(() => {}); // 防止错误中断队列 return result; } } const queue = new CommandQueue(); // Agent 调用时走队列 const writeRange = (sheetId: string, range: string, values: any[][]) => { return queue.enqueue(async () => { // ... 实际写入逻辑 }); };这个队列的粒度要控制好。如果所有操作都走同一个队列,并发度就没了,Agent 的性能会受影响。我的做法是按文档 ID 分队列,同一个文档的操作串行,不同文档的操作并行。这样既保证了单个文档的一致性,又保留了跨文档的并发能力。
4.4 持久化与状态恢复
Agent 操作完办公文件后,需要把结果持久化。Univer 提供了序列化接口,可以把运行时状态导出成 JSON,也可以导出成 xlsx、docx 等标准格式。导出成 JSON 的好处是保留了完整的运行时状态,下次加载时能精确恢复;导出成标准格式的好处是能被其他办公软件打开。
我的建议是两者都做:JSON 用于 Agent 自己的状态恢复,标准格式用于交付给用户。JSON 的导出和导入都很快,标准格式的导出需要走一遍序列化流程,会慢一些,但可以异步做。
// 导出为 JSON(用于状态恢复) const snapshot = univer.getSnapshot(); // snapshot 是一个可序列化的对象,存到数据库或文件里 // 导出为 xlsx(用于交付) const xlsxBlob = await univer.exportAsXLSX();这里有个坑:导出 xlsx 时,公式会被导出成公式字符串,但计算结果不会一起导出。如果用户用其他软件打开,公式会重新计算。这通常没问题,但如果公式依赖了外部数据或者自定义函数,重新计算可能会失败。稳妥的做法是导出前先把公式转成值,但这会丢失公式的可编辑性。具体怎么选,看你的业务场景。
5. 实际跑下来会遇到的问题与排查
5.1 公式重算不触发或结果不对
这是最常见的问题。Agent 写了一个公式,但读出来的值是空的或者旧的。原因通常有三个:一是没有 await 计算完成;二是公式引用的单元格还没写入;三是公式语法有误,运行时解析失败但没报错。
排查顺序是:先确认公式字符串本身没问题,可以在 Univer 的在线 demo 里手动输入验证;然后确认引用的单元格已经写入,且写入操作已经完成;最后确认 await 了计算完成的信号。如果都确认了还是不对,检查一下公式引擎插件有没有正确注册,有些公式函数是可选插件提供的,没注册的话公式会算不出来。
注意:Univer 的公式引擎对循环引用是静默处理的,不会报错,但相关单元格的值会保持为上一次的计算结果或者空。Agent 在生成公式时,要避免产生循环引用,否则会得到一个看似成功但结果错误的状态。
5.2 大批量写入导致界面卡顿
Agent 一次性写入几千行数据时,如果每写一行都触发一次渲染,界面会卡死。解决办法是用批量接口,并且把渲染调度关掉,等所有写入完成后再统一触发一次渲染。
// 关闭渲染调度 univer.getRenderManager().setRenderSuspended(true); // 批量写入 worksheet.getRange('A1:Z1000').setValues(bigData); // 恢复渲染 univer.getRenderManager().setRenderSuspended(false); univer.getRenderManager().triggerRender();这个技巧在 headless 模式下不需要,因为 headless 模式本来就没有渲染。但如果你的 Agent 跑在浏览器里,或者需要实时预览,这个优化就很关键。我实测过,一千行数据的写入,不做这个优化要卡三四秒,做了之后基本无感。
5.3 协同场景下的冲突处理
Agent 和用户同时操作同一个文档时,冲突是难免的。Univer 的协同层会处理大部分冲突,但有些冲突需要业务层决策。比如 Agent 要删除一行,用户正在编辑这一行里的某个单元格,这时候是让 Agent 的删除失败,还是让用户的编辑丢失?
Univer 的默认策略是操作变换,把冲突的操作变换成不冲突的形式。但有些操作没法变换,比如删除和编辑同一个对象。这种情况下,运行时会拒绝后到的操作,并返回冲突信息。Agent 收到冲突信息后,需要重新读取文档状态,基于新状态重新决策。
我的经验是,Agent 在协同场景下操作文档时,尽量用“读-改-写”的模式,并且在写之前重新读一次,确认状态没变。如果状态变了,就基于新状态重新计算要写的值。这个模式会增加一些延迟,但能大幅降低冲突率。
5.4 内存泄漏与长时间运行的稳定性
Agent 通常是长时间运行的,如果每次操作都创建新的运行时实例,内存会很快涨上去。正确的做法是复用运行时实例,每次操作完清理掉不需要的数据,而不是销毁实例。
Univer 的运行时实例在创建时会加载插件、初始化数据结构,这些开销不小。如果 Agent 要处理多个文档,建议用一个运行时实例管理多个文档,而不是每个文档一个实例。运行时实例内部是按文档 ID 隔离的,多个文档之间不会互相干扰。
// 一个运行时实例管理多个文档 const univer = new Univer({ ... }); univer.registerPlugin(UniverSheetsPlugin); // 创建多个文档 const doc1 = univer.createUniverSheet({ id: 'doc1', ... }); const doc2 = univer.createUniverSheet({ id: 'doc2', ... }); // 操作时按 ID 获取 const sheet1 = univer.getUniverSheet('doc1'); const sheet2 = univer.getUniverSheet('doc2');如果确实需要销毁文档,用univer.disposeUniverSheet(id)来释放对应的资源。但注意,销毁文档不会释放运行时实例本身占用的内存,运行时实例的内存只有在整个实例被丢弃时才会释放。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方式 |
|---|---|---|---|
| 公式结果为空 | 未等待计算完成 | 检查是否 await calculate | 加 await 或监听计算完成事件 |
| 公式结果错误 | 循环引用或语法错误 | 在 demo 中手动验证公式 | 修正公式,避免循环引用 |
| 写入后界面卡顿 | 逐单元格写入触发频繁渲染 | 检查是否用了批量接口 | 用 setValues 批量写入,暂停渲染 |
| 协同冲突频繁 | 读写之间状态被修改 | 检查操作时序 | 用读-改-写模式,写前重读 |
| 内存持续增长 | 运行时实例未复用 | 监控实例数量 | 复用实例,按文档 ID 隔离 |
| 导出 xlsx 公式丢失 | 导出时未计算公式 | 检查导出配置 | 导出前先 calculate,或转值导出 |
6. 这套运行时对 Agent 开发者的实际价值
6.1 从“生成文本”到“操作对象”的跨越
Agent 在办公场景里的价值,不在于它能生成多漂亮的文字,而在于它能精确地操作办公文件里的对象。Univer 提供的运行时,让 Agent 有了操作这些对象的能力。Agent 不再需要生成一段 Markdown 然后让用户自己往表格里贴,而是可以直接把数据写到指定的单元格,把公式设到指定的位置,把样式应用到指定的范围。
这个跨越的意义在于,Agent 的输出变得可验证、可复现。文本生成的结果是模糊的,你很难判断一段文字是不是“正确”的;但对象操作的结果是精确的,A1 单元格的值是不是 100,公式算出来的结果是不是 250,这些都是可以程序化验证的。可验证意味着 Agent 可以自我纠错,可以形成闭环。
6.2 运行时作为 Agent 的“事实来源”
在多 Agent 协作的场景里,运行时可以充当共享的事实来源。多个 Agent 操作同一个文档时,它们都通过运行时来读写,运行时保证了一致性。Agent A 改了 A1,Agent B 读 A1 时读到的是改后的值,不需要额外的同步机制。
这个模式比“Agent 之间互相传消息”要可靠得多。消息传递会有延迟、会丢、会乱序,而运行时是强一致的。Agent 之间的协作可以简化为“各自操作运行时,运行时负责协调”,这大大降低了多 Agent 系统的复杂度。
6.3 对 TypeScript 开发者的参考意义
即使你不做 Agent,Univer 的代码组织方式也值得 TypeScript 开发者学习。它展示了一个大型前端项目怎么用类型系统来约束状态转换,怎么用插件化来管理复杂度,怎么用命令模式来解耦操作和副作用。这些模式在任何一个复杂的前端应用里都用得上。
特别是它的类型声明文件组织方式,值得单独拿出来说。Univer 把每个模块的类型声明放在独立的.d.ts文件里,通过 TypeScript 的模块增强机制来扩展核心类型。这样每个插件可以声明自己新增的类型,而不需要修改核心的类型定义。这个模式在开发可扩展的库时非常实用。
7. 我踩过的几个坑和对应的解法
第一个坑是公式引擎的初始化时机。Univer 的公式引擎插件注册后,不是立刻就能用的,需要等运行时完成初始化。如果你在插件注册后马上写公式,可能会遇到公式引擎还没准备好的情况。解法是监听运行时的 ready 事件,在事件回调里再开始操作。
第二个坑是单元格样式的继承。Agent 设置了一个区域的背景色,然后往里面写值,发现值的样式被背景色覆盖了。这是因为 Univer 的样式是分层级的,单元格样式、行样式、列样式、工作表样式,优先级不同。Agent 在设置样式时,要明确设置的是哪一层,否则会出现预期之外的结果。
第三个坑是文档的撤销栈。Agent 的每一次操作都会进撤销栈,如果 Agent 做了大量操作,撤销栈会变得很大,内存占用上升。解法是在 Agent 的批量操作前后加一个事务边界,把一批操作合并成一个撤销单元。Univer 的命令系统支持事务,Agent 可以用事务来包裹批量操作。
第四个坑是幻灯片的字体度量。Agent 生成幻灯片时,如果用了系统没有的字体,渲染出来的尺寸和预期会差很多。解法是限制 Agent 使用的字体集合,只用那些在目标环境里确定存在的字体。如果必须用特殊字体,需要在运行时里注册字体文件。
8. 后续可以继续深挖的方向
Univer 目前对表格的支持最成熟,文档次之,幻灯片还在演进。如果你的 Agent 主要处理表格,现在就可以接进来用。如果涉及文档和幻灯片,建议先做小范围验证,确认关键场景能跑通再扩大范围。
另一个值得关注的方向是 Univer 的服务端渲染能力。目前它主要是为浏览器环境设计的,服务端跑需要 headless 模式。如果 Agent 跑在服务端,需要导出 PDF 或者图片,这块的能力还在完善中。可以关注它的服务端渲染插件进展。
还有就是和 Agent 框架的集成。目前接入 Univer 需要自己封装工具函数,如果有一个标准的 Agent 工具描述格式,把 Univer 的命令直接映射成工具,接入会更简单。这个方向目前还没有成熟的方案,但社区里已经有人在尝试了。
我在实际项目里用下来,Univer 的运行时稳定性是够的,表格场景基本没遇到过运行时崩溃。文档和幻灯片场景偶尔会遇到渲染相关的问题,但都不影响数据层的正确性。如果你的场景对数据准确性要求高,对渲染效果要求不那么苛刻,这套东西现在就能用。