开源项目 OfficeCLI 的 watch 预览:让 Agent 改文档时你在浏览器里同步看到

2026-08-05

本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。

**OfficeCLI 的 watch 不是文件监听器,而是一条「谁改谁推」的单向推送链路——只有 officecli 自己发出的改动会让浏览器刷新,你在别的程序里手动改同一个文件,这个预览一无所知。**这句话不是我的推断,是 src/officecli/CommandBuilder.Watch.cs 里 watch 命令的描述原文写死的:Start a live preview server that refreshes when officecli modifies the document (external edits are not detected). 把这个前提认下来,后面的设计取舍才讲得通。

先做个身份说明,免得读串了。OfficeCLI 是 GitHub 上的一个开源项目(https://github.com/iOfficeAI/OfficeCLI ,Apache-2.0,NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护),它不是微软的产品,也跟微软没有从属或授权关系;文中说到 Word、Excel、PowerPoint 时,指的是这三种文件格式和对应的桌面应用,不是这个项目的归属。它做的事是给 Agent 提供一套单二进制的 Office 文档读写能力,机器上不需要装 Office。

一、这块要解决的问题:改完之后那一步「打开看看」

让 Agent 改一份 .docx 或 .pptx,真正费时间的不是改,是验证。Agent 执行完一条 set,你不知道它把字号调成了什么样子、表格有没有被撑破、那张图是不是压到了页脚。于是你打开文件、看一眼、关掉、让它继续改、再打开——这个循环里,每一次「打开」都要求文件已经落盘、没有被占用,而且你得自己找到刚才改的位置。

watch 模式想省掉的就是这一步。它在本机起一个 HTTP 服务(默认端口 26315,--port 可改,写 0 就让操作系统分配临时端口),你在浏览器里开着这一页;此后每条改动命令跑完,页面自己变。命令行里还配了 watch goto,能把浏览器直接滚到你刚改的那个段落。

这条链路和站内已有的几篇是分工关系:Pascal Editor 的 MCP 实时同步 讲的是另一个项目里 Agent 与三维场景的双向同步,给 Agent 做可观察日志 讲的是用文本轨迹回溯 Agent 干了什么,AI 建站工具横评 是工具选型视角;本篇不比工具、不排座次,只把 OfficeCLI 这一条「改动 → 渲染 → 推送 → 浏览器」的链路拆开,讲清它做到哪一步、在哪一步失真。

二、链路怎么走:四段接力,中继服务从不碰文件

整条链路拆成四段。第一段在改动命令那一侧:src/officecli/CommandBuilder.cs 里有个 NotifyWatch,每条改动命令收尾时先调 WatchServer.IsWatching(filePath) 问一句「有人在看吗」,没人看就直接返回,有人看才把当前内存里的文档渲染成 HTML。渲染结果按格式分流——Excel 与 Word 走整篇 ViewAsHtml(),PowerPoint 如果能定位到具体是第几页,就只渲染那一页的片段,动作标成 replace

第二段是传输。渲染好的 HTML 塞进一个 WatchMessage 对象,序列化成 JSON,经命名管道(named pipe,同一台机器上两个进程之间的通信通道,不走网络)发给 watch 进程。管道名不是文件名,是把绝对路径做 SHA256 之后取十六进制前 16 位(Windows 与 macOS 上会先把路径整体转成大写再算,这两个平台的文件名默认不区分大小写,同一个文件写成两种大小写必须落到同一个管道上):

var hash = Convert.ToHexString(
    System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes(fullPath)))[..16];
return $"officecli-watch-{hash}";

同一个哈希还派生出临时目录下的 marker 文件 officecli-watch-<hash>.port,里面写着 {pid}\n{port}\n{启动时刻的 UTC ticks}\n。一个文件对应一个管道、一个 marker、一个端口,IsWatching 就是读这个 marker 再验一下进程还活着。第三行的启动时刻是为了防 pid 回收——只看 pid 的话,崩溃进程的 pid 被别的程序捡走后,这里会永远报告「有 watch 在跑」。比对留了 2 秒容差,因为 Linux 上同一个活进程被别人读到的启动时刻会有几百微秒的抖动。

