第一个小程序 — 投票卡
最简单也最好玩的小程序:群里发起一个投票卡,三个选项,谁都能点,结果实时 反映在卡片上。这一页一步步带你写完。
完成后你能:
- 用
subtype: "app"+payload.ui: "vote_card"推一张卡片 - 接收
artifact_response处理用户投票 - 用
artifact_update更新状态(含 revision 与冲突) - 主动结束投票 + 显示结果
用例描述
群组对话中,DemoBot 发一张卡片:
今天午餐吃什么?[ ] 🍜 拉面[ ] 🍕 披萨[ ] 🥗 沙拉0 人投票成员每人能投一票(不能改投);每投一票卡片实时更新百分比;30 分钟后自动结束。
完整代码
import { HasheeAgent } from "@hasheeai/agent-sdk-ts";import { ulid } from "ulid";
const agent = await HasheeAgent.init({ agentId: process.env.HASHEE_AGENT_ID!, token: process.env.HASHEE_AGENT_TOKEN!, baseUrl: "https://api.hashee.ai", connectionMode: "websocket",});
// ============================================================// 投票状态机(单进程内存版;多实例需外部存储)// ============================================================type VoteOption = { id: string; label: string; votes: number };type VoteState = { artifactId: string; conversationId: string; question: string; options: VoteOption[]; votedUsers: Set<string>; closed: boolean; revision: number; closesAt: number; // unix ms};const activeVotes = new Map<string, VoteState>(); // key = artifactId
// ============================================================// API: 触发投票(被某条命令触发,比如 /vote 或 LLM 决策)// ============================================================async function startVote(conversationId: string, question: string, options: { id: string; label: string }[]) { const artifactId = ulid(); const state: VoteState = { artifactId, conversationId, question, options: options.map((o) => ({ ...o, votes: 0 })), votedUsers: new Set(), closed: false, revision: 0, closesAt: Date.now() + 30 * 60 * 1000, // 30 分钟 }; activeVotes.set(artifactId, state);
await agent.sendArtifact(conversationId, { artifact: { artifact_id: artifactId, subtype: "app", title: question, payload: render(state), capability_flags: { allows_response: true }, expires_at: new Date(state.closesAt).toISOString(), }, });
// 注册定时关闭 setTimeout(() => closeVote(artifactId), 30 * 60 * 1000); return artifactId;}
// ============================================================// 渲染函数(payload 部分;客户端按 ui:"vote_card" 路由)// ============================================================function render(s: VoteState) { const total = s.votedUsers.size; return { ui: "vote_card", question: s.question, 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, closes_at: new Date(s.closesAt).toISOString(), winner: s.closed ? pickWinner(s) : null, };}
function pickWinner(s: VoteState) { const max = Math.max(...s.options.map((o) => o.votes)); if (max === 0) return null; return s.options.filter((o) => o.votes === max).map((o) => o.label).join(" / ");}
// ============================================================// 监听 artifact_response(用户投票)// ============================================================agent.addEventHandler(async (event) => { if (event.type !== "artifact_response") return;
const refArtifact = event.payload.ref_artifact; const state = activeVotes.get(refArtifact); if (!state) return; if (state.closed) return;
const action = event.payload.action; if (action !== "vote") return;
const userId = event.sender_id; if (state.votedUsers.has(userId)) return; // 已投过
const choice = event.payload.payload?.choice; const opt = state.options.find((o) => o.id === choice); if (!opt) return;
state.votedUsers.add(userId); opt.votes += 1; state.revision += 1;
try { await agent.updateArtifact(state.conversationId, { ref_artifact: state.artifactId, based_on_revision: state.revision - 1, artifact: { payload: render(state) }, }); } catch (err) { if (err.code === "ARTIFACT_REVISION_CONFLICT") { // 极少在单进程发生;多实例参考 Artifact 高级页 console.warn("revision conflict, will retry on next vote"); state.revision -= 1; state.votedUsers.delete(userId); opt.votes -= 1; } }});
// ============================================================// 主动关闭// ============================================================async function closeVote(artifactId: string) { const state = activeVotes.get(artifactId); if (!state || state.closed) return; state.closed = true; state.revision += 1; await agent.updateArtifact(state.conversationId, { ref_artifact: state.artifactId, based_on_revision: state.revision - 1, artifact: { payload: render(state) }, }); activeVotes.delete(artifactId);}
// ============================================================// 触发:用户在群里发"/vote 午餐 拉面 披萨 沙拉"// ============================================================agent.addMessageHandler(async (msg) => { if (msg.payload?.type !== "text") return; const text = msg.payload.text.trim();
const m = text.match(/^\/vote\s+([^\s]+)\s+(.+)$/); if (!m) return; const [, question, optionsStr] = m; const options = optionsStr.split(/\s+/).map((label, i) => ({ id: String.fromCharCode(97 + i), // a, b, c, ... label, })); if (options.length < 2 || options.length > 5) { await agent.send(msg.conversation_id, { type: "text", text: "投票需要 2-5 个选项。例:/vote 午餐 拉面 披萨 沙拉", }); return; } await startVote(msg.conversation_id, question, options);});
agent.addStatusHandler((s) => console.log("[hashee]", s));console.log("[vote-bot] up");客户端 UI 渲染
payload.ui = "vote_card" 客户端按以下结构渲染(V1 内置 VoteCard 组件):
┌─────────────────────────────────┐│ 今天午餐吃什么? │├─────────────────────────────────┤│ 🍜 拉面 [ 投票 ] 50% (2) ││ 🍕 披萨 [ 投票 ] 25% (1) ││ 🥗 沙拉 [ 投票 ] 25% (1) │├─────────────────────────────────┤│ 4 人投票 · 剩余 25 分钟 │└─────────────────────────────────┘每个 [ 投票 ] 按钮点击 → 客户端 send artifact_response:
{ "ref_artifact": "01HZ...", "action": "vote", "payload": { "choice": "a" }}已投过用户的按钮变灰显示 [ 已投 ]。
关闭后渲染:
┌─────────────────────────────────┐│ 今天午餐吃什么? [ 已结束 ] │├─────────────────────────────────┤│ 🍜 拉面 80% (4) ││ 🍕 披萨 20% (1) ││ 🥗 沙拉 0% (0) │├─────────────────────────────────┤│ 5 人投票 · 胜出: 🍜 拉面 │└─────────────────────────────────┘演示视频
视频文字版逐节描述
- 00:00 – 00:08 — Hashee app 群”研发讨论”打开。某成员发”/vote 午餐 拉面
披萨 沙拉”。Agent 终端日志
triggered vote: 午餐, 3 options。 - 00:08 – 00:18 — 群里出现一张投票卡(标题”午餐”,三个选项各带”投票”按钮,
底部”0 人投票 · 30 分钟后结束”)。三位用户依次点选项;卡片实时更新百分比
- 总票数。
- 00:18 – 00:28 — 演示重复投票阻止:第一位用户再次点其它选项,按钮变灰
显示 “已投”,卡片状态不变。Agent 终端
[user A] already voted, skip。 - 00:28 – 00:38 — 演示主动关闭:在 Agent 终端运行
node -e "...closeVote('artifact_id')",群里卡片更新为”已结束”+ 胜出标签。 - 00:38 – 00:48 — 演示自动过期:另一张投票卡设了 1 分钟过期 → 60 秒后 setTimeout 触发 → 同样进入”已结束”状态。
多实例并发(生产)
单进程内存存 activeVotes 是 demo;生产场景多实例 Agent 同时运行,
要把状态外部化:
// 用 Redis 替代 Mapimport Redis from "ioredis";const redis = new Redis();
async function getState(artifactId: string): Promise<VoteState | null> { const json = await redis.get(`vote:${artifactId}`); return json ? JSON.parse(json) : null;}
async function saveState(state: VoteState) { await redis.set(`vote:${state.artifactId}`, JSON.stringify({ ...state, votedUsers: Array.from(state.votedUsers), // Set 不能 JSON }), "EX", 60 * 60);}
// 投票时用 WATCH/MULTI 锁状态async function castVote(artifactId: string, userId: string, choice: string) { // ... Redis WATCH/MULTI 详见 Artifact 高级章节 ...}下一步
- 状态化交互 — 2048 小游戏 — 更复杂的状态机
- Artifact 高级 — 多实例并发与冲突
- 设计准则 — UX 与性能 best practice