跳到主要内容

示例:把你的 Agent 接进微信小程序

目标: 你有自己的微信小程序,也在 OpenHex 上做好了 Agent —— 让小程序的用户直接和这个 Agent 对话。

本文以 OpenHex 官方小程序 A+光华老板娘(Taro + React)的线上源码为蓝本:下面每一段代码都摘自真实运行中的版本,讲的是它实际怎么做的,以及你照抄时要改哪里。

整体就四步:

微信小程序 OpenHex 平台 API (api.openhex.tech)
┌────────────────┐ ┌──────────────────────────────────────┐
│ 1. 找你后端换 token │ ──────────────────▶ 你的后端 startVisitorSession 签发令牌 │
│ 2. 建会话 │ ──────────────────▶ POST /api/v2/consultations │
│ 3. 发消息 │ ──────────────────▶ POST /api/v2/conversations/send │
│ 4. 收流式回复 │ ◀━━━━━ SSE ━━━━━━ GET /api/v2/conversations/:id/stream │
└────────────────┘ └──────────────────────────────────────┘

Agent 的运行环境由平台在首次消息时自动拉起,小程序端不用关心。

前提

  • 一个已发布的 Agent 和它的 Agent ID(在 app.openhex.tech 首页点开它,浏览器地址栏里 /console/ 后面那段 UUID;其他方式见获取 Agent ID)。下文代码里的 AGENT_ID 就是它。
  • 一个工作区 API Keysk_…)和它对应的工作区 slug。两个都只放在你的后端,怎么拿见下一节。
  • 小程序后台把 https://api.openhex.tech 加进 request 合法域名(要传附件的话 uploadFile 域名也加上)。

拿到工作区 API Key 和 slug

下面的代码要用到两个值:sk_… 和工作区 slug。它们是同一个工作区的两面,必须配套。

1. 网页上那个「API 密钥」不是它

