跳转到内容

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内置 rendererpayload 字段约定
infoInfoCardtitle / body / actions[]
formFormCardfields[] / submit_label
progressProgressCardpercent / eta_s / status
approvalApprovalCardrequest / approve_label / reject_label / timeout_s
appAppRenderer(可扩展)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, 最终客户端看到正确的合并状态。

下一步