跳转到内容

第一个小程序 — 投票卡

最简单也最好玩的小程序:群里发起一个投票卡,三个选项,谁都能点,结果实时 反映在卡片上。这一页一步步带你写完。

完成后你能:

  • 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 替代 Map
import 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 高级章节 ...
}

详见 Artifact 高级 — 冲突处理

下一步