最近我对某主流对话产品的“智能工作区”做了次比较深入的逆向观察。这个界面和传统的聊天气泡完全不同AI 的回复会变成一个个结构化卡片表格、图表、代码面板、可编辑文档还能在画布上拖拽重组。作为前端开发者我第一反应不是“这个 UI 真好看”而是想搞清楚它底层到底怎么把模型输出变成可交互的界面。这篇文章就是我对整个逆向思路、关键节点和复现过程的一份完整记录。我会按实际操作的顺序来写先讲我怎么拆解这个目标的再讲协议层和前端表现层分别暴露了哪些线索最后给出一个可以自己复现的最小版本。过程中会穿插一些踩坑记录希望对跟我一样喜欢“扒产品”的朋友有帮助。1. 项目背景与逆向目标1.1 这个 UI 到底特别在哪传统的 AI 对话界面核心交互载体是流式输出的纯文本。模型在说话页面在滚动用户只能被动阅读。但“智能工作区”这种形态把聊天和多模态画布揉在了一起。我观察到的关键差异有三点。第一内容被模块化了一段回复往往不是一个长文本而是多个语义独立的可视块块与块之间可以折叠、展开、单独复制甚至拖动排序。第二渲染方式从“纯文本排版”变成了“组件分发”表格、图表、代码块都有独立的渲染器交互方式也完全不同。第三状态是持久的你刷新页面之后之前生成的图表和编辑内容还在说明前端一定做了序列化存储而不只是瞬时的 DOM。这三点看起来简单但对前端架构的要求非常高。纯聊天组件做不了拖拽Markdown 渲染做不了图表联动静态页面做不了持久化编辑。所以它背后一定是一套组合方案而不是某一个框架的默认能力。1.2 为什么用逆向工程的方式研究其实官方文档里对“智能工作区”的设计理念讲了不少但文档只会告诉你“能做什么”不会告诉你“怎么做”。我想了解的是具体实现路径用什么前端框架、数据以什么格式传输、渲染层如何组织组件、交互状态如何管理。更重要的是理解这套实现方式对日常开发有直接帮助。我最近在做一个知识库问答系统也遇到了同样的需求用户提问之后AI 的回答可能是表格、代码、图文混排不能都用 Markdown 糊在一个气泡里。所以我需要用逆向得到的思路来指导自己的架构设计而不是从零拍脑袋。从技术选型的角度来说这种逆向研究还有一个好处可以直接获取一线产品的工程策略。比如它是把 SVG 图表直接嵌在卡片里还是用 Canvas 统一绘制表格交互是基于 DOM 操作还是虚拟滚动这些判断用经验猜不出来的必须通过实际观察来验证。1.3 我的分析路线总览我把整个逆向过程拆成四条线同时推进行为观察不碰任何代码只通过交互推测功能边界网络层分析抓取请求响应研究数据协议前端代码检查分析 Bundle 结构、组件命名、CSS 特征工程复现基于观察结论写一个最小可运行的 Demo这四条线并不严格分前后很多时候是交错的。比如我先从网络层确认了传输格式是“流式 JSON 块”然后带着这个认知去看 DOM 结构就能猜出哪些节点是前端组件的产物哪些是模型原始输出的映射。2. 环境准备与基础观察方法2.1 工具清单逆向一个现代前端应用不需要装什么特殊软件我的核心工具就是浏览器自带的开发者工具再加上一个抓包代理工具用来观察 HTTPS 请求的具体载荷。我习惯的工作台是这样的浏览器使用无痕模式避免插件干扰页面逻辑DevTools 打开 Network、Sources、Performance 三个面板抓包工具配置好 HTTPS 证书不然看不到请求体React DevTools 用于查看组件树和 Props这个后面会非常有用有一个很容易忽略的点大部分站点都有前端监控和错误上报如果开着来源不明的浏览器插件可能会被当成异常流量导致部分接口响应被降级。所以我全程用无痕窗口并且关掉所有会改写请求头的扩展。2.2 第一轮功能行为观察正式抓包之前我先花了一天时间在界面上做行为探索。纯粹看交互不关心实现记录“在什么输入下界面会产生什么类型的内容块”。我梳理出的行为规律大致是这样的用户输入类型界面响应交互特征要求做数据对比表生成表格卡片附带排序操作表格可单独导出要求画趋势图生成 SVG 图表卡片可直接下载鼠标悬停有 Tooltip要求写一段代码生成代码面板带语法高亮支持一键复制和运行按钮要求修改某段内容打开文档编辑器卡片光标可定位、内容可保存这些行为的共同点是它们都不在聊天气泡里渲染而是“弹”到了右侧的工作区。工作区内部的元素可以点选、拖动、缩放。这个交互层级说明前端至少有两层容器一层是会话消息流一层是画布式的内容容器。2.3 第二轮DOM 结构与样式特征行为观察之后我把目标切到 DOM 结构。随便打开一个包含表格卡片的会话检查元素后发现几个明显的线索。第一工作区的根容器不是普通的 div而是一个带有绝对定位上下文和 transform 属性的层。页面的滚动事件并不会触发普通文档流而是由容器内部的事件系统自行管理的。这说明它做的是“画布式虚拟 DOM”不是传统页面流。第二卡片的 DOM 深度非常浅。我原以为会看到一段巨大的 HTML 嵌套结构实际上每个卡片节点下面的子节点层级很少很多区域都是通过绝对定位的矩形元素拼出来的。换句话说渲染层在刻意压低 DOM 复杂度以避免卡片数量多时浏览器卡顿。第三CSS 变量被大量使用颜色、圆角、间距都以变量形式挂在根节点上。这种设计方便主题切换也方便做多卡片风格统一。后来我在复现 Demo 时也沿用了这套变量规范效果非常好。3. 协议拆解与数据结构还原3.1 抓包看到的请求节奏接着进入网络层。开启抓包后我随便发起一段对话观察 Network 面板里的请求序列。最直观的感受是请求不是一次性的而是分阶段出现的。先是准备阶段会有一个请求建立会话上下文接着是生成阶段链接保持开启服务器持续推送增量数据最后是收尾阶段客户端发送已消费的位置确认。这种“长连接 增量确认”的模式和纯 SSE 不太一样更像是有序的消息帧协议。刚开始看这堆数据会有点懵因为响应体是压缩过的大段 JSON。我的做法是先导出到本地然后用脚本做重放解析按时间轴还原出每次增量数据的内容这样才知道客户端在什么时刻拿到了什么数据。3.2 响应体的“块式结构”设计在解析增量数据的时候我发现它并不是把整段回答一次性推过来而是拆成了粒度很小的“内容块”。每个块都有独立的类型声明和内容载荷块之间可以嵌套。我简化后的思路大致是这样最外层是消息事件里面包含多个内容块内容块的类型字段决定了 UI 层如何渲染它。比如类型是 table 的就带表格数据和列定义类型是 chart 的就带图表类型和数据源引用。这种设计最巧妙的是它让“模型输出”和“渲染表现”彻底解耦了。模型只负责产出结构化的块至于表格怎么画、图表用什么库、卡片怎么布局都是渲染层的事。理解了这一点后面很多前端逻辑就说得通了。3.3 内容块的具体字段观察我把抓到的内容块做了字段统计发现常见的字段是这几类字段作用观察到的典型值block_id块唯一标识一串较长 IDblock_type块类型paragraph、table、chart、code、doc 等content块主体内容不同类型结构不同meta附加信息渲染提示、尺寸偏好delta增量标记表示这个块是替换还是追加这里有个细节特别值得注意同一个块 ID 可能在多次增量里出现但内容不同。第一次推送记录的是“标题空白框架”后面再推“填充数据”。前端会根据 ID 找到已有节点并做增量更新而不是无脑渲染新节点。这就是“流式界面不闪烁”的原因。3.4 解析后的“指令流”推断把块结构和交互行为对应起来我推测出前端内部存在一条非常清晰的“指令流”模型输出的内容块先进入一个归一化层归一化层把不同的块转成统一的消息对象然后派发给对应类型的组件渲染器。例如用户在界面上拖拽一个表格卡片到另一个位置前端并不会重新请求模型而是会在本地生成一个包含新坐标的消息帧发到后端保存。这解释了为什么这块 UI 能保持状态一致——所有操作都经过同一套指令协议。这种协议设计对我影响很大我后来在 Demo 里也用了同样的思路所有交互动作都先变成指令再更新本地状态最后同步到服务端。哪怕是单机版本这样写也远比“直接改 DOM”清晰。4. 前端表现层的还原分析4.1 从组件树看前端框架选型网络层分析结束之后我回到 DevTools 的 Elements 和 React 面板。虽然代码是压缩混淆过的但组件树的层级和 Props 命名会暴露很多信息。我看到组件树里出现了高频的节点类型比如“块容器”“块渲染器”“工具条”“选择器覆盖层”。这些命名风格明显不是普通组件的命名而是为多类型内容设计的抽象层。特别是“选择器覆盖层”这个组件在纯文档站点里基本上不会出现但画布交互却必不可少它负责生成选中框、锚点、拖拽手柄。从组件树还能看出表格、图表、代码面板是独立注册的渲染器它们并不在同一个大组件里做 switch-case而是通过依赖注入方式挂载到渲染器注册表。这样新增一种内容块类型时只需要加一个渲染器不用改动主流程。这个架构思路非常值得借鉴。4.2 为什么用 DOM 而不是 Canvas很多人第一反应是这种卡片画布应该用 Canvas 或者 WebGL 绘制。但我从 DOM 结构可以明确判断它主要用的是 DOM CSS Transform而不是 Canvas。证据有几个DOM 树里能看到文本节点文字可以被选中图表里 hover 时的 Tooltip 是普通 div可以被样式覆盖每个卡片的边框、投影都是 CSS 属性浏览器 DevTools 可以直接高亮。用 DOM 而不是 Canvas 的好处很明显图文内容天然可访问可被搜索引擎索引也可以被复制粘贴。缺点当然是大量 DOM 节点会有性能压力所以它才采用了虚拟化和低层级 DOM 的策略。4.3 卡片交互与状态管理交互层的还原是难度最大的部分。卡片能拖拽、缩放、右键菜单、多选这些操作涉及非常复杂的坐标计算和状态同步。我的推断是前端维护了一个“画布状态树”里面记录每个卡片的位置、尺寸、层级、选中状态。鼠标事件并不直接改 DOM而是先计算坐标差生成一个动作再更新状态树最后把状态树渲染到 DOM 里。这套逻辑跟常见可视化编辑器的做法是一致的。这个推断还有一个佐证拖动卡片时gpu 渲染的 transform 会频繁更新但 DOM 结构不会变化。如果直接操作 DOM top/left会触发重排而用 transform 只需要合成。所以前端对性能的优化非常刻意。4.4 CSS 变量与主题机制在还原过程中另一个给我很大启发的是样式体系。页面的根节点上定义了大量 CSS 变量不只是颜色连网格大小、卡片圆角、间距系数都是从变量读取的。比如网格吸附功能网格间距就是一个变量。用户开启“对齐网格”时卡片位置会以这个变量为步进做取整改变缩放比例时卡片字号和间距也会按比例调整。这种变量化的做法让整套 UI 保持高度一致同时也方便做“紧凑模式”和“舒适模式”的切换。我后来查了一下这种设计在主流设计系统里已经不算新鲜但真正把它应用到如此复杂的画布交互上效果还是很惊艳的。通过变量来控制几何参数意味着动画、缩放、布局全部可以统一响应不用写死数值。5. 复现最小可运行的“智能 UI”Demo5.1 明确复现目标逆向分析做到这一步我决定动手写一个简化版 Demo名字就叫“Mini Canvas AI UI”。目标不是复刻完整产品而是验证我从数据协议到渲染架构的三条核心推断模型输出可以拆成结构化内容块内容块可以映射成多类型卡片组件卡片组件能在画布中自由排列并保持状态我把这个 Demo 设计成纯前端项目后端只提供一个模拟接口直接返回写死的“内容块 JSON 流”这样任何人都能跑起来不需要外部服务。5.2 定义内容块协议第一步先定义协议。我照着抓包观察到的字段简化出了一版 JSON Schema。{ blocks: [ { block_id: b1, block_type: paragraph, content: 这是第一段说明文字, meta: { width: 320 } }, { block_id: b2, block_type: table, content: { columns: [城市, 销量, 同比], rows: [ [北京, 1280, 12%], [上海, 1138, 8%], [广州, 826, 5%] ] }, meta: { width: 480 } }, { block_id: b3, block_type: chart, content: { chart_type: bar, data_source: b2 }, meta: { width: 520, height: 320 } } ] }注意一下chart块里的data_source它不是把图表数据重复一份而是引用表格块的 ID。这正好对应我在协议层观察到的“块间可以互相引用”的设计。这样做的好处是数据只存一份图表只是数据的另一种可视化表达修改表格数据图表会自动变逻辑非常清爽。5.3 前端渲染器架构Demo 的前端我用了 React因为 React 的组件树结构和目标产品的 Fiber 架构最接近而且生态里图表、表格组件都很成熟。核心代码不复杂关键思想是维护一个“渲染器注册表”const renderers { paragraph: ParagraphCard, table: TableCard, chart: ChartCard, code: CodeCard }; function BlockRenderer({ block }) { const Component renderers[block.block_type] || UnknownCard; return Component block{block} /; }渲染器层拿到内容块后各自渲染自己的卡片外部容器负责拖拽、缩放、选中。这样新增块类型时只需要往renderers里加一项不需要改任何分发逻辑。5.4 画布交互的最小实现画布交互我用了开源的拖拽库做基础能力然后手动叠加对齐网格、选中框、层级管理三个功能。对齐网格的核心实现并不难就是在拖动结束以后做一个取整const snap (value, step) Math.round(value / step) * step;缩放的核心是记录每个卡片的初始尺寸和拖拽手柄的位移量实时计算新的宽高。因为卡片内容类型不同缩放策略也不同。表格卡片的缩放会影响列宽分布而段落卡片的缩放更接近宽度变化。选中框实现相对简单用一个绝对定位的矩形覆盖所有选中卡片的包围盒再在四个角和四条边加上手柄。多选时按住 Shift 点击即可这个和常见图编辑器的交互完全一致。5.5 模拟流式增量为了让 Demo 更接近真实产品我还实现了模拟的“流式块加载”。加载按钮点击后不是一次性渲染全部块而是每隔 300 毫秒插入一个块。这需要前端处理增量更新逻辑。我的实现是收到新块时如果当前状态里已经有相同 ID就做替换否则做新增。这和我在抓包中观察到的行为一致。function applyBlocks(state, incomingBlocks) { return incomingBlocks.reduce((acc, incoming) { const index acc.findIndex((b) b.block_id incoming.block_id); if (index -1) { return [...acc, incoming]; } const next [...acc]; next[index] incoming; return next; }, state); }这样即使流式加载过程中某个块被重复推送界面也不会出现闪烁或内容丢失的问题。6. 常见问题与排查技巧实录6.1 抓包看不到数据流怎么办我遇到的第一个坑就是抓包工具能收到请求但响应体全是压缩乱码无法直接阅读。解决办法是两层先确认 HTTPS 证书是否安装完整很多抓包工具在浏览器无痕模式下不会自动信任证书然后在抓包工具里开启“解码压缩内容”选项。还不行的就直接在浏览器 DevTools 的 Network 面板看原始响应虽然读起来费劲但至少能看到结构。另一个小技巧把抓到的响应保存成文件用脚本做格式化再按时间序列重放比直接盯 DevTools 快得多。我写了一个几十行的小脚本专门干这事数据一多肉眼逐个看是不现实的。6.2 前端代码混淆程度高怎么读现在的生产环境代码基本都做了压缩混淆变量名都是字母缩写直接读源码非常痛苦。我的做法是搜索“特征字符串”。比如在源代码文件里搜“block_type”这个字段名几乎可以确定查找到归一化层的核心处理逻辑。再从“renderer”或者“type”这样的关键词往回找就能定位到组件分发的核心框架。另外一个较实用的技巧是借助 React DevTools。混淆只影响 JavaScript 变量名不影响组件树里的displayName。如果你在组件面板里看到一个叫BlockRenderer的节点那基本就能确定它在代码里的职责范围。6.3 复现时卡片对齐总差几个像素我在做对齐网格时碰到的尴尬问题是有些卡片拖完之后总是差几个像素肉眼看不出来网格一开就露馅。排查下来发现是 CSS 盒模型的问题。默认情况下宽高是content-box边框和内边距会额外撑大元素。我做的取整算法计算的是外层盒子的坐标但定位用的可能是内层盒子的坐标导致位置对不上。修复方法很粗暴把所有卡片设为box-sizing: border-box然后统一用外层盒子的getBoundingClientRect()计算坐标。这样取整步进就一致了对齐也没有偏移了。6.4 如何避免触碰合规风险逆向工程最需要守住的底线就是合规和安全。我的原则是三条。第一只做学习和兼容性研究所有抓包数据都留在本地不公开任何私有接口的具体鉴权链路和付费相关细节。第二复现项目必须从零写代码严格按照“观察结论→重新实现”的路线绝不直接拷贝对方的代码、样式和静态资源。第三不用任何方式绕过登录校验、内容付费墙或流量限制遇到验证逻辑直接走正常路径。这套规则保证了整个研究过程正当透明。想评估一套产品的设计思路完全可以在合法框架内看清它的工程骨架没必要触碰灰色地带。7. 这个思路还能延伸到哪些场景复现 Demo 完成之后我又把“内容块协议 画布渲染”这套思路迁移到了两个实际项目中发现它的价值并不局限于对话产品。第一个场景是文档系统。把同款协议套进一个内部知识库一个文档页面就可以包含目录块、表格块、待办块和图表块不同团队提交的内容会分段渲染更新时只推变化块整个文档系统的响应速度提升非常明显。第二个场景是低代码报表。报表页面同样可以抽象成一组内容块筛选器块、指标卡块、折线图块、明细表块。后端下发块配置前端按注册表渲染。业务方要调整布局时不需要改代码只要调整块顺序和尺寸就行。本质上这套架构最大的价值是“标准化数据结构 组件化渲染分发”它把复杂的信息展示问题拆成了两层模型层只负责语义结构化渲染层只负责表现交互。二者通过协议对接任何一侧升级都不会影响另一侧。按照这个思路继续扩展下去下一步我准备把块协议加一个“表单块”类型让 AI 不仅能展示内容还能收集用户输入这样离真正的“智能工作台”就更近了一步。