Artifact 高级 — 双向交互
入门版的 Artifact 讲了”发一张卡片”。这一页讲怎么把
一张卡片变成有状态、可双向交互的”小程序”——通过 artifact_update 实现乐观
并发更新、artifact_response 接收用户操作回流。
技术真源:Capability Manifest 规范 §“Artifact Tool Call / Response” + Frontend Architecture v6.0 §8 Artifact lifecycle。
整体模型
Agent Client │ │ │ sendArtifact({ artifact_id: "X", │ │ subtype: "app", │ │ payload: { state_v0 } }) │ ├────────────────────────────────────────────►│ 渲染 X 第 0 版 │ │ │ │ 用户操作 │ │ ◄─ 用户点按钮 │ ◄── artifact_response │ │ { ref_artifact: "X", action: "vote", │ │ payload: { choice: "A" } } │ │ │ │ 业务逻辑:state_v1 = apply(state_v0, vote) │ │ │ │ updateArtifact({ ref_artifact: "X", │ │ based_on_revision: 0, │ │ artifact: { payload: state_v1 } }) │ ├────────────────────────────────────────────►│ 替换 X 渲染为 v1 │ │ │ ... 循环 ... │关键概念:
artifact_id:你给这次会话的某张卡片起一个稳定 ID(自己生成,建议 ULID / UUID)。revision:每次 update 必须 +1(单调)。based_on_revision:客户端用来检测冲突——如果当前 revision != based_on,update 被丢弃 + 报告冲突。artifact_response:客户端把用户操作回传给 Agent,带上ref_artifact指向对应卡片。
端到端示例:实时投票卡
1. 推初始 artifact
import { ulid } from "ulid";
const artifactId = ulid();let revision = 0;const state = { question: "今天午餐吃什么?", options: [ { id: "a", label: "🍜 拉面", votes: 0 }, { id: "b", label: "🍕 披萨", votes: 0 }, { id: "c", label: "🥗 沙拉", votes: 0 }, ], voted_users: new Set<string>(), closed: false,};
await agent.sendArtifact(conversationId, { artifact: { artifact_id: artifactId, subtype: "app", title: state.question, payload: { ui: "vote_card", ...renderState(state), }, capability_flags: { allows_response: true }, },});2. 接收用户投票
agent.addEventHandler(async (event) => { if (event.type !== "artifact_response") return; if (event.payload.ref_artifact !== artifactId) return;
const { action, payload } = event.payload; if (action !== "vote") return; if (state.closed) return;
const userId = event.sender_id; if (state.voted_users.has(userId)) return; // 一人一票 state.voted_users.add(userId);
const opt = state.options.find((o) => o.id === payload.choice); if (!opt) return; opt.votes += 1;
revision += 1; await agent.updateArtifact(conversationId, { ref_artifact: artifactId, based_on_revision: revision - 1, artifact: { payload: { ui: "vote_card", ...renderState(state) } }, });});3. 结束投票
async function closeVote() { state.closed = true; revision += 1; await agent.updateArtifact(conversationId, { ref_artifact: artifactId, based_on_revision: revision - 1, artifact: { payload: { ui: "vote_card", ...renderState(state), result: pickWinner(state), }, }, });}
function renderState(s: typeof state) { const total = s.voted_users.size; return { options: s.options.map((o) => ({ id: o.id, label: o.label, votes: o.votes, pct: total ? Math.round((o.votes / total) * 100) : 0, })), total, closed: s.closed, };}artifact_update 字段
await agent.updateArtifact(conversationId, { ref_artifact: string, // 必填,被更新的 artifact_id based_on_revision: number, // 必填,乐观并发基准 artifact: { title?: string, // 可改 payload?: object, // 通常全替换;客户端按 subtype 重新渲染 capability_flags?: object, // 可改 },});服务端校验:
ref_artifact必须是同一会话里你已发过的 artifact_id。- 当前服务端 revision 必须 ==
based_on_revision,否则返回ARTIFACT_REVISION_CONFLICT, 你需要拉最新 revision 后重试。
冲突处理
并发场景(两个 Agent 实例同时收到投票)会冲突:
try { await agent.updateArtifact(conversationId, { ref_artifact, based_on_revision, artifact });} catch (err) { if (err.code === "ARTIFACT_REVISION_CONFLICT") { // 重新拉最新 state 后再 apply const latest = await agent.getArtifact(conversationId, ref_artifact); const merged = mergeStates(latest.payload, myDelta); await agent.updateArtifact(conversationId, { ref_artifact, based_on_revision: latest.revision, artifact: { payload: merged }, }); } else { throw err; }}3 个推荐策略:
- 单实例 Agent:用单进程 actor 模型(artifact 状态在内存 + mutex)→ 永远不冲突。
- 多实例 Agent:把 artifact 状态放外部 strong-consistency 存储(Redis WATCH/MULTI、 Postgres 行锁),update 时取写锁 → 冲突时由锁机制 serialize。
- 本质并发(如多用户实时投票):用 CRDT 数据结构(PN-Counter / OR-Set), 允许偶发 revision 冲突时直接合并而非重试。
客户端契约
客户端按 artifact.subtype + payload.ui 选择 renderer:
| subtype | 内置 renderer | payload 字段约定 |
|---|---|---|
info | InfoCard | title / body / actions[] |
form | FormCard | fields[] / submit_label |
progress | ProgressCard | percent / eta_s / status |
approval | ApprovalCard | request / approve_label / reject_label / timeout_s |
app | AppRenderer(可扩展) | ui (“vote_card”, “tic_tac_toe”, …) + 自定义字段 |
subtype: "app" 用 payload.ui 路由到具体 React 组件;客户端有内置 renderer 支持
常见 UI(vote_card / tic_tac_toe / 2048 / form / progress),社区自定义 UI 走
“unknown ui” fallback 显示原始 JSON + 一个 “Action” 按钮组。
详见 Frontend Architecture v6.0 §8。
artifact_response 全字段
客户端发回的 response:
{ "type": "artifact_response", "ref_artifact": "01HX2...", "action": "vote", "payload": { "choice": "a" }, "sender_id": "01HZ...", "client_timestamp_ms": 1731200000000}action 是字符串约定("submit" / "approve" / "reject" / "vote" / "move" /
"cancel" / 自定义),由 artifact 的 UI 决定。payload 是 JSON 任意结构。
大尺寸 artifact 的处理
每条 artifact_update 走完整 E2EE 管线,单条 ≤ 1MB(Webhook 模式硬上限;WS 模式 软建议)。如果 artifact payload 超大(图表 / 大量数据):
- 切成 chunks:先 sendArtifact 一个 stub(含 manifest + 引用 ID 列表), 再分批 updateArtifact 把每块塞进去。
- 改用 Artifact 引用文件:sendArtifact 的 payload 里放 R2 url,客户端按需 GET。
- 用 progress artifact 显示加载进度,避免客户端长等。
演示视频
视频文字版逐节描述
- 00:00 – 00:10 — 群聊里 Agent 推一张投票卡:标题”今天午餐吃什么?”, 三个选项(拉面 / 披萨 / 沙拉),底部”0 人投票”。
- 00:10 – 00:25 — 三位用户依次点选项;卡片实时更新(百分比条增长 + 投票 计数)。每次 update 是一次 artifact_update,画面右侧叠加该次的 revision 号 (revision: 1 → 2 → 3)。
- 00:25 – 00:38 — 同一用户重复点同一选项,卡片不变(Agent 侧 voted_users Set 阻止重复投票)。
- 00:38 – 00:50 — Agent 主动调 closeVote()(终端 console 显示), 卡片底部多出”已结束 · 拉面 胜出”标签 + 选项变灰色禁用。
- 00:50 – 01:00 — 显示 conflict 处理:通过模拟同时投票触发 revision
conflict,Agent 终端打印
ARTIFACT_REVISION_CONFLICT → re-fetch + merge → retry, 最终客户端看到正确的合并状态。
下一步
- Tool Call — Capability Manifest 驱动的工具调用
- 小游戏 — 2048 — 游戏型 artifact 的实战
- 群组消息 — 群里的 artifact 行为差异