第三段是中继。WatchServer 收到消息,更新自己缓存的那份 HTML 字符串,版本号加一,然后经 SSE(Server-Sent Events,服务端单向往浏览器推事件的长连接)广播出去。这一段有条写在文件头上的红线:

// CONSISTENCY(watch-isolation): this file does not reference OfficeCli.Handlers, does not open files,
// does not write to disk.

WatchServer 从头到尾不打开文档、不写磁盘。这条约束的代价后面会讲,好处是明确的:预览进程常驻不会跟改动进程抢文件锁。

第四段在浏览器。注入页面的是两层脚本,第一层 watch-sse-core.js(467 行)管连接与 DOM 更新,第二层 watch-overlay.js(1061 行)管选中、标注、框选,两层之间靠 window._watchReapplyHook 这一个约定的钩子耦合:第一层每次改完 DOM 就调它,第二层把它设成自己的重绘函数。

组成部分它负责什么仓库位置你什么时候会碰到它
watch 命令装配定义 watch / unwatch、--port 开关、四个子命令,启动时先取一份初始 HTMLsrc/officecli/CommandBuilder.Watch.cs想确认默认端口和子命令有哪些
预览中继服务TCP 监听、SSE 广播、管道收件、标注与选中状态src/officecli/Core/Watch/WatchServer.cs排查不刷新、403、端口冲突
推送客户端命令进程这一侧的发送方,也负责 scroll / mark / close 这些指令src/officecli/Core/Watch/WatchNotifier.cs想知道一次改动到底往管道里写了什么
渲染共用件HTML 转义、把包内图片转成 data URI、不可解码图片降级src/officecli/Core/HtmlPreviewHelper.cs预览里出现灰框占位图
浏览器两层脚本SSE 连接与 DOM 更新 / 选中与标注src/officecli/Resources/watch-sse-core.jswatch-overlay.js预览页行为异常时
写入侧挂钩每条改动命令收尾判断有无 watch,在跑就渲染并推src/officecli/CommandBuilder.cs想明白为什么外部编辑不触发刷新

Core/Watch/ 目录一共就三个 .cs 文件:WatchMark.csWatchNotifier.csWatchServer.cs,其中 WatchServer.cs 3052 行,是这块的主体。

三、为什么不是整页重刷:块级与行级 diff

如果每次改动都让浏览器整页重载,长文档的滚动位置、Excel 当前激活的工作表、你正在看的那一屏全都会丢。所以中继服务在广播之前先算 diff。

Word 这边,渲染器在每个块的首尾埋了两个隐藏 span:<span class="wb" data-block="N" style="display:none"> 和对应的 we。中继服务按这对标记把新旧 HTML 各切成一张「块号 → 内容」的表,逐块比对,只把变了的块发出去(ComputeWordPatches)。Excel 同理,按 <tr data-row="..."> 切成行;虚拟滚动(表格行数太多时只往 DOM 里放当前可见的那几十行,其余留在数据里按需渲染)模式下行数据藏在 <script id="virt-data-N"> 里,也一并解析出来。PowerPoint 走的是另一路,直接按页替换、追加、删除,靠 PatchSlideInHtml 在缓存的整篇 HTML 里定位那一页。

diff 的价值在于它知道什么时候该放弃。几处回退到整刷的判断都很实在:

var totalBlocks = Math.Max(oldBlocks.Count, newBlocks.Count);
if (totalBlocks >= 5 && patches.Count > totalBlocks * 0.6)
    return null;

块数不少于 5 且变动超过六成,算 diff 不如整刷划算,直接返回 null。此外,节数变了要整刷(块标记可能跨节,切出来的内容会带上结构标签);Excel 的表头、列宽、图表覆盖层位置变了也要整刷,因为行级补丁只换 <tr>,换不了这些。

