构建AG界面前端:事件流、组件注册表与本地大语言模型
不要再向模型请求 JSX 了。直接流式传输类型化的文本/工具/组件事件,验证属性值,注册 React 组件,并保持渲染流程封闭以便进行修改。
来源: Architecting an AG-UI Frontend in React: Event-Driven Widgets, Design Tokens & Ollama
那些仅能生成 Markdown 内容的演示聊天机器人看似很完善,但一旦有人要求添加表格、图表、日历或表单,其功能就大打折扣。此时它便不再只是个聊天界面封装工具,而变成了一个应用程序——而 Markdown 实在不是合适的应用格式。
经过多次尝试失败后,人们找到了更好的解决方案:不再让模型负责设计用户界面,而是让它来选择操作。前端已经知道如何渲染内容,后端也清楚数据存储的位置,模型只需决定下一步该做什么。
这种分工方式让助手能够渲染十六种成熟的 React 组件,支持后端驱动的用户界面,运行在本地大型语言模型上,无需新增任何前端依赖,仅需注册一个组件即可扩展功能。
简而言之:拒绝模型自动生成的标记格式。让后端按顺序输出各类事件——包括正文、工具状态以及组件指令——而客户端则负责维护记录并按顺序渲染这些事件。模型的唯一任务就是决定由哪个后端功能来响应请求。这样就能获得更稳定的输出结果、可重复使用的组件,而且随着内容目录的扩展,渲染流程也不会变得混乱。
AI生成界面存在的问题
许多AI应用都遵循着一条狭窄的发展路径:
User
↓
LLM
↓
Markdown
↓
React renders Markdown
这种方式虽然简单,但存在诸多限制。用户最终会要求查看活跃成员、比较月度收入、列出日程安排或查看个人资料。这些回应其实都是界面元素,而非段落文字。
当模型编造缺失的组件、遗漏必要的属性,或为并不存在的控件命名时,要求其输出HTML或JSX就会失败。前端随后会因这些虚构的标记而崩溃。生成的标记也无法被类型检查、进行单元测试,无法在聊天界面之外重复使用,也不能在拉取请求中接受审查。组件库所提供的保障也就不复存在了,此时的目标则是确定性。
核心理念:模型选择操作而非组件
后端从不返回HTML、JSX或React组件的名称。每一次响应都是一系列有序的事件流:
{
"events": [
{ "type": "tool", "tool": "list_members", "status": "completed" },
{ "type": "widget", "widget": "table", "props": { "rows": [] } },
{ "type": "text", "text": "I found 24 active members." }
]
}
后端负责传递意图,前端则决定呈现方式。各方的职责十分明确:AI决定内容,React决定实现方式,控件则决定外观。字符串"table"是一个语义键,而非类名——无论是在桌面端虚拟化网格还是在移动端堆叠卡片,后端都无需关心。
事件协议
合约规模保持较小,以便存储在工作内存中。结构化的事件有助于确保其安全性:
type TextEvent = {
type: 'text';
text: string;
};
type ToolEvent = {
type: 'tool';
tool: string;
status: 'running' | 'completed' | 'failed';
error?: string;
};type WidgetEvent = {
type: 'widget';
widget: WidgetName; // union of registered keys
props: Record<string, unknown>; // validated at the boundary
};export type AssistantEvent = TextEvent | ToolEvent | WidgetEvent;
三种事件类型涵盖了助手的所有响应。顺序很重要:工具芯片可以出现在它生成的组件之前,随后再跟上总结句。整个记录看起来就像是对工作流程的描述。
单一渲染管线,无分支语句
每条消息都遵循同一路径:
AiDialog
↓
ChatMessages
↓
ChatMessage
↓
MessagePart
↓
WidgetRenderer
↓
widgetRegistry[widget]
树结构中不存在临时添加的分支语句。每种事件类型都由同一个渲染器处理:文本被转换为聊天内容,工具信息转化为进度状态,组件则变为React组件。新增功能无需重写整个管线——只需注册新的组件即可。原本处理第一个组件的渲染器依然会处理第十六个组件。
可扩展,不可修改
新的组件会被注册,而数据流依然保持封闭状态。这正是开放/封闭原则在聊天界面中的应用。产品扩展问题就变成了目录管理问题,而非需要重写代码的问题。
切勿信任模型:在边界处验证属性
模型的输出属于不可信的输入数据。在组件加载之前,其属性会经过架构验证(示例中使用了Zod)。无效的数据包会被转化为安全的错误界面,而不会在代码结构深处引发运行时异常。
const registry = {
table: {
schema: z.object({
columns: z.array(z.object({ key: z.string(), label: z.string() })),
rows: z.array(z.record(z.unknown())).default([]),
}),
component: TableWidget,
},
// ...
};
function WidgetRenderer({ widget, props }: WidgetEvent) {
const entry = registry[widget];
if (!entry) return <UnknownWidget name={widget} />; const parsed = entry.schema.safeParse(props);
if (!parsed.success) return <WidgetError name={widget} issues={parsed.error.issues} />; const Widget = entry.component;
return <Widget {...parsed.data} />;
}
在边界处进行验证可以避免叶级组件出现崩溃,并能在模型处理数据出错时为开发者提供明确的故障提示。
构建真正的组件库
组件存储在以语义名称为键的注册表中:
export const widgetRegistry = {
table,
profile,
chart,
timeline,
calendar,
markdown,
json,
stats,
progress,
alert,
fileList,
} satisfies Record<string, ComponentType<any>>;
export type WidgetName = keyof typeof widgetRegistry;
WidgetName 是从注册表中获取的,因此 TypeScript 会拒绝未知键。表格、个人资料、图表和表单都会变成普通组件,应用的其他部分可以在聊天界面之外重复使用它们。
为何我选择零依赖
聊天界面复用了现有的设计系统与 React 技术栈,没有新的 UI 库被加入 package.json。这一限制迫使助手使用产品已有的按钮、表格和标记——这样聊天界面与非聊天界面的视觉风格就能保持一致。
无需 React 回调的交互式组件
这些组件不得导入聊天界面,而是通过发出 DOM 自定义事件来实现刷新或打开个人资料等操作:
// Inside a widget — it knows nothing about the app
element.dispatchEvent(
new CustomEvent('ai:action', {
bubbles: true,
detail: { action: 'open_member', payload: { id: member.id } },
})
);
Widget
↓
CustomEvent
↓
AiDialog
↓
AI Agent
该对话框仅监听一次,将事件映射到工具调用上,从而使组件具有可移植性。同一个表格既可以出现在成员页面上,也可以直接用于聊天界面,而无需额外的聊天专用属性。
AI并不决定用户界面
模型从不选择颜色、布局或组件结构,它只负责选择工具。后端调度系统会将工具的执行结果转换为带有经过验证的属性的组件事件,因此界面设计仍由前端工程师掌控。
用工具目录替代硬编码的端点
早期的原型设计会直接硬编码URL,而成熟的版本则提供了一个可供主机发现的工具目录:
{
"message": "Show me all active members",
"tools": [
{ "name": "list_members", "endpoint": "/api/v1/members", "method": "GET" },
{ "name": "get_member", "endpoint": "/api/v1/members/{id}", "method": "GET" },
{ "name": "member_stats", "endpoint": "/api/v1/members/stats", "method": "GET" }
]
}
每个工具都会指定一个端点及其数据结构。模型选择某个工具后,后端会执行该工具,组件事件则会携带执行结果。这样一来,要更换基础设施只需修改目录即可,而无需重新编写提示词。
在保持契约不变的前提下实现流式传输
流式传输仍然会发出相同的事件类型。在文本数据不断传入的过程中,工具的局部状态可能会从running变为completed。渲染器会直接追加或更新事件,而无需创建新的协议。用户在最终组件显示之前就能看到进度指示。
打造具有现代感的聊天体验
主页面依然保持为普通的列表形式。助手隐藏在“与AI的聊天”界面背后,使用页面已有的数据和组件来回答问题。连续性比单独的“AI产品”界面更为重要。
功能标志至关重要
一个功能标志就能控制整个体验:
VITE_ENABLE_AI_CHAT
通过这些标志,团队可以在不重新部署组件代码的情况下,先以测试模式启用对话功能、在负载过高时禁用它,或仅向特定员工开放该功能。
测试组件框架
测试针对三个层面:模式验证会拒绝无效的属性,注册表查询可找到已知的组件,而事件排序则能按预期以工具→组件→文本的顺序进行渲染。组件的单元测试在聊天界面之外运行。端到端测试则会打开对话框,请求添加成员,并确认表格已正确加载。
何时这种架构显得过于复杂
仅需处理段落的纯FAQ机器人仍可使用Markdown格式。当答案需要以接口形式呈现、组件必须在聊天界面之外重复使用,或是团队不希望模型自行构建组件树时,事件协议才显得十分必要。
指导所有决策的设计原则
- 模型负责选择操作,而非标记格式。
- 每条消息都使用同一套渲染流程。
- 组件进行注册,渲染流程不会分支。
- 在组件加载前先验证其属性。
- 组件通过CustomEvents进行通信,而非依赖聊天界面导入。
结果
该助手能够流式传输输入的事件,通过单一流程渲染十六种组件,验证每一个属性,并通过注册实现扩展。由于接口规范基于事件而非UI中的供应商SDK,本地大语言模型后端仍可更换。
总结
试图让模型负责绘制界面是错误的做法。应当教会它们选择所需功能,再由规范化的前端来呈现这些功能,这样才能维护设计系统、确保可测试性并促进发展。聊天界面也就成了产品已信任的那些组件的另一展示平台。
示例中的成员筛选界面仍是一个普通的产品页面。该助手并非另一款产品,而是位于“与AI聊天”控件之后,使用与该页面相同的數據平面及组件来回复。这种连贯性是刻意为之的——当聊天界面创造出独立的视觉语言时,操作员就会不再信任它;而当聊天界面与页面共享组件时,对表格的任何改进都会同时体现在两个地方。
在事件数组中调整顺序也是产品设计语言的一部分。状态为running的tool事件可以放在最前面,让用户看到工作已开始;随后可以是包含多行的widget事件;最后可以用一行总结来结束的text事件。重新排序这些事件无需更改任何React组件,就能改变对话的呈现方式。可将该数组视为故事板,由后端负责编写内容,前端则负责展示。
模式验证绝非可有可无的装饰。模型经常会省略字段、将数字强制转换为字符串,偶尔还会重命名键值。在注册表边界使用 Zod(或类似工具)可以将这些错误转化为可控的错误提示,而非让页面显示空白屏幕。在原始数据旁记录验证失败信息,能比根据用户报告猜测更快地实现工具修复。
CustomEvents 能明确各组件的职责。导入聊天钩子的组件无法在静态页面上重复使用,而仅能发送 member:open 或 table:refresh 事件的组件则可放置在任何位置。对话框会充当适配器,将这些事件转换为工具调用指令。只有该适配器知晓助手会话的相关信息。
流式处理遵循相同的协议。当令牌和工具状态更新时,客户端会直接追加或修改相关事件。并不存在另一种“流式格式”。如果某个工具在运行过程中出现故障,其状态会从running变为failed,同时还会有一条可选的文本事件说明故障原因。这样用户就能看到处理过程的连续性,而不会看到默默停止的旋转图标。
功能标志能够提升运营安全性。通过将十六个组件置于VITE_ENABLE_AI_CHAT的控制之下,团队可以在高负载情况下直接禁用对应对话框,而无需修改代码。再将该标志与针对各工具的授权机制结合使用,即可让只读目录保持正常运行,同时禁止那些可能造成破坏的工具被启用。
测试策略与架构相呼应。单元测试覆盖数据结构及纯组件渲染功能。集成测试将固定的事件数组输入渲染器并捕获其结构快照。端到端测试则向真实或模拟的后端请求数据,并验证表格组件是否能按预期显示相应列。由于这些组件属于普通组件,现有的视觉回归测试套件无需特殊的人工智能工具即可对其进行检查。
尽管如此,仍有一些过度设计的情形。如果产品仅需要用简短段落回答定义性问题,使用 Markdown 依然更为经济。当答案以接口形式呈现、相同组件会出现在聊天界面之外,或是团队拒绝让模型自行构建组件树时,事件架构的优势就会显现出来。通常在内部工具产品成熟后,这三种情况会同时出现。
刻意不添加任何新的前端依赖并非为了追求纯粹性,而是一种有意识的限制。仅为了聊天功能就新增一个图表库就会导致设计系统分裂。重新使用现有的图表和表格组件则迫使助手遵循产品的视觉规范。这样做虽然需要编写一些适配代码,但好处是能保持统一的界面风格和无障碍访问标准。
对于注重隐私的场景而言,本地大语言模型支持非常重要。由于用户界面仅能理解事件,因此将云端模型替换为本地运行时属于后端层面的问题。注册表、验证器以及CustomEvent总线保持不变。正是这种分离机制,才使得开发阶段可以使用Ollama进行前端演示,而生产环境则能使用托管模型,同时无需修改聊天界面。
扩展系统其实相当乏味。只需添加一个组件文件,用架构规范将其注册,将工具处理结果映射为属性,再编写测试即可。无需更改渲染器,也不需要新的事件类型——除非产品真的需要第四种类型,而目前三种已经足够。这种乏味性其实是一种优势:它意味着初级员工无需学习专门的AI UI框架就能贡献组件。
最终的效果更像是通往产品现有功能的对话式路由器,而非简单地给产品加上聊天机器人功能。这才是架构上的优势所在。模型变成了调度器,设计系统依然是界面呈现的权威依据,而后端则仍是数据和权限的权威来源。
在操作层面,除了记录工具目录的变更外,还需维护组件注册的变更日志。当某个工具的响应格式发生变化时,对应的组件架构也必须在同一次拉取请求中同步调整。这样的配对方式能够避免常见的问题:即代理仍能正确输出文本,但组件却会默默显示空列。审核人员应拒绝那些未同步调整组件架构的工具相关拉取请求。
无障碍性得益于对生产环境组件的复用。如果成员列表已经支持键盘导航和屏幕阅读器标签,那么嵌入聊天窗口的表格也会自动具备这些功能。若通过模型动态生成HTML,则会浪费之前的投入。因此,事件架构不仅是提升可靠性的选择,也是保障无障碍性的策略。
核心要点
- 优先使用事件流(文本、工具、组件)而非模型生成的HTML/JSX。
事件流、注册表、验证器以及 CustomEvent 总线共同构成了一个小巧但功能完备的框架,可用于实现人工智能辅助的产品界面设计,同时不会将设计控制权交给模型。应持续优化组件本身,而非仅依赖临时的标记提示,这样随着产品的不断发展,辅助系统依然易于维护。