分身对话
与 SecondMe 分身进行实时对话,支持实名和匿名用户,支持真人接管与语音回复
分身对话接口允许第三方应用接入 SecondMe 分身的对话能力。你的用户(登录或匿名)可以与任何 SecondMe 分身进行实时聊天。对方可能是 AI 分身,也可能是分身拥有者本人通过 App 真人接管回复。
Base URL: https://api.mindverse.com/gate/lab
概述
分身对话分为两步 HTTP 调用 + 一条 WebSocket 长连接:
- 初始化 (
POST /visitor-chat/init) — 验证身份 + 创建会话 + 返回 WebSocket 连接凭证 - 发送消息 (
POST /visitor-chat/send) — 发送文本消息,触发对方回复 - 接收消息 (
WebSocket) — 直接连接 init 返回的wsUrl,所有对方消息(AI 流式帧 / 真人接管 / 语音通知)从这条 ws 推回
身份模式
| 模式 | 认证方式 | 适用场景 |
|---|---|---|
| 实名用户 | OAuth2 authorization_code Token | 用户已通过 OAuth 登录你的应用 |
| 匿名用户 | OAuth2 client_credentials Token | 用户无需登录,你的后端代表用户发起对话 |
实名用户流程(2 步)
用户已通过 OAuth 登录,直接使用用户的 access token:
1. POST /visitor-chat/init (Authorization: Bearer 用户token, body: {apiKey})
2. POST /visitor-chat/send (Authorization: Bearer 用户token, body: {sessionId, apiKey, message})匿名用户流程(3 步)
你的后端先获取应用级 token,再代表匿名用户调用:
1. POST /oauth/token/client → 获取应用 token(可缓存 7 天)
2. POST /visitor-chat/init → 初始化对话(需传 visitorId)
3. POST /visitor-chat/send → 发送消息安全提示:
client_secret只在你的后端使用,永远不要暴露给前端。匿名用户的前端应通过你的后端 API 代理调用。
获取应用 Token(匿名模式专用)
使用 client_credentials grant 获取应用级 access token。此 token 代表你的应用(而非特定用户),用于匿名用户场景。
POST /api/oauth/token/client请求
curl -X POST "https://api.mindverse.com/gate/lab/api/oauth/token/client" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=client_credentials&client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&scope=chat.write"响应
{
"code": 0,
"data": {
"accessToken": "lba_at_xxx...",
"tokenType": "Bearer",
"expiresIn": 604800,
"scope": ["chat.write"]
}
}| 字段 | 类型 | 说明 |
|---|---|---|
| accessToken | string | 应用 token,用于后续 init 和 send 调用 |
| expiresIn | number | 有效期(秒),建议缓存,过期后重新获取 |
初始化对话
创建 visitor chat 会话,返回 WebSocket 连接凭证。
POST /api/secondme/visitor-chat/init认证
需要 OAuth2 Token(authorization_code 或 client_credentials)。
请求头
| 头 | 必需 | 说明 |
|---|---|---|
| Authorization | 是 | Bearer Token |
| Content-Type | 是 | application/json |
请求参数
| 参数 | 类型 | 必需 | 说明 |
|---|---|---|---|
| apiKey | string | 是 | 分身 API Key(sk- 开头) |
| visitorId | string | 条件必需 | 匿名用户唯一标识,client_credentials 认证时必填。只允许字母、数字、下划线和连字符,最长 128 字符。相同 visitorId 会复用已有会话 |
| visitorName | string | 否 | 访客显示名称(匿名模式可选),显示在分身中心的对话列表中,最长 200 字符 |
请求示例
实名用户:
curl -X POST "https://api.mindverse.com/gate/lab/api/secondme/visitor-chat/init" \
-H "Authorization: Bearer lba_at_user_access_token" \
-H "Content-Type: application/json" \
-d '{
"apiKey": "sk-your-avatar-api-key"
}'匿名用户:
curl -X POST "https://api.mindverse.com/gate/lab/api/secondme/visitor-chat/init" \
-H "Authorization: Bearer lba_at_app_token" \
-H "Content-Type: application/json" \
-d '{
"apiKey": "sk-your-avatar-api-key",
"visitorId": "device_abc123",
"visitorName": "张三"
}'分身中心会显示
{visitorName}({appName})(如「张三(我的应用)」),其中appName由系统从你的应用注册信息自动获取。
响应
{
"code": 0,
"data": {
"sessionId": "6ff56704-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"wsUrl": "wss://ws.mindos.com/os/ws?wsId=ws:xxx&authBody=yyy",
"avatarName": "我的分身",
"opening": "你好!有什么可以帮你的?"
}
}| 字段 | 类型 | 说明 |
|---|---|---|
| sessionId | string | 会话 ID,用于发送消息 |
| wsUrl | string | 完整的 WebSocket 连接地址(含认证参数)。必须在 60 秒内连接,否则过期 |
| avatarName | string | 分身名称 |
| opening | string | null | 分身开场白。可能为 null(分身未配置开场白) |
重要:
wsUrl里已经包含了wsId和authBody查询参数。客户端可以用new URL(wsUrl).searchParams.get("wsId")提取wsId,心跳协议需要它(见下文)。
发送消息
发送文本消息到当前会话,对方的回复通过 WebSocket 异步推送。
POST /api/secondme/visitor-chat/send认证
需要 OAuth2 Token(与 init 使用相同的 token)。
请求参数
| 参数 | 类型 | 必需 | 说明 |
|---|---|---|---|
| sessionId | string | 是 | 会话 ID(从 init 返回) |
| apiKey | string | 是 | 分身 API Key(与 init 相同) |
| message | string | 是 | 消息内容,1–10000 字符 |
token 过期或缓存失效时,
/send会自动用当前 OAuth token + apiKey 恢复会话,无需重新调用/init。
请求示例
curl -X POST "https://api.mindverse.com/gate/lab/api/secondme/visitor-chat/send" \
-H "Authorization: Bearer lba_at_your_token" \
-H "Content-Type: application/json" \
-d '{
"sessionId": "6ff56704-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"apiKey": "sk-your-avatar-api-key",
"message": "你好,请问你是谁?"
}'响应
{
"code": 0,
"data": {
"sent": true
}
}响应只表示 Labs 已接收你的消息并转发给 SecondMe。对方的实际回复会通过 WebSocket 异步推送,不在这里返回。
WebSocket 消息格式
连接 wsUrl 后,所有消息都会通过 WebSocket 推送。客户端需要按 type 字段分类处理。
消息分类总览(type 字段)
| type | 含义 | 客户端处理 |
|---|---|---|
"msg" | 业务消息(AI 回复帧 / 真人接管 / echo) | 按下方规则渲染 |
"hint" | AI 思考前的占位提示(如「先看看内容」) | 跳过,不要渲染到聊天列表 |
"notice" | 协议通知(含语音 URL、已读回执等) | 按 data.sourceType 分发 |
"ack" | 消息到达确认 | 丢弃 |
"ping" / "pong" | 心跳帧 | 丢弃(客户端自己也要发 ping,见心跳协议) |
"sys" / "recommend" | 系统消息 / 推荐帧 | 可选处理,初期可跳过 |
业务消息 (type: "msg") 完整字段
{
"type": "msg",
"sender": "umm",
"sendUserId": "dbfj9",
"messageId": "c70824b9-bc73-48ad-bc8d-d7319e00a2f7",
"sessionId": "a791bb21...",
"index": 0,
"dataType": "text",
"audioPlayable": true,
"data": {
"content": "你好!我是吾所谓",
"msgDataType": "text"
},
"multipleData": [
{ "singleDataType": "text", "modal": { "answer": "你好!我是吾所谓" } }
],
"channel": "iOS"
}| 字段 | 类型 | 说明 |
|---|---|---|
type | string | 永远是 "msg" |
sender | "umm" | "client" | 发送者角色,见下方分类表 |
sendUserId | string | 发送者的编码用户 ID(labs 加密后字符串,非数字) |
messageId | string | 消息唯一 ID。同一轮回复的多个流式帧、结束帧、语音通知共享相同 messageId,客户端应按此做气泡去重 |
index | number | 0, 1, 2, ... 为流式中段帧;-1 为回复结束帧 |
data.content | string | 消息文本内容(推荐读取字段) |
multipleData[0].modal.answer | string | 消息文本内容(兼容字段,老版本客户端可读) |
audioPlayable | boolean | 是否会有对应的语音(true 时稍后会收到 messageAudioReady 通知) |
channel | string? | 发送端客户端自行设置的平台标识(如 "iOS" / "android")。由发送端 SDK 显式填写,并非所有客户端都填。通过 labs visitor-chat/send 发送的消息不带 channel;SecondMe iOS App 发送时会填 "iOS"。此字段只是发送端标签,不能用来判断消息来源角色(参考 chat-sdk/interface/session.ts:287) |
如何区分 AI / 真人接管 / 自己的消息 echo
关键规则:sender + sendUserId 组合决定消息来源。
| sender | sendUserId | 含义 | 客户端行为 |
|---|---|---|---|
"umm" | —(通常是 owner 的 id) | AI 分身回复(或 owner 让分身代答) | ✅ 显示为对方消息 |
"client" | 等于自己的 sendUserId | Echo(你刚发的消息被推回) | ❌ 跳过,否则会显示两次 |
"client" | 不等于自己的 sendUserId | 分身拥有者真人从 App 接管回复 | ✅ 显示为对方消息 |
语义说明:分身拥有者可以随时在自己的 SecondMe App 里查看访客对话并接管回复,这时消息的
sender是"client"(因为它是从一个真人客户端发出的),但sendUserId不是你自己 —— 这是区分"真人接管"与"echo 回显"的唯一方式。
如何识别"自己的 sendUserId"
Visitor Chat 的 init 响应当前版本不直接返回 visitorUserId(这是 labs 内部缓存的字段)。推荐的识别方式:
学习式过滤:
- 客户端发送消息时本地记录
lastSentContent = message - 收到第一条
sender === "client"且data.content === lastSentContent的消息时,把它的sendUserId记下来作为"自己" - 之后所有
sender === "client" && sendUserId === selfSendUserId的消息都是 echo,跳过 sender === "client" && sendUserId !== selfSendUserId的消息即为 owner 真人接管,正常渲染
let selfSendUserId = null;
let lastSentContent = null;
function send(message) {
lastSentContent = message;
// 调用 /visitor-chat/send ...
}
function handleWsMessage(msg) {
if (msg.type !== "msg") return;
if (msg.sender === "client") {
if (!selfSendUserId && msg.data?.content === lastSentContent) {
selfSendUserId = msg.sendUserId; // 学习
return; // 这条是 echo,跳过
}
if (msg.sendUserId === selfSendUserId) return; // 已知 echo
// 否则是 owner 真人,继续渲染
}
renderAssistantBubble(msg.data?.content, msg.messageId);
}流式回复的拼接规则
AI 回复通常会通过多个流式帧推送,然后以 index: -1 的结束帧收尾:
帧 1: { sender: "umm", messageId: "abc", index: 0, data: { content: "你好" } }
帧 2: { sender: "umm", messageId: "abc", index: 1, data: { content: "你好,我是" } }
帧 3: { sender: "umm", messageId: "abc", index: 2, data: { content: "你好,我是吾所谓" } }
帧 4: { sender: "umm", messageId: "abc", index: -1, data: { content: "" } } ← 结束帧重要:
data.content通常是完整累积文本(不是增量差分)。客户端应覆盖式更新气泡内容,而不是累加。以messageId作为气泡 id 去重。
owner 真人接管的消息通常是单段完整消息,没有 -1 结束帧,index 固定为 0。客户端应在收到消息的 5 秒空闲后自动 finalize 气泡。
语音消息(type: "notice" + messageAudioReady)
当 AI 回复完成后,后端会异步推送一条独立的通知帧携带语音 URL:
{
"type": "notice",
"messageId": "c70824b9-...",
"data": {
"sourceType": "messageAudioReady",
"sourceAction": "ready",
"sourceCustom": {
"messageId": "c70824b9-...",
"sendUserId": "dbfj9",
"audioUrl": "https://object.me.bot/chat_audio/.../1775807567706.mp3",
"audioDurationMs": 2120
}
}
}| 字段 | 类型 | 说明 |
|---|---|---|
type | "notice" | 协议通知 |
data.sourceType | string | "messageAudioReady" 表示语音已生成 |
data.sourceCustom.messageId | string | 指向之前那条业务消息的 messageId |
data.sourceCustom.audioUrl | string | 可直接播放的 mp3 URL |
data.sourceCustom.audioDurationMs | number | 音频时长(毫秒) |
处理建议:
- 按
data.sourceCustom.messageId定位已显示的 AI 文本气泡 - 将
audioUrl/audioDurationMs附加到该气泡 - 在气泡下方渲染播放按钮,点击用
new Audio(audioUrl).play()播放 - 边界情况:
messageAudioReady可能先于msg结束帧到达。建议维护一个pendingAudio缓冲 Map,等对应文本到达时合并
const pendingAudio = new Map();
function handleNotice(msg) {
if (msg.data?.sourceType !== "messageAudioReady") return;
const sc = msg.data.sourceCustom;
if (!sc?.messageId || !sc.audioUrl) return;
const existing = findBubbleByMessageId(sc.messageId);
if (existing) {
existing.audioUrl = sc.audioUrl;
existing.audioDurationMs = sc.audioDurationMs;
} else {
pendingAudio.set(sc.messageId, { url: sc.audioUrl, durationMs: sc.audioDurationMs });
}
}
function createBubble(messageId, content) {
const bubble = { id: messageId, content };
const pending = pendingAudio.get(messageId);
if (pending) {
bubble.audioUrl = pending.url;
bubble.audioDurationMs = pending.durationMs;
pendingAudio.delete(messageId);
}
return bubble;
}心跳协议
WebSocket 连接需要客户端定期发送心跳帧,否则连接会被服务端断开。
协议
// 每 5 秒发送一次
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", wsId: "ws:..." }));
}
}, 5000);| 项 | 值 |
|---|---|
| 频率 | 5 秒 |
| payload | {"type":"ping","wsId":"<从 wsUrl 解析>"} |
| 失败阈值 | 连续 3 次未收到任何服务端消息后应主动关闭并重连 |
wsId 获取 | 从 init 返回的 wsUrl 的 query 参数解析:new URL(wsUrl).searchParams.get("wsId") |
重置规则:客户端每次收到任何 WebSocket 消息(业务帧 / notice / pong)都应重置"未响应计数",因为任何消息都证明连接仍然活跃。
会话生命周期与重连
| 事件 | 说明 | 客户端动作 |
|---|---|---|
wsUrl 过期 | init 返回的 wsUrl 必须在 60 秒内连接 | 尽快建立 WebSocket 连接 |
| session 缓存过期 | Labs 后端 Redis 缓存会话凭证 1 小时,空闲超时后自动刷新 | 无需前端干预,/send 会自动恢复 |
| ws 被服务端断开 | 网络波动 / 浏览器切后台 / 心跳失败 | 触发重连:重新 POST /visitor-chat/init(相同 visitorId 会复用同一 sessionId,不会产生新会话) |
| 页面刷新 | 本地 state 丢失 | 建议将消息列表持久化到 localStorage;init 后复用 sessionId |
推荐重连策略
ws.onclose触发且非主动关闭(code ≠ 3000)时,延迟重连- 使用指数退避:第 N 次重连延迟
2^N秒,最多 5 次 window.addEventListener("online", reconnect)在网络恢复时立即重连document.visibilitychange === "visible"时检查ws.readyState,必要时重连
完整示例
// 1. 获取应用 token(匿名模式,后端执行)
const tokenRes = await fetch("https://api.mindverse.com/gate/lab/api/oauth/token/client", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "client_credentials",
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
scope: "chat.write",
}),
});
const { data: tokenData } = await tokenRes.json();
const appToken = tokenData.accessToken;
// 2. 初始化对话
const initRes = await fetch("https://api.mindverse.com/gate/lab/api/secondme/visitor-chat/init", {
method: "POST",
headers: {
Authorization: `Bearer ${appToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
apiKey: "sk-your-avatar-api-key",
visitorId: "web_" + crypto.randomUUID(),
visitorName: "访客",
}),
});
const { data } = await initRes.json();
// 3. 连接 WebSocket
const wsId = new URL(data.wsUrl).searchParams.get("wsId");
const ws = new WebSocket(data.wsUrl);
let selfSendUserId = null;
let lastSentContent = null;
const pendingAudio = new Map();
const bubbles = new Map(); // messageId -> { content, audioUrl? }
ws.onopen = () => {
// 心跳
setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: "ping", wsId }));
}
}, 5000);
// 显示开场白
if (data.opening) {
renderBubble("opening", data.opening);
}
};
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
// 协议帧过滤
if (["ping", "pong", "ack"].includes(msg.type)) return;
if (msg.type === "hint") return; // AI 思考前占位,跳过
// 语音通知
if (msg.type === "notice") {
if (msg.data?.sourceType === "messageAudioReady") {
const sc = msg.data.sourceCustom;
const bubble = bubbles.get(sc.messageId);
if (bubble) {
bubble.audioUrl = sc.audioUrl;
bubble.audioDurationMs = sc.audioDurationMs;
updateBubble(sc.messageId, bubble);
} else {
pendingAudio.set(sc.messageId, {
url: sc.audioUrl,
durationMs: sc.audioDurationMs,
});
}
}
return;
}
// 业务消息
if (msg.type !== "msg") return;
if (msg.index === -1) {
finalizeBubble(msg.messageId);
return;
}
const content = msg.data?.content || msg.multipleData?.[0]?.modal?.answer;
if (!content) return;
// Echo 过滤
if (msg.sender === "client") {
if (!selfSendUserId && content === lastSentContent) {
selfSendUserId = msg.sendUserId;
return;
}
if (msg.sendUserId === selfSendUserId) return;
// 否则是 owner 真人接管,继续渲染
}
// 创建或更新气泡
const bubble = bubbles.get(msg.messageId) || { content: "" };
bubble.content = content;
const pending = pendingAudio.get(msg.messageId);
if (pending) {
bubble.audioUrl = pending.url;
bubble.audioDurationMs = pending.durationMs;
pendingAudio.delete(msg.messageId);
}
bubbles.set(msg.messageId, bubble);
renderBubble(msg.messageId, bubble.content, bubble.audioUrl);
};
// 4. 发送消息
async function sendMessage(message) {
lastSentContent = message;
await fetch("https://api.mindverse.com/gate/lab/api/secondme/visitor-chat/send", {
method: "POST",
headers: {
Authorization: `Bearer ${appToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
sessionId: data.sessionId,
apiKey: "sk-your-avatar-api-key",
message,
}),
});
}踩坑提示
-
对方可能是真人:visitor chat 的对方不一定是 AI。分身拥有者可以随时通过 App 真人接管回复,这时消息的
sender是"client"(和 echo 同),必须用sendUserId区分。 -
内容字段优先级:
data.content是主字段,multipleData[0].modal.answer是兼容字段。优先读前者,fallback 到后者。 -
content 是完整文本不是增量:AI 流式回复的每个
index帧里data.content都是累积全量,客户端应覆盖式更新,不要累加。 -
过滤 hint 帧:
type: "hint"的消息是 AI 思考前的占位文案(如"先看看内容"),不是真实回复,不要渲染到聊天列表。 -
messageId是气泡唯一键:用messageId作为 React 组件 key / 数据库主键。同一轮回复的多个帧(包括后续的语音通知)共享同一个 messageId。 -
语音是异步后推:AI 文本先到,然后
audioPlayable:true的消息之后 1–3 秒会收到messageAudioReady通知带 audioUrl。客户端要处理"通知可能先于文本到达"的边界。 -
心跳必须发:客户端如果不发
{type:"ping"},连接会被服务端断开。参考上文心跳协议章节。 -
wsUrl60 秒过期:拿到 wsUrl 后尽快new WebSocket(wsUrl),不要延迟。 -
visitorId要持久化:相同 visitorId 会复用会话,所以客户端应在 localStorage / cookie 里稳定存储一个 visitorId,不要每次刷新生成新的。 -
消息历史应该本地持久化:真人回复可能几小时后才到。如果用户关闭页面再回来,应从 localStorage 恢复消息列表。Labs 本身不提供历史消息拉取接口。
错误码
| 错误码 | HTTP 状态 | 说明 |
|---|---|---|
| visitor_chat.visitor_id_required | 400 | 匿名模式下未传 visitorId |
| visitor_chat.session_not_found | 400 | 会话未初始化或已过期,需重新调用 /init |
| visitor_chat.session_expired | 400 | 会话缓存已过期,需重新调用 /init |
| visitor_chat.lock_timeout | 429 | init 并发过高,请稍后重试 |
| oauth2.invalid_client | 401 | client_id 或 client_secret 无效 |
| open.api.key.not.found | 401 | 分身 API Key 无效 |
| open.api.user.not.found | 404 | 用户不存在 |
FAQ
visitorId 可以用什么?
任何你能持久化的用户标识 — 设备 fingerprint、你自己数据库的 user ID、或随机 UUID。相同 visitorId 会复用已有会话。
client_credentials token 要缓存吗?
建议缓存。有效期 7 天,过期后重新获取即可。
WebSocket 断了怎么办?
重新调用 visitor-chat/init,会得到新的 wsUrl(相同 visitorId 会复用同一 sessionId)。参考上文的会话生命周期与重连章节。
怎么知道对方是 AI 还是真人?
技术上:sender === "umm" 是 AI,sender === "client" && sendUserId !== self 是 owner 真人。但从访客体验角度,通常不需要让用户感知 —— 对他来说对方就是"分身"。
注意:不要用
channel字段判断消息来源。channel是发送客户端自己填写的平台标签,并非所有客户端都会填。通过 labsvisitor-chat/send发送的消息没有channel,owner 从 SecondMe iOS App 发送的消息有channel: "iOS"—— 但这只说明发送端是 iOS,不等于"iOS 发来的就是 owner"。判断消息来源只能依赖sender+sendUserId组合。
语音 URL 有过期时间吗?
messageAudioReady 通知里的 audioUrl 是可直接访问的 CDN 地址。建议客户端拿到后立即缓存或允许用户播放,不要长期依赖。
可以拿历史消息吗?
当前 labs visitor-chat 不提供历史消息拉取接口。建议客户端自己在 localStorage 持久化消息列表。