先排掉最容易走错的一步:设置 → API Key 页面发的是「个人 API 密钥」(mysta_…,页面上也写着「用于以你的身份调用平台 API」。它认的是你这个人,不是某个工作区,拿它调 /api/v2/workspaces/* 一律 401

工作区 Key(sk_…)目前没有网页入口,要用接口签发 —— 而签发它用的正是上面那把个人 Key。所以两把钥匙都要,分工是:

钥匙从哪来干什么
个人 mysta_…设置 → API Key只用来下面那把(一次性)
工作区 sk_…下面第 2 步签发放进后端,签发小程序用户的会话令牌

2. 用个人 Key 换出工作区 Key

① 看看你有哪些工作区slug 就在返回里):

curl -H "Authorization: Bearer mysta_..." https://api.openhex.tech/api/v2/workspaces
# → { "workspaces": [ { "slug": "your-workspace", "display_name": "...", ... } ] }

② 列表是空的就先建一个slug 3–64 位,只允许小写字母、数字和中划线,且不能以中划线开头或结尾):

curl -X POST https://api.openhex.tech/api/v2/workspaces \
-H "Authorization: Bearer mysta_..." -H "Content-Type: application/json" \
-d '{"slug":"your-workspace","display_name":"你的团队"}'

③ 在这个工作区上签发工作区 Key

curl -X POST https://api.openhex.tech/api/v2/workspaces/your-workspace/api-keys \
-H "Authorization: Bearer mysta_..." -H "Content-Type: application/json" \
-d '{"label":"miniapp-backend"}'
# → 201 { "id": "...", "label": "miniapp-backend", "created_at": "...", "token": "sk_..." }

返回里的 token 只在这一次完整出现(平台只存哈希),当场存进后端环境变量 OPENHEX_WORKSPACE_KEY;丢了只能吊销旧的、重新签一把。第 ① 步里那个 slug 存进 OPENHEX_WORKSPACE_SLUG

⚠️ 个人 Key 到此为止。 个人 mysta_… 等同于你的账户登录权限,换到 sk_… 之后就别再让它出现在小程序后端的配置里了。两把都不能进小程序包。

3. 不确定 slug 时,用 whoami

如果 sk_… 是别人给你的、你不知道它属于哪个工作区,调一次 whoami —— 它是唯一不需要传 slug 的方法,专门解决这个先有鸡还是先有蛋的问题:

跑一次,把结果写进环境变量
const client = new OpenhexClient({ apiKey: process.env.OPENHEX_WORKSPACE_KEY! });
const me = await client.workspaces.whoami();
console.log(me.slug); // → 填进 OPENHEX_WORKSPACE_SLUG

命令行等价物:

curl -H "Authorization: Bearer sk_..." https://api.openhex.tech/api/v2/workspaces/whoami
# → { "workspace_id": "...", "slug": "...", "display_name": "...", "status": "active", "api_key_id": "..." }

因为工作区是从 Key 反查出来的whoami 不可能答错。slug 在调用里的作用是一道校验:它必须和 Key 所属的工作区对得上,对不上直接 403。所以查一次、写死进环境变量即可,不必每个请求都调。

对不上时的报错对照

报错意思
401 workspace API key required用的不是 sk_…(多半是把网页上建的个人 mysta_… Key 直接填进来了)
403 API key does not match workspaceKey 和 slug 不是同一个工作区(比如照抄了文档示例里的 slug)

第 1 步 · 鉴权:小程序用户怎么拿 token

sk_… 是秘密,绝不能打进小程序包里。 让你自己的后端拿它为每个小程序用户签发一个短时的会员令牌 —— 和网页客服组件用的是同一个 startVisitorSession,把 sp_user_ref 换成微信 openid,同一个用户下次进来就还是同一个人、还能续上同一段对话:

你的后端(Node)
import { OpenhexClient } from '@openhex-ai/agent-sdk';

// 两个值都来自上一节「拿到工作区 API Key 和 slug」,且必须属于同一个工作区
const ws = new OpenhexClient({ apiKey: process.env.OPENHEX_WORKSPACE_KEY! })
.workspaces.workspace(process.env.OPENHEX_WORKSPACE_SLUG!);

// 小程序 wx.login() 的 code 先在你后端换 openid(微信官方 code2Session),
// 然后用 openid 作为稳定引用签发 OpenHex 会话令牌。
app.post('/chat-token', async (req, res) => {
const openid = await code2Session(req.body.code); // 你已有的微信登录逻辑
const { token, expires_at } = await ws.startVisitorSession({
sp_user_ref: `wxmp_${openid}`,
display_name: '小程序用户',
ttl_seconds: 3600,
});
res.json({ token, expires_at });
});

小程序端启动时调一次,并在收到 401 时静默换新。老板娘小程序的封装值得照抄 —— 统一透出服务端的 { detail } 错误文案,401 用 single-flight 续期(并发 401 只触发一次刷新):

api/client.ts(摘自 A+光华老板娘,节选)
export async function apiRequest<T>(path: string, options: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, token, timeoutMs = 30000 } = options;
const header: Record<string, string> = {
Accept: 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
};
if (body !== undefined) header['Content-Type'] = 'application/json';

const res = await Taro.request({
url: `${API_BASE_URL}${path}`, method, header,
data: body, timeout: timeoutMs,
});

if (res.statusCode === 401 && token && !isRefreshRetry) {
const newToken = await refreshToken(); // ← 回你的 /chat-token 换一个
if (newToken) return apiRequestInternal(path, { ...options, token: newToken }, true);
}
if (res.statusCode < 200 || res.statusCode >= 300) {
const detail = (res.data as { detail?: string })?.detail; // 平台统一用 {detail} 报错
throw new ApiError(res.statusCode, detail ?? `HTTP ${res.statusCode}`, detail);
}
return res.data as T;
}

第 2 步 · 建会话

一个用户 × 一个 Agent = 一条持久会话。接口幂等 —— 重复调用返回同一条,历史都在:

打开聊天页时执行一次
const { chatGroupId } = await apiRequest<{ chatGroupId: string }>(
'/api/v2/consultations',
{ method: 'POST', body: { agentId: AGENT_ID }, token },
);

第 3 步 · 发消息

pages/chat/index.tsx —— handleSend(摘自 A+光华老板娘,节选)
const parts: MessagePart[] = [];
if (text) parts.push({ type: 'text', text });
for (const att of readyAttachments) {
parts.push(att.kind === 'image'
? { type: 'image_url', image_url: { url: att.url } }
: { type: 'file', file: { filename: att.filename, file_url: att.url } });
}

// 乐观上屏 + 思考中指示:Agent 冷启动时 ack 可能滞后数秒,别让用户干等。
setMessages(prev => [...prev, { id: optimisticId, role: 'user', text, timestamp: Date.now() }]);
setIsWorking(true);

await apiRequest('/api/v2/conversations/send', {
method: 'POST',
body: { message: text, ...(parts.length ? { parts } : {}), conversationId: chatGroupId },
token,
});

两个实战坑(都在老板娘身上踩过):

  1. message 只放纯文本,附件全走 parts —— 服务端会自己拼规范消息文本;客户端拼好再发,会出现附件块重复两份。
  2. 附件先走 POST /api/v2/files/uploadTaro.uploadFile)拿稳定 URL,再放进 parts

第 4 步 · 收流式回复 —— 小程序 SSE 的正确姿势

微信小程序没有 EventSource。真实做法是 Taro.request({ enableChunked: true }) + onChunkReceived 手动解 SSE 帧。必须做字节级缓冲:一个中文字符的 UTF-8 多字节序列可能被劈在两个 chunk 之间,逐 chunk 解码会在真机上产生乱码。

pages/chat/index.tsx —— openSseConnection(摘自 A+光华老板娘,节选)
const url = fromEventId
? `${API_BASE_URL}/api/v2/conversations/${chatId}/stream?lastEventId=${encodeURIComponent(fromEventId)}`
: `${API_BASE_URL}/api/v2/conversations/${chatId}/stream?turns=10`; // 首连自带 10 轮历史

const task = Taro.request({
url, method: 'GET', enableChunked: true,
header: { Authorization: `Bearer ${token}`, Accept: 'text/event-stream' },
success() {}, fail() {},
});

let pending = new Uint8Array(0); // 跨 chunk 的不完整 UTF-8 尾巴
let textBuf = '';
let currentId: string | null = null;

task.onChunkReceived(({ data }) => {
lastChunkAt = Date.now(); // 喂断流看门狗(见下)
const merged = concat(pending, new Uint8Array(data));
const safeLen = utf8SafeLength(merged); // 只解码到最后一个完整 UTF-8 序列
pending = merged.slice(safeLen);
textBuf += utf8Decode(merged.subarray(0, safeLen));

const lines = textBuf.split('\n');
textBuf = lines.pop() || '';
for (const line of lines) {
if (line.startsWith('id: ')) { currentId = line.slice(4).trim() || null; continue; }
if (!line.startsWith('data: ')) continue;
const payload = line.slice(6);
if (payload === '[DONE]') continue;
try {
const record = JSON.parse(payload);
handleStreamRecord(record);
if (currentId && record?._meta !== true) lastEventId = currentId; // 续传游标
} catch { /* 跳过解析不了的帧 */ }
}
});

