设计准则与渲染约定
写小程序 / 小游戏不只是”能跑”——还要在 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 平滑过渡到 afterV1 客户端内置 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
国际化
title 和 actions[].label 等用户可见字段建议:
- 你的 Agent 自己 i18n(按用户语言提供不同字符串)
- 或用
description_i18n_key+ 客户端翻译(仅工具型 artifact 有此 hook)
V1 客户端内置 renderer 的内嵌文字(如 “投票” / “撤销” 按钮)已 i18n 到 20 语言;你的 payload 文字归你自己处理。
调试技巧
// 在 dev 时把 artifact ID 贴到 console,方便手动调用 update / abortconsole.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-200ms | OK |
| > 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
下一步
- Artifact 高级 — revision / 冲突 / 状态外部化
- Tool Call — 让 Agent 调客户端工具的另一种交互
- 发送回复 — 文本消息 best practice