Pouchy 伴侣接入指南 — 网页游戏 · 实时语音陪玩

面向第三方网页游戏(如德州扑克)接入 Pouchy 伴侣,用实时语音陪玩:伴侣在通话里出声, 跟着你推送的牌局状态实时反应。本文是给对接方的接口说明,配合可运行示例 examples/poker-voice/。


一、对接方需要拿到的东西(交付清单)

文件 / 资源 作用
@pouchy_ai/companion-sdk(packages/companion-sdk/) 接入用的 JS SDK。已发布到公共 npm:npm install @pouchy_ai/companion-sdk;也可经 esm.sh / jsDelivr / 自托管 CDN(pouchy.ai/sdk/companion-sdk.js)直接引入。仓库内开发仍可本地构建。
本文档 docs/companion-voice-integration.md 接口说明(语音 + world-state 配方)。
examples/poker-voice/ 可运行的德州扑克语音陪玩示例,复制即用。
OpenAPI GET /api/companion/openapi.json REST 面的机读契约(可生成 client / 导入 Postman)。
协议规范 docs/companion-sdk-protocol.md 底层 wire 协议(只有要自己实现传输时才需要)。

纯 JS/TS SDK,浏览器即可跑(WebRTC + 麦克风)。语音音频走provider 直连(ElevenLabs Convai 主、 OpenAI Realtime 兜底),Pouchy 服务器不在音频链路里。


二、令牌与权限(token)

伴侣属于玩家本人的账号。游戏需要拿到该玩家的一个 Pouchy access token(pchy_…),两种方式都行:

  • 个人令牌(最快): 目前应用内还没有"伴侣接入密钥"面板 —— 玩家登录 pouchy.ai 后调用 POST /api/companion/keys(Bearer 为 Firebase ID token)生成。不传 scopes 即得 "安全默认权限包",已经包含语音陪玩需要的 call / voice / events.subscribe / worldstate.write(以及 chat / files / memory.*:app)。 不要额外传 wallet.spend / social.message / skills.execute 这些敏感权限,陪玩用不到。 token 只在响应里出现一次,记得当场复制。
  • Login with Pouchy(OAuth 2.1 + PKCE): 游戏注册一个 client(/app/developer/oauth),走授权码流程拿 access_token。

语音陪玩实际用到的 scope(默认 key 已包含,无需手动勾):call(必需)· voice · events.subscribe · worldstate.write(+ 可选 chat)。陪玩不需要敏感权限。


三、四步接通(核心代码)

import { createCompanion } from '@pouchy_ai/companion-sdk';

// 1) 建客户端(声明语音模态)
const companion = createCompanion({
  baseUrl: 'https://pouchy.ai',
  token: PLAYER_TOKEN,          // 玩家的 pchy_… 令牌
  surface: 'poker-table',       // 标识这个接入点
  modalities: ['text', 'voice']
});

// 2) 握手 + 打开出站事件流
await companion.connect();
companion.start();

// 3) 开实时语音通话(浏览器会请求麦克风)
const call = await companion.connectCall({
  locale: 'zh',
  audioTags: true,           // (0.64.0,可选)让伴侣真的笑、叹气、耳语 —— Eleven v4 语气演绎
  onTranscript: ({ role, text }) => render(role, text),     // 字幕(可选)
  onSpeakingChange: (speaking) => toggleMicGlow(speaking),  // 说话指示(可选)
  onError: (err) => console.warn(err)
});

// 4) 把牌局状态推给伴侣;⭐ 的时刻它会在通话里出声反应
companion.sendWorldState({ type: 'poker.hand', data: { hole: 'A♠ K♠', blinds: '10/20' }, retained: true });
companion.sendWorldState({
  type: 'poker.event.river',
  data: 'The river is K♦ — top pair, top kicker.',
  salience: 0.85,
  voiceRelevant: true        // ← 这一条会被推进通话,伴侣立刻出声评论
});

// 结束
call.close();

