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 控制类工具都不需要这些权限。