> For the complete documentation index, see [llms.txt](https://docs.convai.com/api-docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.convai.com/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/multi-character-rooms.md).

# 在一个房间中托管多个角色

将一组 Convai 角色连接到一个实时房间，选择由哪个角色回答，实时更改角色列表，并允许第二个人加入。

在一个实时房间中托管多个角色，无需重新连接即可与其中任何一个对话。此页面适用于有多个发言者的场景，例如一位引导者和一位评估者、群体面试或一组 NPC。

{% hint style="warning" %}
多角色房间处于测试版。它们需要 `MULTI_CHARACTER_V0` 你账户上的权限，默认是关闭的。没有它，使用名册连接会失败，并返回 `403 MULTI_CHARACTER_V0_DISABLED`.
{% endhint %}

### 名册房间的工作方式

房间中的每个角色都是独立参与者，拥有自己的声音、对话历史和生命周期。房间是共享的；角色状态不是。

你通过传入一个有序的 `characters` 数组，而不是 `characterId`。每个条目都会成为名册中的一个成员：

* `characters[0]` 是初始活动成员，也就是最先回答的那个。
* 同一个 `characterId` 可以出现多次。每个条目都是一个独立副本，拥有自己的历史和声音。
* 每个成员都会获得一个 `membershipId`。通过以下方式指代成员： `membershipId`，绝不要通过 `characterId`，因为同一角色的两个副本共享一个 `characterId`.

名册房间需要实时（WebRTC）传输，以及一个非空的 `endUserId`.

### 使用名册连接

{% tabs %}
{% tab title="React" %}
{% code title="src/App.tsx" %}

```tsx
import { useConvaiClient } from '@convai/web-sdk/react';
import { ConvaiWidget } from '@convai/web-widget/react';

const GUIDE = 'YOUR_CHARACTER_ID';
const ASSESSOR = 'YOUR_SECOND_CHARACTER_ID';

export default function App() {
  const client = useConvaiClient({
    apiKey: 'YOUR_API_KEY',
    endUserId: 'player-42',
    characters: [
      { characterId: GUIDE },     // characters[0] 先回答
      { characterId: ASSESSOR },
      { characterId: GUIDE },     // 引导者的第二个独立副本
    ],
    maxNumParticipants: 2,
  });

  const { characters, activeMembershipId } = client.state;

  return (
    <div>
      <ul>
        {characters.map((c) => (
          <li key={c.membershipId}>
            {c.displayName ?? c.membershipId.slice(0, 8)} · {c.provisioningStatus}
            {c.membershipId === activeMembershipId && ' · 活动中'}
            {c.failureCode && ` · 失败：${c.failureCode}`}
          </li>
        ))}
      </ul>
      <ConvaiWidget convaiClient={client} />
    </div>
  );
}
```

{% endcode %}
{% endtab %}

{% tab title="TypeScript" %}
{% code title="src/main.ts" %}

```typescript
import { ConvaiClient, ConvaiRoomError } from '@convai/web-sdk/core';

const GUIDE = 'YOUR_CHARACTER_ID';
const ASSESSOR = 'YOUR_SECOND_CHARACTER_ID';

const client = new ConvaiClient({
  apiKey: 'YOUR_API_KEY',
  endUserId: 'player-42',
  characters: [
    { characterId: GUIDE },
    { characterId: ASSESSOR, characterSessionId: 'resume-this-history' },
  ],
  sharedSessionKey: 'match-7-party-a',
  maxNumParticipants: 2,
});

try {
  await client.connect();
  console.log(client.roomSession?.roomSessionId);
  console.log(client.characters.map((c) => c.membershipId));
} catch (error) {
  if (error instanceof ConvaiRoomError) {
    // 记录 requestTraceId：支持团队会用它来查找请求。
    console.error(error.status, error.code, error.requestTraceId);
  }
}
```

{% endcode %}
{% endtab %}
{% endtabs %}

* `characters` 并且 `characterId` 互斥。两者都传入会在发送任何请求之前抛出错误。
* 名册条目使用裸字符 UUID。那些版本选择器 `characterId` 支持，例如 `<uuid>-draft` 或 `<uuid>-1.2`，在名册中会被拒绝。
* `characterSessionId` 在条目上会恢复该成员先前的历史。它在名册内必须唯一。
* `client.characterId` 并且 `client.characterSessionId` 镜像 `characters[0]`.
* `sharedSessionKey` 将请求归入同一个房间：匹配的创建请求会复用现有房间。

### 区分成员

`client.characters` （以及 `client.state.characters` 在 React 中）按顺序列出名册。在单角色会话中它为空，因此检查其长度即可区分二者。每个条目都是一个 `ConvaiCharacterInstance` 携带其 `membershipId`, `characterId`, `displayName` （如果运行时发送了名称），以及其状态。

成员产生的所有内容都由以下内容归属： `membershipId`，因此同一角色的两个副本绝不会合并：

* 转录消息携带 `membershipId` 并且 `characterId`.
* `characterAudioTrack` 用于指明每个音轨的拥有成员。
* `characterReady` 传入变为就绪的成员。

#### 就绪状态不等于派发

`provisioningStatus` 仅描述派发状态： `dispatch_queued`, `dispatch_accepted` 或 `dispatch_failed`. `dispatch_accepted` 并不表示已就绪。只有当其自身的就绪信号到达后，成员才可使用，这会设置 `isReady` 为 `true` 并触发 `characterReady`.

```ts
client.on('characterReady', (member) => {
  console.log('ready', member?.membershipId);
});

client.on('characterStatus', ({ membershipId, status, failureCode }) => {
  if (status === 'failed') console.warn(membershipId, failureCode);
});
```

`characterStatus` 报告运行时添加的成员，以及在后台派发失败的成员。不要仅凭 `就绪` 状态就启用交互；请等待 `characterReady`.

#### 播放每个成员的音频

内置组件会渲染一份共享转录，并按消息归属，同时播放每个成员的音频。若要为每个角色单独显示一个面板，请自行从 `characterAudioTrack`绑定音频。该事件在名册房间中总会标明所属成员，无法归属到某个成员的轨道会被丢弃。

```ts
client.on('characterAudioTrack', ({ membershipId, track }) => {
  const el = document.querySelector<HTMLAudioElement>(`audio[data-member=\"${membershipId}\"]`);
  if (el) el.srcObject = new MediaStream([track]);
});
```

#### 将某个成员静音

传入一个 `membershipId` 为 `setCharacterMuted` 以使一个成员静音并让其余成员保持可听。不传入它则会对房间中的所有角色生效。参见 [将角色的声音静音](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/mute-the-characters-voice.md).

```ts
const [, second] = client.characters;
client.setCharacterMuted(true, { membershipId: second.membershipId });
client.isCharacterMuted(second.membershipId);   // true
```

### 切换回答者

只有当前活动成员会消耗用户轮次。调用 `setInteractionTarget` 并传入 `membershipId` ，然后再把下一条消息定向到其他成员。

```ts
const assessor = client.characters.find((c) => c.characterId === ASSESSOR)!;