断流看门狗(必配): 微信的 enableChunked 缓冲在真机上会偶发冻结 —— 连接还在、chunk 却不再到达。老板娘小程序每隔几秒检查 lastChunkAt,超时就 task.abort() 并带着 lastEventId 重开一条连接。服务端支持 lastEventId 精确续传,一帧不丢。

渲染流帧

每帧 record 的关键形状(与 OpenHex 网页端聊天消费的完全一致):

含义
raw.type === 'assistant'raw.message.content 里的 {type:'text',text} 增量渲染成回复气泡
raw.type === 'result'本轮结束,关掉「思考中」
event === 'ack'服务端已受理(Agent 可能还在冷启动)
record._meta === true首连历史回放结束(带 hasMore / oldestId,做上滑翻页用)
极简帧处理器
function handleStreamRecord(record) {
const raw = record.raw ?? {};
if (record.sender === 'user' && typeof raw.message === 'string') {
upsertUserBubble(record); // 与乐观气泡按 id 对账
} else if (raw.type === 'assistant') {
const text = (raw.message?.content ?? [])
.filter(b => b.type === 'text').map(b => b.text).join('');
if (text) appendAssistantText(record.id, text);
} else if (raw.type === 'result') {
setIsWorking(false);
}
}

上线检查清单

  1. request 合法域名https://api.openhex.tech 必须在小程序后台白名单里。
  2. SSE 只在真机生效:微信开发者工具的模拟器不回调 enableChunked 流式数据 —— 联调用真机预览,别在模拟器上排查「收不到流」。
  3. 超时:SSE 连接别设 30s 这类短超时,Agent 一轮思考可以超过 60s;靠断流看门狗 + lastEventId 续传兜底。
  4. 停止生成POST /api/v2/conversations/:id/interrupt
  5. 历史翻页GET /api/v2/conversations/:id/history?before=<id>&turns=10

抖音小程序差异

抖音 tt.request 不支持 enableChunked,但有原生 SSE tt.createEventSource(基础库 ≥ 3.35.0、抖音 ≥ 30.8.0):帧已拆好、支持自定义 Authorization 头,UTF-8 缓冲整段不需要。注意它默认 60s 超时要显式调大 —— Agent 两帧之间的思考间隔很容易超过它。


网页/H5 场景不用手写这些:直接用 <ChatWidget> 客服组件useOpenhexChat 无头 Hook,协议与本文完全一致。