MINARA
参考协议

UI Block 协议

在 chat 流里随 markdown 文本一起推送结构化 UI 卡片的协议

UI Block 协议(UBP) 是 agent 推送 UI 卡片的传输契约。第一期用于功能引导,Phase 2 会接入沙箱化的外部 connector UI。协议直接走现有的 chat SSE 流,每个 ui_block 事件和 text_deltatool_call_* 并列,按到达顺序渲染。

为什么单独做一个协议

Agent 已经能流式输出 markdown 文本和工具结果。现有事件覆盖不了两件事:

  1. 回答之后挂动作卡片。用户问「怎么入金」,回答完应该顺手挂一张「立即入金」按钮。markdown 链接也行,但卡片更易扫读,并且可以做点击埋点。
  2. 开放的扩展点。外部 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_deltaui_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。当前白名单是 depositwithdraw,加新 modal 需要同时改 schema 枚举和接入 web-ui 的 opener。
  • external 在新标签页打开外链。Agent schema 只校验 https:// 前缀;renderer 在点击时再校验 host allow-list(当前 minara.aiwww.minara.aiagent.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 类型

  1. apps/agent/src/ui-blocks/builtin/<name>.ts 写 zod schema,导出对应的 TS 类型让 web-ui 能引用。
  2. apps/agent/src/ui-blocks/registry.ts 注册(由 ui-blocks/index.tsapp.ts 引入时自动触发副作用)。
  3. apps/web-ui/src/components/chat/blocks/<Name>Card.tsx 写 renderer,并加到 dispatcher(apps/web-ui/src/components/chat/blocks/registry.tsx)。
  4. Web-ui 的 clientCapabilityTokens() 会自动把新条目纳入 ?capabilities= 握手,下一次部署就生效。

如果 block 含用户可见的字符串,按 i18n 规则在 apps/web-ui/src/i18n/en/common.jsonapps/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.json manifest。
  • _meta.outputTemplate block 的沙箱 iframe renderer。
  • postMessage 桥,加宿主 action 白名单(navigaterunSlashopenModal)。
  • @minara/connector-sdk npm 包,提供 block 声明 helper。

走 iframe 通路的外部 block 不会直接接触 DOM 或 app state,所有 action 走的还是和内置 block 同一套白名单 gateway 路由。

和邻近协议的对照

维度MCPChatGPT Apps SDKUBP v1
传输JSON-RPC over stdio / SSEMCP + UI templateschat 流上 inline 的 SSE 事件
标识URI(scheme://host/pathtool name + _meta.outputTemplate<ns>.<type>@<major>
Capability 协商initialize 握手client 声明支持的 templates?capabilities= + start.supported_blocks
SchemaJSON SchemaJSON Schemazod schema 在 agent 端;共享的 wire 类型在 @minara/types
外部扩展MCP serverconnector manifestconnector.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.tsWire 类型,capability token parse/format
packages/gateway-client/src/sse.tsAgentEvent 联合,含 ui_block 变体
apps/agent/src/ui-blocks/registry.tsAgent 端 block schema,emit 时校验
apps/agent/src/ui-blocks/builtin/feature-recommendation.ts内置 block schema
apps/agent/src/ui-blocks/emit.tsCapability 校验,per-turn 上限,跨卡去重
apps/agent/src/ui-blocks/feature-recommender.ts回合后推荐决策 + emit
apps/web-ui/src/components/chat/blocks/Web-ui dispatcher,卡片,action invokers

本页目录