UI Block 协议
在 chat 流里随 markdown 文本一起推送结构化 UI 卡片的协议
UI Block 协议(UBP) 是 agent 推送 UI 卡片的传输契约。第一期用于功能引导,Phase 2 会接入沙箱化的外部 connector UI。协议直接走现有的 chat SSE 流,每个 ui_block 事件和 text_delta、tool_call_* 并列,按到达顺序渲染。
为什么单独做一个协议
Agent 已经能流式输出 markdown 文本和工具结果。现有事件覆盖不了两件事:
- 回答之后挂动作卡片。用户问「怎么入金」,回答完应该顺手挂一张「立即入金」按钮。markdown 链接也行,但卡片更易扫读,并且可以做点击埋点。
- 开放的扩展点。外部 skill 之后会想在 chat 里渲染自己的数据(跨链报价、自定义自选列表)。需要一个统一的传输格式,让第一方入金卡片和第三方跨链卡共用同一个 wire 契约。
设计参考了两个邻近协议:
- Anthropic MCP:命名空间化的 resource,加会话起点的版本协商。
- ChatGPT Apps SDK:
_meta.outputTemplate提示客户端自渲染;iframe 沙箱。
UBP 不是独立的 server,而是 chat 流上的一种事件,比 MCP 更轻,但能覆盖「卡片归属哪段回答」的契约。
Wire 形态
ui_block 事件挂在现有 chat SSE 联合上:
type AgentEvent =
| { type: "start"; data: { ts?: number; session_id?: string; supported_blocks?: UiBlockSupport[] } }
| { type: "text_delta"; data: { text: string } }
| { type: "ui_block"; data: UiBlockEvent }
// … tool_call_*, sub_tool_*, pending_question_*, done, error, …
interface UiBlockEvent {
id: string; // 当前轮内稳定 id
namespace: string; // `minara.*` 保留;外部开发者自选命名空间
block_type: string; // 例如 `feature_recommendation`
version: string; // semver;renderer 按 `^major` 匹配
data: unknown; // emit 前已经按已注册 schema 校验过
position?: "after_text" | "inline" | "before_text";
_meta?: {
"minara/outputTemplate"?: string; // 沙箱 renderer 预留位
"minara/source"?: string; // "skill" | "scenario" | …
"minara/reason"?: string; // 非用户可见的 debug 字符串
[vendorKey: string]: unknown;
};
}位置语义由到达顺序决定。Agent 把 text_delta 和 ui_block 交替输出,客户端按到达顺序渲染,所以 inline 卡就是夹在两段文本中间到达的那一张。
Capability 协商
客户端通过 chat stream URL 的 ?capabilities= 查询参数声明自己能渲染的 block:
POST /v1/chat/stream?capabilities=minara.feature_recommendation@1,acme.tools.bridge_quote@2每个 token 形如 <namespace>.<block_type>@<major>。多段命名空间按最后一个点切分,所以 acme.tools.bridge_quote@2 解析为 namespace=acme.tools、block_type=bridge_quote、major=2。
Agent 把客户端 token 和自己的 registry 取交集,在 start 事件里回写:
{
"type": "start",
"data": {
"ts": 1736000000000,
"session_id": "…",
"supported_blocks": [
{ "namespace": "minara", "block_type": "feature_recommendation", "version": 1 }
]
}
}凡是不在交集里的 block 类型,agent 这一轮绝不输出。emitUiBlock() 管道会直接拒绝(错误码 ui_block_not_supported_by_client),LLM 工具调用路径把这个错误码透传给模型,并在描述里要求模型改用 inline markdown 链接降级。
Capability token 严格语法
namespace = SEGMENT ("." SEGMENT)*
wire-token = namespace "." SEGMENT "@" MAJOR
SEGMENT = [a-z0-9_-]+
MAJOR = [1-9][0-9]*拒绝大写字母、空白、多个 @、前导 0 / 零 / 负数版本、空段 / 前导点 / 尾随点。解析器只看语法,兼容性判断由 registry 层负责。
内置 block:minara.feature_recommendation@1
最多 2 个按钮的引导卡,agent 在助手回答末尾(或回答中间)输出,把用户推到一个具体的下一步:打开入金 modal、跳到行情页、预填 slash 命令等。
数据结构
{
items: Array<{
id: string; // 卡内唯一小写标识,用于点击埋点
label: string; // ≤20 字符,按钮文字
hint?: string; // ≤48 字符,可选的一行说明
icon?: // 可选,allow-list 枚举
| "wallet" | "deposit" | "swap" | "buy" | "sell"
| "autopilot" | "workflow" | "chart" | "search"
| "settings" | "alert" | "research";
action:
| { kind: "route"; target: string } // /<in-app-path>
| { kind: "slash"; command: string } // "/buy 100 BTC"
| { kind: "modal"; name: "deposit" | "withdraw"; params?: Record<string, unknown> }
| { kind: "external"; url: string }; // 仅 https;客户端再校验 host allow-list
}>; // 最少 1,最多 2
}route走 SPA router 内部跳转。Target 必须以/<字母数字>开头。校验拒绝//、\、CR/LF/TAB、..路径段以及它们的百分编码变种(%2e、%5c、%0a等),堵住浏览器 URL 规范化绕过的口子。slash在 chat 输入框预填 slash 命令,不自动发送。参数体禁止 C0 控制字符和 DEL。modal按 id 打开已注册的 modal。当前白名单是deposit和withdraw,加新 modal 需要同时改 schema 枚举和接入 web-ui 的 opener。external在新标签页打开外链。Agent schema 只校验https://前缀;renderer 在点击时再校验 host allow-list(当前minara.ai、www.minara.ai、agent.minara.ai),不在名单内的 URL 静默拒绝并 console.warn。
UX 数量上限
代码里今天落地了两层上限,第三层在 session 级状态接入后启用:
| 层级 | 上限 | 当前是否启用 |
|---|---|---|
| 单卡 | items ≤ 2(schema) | 是 |
| 单轮 | ui_block 事件 ≤ 2 | 是 |
| 单会话 | ui_block 事件 ≤ 20(防御性硬顶) | 预留,Phase 2 |
跨卡 action 目标去重在每轮状态里跑:两张卡都推 /wallet/deposit 会折叠成第一张。Slash 去重按 verb + 非数字 token 算,所以 /buy 100 BTC 和 /buy 999 BTC 会折叠,/buy 100 ETH 保留。
Agent 怎么决定输出
由回合后 recommender 决定:主回合流式输出结束后,gateway 发起一次小型辅助 LLM 调用,读取用户问题和最终回答文本,再判断是否值得附一张卡片。决策 prompt 写得很明确:不要复述刚才说过的话,只能引导用户到一个可点击的下一步,拿不准就跳过。
recommender 只在满足以下条件时运行:客户端在握手中声明了 minara.feature_recommendation@1 能力、该轮产出了真实文本、且该轮没有其他 ui_block 已发出。没有兜底的确定性 ranker / 关键词路径。如果模型判断这一轮没有合适的卡片,整轮就保持沉默。MAX_BLOCKS_PER_TURN = 2 上限通过共享的 emit 管线作用于所有 block 生产方。
怎么加新的内置 block 类型
- 在
apps/agent/src/ui-blocks/builtin/<name>.ts写 zod schema,导出对应的 TS 类型让 web-ui 能引用。 - 在
apps/agent/src/ui-blocks/registry.ts注册(由ui-blocks/index.ts被app.ts引入时自动触发副作用)。 - 在
apps/web-ui/src/components/chat/blocks/<Name>Card.tsx写 renderer,并加到 dispatcher(apps/web-ui/src/components/chat/blocks/registry.tsx)。 - Web-ui 的
clientCapabilityTokens()会自动把新条目纳入?capabilities=握手,下一次部署就生效。
如果 block 含用户可见的字符串,按 i18n 规则在 apps/web-ui/src/i18n/en/common.json 和 apps/web-ui/src/i18n/zh/common.json 同一个 commit 里加 chat.<feature>.* key。
Phase 2:外部 connector(尚未发布)
Phase 1 只发协议契约和第一个内置 block。Agent registry 已经在类型上预留了 source: "external"。Web-ui 这边,非 minara.* 的 block 当前会落到 UnknownBlockCard,外部 connector 的 dispatcher 通路要等 Phase 2 才接通。Phase 2 会补:
- 带
ui_blocks段的connector.jsonmanifest。 _meta.outputTemplateblock 的沙箱 iframe renderer。postMessage桥,加宿主 action 白名单(navigate、runSlash、openModal)。@minara/connector-sdknpm 包,提供 block 声明 helper。
走 iframe 通路的外部 block 不会直接接触 DOM 或 app state,所有 action 走的还是和内置 block 同一套白名单 gateway 路由。
和邻近协议的对照
| 维度 | MCP | ChatGPT Apps SDK | UBP v1 |
|---|---|---|---|
| 传输 | JSON-RPC over stdio / SSE | MCP + UI templates | chat 流上 inline 的 SSE 事件 |
| 标识 | URI(scheme://host/path) | tool name + _meta.outputTemplate | <ns>.<type>@<major> |
| Capability 协商 | initialize 握手 | client 声明支持的 templates | ?capabilities= + start.supported_blocks |
| Schema | JSON Schema | JSON Schema | zod schema 在 agent 端;共享的 wire 类型在 @minara/types |
| 外部扩展 | MCP server | connector manifest | connector.json(Phase 2) |
| 渲染隔离 | client 自定 | iframe + postMessage 沙箱 | 内置 React 组件 ‖ iframe 沙箱(Phase 2) |
UBP 故意选择「现有 stream 上 inline 事件」这种形态,没有新增传输层。「卡片随回答一起出」这种场景里这个形态最合适,并且结构已经为外部 connector 留好了口子,未来不需要改 wire 格式。
埋点
点击埋点 POST 到 /v1/telemetry/block-action,body 是 { blockId, itemId, action }。Endpoint 落日志并返回 204,分析管道接结构化日志行即可。
埋点失败保持静默,用户实际触发的动作不会因为埋点失败而中断。
关键文件
| 路径 | 作用 |
|---|---|
packages/types/src/ui-blocks.ts | Wire 类型,capability token parse/format |
packages/gateway-client/src/sse.ts | AgentEvent 联合,含 ui_block 变体 |
apps/agent/src/ui-blocks/registry.ts | Agent 端 block schema,emit 时校验 |
apps/agent/src/ui-blocks/builtin/feature-recommendation.ts | 内置 block schema |
apps/agent/src/ui-blocks/emit.ts | Capability 校验,per-turn 上限,跨卡去重 |
apps/agent/src/ui-blocks/feature-recommender.ts | 回合后推荐决策 + emit |
apps/web-ui/src/components/chat/blocks/ | Web-ui dispatcher,卡片,action invokers |