最讲究的一处是 WordPatchPayloadStraddlesStructure。浏览器侧打补丁时是在两个标记之间走兄弟节点,这要求两个标记处在同一层 DOM 深度;而一个段落里如果插了分页符,它的块标记就会跨过页容器,切出来的片段带着孤立的结束标签。这个函数不去枚举「已知会出问题的容器」,而是直接检查这段 HTML 是不是标签配平的——有先闭后开的,或者结束时还有没闭合的,一律判为不安全,回退整刷。行内标签和空元素不参与计数,因为它们按构造就不会跨块。

浏览器那一侧还有最后一道兜底:每条补丁带 baseVersion,客户端手上的版本对不上就 location.reload()。收到 doc-switched 事件(服务端被 POST /api/switch 换了目标文档)也是直接重载。

四、还原度边界:这是 CSS 近似,不是排版引擎

这是全篇最需要说清楚的一节。watch 预览里的画面,是把 OOXML(.docx/.xlsx/.pptx 本质上是个 zip 包,里面装着一堆描述内容与格式的 XML)翻译成 HTML + CSS 的结果,翻译过程中有大量地方是「近似」而不是「等价」。

翻译代码摊在名字带 HtmlPreview 的 18 个源文件里——PowerPoint 6 个、Word 7 个、Excel 4 个,加上共用的 Core/HtmlPreviewHelper.cs。这些同名前缀的文件是 C# 的分部类(partial class,一个类的代码拆到多个文件里写,编译时合成一个类),所以它们其实是同一个 handler 的不同侧面。

在这些文件里区分大小写地 grep 一遍小写的 approximat,命中 43 行(自己数一遍就能复现;不区分大小写会多出几行注释开头的大写写法),全是代码自陈的近似,随便举几个:图案填充(a:pattFill,OOXML 里的预设网点/斜线底纹)用 CSS 的 repeating-linear-gradient 凑;自定义形状里带贝塞尔曲线的轮廓,采样成 polygon() 顶点;斜面立体效果(bevel)用一层内阴影充数;双色调(duotone)图片滤镜用 sepiahue-rotate 往目标色偏;OOXML 的虚线预设分得很细(长划、点划、点点划、自定义间隔都有),CSS 的 border-style 只能表达有限几种,代码里点线还能对上 dotted,整个 dash / dash-dot 家族连同自定义间隔一律压成 dashed。这些取舍在注释里写得很直白,用的原则是「用 CSS 近似,也别留白」。

还有一类是明确画不出来、直接降级的。HtmlPreviewHelper.PartToDataUri 会检查图片的 content type,遇到 WMF/EMF(Windows 上的矢量图元文件格式,浏览器 <img> 标签不认)和 TIFF,不去硬转,而是生成一张自带的 SVG 占位图——浅灰底、灰边框、中间写着 WMF、EMF 或 TIFF 三个字母。原因写在注释里:这个项目刻意不依赖 System.Drawing/GDI,跨平台没有可用的转码器,与其让浏览器显示一个碎图图标,不如给个干净的框。所以预览里看到灰框写着 EMF,那不是文件坏了。

另一处失真跟标注功能有关。PowerPoint 的组合形状(group)内部的子形状,渲染时只有外层组带 data-path,子形状没有。于是 FindDataPathInHtml 做了个逐级上溯:精确路径找不到,就砍掉最后一段再找,一直退到能命中的祖先。结果是你给组内某个形状打的标注,视觉框会落在整个组上——功能没断,精度掉了一级。仓库里给这个行为挂了 CONSISTENCY(pptx-group-flatten) 标记,全项目所有相关位置一起管。

要看真实排版效果,得走另一条路:view <file> screenshot 支持 --render 开关,取值 auto(默认,Windows 上装了 Word/PowerPoint 就用系统原生渲染,其它平台退回 html)、native(强制系统原生,不可用就报错)、html。watch 预览走的始终是 html 这一路。也就是说,watch 适合看「内容对不对、结构乱没乱」,签发前的最终版式确认应该用原生渲染那条路。

五、这个设计放弃了什么

