SecondMeSecondMe API
SecondMe API

分身对话

与 SecondMe 分身进行实时对话,支持实名和匿名用户,支持真人接管与语音回复

分身对话接口允许第三方应用接入 SecondMe 分身的对话能力。你的用户(登录或匿名)可以与任何 SecondMe 分身进行实时聊天。对方可能是 AI 分身,也可能是分身拥有者本人通过 App 真人接管回复。

Base URL: https://api.mindverse.com/gate/lab


概述

分身对话分为两步 HTTP 调用 + 一条 WebSocket 长连接:

  1. 初始化 (POST /visitor-chat/init) — 验证身份 + 创建会话 + 返回 WebSocket 连接凭证
  2. 发送消息 (POST /visitor-chat/send) — 发送文本消息,触发对方回复
  3. 接收消息 (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"]
  }
}
字段类型说明
accessTokenstring应用 token,用于后续 init 和 send 调用
expiresInnumber有效期(秒),建议缓存,过期后重新获取

初始化对话

创建 visitor chat 会话,返回 WebSocket 连接凭证。

POST /api/secondme/visitor-chat/init

认证

需要 OAuth2 Token(authorization_codeclient_credentials)。

请求头

必需说明
AuthorizationBearer Token
Content-Typeapplication/json

请求参数

参数类型必需说明
apiKeystring分身 API Key(sk- 开头)
visitorIdstring条件必需匿名用户唯一标识,client_credentials 认证时必填。只允许字母、数字、下划线和连字符,最长 128 字符。相同 visitorId 会复用已有会话
visitorNamestring访客显示名称(匿名模式可选),显示在分身中心的对话列表中,最长 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": "你好!有什么可以帮你的?"
  }
}
字段类型说明
sessionIdstring会话 ID,用于发送消息
wsUrlstring完整的 WebSocket 连接地址(含认证参数)。必须在 60 秒内连接,否则过期
avatarNamestring分身名称
openingstring | null分身开场白。可能为 null(分身未配置开场白)

重要wsUrl 里已经包含了 wsIdauthBody 查询参数。客户端可以用 new URL(wsUrl).searchParams.get("wsId") 提取 wsId,心跳协议需要它(见下文)。


发送消息

发送文本消息到当前会话,对方的回复通过 WebSocket 异步推送。

POST /api/secondme/visitor-chat/send

认证

需要 OAuth2 Token(与 init 使用相同的 token)。

请求参数

参数类型必需说明
sessionIdstring会话 ID(从 init 返回)
apiKeystring分身 API Key(与 init 相同)
messagestring消息内容,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"
}
字段类型说明
typestring永远是 "msg"
sender"umm" | "client"发送者角色,见下方分类表
sendUserIdstring发送者的编码用户 ID(labs 加密后字符串,非数字)
messageIdstring消息唯一 ID。同一轮回复的多个流式帧、结束帧、语音通知共享相同 messageId,客户端应按此做气泡去重
indexnumber0, 1, 2, ... 为流式中段帧;-1 为回复结束帧
data.contentstring消息文本内容(推荐读取字段)
multipleData[0].modal.answerstring消息文本内容(兼容字段,老版本客户端可读)
audioPlayableboolean是否会有对应的语音(true 时稍后会收到 messageAudioReady 通知)
channelstring?发送端客户端自行设置的平台标识(如 "iOS" / "android")。由发送端 SDK 显式填写,并非所有客户端都填。通过 labs visitor-chat/send 发送的消息不带 channel;SecondMe iOS App 发送时会填 "iOS"此字段只是发送端标签,不能用来判断消息来源角色(参考 chat-sdk/interface/session.ts:287

如何区分 AI / 真人接管 / 自己的消息 echo

关键规则sender + sendUserId 组合决定消息来源。

sendersendUserId含义客户端行为
"umm"—(通常是 owner 的 id)AI 分身回复(或 owner 让分身代答)✅ 显示为对方消息
"client"等于自己的 sendUserIdEcho(你刚发的消息被推回)❌ 跳过,否则会显示两次
"client"不等于自己的 sendUserId分身拥有者真人从 App 接管回复✅ 显示为对方消息

语义说明:分身拥有者可以随时在自己的 SecondMe App 里查看访客对话并接管回复,这时消息的 sender"client"(因为它是从一个真人客户端发出的),但 sendUserId 不是你自己 —— 这是区分"真人接管"与"echo 回显"的唯一方式。

如何识别"自己的 sendUserId"

Visitor Chat 的 init 响应当前版本不直接返回 visitorUserId(这是 labs 内部缓存的字段)。推荐的识别方式:

学习式过滤

  1. 客户端发送消息时本地记录 lastSentContent = message
  2. 收到第一条 sender === "client"data.content === lastSentContent 的消息时,把它的 sendUserId 记下来作为"自己"
  3. 之后所有 sender === "client" && sendUserId === selfSendUserId 的消息都是 echo,跳过
  4. 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.sourceTypestring"messageAudioReady" 表示语音已生成
data.sourceCustom.messageIdstring指向之前那条业务消息的 messageId
data.sourceCustom.audioUrlstring可直接播放的 mp3 URL
data.sourceCustom.audioDurationMsnumber音频时长(毫秒)

处理建议

  1. data.sourceCustom.messageId 定位已显示的 AI 文本气泡
  2. audioUrl / audioDurationMs 附加到该气泡
  3. 在气泡下方渲染播放按钮,点击用 new Audio(audioUrl).play() 播放
  4. 边界情况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,
    }),
  });
}