audioTags: true(SDK 0.64.0,可选):通话走 ElevenLabs 时,伴侣会把 [laughs]、[whispers] 这类语气 演出来(笑出声、叹气、压低声音耳语),而不是念出来。字幕里本来会带着这些标记,connectCall 已经替你在 onTranscript 和固化进记忆的通话记录里去掉了。服务端可能不批准(例如走了 OpenAI Realtime 备用线路,或运营方 关闭了这项能力),不批准时就是普通通话。如果你不用 connectCall 而是拿 startCall 的凭据自己接 ElevenLabs, 请对每一行转写调用 stripAudioTags(text)。SDK 0.70.0 起,获批的通话里伴侣说的每一行还会在 onTranscript 里带上 audioTags: string[]——这一行演出的语气标记,按顺序(没有就是 []);自己接 ElevenLabs 时用导出的 extractAudioTags(text) 拿到同一份列表,它和 stripAudioTags 去掉的标记完全一致,不要自己另写匹配正则。

record: true(SDK 0.65.0,可选,需在 dashboard 上为该 agent 打开「通话录音」开关):由语音服务商录下整通电话、双方的声音, 所以设备上采不到通话音频的原生 App 也能拿到和网页一样的录音。通话接通前 call.recording 就告诉你这一通 录不录(false 时 call.recordingUnavailable 说明原因:not_enabled 该 agent 的录音开关没开 / not_configured 部署上 没有开放录音 / no_conversation_id / store_failed),请按它如实告诉读者。挂断后用 companion.getCallRecording(call.callGen) 取:{ state: 'ready', audio }(MP3 Blob)、 { state: 'pending', retryAfterSec }(服务商还在处理,稍后重试),或 { state: 'none' | 'expired' | 'deleted' }。 录音保留 7 天,请转存到你自己的存储;deleteCallRecording(callGen) 删除服务商那一侧的副本(音频和文字记录)。

avatar: { faceId }(SDK 0.75.0,可选,需要 call.video 权限):给通话加一张会说话的视频头像(Simli)。 脸由你用项目的 admin key 自助建(建脸功能默认不开放,需要先联系 Pouchy 为你的项目开通,未开通时创建会返回 403):admin-sdk 的 createVideoFace(image)(或 POST /v1/admin/video-faces)用一张角色原画建脸, 建好后约 10 分钟内会自动变成 ready(不用轮询;listVideoFaces() 可以随时查看并立即刷新状态),之后即可使用(建脸要几分钟到几小时,按建成的脸计费,失败不收费)。faceId 必须是你项目里已建好的脸; 项目有了建好的脸之后,会话才会带上 call.video, 铸会话时可以用 withhold_scopes 按读者扣掉它(保留语音、去掉脸,适合未成年档位)。成功时 call.video 里是 Simli 会话(sessionToken、iceServers),用 simli-client 打开,并把伙伴那一路的远端音轨喂给它 (listenToMediastreamTrack)。拿不到视频时通话照常接通,call.videoUnavailable 说明原因 (not_enabled / not_configured / face_not_allowed / face_not_ready 脸还在建 / mint_failed)。视频在语音之外按分钟另计费。

brief(SDK 0.67.0,可选,需要 chat.instructions 权限,即 dashboard 上该 agent 的「单轮指令」开关): 告诉伙伴这通电话聊什么,比如「她刚收到录取通知」「你在车站给她打电话」。伙伴会围绕它开口、围绕它聊, 但不会照读出来。最多 1,000 字。没有该权限时请求会被拒绝(missing_scope),不会扣费。 请从你自己后端签发的 token 发送,不要用终端用户能改的 token。