**放弃了文件系统监听。**它不 watch 磁盘,只接管道推送。任何不经过 officecli 的修改都不会反映到页面上,页面还会一直显示旧内容,不给任何提示。

**放弃了 DOM 解析。**因为 WatchServer 不许引用文档处理层,标注的解析只能拿正则在缓存的 HTML 字符串上刮:先剥掉 <script><style> 的内容(否则脚本里的文字会被当成正文匹配上),再去标签、解实体、做 NFC 归一化(同一个带音标或组合符号的字在 Unicode 里可能有拆开和合成两种编码写法,统一成合成形式,找的和存的才对得上)。注释里承认「真正的 HTML 解析器会更准确,但会引入耦合,这个版本拿精度换隔离」。

**放弃了稳定 ID。**标注和选中都是纯位置寻址,没有指纹、不做漂移检测。你在第 3 段上打了标注,然后在第 2 段前面插了一段,标注就指到别的内容上了;只有当客户端报告路径解析失败或者文本匹配不上时,那条标注才会被翻成 stale。这个限制写成了 CONSISTENCY(path-stability) 标记,覆盖标注、选中和以后所有用路径的地方,注释里特意写明「要改就一起改,别只补标注」。

**放弃了状态持久化。**标注全在内存里,进程一停就没了。POST /api/switch 切换目标文档时,标注和选中会被清空——同一个文件再切一次也清,注释里把这个定为「switch 永远重置标注」的契约。

**放弃了多写者。**同一个文件不能起两个 watch,RunAsync 开头查到 marker 就抛 Another watch process is already running/api/switch 想切到一个已被别的 watch 占着的文件,返回 409 并把对方端口带回来,让调用方去复用。

**它还会自己退出。**没有任何浏览器连着、且超过空闲时长没活动,就打印一行 idle timeout 然后关掉。默认 5 分钟,OFFICECLI_WATCH_IDLE_SECONDS 可以在 1 秒到 86400 秒之间调。

关于风险,有三件事必须说清楚。

其一,这条链路会真的改你磁盘上的原文件,不是副本。预览页面上的编辑操作走 POST /api/sendPOST /api/batch,服务端并不在自己进程里操作文档,而是拿当前可执行文件路径 spawn 一个 officecli 子进程去执行 set/add/remove/move/swap/get/batch,目标就是 watch 正在盯的那个文件。子进程改完会自己经管道通知回来,触发刷新。

其二,页面上看到的可能还没落到盘上。watch 启动时会先问常驻进程要一份 HTML,之后的推送也来自命令进程的内存态。常驻进程的落盘策略在 OFFICECLI_RESIDENT_FLUSH 里,默认是 auto:空闲防抖,间隔按测得的保存耗时自适应,取 4 倍指数移动平均值并夹在 2 秒到 10 秒之间;另外三档是 each(每条改动命令返回前落盘)、固定秒数、off(只在 save/close/关停时落盘)。常驻进程本身还有个默认 12 分钟的空闲关闭。所以「预览已经对了」和「文件已经对了」是两件事,要交给别人之前该显式落一次盘。

其三,批量中途失败留下什么,取决于开关。batch 默认是原子的:任何一条失败,整批回滚,磁盘上什么都不会变。加 --best-effort 就是旧语义,成功的那些留着;--stop-on-error 是遇错即停,配 --best-effort 时之前成功的保留,默认原子模式下则两种情况都不落任何改动。这三个开关的语义差别不小,接进自动化流程前先明确选哪个。

暴露面这块,服务只绑回环地址,并且对每个请求校验 Host 头——只认 localhost127.0.0.1[::1]::1,为的是防 DNS 重绑定(恶意网页把域名解析到 127.0.0.1,从浏览器里访问你的本地端口;这类请求的 Host 头带的是攻击者的域名,脚本伪造不了)。反向代理场景可以用 OFFICECLI_WATCH_ALLOWED_HOSTS 加白名单。会改状态的那几个 POST 端点还额外校验 Origin,跨源直接 403。请求头累计超过 32KB 就停止读取,POST body 上限 64KB、读取超时 3 秒,防的是慢速攻击。标注颜色在服务端做白名单校验(三种十六进制写法、rgb/rgba 函数、一组具名色,字符串超过 64 字符直接拒),因为浏览器会把这个值原样写进元素的 style.backgroundColor。用户自带的正则匹配统一加 500 毫秒超时,避免灾难性回溯(某些写法的正则碰上特定输入,匹配尝试的分支数会指数级膨胀,一条正则就能把线程占死)把重算循环卡死。