await client.setInteractionTarget(assessor.membershipId);
client.sendUserTextMessage('Now grade my answer.');
```

从以下内容渲染当前活动成员： `activeCharacterChange` 而不是依赖你自己的调用，因为房间中的其他参与者也可以切换目标。

```ts
client.on('activeCharacterChange', ({ previous, active, routeEpoch }) => {
  console.log('now talking to', active, 'at epoch', routeEpoch);
});
```

* `setInteractionTarget(null)` 会关闭所有通道。在你再次设置目标之前，没有成员会听到用户。
* 尚未就绪的目标会在命令发送前被拒绝。
* 如果其他参与者先切换了，调用会基于服务器当前状态重试一次。

### 实时添加和移除成员

`updateCharacterRoster` 会更改已连接房间的名册。 `移除` 接受的是成员 ID，而不是角色 ID。

```ts
await client.updateCharacterRoster({
  add: [{ characterId: NARRATOR }],
  remove: [someMembershipId],
});
```

* 移除当前活动成员需要一个已就绪并留在房间中的替代者。若你不传入 `replacementTarget`.
* 在每个新成员发出自己的就绪信号之前，不能对其进行定向。请关注 `characterReady`.
* 房间不能为空。
* 如果其他参与者先更改了名册，调用会以冲突错误拒绝，由你来协调处理。它不会重试。
* `rosterChange` 会在变更后携带新增、移除的成员以及变更后的名册触发。

{% hint style="info" %}
丢失的确认不算失败。当服务器确认丢失时，Promise 会拒绝，并提示结果未知。请读取 `client.roomSession` ，以在发送冲突命令前查看实际发生了什么。
{% endhint %}

### 让其他人加入

第二个人通过以下方式加入第一个人创建的房间： `roomSessionId` 或者通过 `sharedSessionKey`。加入不会发送名册：名册属于服务器，而加入操作绝不会添加、移除或修复角色。

```typescript
const second = new ConvaiClient({ apiKey: 'YOUR_API_KEY' });

