自研的可视化工作流画布上线验证过一轮。结论是前端不够好。
此时面对的选择是:继续改自研的前端,或者引入一个已经成熟的。
最后引了一个开源的画布工作台——React 19 + zustand + Vite 的全栈项目,AGPL-3.0 加 CLA。决策原文写在一句话里:
其前端全量原样作为我们的新画布产品(所有页面、功能、设计不动),把它的服务层从「用户自填 API key + 浏览器本地存储」改接到我们的平台(Bearer 鉴权、预扣-结算计费、眼部打码合规、Provider 网关、服务端多租户存储)——换皮不换脏器,方向反过来:皮是它的,脏器是我们的。
引入开源项目通常的做法是「拿它的核心能力,自己写界面」。这一条方向相反:界面全部保留,只换背后的东西。
本文是决策记录,不是交付成果。
一、判断是怎么下的
同一天的决策记录里写着两条:
- 用户判断现有自研画布「功能缺陷严重,只有计费层值得保留」;要它的所有功能和页面,喜欢它的设计。
- 书面商业授权已拿到。工程侧仍要求:协议文件归档进公司合规存档;覆盖闭源 SaaS、二次修改;以仓库 CLA 为基础覆盖全部贡献者代码。归档完成前代码不进仓库。
第一条解释为什么换。要的是功能和页面本身——不是「参考它的设计思路」,是整套拿来用。
第二条是一条硬门槛。AGPL-3.0 的常规要求对闭源 SaaS 是冲突的,所以走商业授权;授权是纸质合同,由本人保管,不入库。但「不入库」和「不归档」是两件事:代码进仓库之前,协议必须先归档。实施计划里把这条写成了前置条件——
授权门槛:Task 8(fork 引入)动手前必须确认商业授权协议已归档。Task 1-7 是纯我们的代码,不受此限制。
所以当天的工作顺序是:先写适配层(自己的代码),再引 fork。上午到下午三点多,落的是画布适配层的契约、脚手架、生图端点、任务表、改图端点、音频端点——全部是自有代码。fork 直到 16:11 才进仓库。
上游代码在引入之前只在一个只读目录里做参考,不允许复制进仓库。
二、被替换的五项
保留前端的前提是「只换服务层」。服务层具体是这五项:
用户自填 API key → 平台注入且锁定。
启动引导模块:读取平台登录 token,写入 config store:各 channel
baseUrl= 平台适配层地址、apiKey= token;锁定为不可编辑。 config 页:隐藏「模型/API key/baseUrl」配置区块与 WebDAV 区块,保留主题等本地偏好。 未登录访问:跳回平台登录页。
验收清单里对应一条:config 页无任何自填 key 入口。
浏览器本地存储 → 服务端多租户存储。 上游把画布项目存在 IndexedDB 里。改成实现它的 persist storage 接口,转成服务端读写。
直连模型 → 平台适配层。 这一项之所以可行,是因为上游的生成调用走的是可配 baseUrl 的 OpenAI 兼容契约:
生成调用走可配 baseUrl 的 OpenAI 兼容契约(
/v1/images/generations、/v1/images/edits、/v1/videos、/v1/audio/speech、/v1/responses)——我们做一层兼容适配路由即可接管全部生成。
视频那条还有一条原生契约路径,映射成本更低。适配层做的事是:鉴权 → 预扣 → 打码 → 走平台网关 → 结算或退款。
计费。 每个端点一次调用对应一个计费操作,复用平台已有的预扣-结算-退款基建,幂等键取前端请求 id。
合规。 视频生成提交上游之前先做眼部打码,沿用平台既有的约束。
这五项里,前两项改的是前端的入口,后三项拦在适配层。适配层是新增的,不改上游代码。
三、改动面有多大
引入的规模需要摆出来看:
| 项 | 数量 |
|---|---|
| 保留页面 | 8 个(首页、图片、视频、资产、提示词、画布、画布详情、配置) |
| 前端源码 | 146 个文件、约 25,000 行 |
| 依赖 | 29 个 + 9 个开发依赖 |
| 本地改动登记 | 28 条 |
25,000 行代码里,自己改的那部分是薄的。所有改动集中在少量新增文件和明确标记的最小补丁里。
新增的部分只放一个目录:src/platform/。这是画布前端与平台之间唯一的接缝,引入时它是个空目录,所有平台相关代码都写在这里。
配置页的裁剪就是这种薄改动的一个例子。平台版不让用户自填 API key,也不使用 WebDAV 同步。做法是在标签页数组末尾加一个 filter,而不是把代码块删掉:
平台版不让用户自填 API key、不用 WebDAV 同步。用过滤而非删除代码块,把上游同步冲突面降到最低
同类的地方还有几处:某个页面文件保留不删、某个面板组件保留不删,用不上就不渲染。多留一份死代码,换回来的是将来同步时少一处冲突。
四、把纪律写成文件
改动最小化这件事,靠人记是记不住的。所以有一份登记文件,每次改上游代码都往上加一条:文件、改了什么、为什么。
纪律写在文件里:
成本控制的唯一阀门:本地改动必须最小化,且每一处都登记在下方清单里。任何新增的本地 patch 都要追加记录,否则下次同步会踩雷。
同步流程也写好了:拉上游新版本 → 与登记的基线版本做差异 → 评估冲突 → 挑有价值的改动 → 双环境对照回归 → 更新基线。
基线信息记在同一个文件里:上游版本号、基线提交、引入日期、许可证、商业授权状态。
五、部署方式里有一条硬约束
画布前端不是整页跳转,而是同源 iframe 嵌在平台外壳里,挂在同域子路径下。
这里有一条不能动的约束:iframe 绝对不能加 sandbox 属性。
加了之后 iframe 会变成不透明源,里面读不到平台的登录 token,「平台注入」这条链路直接失效。已经有测试把这条锁住了。
这条约束的由来值得记:它不是一个可以商量的配置项,而是「同源」这个前提的具体后果。一旦给 iframe 加了 sandbox,前面那五项替换里的第一项——平台注入 token——就没有实现路径了。
六、非目标
决策记录里写了四条不做的事:
- 不修改它的视觉设计与交互(品牌融合另议)。
- 不在 P0 引入本地助手。
- 不迁移旧自研画布的项目数据到新画布。
- 不自动跟随上游 release。
第三条还需要一个交代:旧画布的项目数据怎么办。决策是先不迁——理由后面写进另一份文档时会更清楚,这里先记结论:旧数据不迁移。
第四条是「定期人工同步」,不是「自动跟随」。这条在第二天就改了,是下一篇的事。
七、已知的六处差异
融合版和上游独立部署之间,有六处用户能感知的差异:模型来源、计费、打码、可选模型集合、存储位置、本地 Agent。
这六处是主动选择的结果,不是遗漏,所以在文档里逐条列出来,作为验收时的对照基线。
对应的验收方式是双环境并排:上游的独立部署和融合版放在一起,同一套操作序列逐页跑一遍,除这六处之外行为要一致。
其余验收里有两条是钱和合规相关的:每类生成动作断言只扣一次费、失败退款、视频走视频点;含真人脸的图生视频,提交上游前必须已经打码。
八、一段没有写进文档的判断
收尾说一件文档里没有的事。
「保留前端的松散连线语义,是因为它对创作类工具是优点」——这个论证在当时的文档里不存在。文档给出的理由只有两句:功能缺陷严重、只有计费层值得保留;喜欢它的设计。
第二天写下一份设计时,出现的表述是「连线保持上游的松散语义(这是保留该前端的核心理由)」。也就是说,这个判断是在需要决定「要不要给连线加类型校验」的时候才被明确写下来的,而不是引入当天就有的论证。
把理由补写在事后是可以的,但我要分清哪句是当时的判断、哪句是后来的归纳。当时就是嫌它功能少、界面简陋——这个理由本身足够支撑引入,不需要再加一层架构上的正当性。
九天后这套东西被推翻重写,原因正是这条松散连线。那是另一段。
九、这份决策记录留了什么
三条可复用的:
替换面要窄,而且要可枚举。 「只换服务层」这句话之所以站得住,是因为能列出五项具体的东西。如果列不出来,说明改动面还没摸清。
纪律要写成文件,不能靠记。 改动登记清单和唯一阀门那句话,是把「最小改动」从愿望变成可执行检查的唯一办法。
硬门槛要挡在动手之前。 授权归档挡在 fork 引入之前,实施顺序就是照这个排的。
以及一条当时没写、后来才证明重要的:引入之前要明确「不做什么」。四条非目标里,「不修改它的视觉设计与交互」这条后来一直没破。
■