六、上手与避坑清单

**页面不刷新,先确认改动是不是 officecli 发出的。**外部编辑不检测这一条,出现在命令描述里而不是错误提示里,所以踩了不会有任何反馈。做法是把「改文档」这个动作全部收敛到 officecli 命令上;确实要看外部改动,只能停掉重开。

**同一文件重复 watch 会直接抛错。**因为一个文件只能有一个管道监听者。启动前用命令自己的探测逻辑判断一下,或者看临时目录里有没有对应的 marker 文件。进程崩了留下的 marker 不用手动删,下次探测发现 pid 已死或启动时刻对不上会自动清掉。

**长任务跑一半回来发现 watch 没了。**默认 5 分钟无连接就自动退出,如果你的流程是「先起 watch,让 Agent 跑二十分钟,再回来看」,中间没有浏览器连着,它早退了。要么让页面一直开着(SSE 连接算活动,还有 30 秒一次的心跳),要么把 OFFICECLI_WATCH_IDLE_SECONDS 调大。

**容器或反向代理后面访问返回 403。**这是 Host 白名单在拦,不是端口不通。错误正文里已经写清了怎么办:启动前设 OFFICECLI_WATCH_ALLOWED_HOSTS

**预览对了就以为交付物对了。**常驻模式下磁盘可能落后几秒到十几秒,取决于 flush 档位。交付前显式落盘,或者把这个环境变量设成 each 换确定性。

**标注在文档结构变动后指向错位。**位置寻址无指纹,插入删除都会让后面的路径整体错位,而且不一定会被翻成 stale——路径依然解析得到,只是指到了别人身上。改完结构后重新读一遍标注列表,别信改动之前打的那批。

后台跑的 watch 用 kill -INT 停不掉。.NET 运行时对没有控制终端的进程会屏蔽 SIGINT/SIGQUIT,这一条在源码注释里作为已知限制写明了。用 unwatch 命令或者 SIGTERM,这两条路都通。

**goto 找不到目标时会失败而不是静默成功。**服务端拿缓存的 HTML 做一次存在性检查,没找到就回 err: 前缀的错误。这是好事:脚本里可以靠退出码判断锚点是否还在,不用自己再验一遍。

收尾

判断这套东西值不值得接进你的流程,可以按四个问题过一遍:改动是不是全部经由 officecli 发出(不是的话预览就是死的);你要看的是内容结构还是最终版式(后者请走原生渲染那一路);你的自动化会不会在没有浏览器连着的情况下长时间空跑(会的话先调空闲时长);交付前有没有一个明确的落盘动作(没有就补上)。

想继续往下读代码,路径按这个顺序走最省力:CommandBuilder.Watch.cs 看命令面(最短,一百多行);CommandBuilder.cs 里的 NotifyWatch 看写入侧挂钩;Core/Watch/WatchNotifier.cs 看协议长什么样;最后再啃 Core/Watch/WatchServer.cs,它的 diff 与回退判断是这条链路里工程含量最高的部分。想看还原度到底损在哪,就去 grep 那些 HtmlPreview 文件里的 approximate 注释,每一条都写清了为什么只能近似。

顺带一提,这套「让 Agent 的改动即时可见」的思路,和 给 Agent 定改动边界约定让 Agent 自己做自检查 是同一个方向上的三种手段:一个把改动限住,一个让改动自证,watch 这一个让改动当场可见。三者不互斥。

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 OfficeCLI 开源项目常驻模式:进程不退,文档改动何时落盘开源项目 OfficeCLI 的 11 个技能包:里面写了什么,和命令行工具怎么分工

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。