Pouchy World 里的电话:用某个 world 角色的会话 token 打电话时,服务端会自动把剧情此刻的状态放进通话: 当前场景、故事时钟、flags、已经揭示的事实(带原文),以及它最近几拍说过的话。 不包含角色的秘密、私人笔记、目标和尚未揭示的事实(API 1.5.3):通话的 instructions 会交到读者的设备上 (ElevenLabs 网页 SDK 只能从客户端接收提示词覆盖),所以里面的一切都能在浏览器里读到。人设提示词和 brief 也一样: 不要放读者不该看到的内容。 角色自己的秘密、笔记、目标改由一个通话中的服务端工具(recall_private_story)提供:ElevenLabs 的服务器直接来问 Pouchy 的服务器, 内容不经过读者设备;通话指令里只写「有这个工具」。只有调用它的那一句会多几百毫秒,其他句子不受影响。 它防的是什么、不防什么:这个工具保证私密层不出现在读者的浏览器里(instructions、网络请求、devtools 都看不到)。 但它挡不住存心剧透的读者:通话的提示词由读者设备提供,改写提示词(或者在通话里直接诱导模型)就能让角色调用这个工具, 再把结果说出来。这样的读者拿到的只是自己这条故事线的剧透(凭证绑定他本人的账号和会话,世界由服务端从会话读取)。 所以请把角色的秘密当作剧透级内容,而不是机密:有真实价值的东西(有奖谜题的答案、付费内容、其他玩家的私密信息) 不要放进角色的 secrets,留在你自己的服务端,通过 world 的 beat 揭示。 call.worldBrief 告诉你结果:included / no_story(这个世界没有绑定剧情包)/ unavailable(没读到,通话照常进行)。 电话里说的话不会自动改写剧情:挂断后,请在 world 的 turn 接口发一拍,trigger.context 写「刚才是一通电话」, text 写通话要点,需要世界记住的结果放在 proposedPatches 里。

dryRun、audioTags 与音色(API 1.5.1):dryRun: true 返回的是这通电话实际会发出去的指令,并标明走哪条路: provider(配置了 ElevenLabs 时是 elevenlabs-convai,否则 openai-realtime);ElevenLabs 这条路上请求了 audioTags 时回包带 audioTags: true,指令里是允许方括号语气标签的那一版。通话的 ElevenLabs agent 用 eleven_v4_turbo 发声,所以任何要说出口的文字 (包括 firstMessage)里的方括号标签都会被演出来,不会被念出来;audioTags 决定的是教不教模型自己发标签、SDK 是否从转写里剥掉标签。 /call 的 voice 字段只作用于 OpenAI Realtime 兜底;ElevenLabs 通话用的是 agent 自己按语言配置的 voices.<locale>.elevenLabs (dashboard 或 Admin API 的 voices),这个音色 id 必须是 Pouchy 的 ElevenLabs 账号能用的。 通话有自己的「现实约束」:它只能说话,没有生成文件、图片、执行技能或改剧情的工具,不论 token 带了哪些权限。

connectCall 一步到位:它申请通话凭据 → 打开到 provider 的 WebRTC(麦克风+扬声器)→ 并自动把服务器 推来的 voiceRelevant 时刻注入到通话里。你不用手动桥接,只管 sendWorldState。

陪玩会被记住(跨会话记忆):语音音频是 provider 直连的,Pouchy 听不到通话内容,所以记忆是在 通话结束时固化的。你调 call.close() 时,SDK 会自动把这次通话的字幕 + 你推过的 world-state(发牌、 输赢、关键时刻)一起发回服务器,提炼成长期记忆。所以一定要在结束时调 call.close()(SDK ≥ 0.3.0)。 这样玩家回到 Pouchy 主应用问"刚才德州打得怎样",伴侣就知道。非语音会话在结束时手动调 companion.endSession()。 想让记忆进入跨应用共享的核心大脑,给 token 加 memory.write:core 权限即可。


四、world-state 配方(陪玩好不好,全看这里)

伴侣的"陪玩感"来自你推的牌局上下文。两类事件:

  • retained: true —— 当前状态(按 type 合并/覆盖):当前手牌、盲注、筹码、阶段。
  • transient(不带 retained) —— 一次性事件,带 salience(0–1,越戏剧越高); 关键时刻加 voiceRelevant: true,伴侣会在通话里当场出声。

德州扑克建议推送:

时机 例子 建议
发底牌 { type:'poker.hand', data:{hole:'A♠ K♠', blinds:'10/20'}, retained:true } retained
轮到玩家 { type:'poker.event.your_turn', data:'…pot 30', salience:0.7, voiceRelevant:true } ⭐ 出声
翻牌/转牌/河牌 { type:'poker.event.river', data:'river K♦ …', salience:0.85, voiceRelevant:true } ⭐ 关键牌出声
对手动作 { type:'poker.event.opponent_raise', data:'raised to 120', salience:0.5 } 一般不出声,只做上下文
摊牌/输赢 { type:'poker.event.win', data:'won 260 pot!', salience:0.95, voiceRelevant:true } ⭐ 出声

