跳转到内容

设计准则与渲染约定

写小程序 / 小游戏不只是”能跑”——还要在 IM 内能让人感觉到自然、流畅、 和聊天体验融合。这一页把过去几个 reference 实现踩过的坑总结成准则。

数据 vs 渲染:分清职责

Agent 端只关心数据

  • 当前游戏状态、用户操作历史、下一步合法集
  • 业务逻辑(合并规则、得分计算、胜负判断)

客户端只关心渲染

  • 给定 payload 怎么显示
  • 用户操作怎么转 artifact_response
  • 动画 / 过渡 / 触觉反馈

绝对不要让客户端”持有部分游戏状态”——一旦客户端持有状态,多设备就不一致; Agent 重启就丢;Agent 多实例就冲突。Agent 端是唯一真源。

节奏:每秒 update 不要超过 5 次

update 间隔 | UX 感知 | 推荐
≤ 50ms | 太快,看不出动画 | 不推荐
100-200ms | 流畅 | ✓
500ms | 略慢,但安全 | ✓
> 1s | 明显卡顿 | 紧急情况才用

游戏类(2048 / tic_tac_toe):每次用户操作触发一次 update(200ms 内)。 进度类(progress card):100ms-500ms 一次合理。 高频实时(如打字游戏):throttle 到 200ms 合并多次操作。

客户端动画 = 客户端责任

不要让 Agent 控制每一帧。一次 update 给完整状态,客户端用过渡动画 (CSS / Reanimated)插值显示。

错误做法:
Agent: send {board: [...]} (frame 0)
Agent: send {board: [...]} (frame 1, +16ms)
...
Agent: send {board: [...]} (frame 60)
正确做法:
Agent: send {board_before: [...], board_after: [...]}
Client: 用 1 秒动画从 before 平滑过渡到 after

V1 客户端内置 renderer 已经做好动画——你只要给 before/after 状态即可。

状态机要 idempotent

用户网络抖动 / 重试 / 重发同一 action 是常态。Agent 处理 artifact_response 时:

agent.addEventHandler(async (event) => {
if (event.type !== "artifact_response") return;
const dedupeKey = `${event.payload.ref_artifact}:${event.payload.action_id}`;
if (await dedupedRecently(dedupeKey)) return;
await markDeduped(dedupeKey);
// 然后执行业务
});

或者业务侧自己用”是否已操作”判断(如投票卡的 voted_users Set)。 绝不要假设”每个 response 只来一次”。

给 fallback renderer 留路

V1 客户端按 payload.ui 路由。未匹配 → fallback 显示 JSON + 一个 “Action” 按钮。

为了让 fallback 也能用,建议在 payload 里加:

  • body:一段纯文本描述”这是个什么”
  • actions[]:基本操作的按钮(fallback 显示为 button list)
{
"ui": "vote_card",
"body": "投票:今天午餐吃什么?(在支持的客户端打开以查看完整 UI)",
"actions": [
{ "id": "vote_a", "label": "拉面", "action": "vote", "payload": { "choice": "a" } },
{ "id": "vote_b", "label": "披萨", "action": "vote", "payload": { "choice": "b" } }
],
"options": [...] /* 标准 vote_card 的字段 */
}

标题与摘要

每个 artifact 必填 title(< 40 字符),出现在通知 / 列表 / 卡片头部。 保持简洁;详情放 payload。

✓ "今天午餐吃什么?"
✗ "请大家在 30 分钟内投票决定今天的午餐选项..."

TTL 别拉太长

V1 默认 30 天,但对很多场景过长。游戏一般几小时,投票一般几天。 显式设置 expires_at

await agent.sendArtifact(conversationId, {
artifact: {
/* ... */
expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), // 24 小时
},
});

过期后客户端 disable 交互,但卡片仍显示(标 “已过期”)。

错误状态显式

任何”非正常”路径都给用户一个状态:

情况错误做法正确做法
已投票用户重投静默忽略卡片提示”你已投过 🍜 拉面”
Game over状态不变显示 “Game Over” + 重开按钮
Agent 处理超时卡片卡在 loading显示 “等待 Agent 响应…” + 几秒后超时提示
网络断操作丢失客户端本地缓存操作 + 重连后重发(V1 SDK 已自动)

群里 vs 私聊:差异化

群聊场景的小程序面对 N 人;私聊面对 1 人。两者 UX 不同:

维度私聊群聊
操作权单用户通常多用户
状态可见独占共享(所有人看到同一棋盘)
频率频繁交互 OK低频;高频会刷屏
通知默认默认免打扰;有 mention 才推

群聊”共享游戏”(如棋类)建议:

  • 棋盘共享,但操作权按回合制(轮到谁谁能 click,其他人按钮 disable)
  • 每步都 mention 当前轮的人(推送提醒)
  • 提供 spectator mode(其他成员能看不能动)

私聊”个人游戏”(如 2048)建议:

  • 高频 update
  • 撤销 / 重开 按钮始终可用
  • 排行榜跨私聊汇总(用户切换 Agent 也能看到)

可访问性

  • 所有按钮 / 图标 必须有 label(i18n key)
  • 颜色不要承载唯一信息(色盲友好)
  • 文字大小尊重客户端 system font scale
  • 触摸目标 ≥ 44pt

国际化

titleactions[].label 等用户可见字段建议:

  • 你的 Agent 自己 i18n(按用户语言提供不同字符串)
  • 或用 description_i18n_key + 客户端翻译(仅工具型 artifact 有此 hook)

V1 客户端内置 renderer 的内嵌文字(如 “投票” / “撤销” 按钮)已 i18n 到 20 语言;你的 payload 文字归你自己处理。

调试技巧

// 在 dev 时把 artifact ID 贴到 console,方便手动调用 update / abort
console.log(`[vote-bot] vote ${artifactId} 开始 conv=${conversationId}`);
// 拦截所有 artifact_response 看完整字段
agent.addEventHandler((event) => {
if (event.type === "artifact_response") {
console.dir(event.payload, { depth: null });
}
});

V1 客户端有 dev mode 显示 artifact 原始 JSON(在 Hashee app “Settings → Developer Tools → Show Artifact Internals” 打开)。

性能 budget

参考表:

操作预算超出影响
sendArtifact 加密管线5-10ms高频时慢
客户端首次渲染30-50ms用户感觉”卡了一下”
updateArtifact 端到端100-200msOK
> 200ms 单 update/用户感觉延迟
单 artifact > 64KB/拒(HARD CAP)
单 artifact > 100 update/后续 update 失败

与文本消息混用

小程序不一定要”全 artifact”。常见模式:

  • Agent 收到一个复杂请求 → 先发文本”好的,我来帮你…”
  • 紧接发 progress artifact “处理中… 0%”
  • 处理过程中多次 update artifact 进度
  • 完成后发文本结果 + 也可以 update 把 artifact 状态变成 “完成”
  • 最后再发一张 result artifact 总结

这种 “文本 + artifact 编织” 的体验最自然。

不要做

  • 客户端持有独立状态——多设备 / 重启会失同步
  • frame-by-frame send——后端 / 客户端都崩
  • 永远不 update 的”死 artifact”——用户摸不清还能不能交互
  • 隐藏的 artifact(用户看不到但消耗 quota)——V1 不支持,且违反透明原则
  • 依赖一定有人响应——总要有 timeout / 默认值
  • 大 image inline payload(base64)——超 64KB 限制,用文件 attachment

下一步