踩坑提示

  1. 对方可能是真人:visitor chat 的对方不一定是 AI。分身拥有者可以随时通过 App 真人接管回复,这时消息的 sender"client"(和 echo 同),必须用 sendUserId 区分。

  2. 内容字段优先级data.content 是主字段,multipleData[0].modal.answer 是兼容字段。优先读前者,fallback 到后者。

  3. content 是完整文本不是增量:AI 流式回复的每个 index 帧里 data.content 都是累积全量,客户端应覆盖式更新,不要累加。

  4. 过滤 hint 帧type: "hint" 的消息是 AI 思考前的占位文案(如"先看看内容"),不是真实回复,不要渲染到聊天列表。

  5. messageId 是气泡唯一键:用 messageId 作为 React 组件 key / 数据库主键。同一轮回复的多个帧(包括后续的语音通知)共享同一个 messageId。

  6. 语音是异步后推:AI 文本先到,然后 audioPlayable:true 的消息之后 1–3 秒会收到 messageAudioReady 通知带 audioUrl。客户端要处理"通知可能先于文本到达"的边界。

  7. 心跳必须发:客户端如果不发 {type:"ping"},连接会被服务端断开。参考上文心跳协议章节。

  8. wsUrl 60 秒过期:拿到 wsUrl 后尽快 new WebSocket(wsUrl),不要延迟。

  9. visitorId 要持久化:相同 visitorId 会复用会话,所以客户端应在 localStorage / cookie 里稳定存储一个 visitorId,不要每次刷新生成新的。

  10. 消息历史应该本地持久化:真人回复可能几小时后才到。如果用户关闭页面再回来,应从 localStorage 恢复消息列表。Labs 本身不提供历史消息拉取接口。


错误码

错误码HTTP 状态说明
visitor_chat.visitor_id_required400匿名模式下未传 visitorId
visitor_chat.session_not_found400会话未初始化或已过期,需重新调用 /init
visitor_chat.session_expired400会话缓存已过期,需重新调用 /init
visitor_chat.lock_timeout429init 并发过高,请稍后重试
oauth2.invalid_client401client_id 或 client_secret 无效
open.api.key.not.found401分身 API Key 无效
open.api.user.not.found404用户不存在

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 是发送客户端自己填写的平台标签,并非所有客户端都会填。通过 labs visitor-chat/send 发送的消息没有 channel,owner 从 SecondMe iOS App 发送的消息有 channel: "iOS" —— 但这只说明发送端是 iOS,不等于"iOS 发来的就是 owner"。判断消息来源只能依赖 sender + sendUserId 组合。

语音 URL 有过期时间吗?

messageAudioReady 通知里的 audioUrl 是可直接访问的 CDN 地址。建议客户端拿到后立即缓存或允许用户播放,不要长期依赖。

可以拿历史消息吗?

当前 labs visitor-chat 不提供历史消息拉取接口。建议客户端自己在 localStorage 持久化消息列表。