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 |