经验法则:只给真正值得开口的时刻打 voiceRelevant(轮到你、关键牌、输赢)。把每个小动作都标出声, 伴侣会变成话痨。其余只做 retained 状态或低 salience 事件,作为它的背景认知。


五、provider 依赖说明

  • ElevenLabs Convai(主): 需要可选 peer 依赖 @elevenlabs/client(npm i @elevenlabs/client)。 示例已包含。
  • OpenAI Realtime(兜底): 自带 WebRTC,零额外依赖。

服务器按账号配置自动选 provider(EL 优先 → OpenAI 兜底),客户端无法强制;两者 connectCall 用法完全一致。

无打包器(no-bundler)项目:直接从 Pouchy 源加载 SDK(自带 CORS,无需 vendor), @elevenlabs/client 走 esm.sh。在 module script 之前加 import map:

<script type="importmap">
{
  "imports": {
    "@pouchy_ai/companion-sdk": "https://pouchy.ai/sdk/companion-sdk.js",
    "@elevenlabs/client": "https://esm.sh/@elevenlabs/client@1.9.0"
  }
}
</script>
<script type="module">
  import { createCompanion } from '@pouchy_ai/companion-sdk';
</script>

详见 companion-integration-faq.md 第 6 条。


六、本地跑通示例

cd packages/companion-sdk && npm i && npm run build   # 先构建 SDK
cd ../../examples/poker-voice && npm i && npm run dev  # 跑示例

打开 localhost 地址 → 粘贴带 voice+call 的 token → 连接 → 开始语音陪玩 → 点牌桌按钮, 听伴侣在通话里反应。


七、让伴侣用语音/文字操作你的游戏(暂停、开关功能…)

声明你的游戏能做的动作,伴侣在语音通话或文字里都能调用它们——同一套回调,你只写一份逻辑 (SDK ≥ 0.4.0)。玩家说"暂停一下 / 把提示关掉",伴侣就会触发对应工具:

const companion = createCompanion({
  baseUrl: 'https://pouchy.ai', token: PLAYER_TOKEN, surface: 'poker-table',
  modalities: ['text', 'voice'],
  tools: [
    { name: 'pause_game', description: '暂停当前牌局', parameters: { type:'object', properties:{} } },
    { name: 'set_feature', description: '开关一个功能(如发牌提示)',
      parameters: { type:'object', properties:{ feature:{type:'string'}, on:{type:'boolean'} }, required:['feature','on'] } }
  ]
});
await companion.connect();
companion.start();                 // 文字/事件流
const call = await companion.connectCall({ locale: 'zh' });   // 语音

companion.onToolCall(async ({ id, name, args }) => {           // 语音 + 文字共用
  const a = JSON.parse(args);
  if (name === 'pause_game') game.pause();
  if (name === 'set_feature') ui.toggle(a.feature, a.on);
  await companion.sendToolResult(id, { ok: true });           // 回报结果,语音里它会据此应一句
});
  • 良性的应用内操作(暂停、开关功能、高亮某处)立即执行,不弹确认。
  • provider 差异(重要):语音工具调用在 OpenAI Realtime 上是全动态的(你声明什么就能调什么); ElevenLabs Convai 只会调它预先配置过的工具,共享 Convai agent 没法在运行时接收任意第三方工具。 provider 由部署统一决定(配置了 ElevenLabs 就走 ElevenLabs),没有按项目/账号固定 provider 的开关, 客户端也选不了。所以要让语音可靠地操作 app,用通用 host verb invoke_action(一次性登记在共享 Convai agent 上,见 companion-host-control.md):invoke_action({ action: "pause_game" }) 在语音侧 会被 SDK 解包成 name: "pause_game" 交给同一个 onToolCall。文字路径不受此限,任何 provider 都行。

八、(进阶)敏感操作:发消息 / 转账 / 跑技能

如果想让伴侣发起敏感操作(给对手发消息、转筹码/打赏、跑一个技能),那不会被静默执行:服务器会拦截并弹出 Pouchy 托管确认页让玩家本人批准(见 companion.confirm_request 事件 + /companion/confirm 页)。 普通陪玩 + 上面的 app 控制类工具都不需要这些权限。