别造仪表板,造造仪表板的能力
讨论内部应用如何支持各部门自助定制仪表板而非硬编码。架构思考有价值但具体方案不足。
讨论内部应用如何支持各部门自助定制仪表板而非硬编码。架构思考有价值但具体方案不足。
我做过的每一个内部应用,都有同一个 bug,而且它不在代码里。
它为十二个部门提供同一个仪表盘。财务控制部门想看各区域的利润率;客服部门想看某个特定客户最近的五张工单;仓库只想要一个数字,而且要大到站在大厅另一头也能看清。于是,这个界面成了各方妥协的产物,所有人也都默默学会了忽略其中 80% 的内容。然后,我们又加上筛选器,再加上保存视图,最后再做一个包含四十个字段、却只有三个人真正打开过的报表生成器。
视图之所以不对,是因为早在问题出现的几个月前,它就已经被决定好了。
所以,我不再交付视图。在 DRYL 中,应用现在交付的是一种创建视图的能力——就在需要它的那一刻,用使用者本来就会说的话来创建。这篇文章要讲的是三个关键部分,它们让这套东西从一个聚会上的炫技演示,变成了我敢放到财务团队面前使用的软件:canvas、document 和 dock;另外还有 voice session,它后来成了整个开发过程中最有趣的部分。
DrylAiCanvas 渲染的是 CanvasSpec:一棵由带类型节点组成的树。模型不会输出 HTML,当然更不会输出 Razor。它输出的是这样的内容:
{
"id": "revenue",
"type": "lineChart",
"data": { "source": "sales.byMonth", "params": { "year": 2026 }, "refresh": "interval:30s" }
}
节点类型来自一个封闭的目录:stack、grid、card、tabs、accordion、form、stat、kpi、table、dataGrid、timeline、chart 系列、markdown、list、keyValue、code、emptyState,以及具备交互能力的 inputText、textarea、select、slider、toggle 和 button。每种类型都通过应用其他部分所使用的同一套组件进行渲染,因此,生成的视图不会变成一套间距更糟糕的第二设计系统。
未知类型或无效 prop 不会让整个页面崩掉。它会被渲染成占位符,而验证失败信息会以一条简单、明确的纠正语句返回给模型,模型会在下一轮将其修复:
Artifact created: 14 elements, 3 inputs. Some elements were invalid and are shown
as placeholders — fix via update_artifact: unknown data source 'sales.byQuarter'.
这个循环正是它之所以能够奏效的主要原因。模型是在用一套由我控制的词汇表创作文档,而不是在编写我的前端。
这是其他一切所依赖的约束:一个会把数字直接写进图表的模型,只能算演示,不算软件。
数据源会在启动时注册。模型看到的只有名称、一句话描述、从 record 派生出的参数 schema,以及结果的数据结构。它永远看不到任何一行真实数据。
public sealed record SalesParams(int Year, string? Region = null);
builder.Services.AddDrylCanvasDataSource("sales.byMonth",
"Revenue per month in thousands of euros.",
async (SalesParams p, CanvasDataContext ctx, CancellationToken ct) =>
{
var db = ctx.Services.GetRequiredService<AppDb>();
var rows = await db.SalesAsync(p.Year, p.Region, ct);
return CanvasData.Series(rows.Select(r => r.Month), ("Revenue", rows.Select(r => r.Total)));
});
ctx.Services 是 circuit 的 scope。租户和当前登录用户都在那里解析,永远不会通过 spec 传递。这意味着,模型无法通过编写某个巧妙的 prop 来影响它们。一个凭空臆造的 binding,最坏的结果也只是引用了一个不存在的数据源——这会产生一条纠正语句,而不是一起事故。
Action 的工作方式相同,只不过多了一条我很喜欢的规则:
builder.Services.AddDrylCanvasAction("order.approve",
"Approves an order. Asks the user to confirm.",
async (ApproveArgs a, CanvasActionContext ctx, CancellationToken ct) => { /* … */ });
AI 可以放置一个按钮,也可以给它添加标签,但它永远不能按下这个按钮。只有用户亲自按下按钮,才会执行命令。整个系统中不存在任何允许模型触发 action 的代码路径。这是由系统结构保证的属性,而不是写在 prompt 里的指令。
一个按下 F5 就消失的视图,只能算演示。CanvasDocument 是能够经受页面重新加载、用户切换和系统部署的快照。
var doc = CanvasDocument.Capture(workspace, "Monday morning", form);
await store.SaveAsync(doc);
// …later, on another machine, after a deploy
doc.Restore(workspace);
这里面有四项设计决策,如果放到 code review 中,我会坚定地为它们辩护:
Binding 会被保存,数字不会。恢复后的 document 会向已注册的数据源请求最新值。Document 永远不是数据库的一份过期副本——它保存的是问题,而不是答案。星期四打开星期一的看板,看到的会是星期四的数据。
读取操作受 schema 约束。TryFromJson 是唯一入口,因为版本检查就在这里。由较新版本构建程序写出的 document 会被明确拒绝,并给出一句人类能够看懂的提示,而不是只反序列化一半,悄悄产生某种难以察觉的错误结果。
AsTemplate(title) 可以创建相同的视图,但不包含 store id,并使用一个新标题。应用正是通过这种方式交付标准仪表盘:团队的“星期一早晨”看板会成为一个起点,人们可以 fork 并按需调整,而不是让它沦为 Confluence 里的一张截图。
实时表单值会被折叠进去。执行 capture 时,用户已经输入的内容会被折叠进节点的 value prop;恢复时,交互节点会用这个 prop 初始化自身。一个填到一半的表单,恢复后仍然是填到一半的状态,而加载流程中不需要为此编写任何专门代码。
还有一个小细节:正在执行退出动画的视图会被跳过。正在离场的东西,不属于 document。
用来修改 document 的东西叫作 DrylCanvasDock,它的设计要求只有一句话:做一个命令栏,而不是聊天框。
Artifact 本身才是答案,旁边的文字不是,因此文字没有资格占据一半屏幕。界面里只有一个输入框、一行实时状态——Building · 7 elements——只有当你主动要求时,才会显示完整对话记录。
有两个细节很值得借鉴:
Dock 位于浏览器的 top layer 中(popover="manual"),因为 position: fixed 元素的位置是相对于最近一个设置了 transform 或 backdrop-filter 的祖先元素计算的。而在一个由玻璃质感卡片组成的应用里,这个祖先基本总会是某张卡片。如果你的浮动面板曾经莫名其妙地被裁切,原因就在这里。
在 canvas 上选中一个元素后,dock 中会出现一个上下文 chip,并在你的下一句话前加上该节点的 id、type 和 label。这样一来,“把它改成柱状图”就会准确作用于目标节点,而不是交给模型去做最优猜测。随后,更新会以 patch 的形式流式到达:每隔 260 ms 应用一个 op,让一次修改呈现为一段编排好的动作,而不是画面闪烁。一次操作,一个动作。
最新加入的部分是 DrylVoiceRun。按下 dock 中的麦克风后,dock 本身就会变成对话界面——composer、chip 和建议项会退到一旁,取而代之的是一个 orb,以及刚刚说出的最后一句话。
音频永远不会经过 .NET。浏览器通过 WebRTC peer connection 直接连接 realtime API。让音频经过 Blazor Server circuit,会在每个方向上增加几百毫秒延迟,而这几百毫秒恰恰决定了它听起来像一场对话,还是像在使用对讲机。
服务器只负责发起一次 HTTP 调用:签发一个 60 秒后过期、并且已经将完整 session 信息写入其中的 ek_… client secret。浏览器永远看不到 API key,也无法修改 model、instructions 或 tool list——这些内容都已经封装在签发的 token 中。
Tool call 会通过 data channel 返回,并落到 C# 中:
[JSInvokable]
public async Task<string> OnToolCallAsync(string callId, string name, string argumentsJson)
{
var tool = Options.FindTool(name); // only what the host registered
if (tool is null) return Fail($"Unknown tool \"{name}\".");
var result = await tool.InvokeAsync(ParseArguments(argumentsJson));
return result as string ?? JsonSerializer.Serialize(result);
}
浏览器能够提供的只有一个名称。实际执行的是 host 放进列表里的内容,因此,即使页面被篡改,也无法凭空创造出一个 tool。而且这里永远不会抛出异常:如果模型一直等待一个永远不会到达的结果,它会在对话中途停住,再也无法继续;但如果它能读到错误信息,就可以接着交流。
把文本 Agent 所使用的同一份 tool list 交给它——包括 create_artifact、update_artifact 和 open_view——整个循环就闭合了。你说:“按区域展示上个季度的数据,再把昨天仍未处理的订单放到旁边。”在你这句话还没说完时,图表就已经开始绘制,第二个视图也正在第一个视图旁边打开。
还有两件事,如果重做一次,我仍然会采用相同的方式:
Voice 会映射到这个库现有的五种 AiState 值上——Listening、Thinking、Speaking 分别变成 Active、Thinking、Streaming。不引入任何新词汇。整个 UI 使用同一种语言呼吸,因此,即使不看标签,你也能感受到 AI 正在哪里工作。
Input level 被刻意留在 .NET 之外。一个每秒更新 30 次、每次都会触发 state-change event 的 level,意味着为了一个装饰效果每秒渲染 30 次。它会一直留在浏览器中,并通过驱动 orb 上的一个 CSS 变量来实现效果。
这不是在应用角落里放一个聊天气泡,让所谓的“应用内 AI”总结你眼前已经看得到的页面。Canvas 本身就是操作界面,而 assistant 是你重塑它的方式。屏幕不再是十二个部门彼此妥协的结果,而会成为某个人此刻所提问题的答案——如果这个屏幕后来被证明很好用,他们就可以把它保存下来,让它成为应用的一部分。
再坦诚说说它的限制,因为如果我是使用者,我会想提前知道这些:
Catalog 是有意设计成封闭的。它不会渲染你的任意组件。这是为了保证模型永远无法破坏 UI 而必须付出的代价。
一次生成需要一两秒。动作编排可以改善体验,但无法让它瞬间完成。
数据源和 action 需要人工整理。描述含糊,就必然会得到糟糕的 UI。这就是新时代的“命名难题”。
DRYL 是开源项目,支持 Blazor Server 和 WebAssembly,不依赖任何 npm package。Canvas 位于 DRYL.Components 中,Agent 相关部分位于 DRYL.Components.Agents 中。
👉 components.dryl.dev
如果你用它构建了什么东西——或者以某种有趣的方式把它弄坏了——我真的很想听听。
如果需要采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。