await second.joinRoom({
  roomSessionId: 'the-room-session-id',   // 或：sharedSessionKey: 'match-7-party-a'
  endUserId: 'player-99',
});

console.log(second.characters.length);   // 现有名册
```

* 只传入一个定位器。 `roomSessionId` 更推荐。
* `endUserId` 是必需的。
* 容量按以下标准统计不同的人： `endUserId` ，默认值为 1。设置 `maxNumParticipants` 在创建房间时设置，否则加入将失败，并返回 `409 已达到真人房间容量上限`。同一个人的重新连接不会占用额外名额。

### 限制

* **仅支持实时传输。** 名册房间不在 WebSocket 或 SSE 传输上运行。请参见 [选择传输方式](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/choose-a-transport.md).
* **不支持 action protocol v2 或客户端工具。** `capabilities.actionProtocolVersion: 2` （或 `modelOutputVersion: 2`）需要单角色房间，因此 `actionConfig.tools` 也不可用。版本 1 可用。SDK 会在发送请求前拒绝这种组合。
* **`resetSession()` 会抛出** 在名册房间中，因为每个成员的会话由服务器管理。
* **重复使用一个 `sharedSessionKey`** 并使用不同的有序名册、创建者或容量，会失败并返回 `ROSTER_FINGERPRINT_MISMATCH`。重新排序相同角色也算不同，因为它会改变初始成员。
* **配置重试。** 当相同请求仍在创建房间时，SDK 会以有限退避进行重试。传入 `rosterProvisioningRetry: false` 以自行处理 `ROSTER_PROVISIONING_IN_PROGRESS` 。

### 错误

名册 `connect()` 并且 `joinRoom()` 会抛出 `ConvaiRoomError` 当房间无法创建或加入时。它携带 `status`, `代码`, `requestTraceId`, `详情` 并且 `是否可重试`。请包含 `requestTraceId` 联系支持时。

| Code                                    | 状态  | 含义                                           |
| --------------------------------------- | --- | -------------------------------------------- |
| `MULTI_CHARACTER_V0_DISABLED`           | 403 | 你的账户未启用多角色房间。                                |
| `MULTI_CHARACTER_ROSTER_LIMIT_EXCEEDED` | 403 | 名册数量超过了你的账户允许范围。                             |
| `CHARACTER_NOT_FOUND_OR_INACCESSIBLE`   | 404 | 名册条目指定了一个你无法使用的角色。                           |
| `ROSTER_FINGERPRINT_MISMATCH`           | 409 | 一个 `sharedSessionKey` 被重复使用时使用了不同的名册、创建者或容量。 |
| `ROSTER_PROVISIONING_IN_PROGRESS`       | 409 | 房间仍在创建中。已自动重试。                               |
| `已达到真人房间容量上限`                           | 409 | 房间中已经有 `maxNumParticipants` 人。               |

### API 参考

| 属性 / 方法                              | 类型                                                                          | 描述                                                                                                                             |
| ------------------------------------ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ |
| `characters` （配置）                    | `ConvaiCharacterSpec[]`                                                     | 有序名册，传入它而不是 `characterId`。每个条目是 `{ characterId, characterSessionId? }`.                                                        |
| `sharedSessionKey` （配置）              | `string`                                                                    | 将创建请求归入同一个房间；也是一个加入定位器。                                                                                                        |
| `connectAttemptId` （配置）              | `string`                                                                    | 用于一次逻辑连接的幂等键。仅在重试相同请求时复用。                                                                                                      |
| `maxNumParticipants` （配置）            | `数值`                                                                        | 房间中最多不同人数。角色不计数。默认值 1。                                                                                                         |
| `rosterProvisioningRetry` （配置）       | `{ attempts?, initialDelayMs? } \| false`                                   | 在房间配置中重试。默认从 500 毫秒开始重试 4 次。                                                                                                   |
| `characters`                         | `ConvaiCharacterInstance[]`                                                 | 按顺序排列的名册。在单角色会话中为空。也在 `状态`.                                                                                                    |
| `activeMembershipId`                 | `string \| null`                                                            | 当前接收用户轮次的成员。也在 `状态`.                                                                                                           |
| `roomSession`                        | `ConvaiRoomSession \| null`                                                 | 服务器拥有的房间快照： `roomSessionId`, `activeMembershipId`, `routeEpoch`, `rosterEpoch`, `partialDispatch`, `characters`。在单角色会话中为 null。 |
| `setInteractionTarget(membershipId)` | `(membershipId: string \| null) => Promise<ActiveCharacterChange>`          | 将后续用户轮次路由到另一个成员。 `null` 关闭所有通道。                                                                                                |
| `updateCharacterRoster(options)`     | `(options: UpdateCharacterRosterOptions) => Promise<CharacterRosterChange>` | 添加成员（`add`），通过成员 ID 移除它们（`移除`），并可选地指定一个 `replacementTarget`.                                                                   |
| `joinRoom(options)`                  | `(options: JoinRoomOptions) => Promise<void>`                               | 通过以下方式加入现有房间： `roomSessionId` 或 `sharedSessionKey`，并且需要一个 `endUserId`.                                                         |
| `appendMessage(message)`             | `(message: AppendMessageOptions) => ChatMessage`                            | 在本地插入一条转录行，而不发送它。传入 `membershipId` 以将其归属给某个成员。                                                                                 |
| `setCharacterMuted(muted, options?)` | `(muted: boolean, options?: { membershipId?: string }) => void`             | 使所有角色静音，或使某个成员静音。                                                                                                              |
| `isCharacterMuted(membershipId?)`    | `(membershipId?: string) => boolean`                                        | 房间级设置，或某个成员的实际设置。                                                                                                              |
| `characterReady` （事件）                | `(instance: ConvaiCharacterInstance \| undefined) => void`                  | 某个成员发送了其就绪信号。                                                                                                                  |
| `characterStatus` （事件）               | `(status: CharacterStatusEvent) => void`                                    | 某个成员正在 `启动中`, `就绪` 或 `失败`，带有 `failureCode` ，在失败时。                                                                              |
| `activeCharacterChange` （事件）         | `(change: ActiveCharacterChange) => void`                                   | 活动成员已变更： `先前`, `当前`, `routeEpoch`.                                                                                             |
| `rosterChange` （事件）                  | `(change: CharacterRosterChange) => void`                                   | 名册已变更： `已添加`, `已移除`, `characters`, `activeMembershipId`.                                                                       |
| `characterAudioTrack` （事件）           | `(event: CharacterAudioTrackEvent) => void`                                 | 一条音频轨道，其 `membershipId` 并且 `characterId` 为其所有者。                                                                                |

有关完整事件列表，请参见 [事件参考](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/event-reference.md).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation by asking a question.

Perform an HTTP GET request on the following URL with the `ask` and `goal` query parameters:

```
GET https://docs.convai.com/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/multi-character-rooms.md?ask=<question>&goal=<user_goal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is what the user is ultimately trying to achieve, the reason they need the answer. Sharing it helps GitBook give you a better, more relevant answer. A goal is most helpful when it describes the outcome the user wants rather than restating the question. For example, with `ask=how do I create an API token`, a goal like `automate deployments from our CI pipeline` lets GitBook tailor the answer to that use case.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
