> 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/sse-transport.md).

# 通过 SSE 传输聊天

通过服务器发送事件将文本发送给 Convai 角色，将 API 密钥保留在你的服务器上，播放角色的声音，并在 Node 下运行客户端。

通过 SSE 传输与 Convai 角色聊天：输入文本，回复会以文本流式返回，并且可选地以语音返回。每条消息都是一个 HTTP `POST` 其响应是事件流，并且消息之间不会保持任何连接。没有 WebRTC、没有房间，也没有麦克风，因此 SSE 传输也可在 Node 下运行。使用此页面来设置聊天、避免将 API 密钥暴露在页面上、播放角色的声音，或从服务器调用角色。

{% hint style="warning" %}
SSE 传输背后的交互 API 处于 beta 版。生产环境可访问： `https://interaction-api.convai.com/v1/interactions`，在预发布环境（`https://interaction-api-stg.convai.com/v1/interactions`）以及自托管部署中可用。
{% endhint %}

### 先决条件

* 已安装 Convai Web SDK。请参阅 [Web SDK 快速入门](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/quickstart.md).
* 交互 API URL：上面的生产或预发布端点、你的自托管端点，或你自己的服务器路由（将请求转发到其中一个端点）。

### 连接并发送消息

提供 `interactionApiUrl` 会在……时选择 SSE 传输 `transport` 被省略时。你也可以显式设置 `transport: 'sse'` 。

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

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

export default function App() {
  const client = useConvaiClient({
    apiKey: 'YOUR_API_KEY',
    characterId: 'YOUR_CHARACTER_ID',
    interactionApiUrl: 'YOUR_INTERACTION_API_URL',
  });

  return <ConvaiWidget convaiClient={client} />;
}
```

{% endcode %}
{% endtab %}

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

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

const client = new ConvaiClient({
  apiKey: 'YOUR_API_KEY',
  characterId: 'YOUR_CHARACTER_ID',
  interactionApiUrl: 'YOUR_INTERACTION_API_URL',
});

client.on('characterSessionId', (id) => console.log('会话', id));
client.on('messagesChange', (messages) => {
  const last = messages.at(-1);
  if (last) console.log(last.type, last.content);
});
client.on('botTurnCompleted', () => console.log('回复完成'));

await client.connect();
client.sendUserTextMessage('请用恰好五个词说你好。');
```

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

以下两种配置会导致 `connect()` 抛出：

| 配置                                         | 错误                                 |
| ------------------------------------------ | ---------------------------------- |
| `transport: 'sse'` 在没有 `interactionApiUrl` | `使用 SSE 传输时必须提供 interactionApiUrl` |
| `transport: 'sse'` 与 `enableVideo: true`   | `SSE 传输仅支持文本交互`                    |

#### 关键行为

* **`connect()` 不会发起网络请求。** 它会将会话标记为就绪，并同时发出 `connect` 并且 `botReady` 。第一次请求会在你发送消息时发出。
* **角色会话 ID 会在第一次交互时到达。** 没有 `/connect` 握手，因此服务器会在第一条消息时分配 ID，客户端会发出 `characterSessionId`。该事件可能会在后续消息中以相同的值再次触发，因此请将其视为幂等。
* **API 密钥会以 `Authorization: Bearer …`，认证令牌会以 `API-AUTH-TOKEN`.** 当你同时提供两者时，SDK 只会发送 `authToken`。直到 1.9.3 版本，SDK 还会发送 `authToken` 作为 Bearer 值，这会被交互 API 拒绝，并返回 `401`.
* **`botTurnCompleted` 标记回复结束。** `turnEnd` 在 SSE 上永远不会触发，因为它依赖于角色的说话状态，而这种传输不会报告该状态。
* **麦克风、视频、屏幕共享和口型同步在此传输上都不起作用。** 语音输出是例外；请参阅 [播放角色的声音](#play-the-characters-voice).

### 将 API 密钥保存在服务器路由中

浏览器代码中的 API 密钥对任何打开页面的人都可见。请将 `interactionApiUrl` 改为指向你自己服务器上的某个路由。该路由会添加密钥、固定角色，并以无缓冲方式流式返回响应，因此文本会在生成时立即到达。

{% code title="api/interactions.ts" %}

```ts
// 一个 Vercel Edge 函数；任何能够流式返回响应的服务器都可以。
export const config = { runtime: 'edge' };

export default async function handler(request: Request): Promise<Response> {
  if (request.method !== 'POST') return new Response(null, { status: 405 });
  const body = await request.json();

  const upstream = await fetch(process.env.CONVAI_INTERACTION_API_URL!, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.CONVAI_API_KEY}`,
      Accept: 'text/event-stream',
      'Content-Type': 'application/json',
    },
    // 在这里固定角色，这样该路由就不能用于其他角色。
    body: JSON.stringify({ ...body, character_id: process.env.CONVAI_CHARACTER_ID }),
    signal: request.signal,
  });

  return new Response(upstream.body, {
    status: upstream.status,
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform' },
  });
}
```

{% endcode %}

在浏览器中，为密钥和角色传入占位符，因为该路由会提供真实值：

{% code title="src/chat.ts" %}

```ts
import { ConvaiClient } from '@convai/web-sdk/core';

const client = new ConvaiClient({
  apiKey: 'proxied',
  characterId: 'proxied',
  interactionApiUrl: `${window.location.origin}/api/interactions`,
});
```

{% endcode %}

设置 `CONVAI_API_KEY`, `CONVAI_CHARACTER_ID` 并且 `CONVAI_INTERACTION_API_URL` 仅作为服务器环境变量。一个 `VITE_` 或 `NEXT_PUBLIC_` 前缀会将它们打包进页面。

### 播放角色的声音

在 SSE 上，语音默认关闭。启用 `ttsEnabled: true`后，承载回复文本的同一响应也会以 `audioData` 事件。 `toggleTts(enabled)` 的形式携带其语音，并会更改下一条消息的设置。

SDK 会发出音频但不会播放它， `ConvaiWidget` 也不会由 `createConvaiWidget()` 帮你播放。每个 `audioData` 数据块都包含以下字段：

* `音频`：base64 编码的有符号 16 位小端 PCM，不包含 WAV 头。
* `sample_rate` 并且 `channels`：在构建音频缓冲区时，应从每个数据块中读取它们，而不是硬编码。

此播放器使用 Web Audio 将数据块依次排队播放，初始为静音，并可通过扬声器按钮取消静音：

{% code title="src/voice.ts" %}

```ts
import { ConvaiClient } from '@convai/web-sdk/core';

const client = new ConvaiClient({
  apiKey: 'YOUR_API_KEY',
  characterId: 'YOUR_CHARACTER_ID',
  interactionApiUrl: 'YOUR_INTERACTION_API_URL',
  ttsEnabled: false,
});

const ctx = new AudioContext();
let botMuted = true;
let endsAt = 0;
const sources = new Set<AudioBufferSourceNode>();

function stopVoice() {
  sources.forEach((s) => s.stop());
  sources.clear();
  endsAt = 0;
}

client.on('audioData', ({ audio, sample_rate, channels }) => {
  if (botMuted) return;
  const bytes = Uint8Array.from(atob(audio), (c) => c.charCodeAt(0));
  const view = new DataView(bytes.buffer);
  const frames = bytes.length / 2 / channels;
  const buffer = ctx.createBuffer(channels, frames, sample_rate);
  for (let ch = 0; ch < channels; ch++) {
    const out = buffer.getChannelData(ch);
    for (let i = 0; i < frames; i++) out[i] = view.getInt16((i * channels + ch) * 2, true) / 0x8000;
  }
  const source = ctx.createBufferSource();
  source.buffer = buffer;
  source.connect(ctx.destination);
  source.onended = () => sources.delete(source);
  sources.add(source);
  const startAt = Math.max(endsAt, ctx.currentTime + 0.04);
  source.start(startAt);
  endsAt = startAt + buffer.duration;
});

// 将其连接到扬声器按钮。浏览器只会在用户手势触发后开始播放音频。
function setBotMuted(muted: boolean) {
  botMuted = muted;
  if (muted) stopVoice();
  else void ctx.resume();
  client.toggleTts(!muted);
}

function send(text: string) {
  stopVoice(); // 新消息会切断上一条回复的语音
  client.sendUserTextMessage(text);
}

await client.connect();
```

{% endcode %}

在 React 中，改为在 effect 中订阅： `useEffect(() => client.on('audioData', play), [client])`. `on()` 会返回取消订阅函数，因此 effect 会自行清理。

#### 播放说明

* **通过手势解锁音频。** 调用 `AudioContext.resume()` 请通过点击或按键触发，否则浏览器会保持上下文挂起并且不会播放任何内容。
* **当用户发送消息时，停止队列中的音频。** `botTurnCompleted` 会在最后一个数据块送达时触发，而不是在其播放完成时，因此上一条回复可能仍在说话。
* **文本和音频是独立到达的。** `bot-llm-text` 消息和 `audioData` 数据块彼此不会等待。
* **没有说话状态。** `isSpeaking` 如果没有麦克风，这个状态几乎没有意义。请从你自己的来源 `onended`.

### 在 Node 或服务器上使用客户端

SSE 既不需要 WebRTC，也不需要 DOM，因此它是唯一能在浏览器之外运行的传输方式：Node 脚本、服务端渲染、无服务器函数、CI 检查和批处理作业。导入 `@convai/web-sdk/core` 到那里。根目录和 `@convai/web-sdk/react` 入口点会加载仅限浏览器的代码。

{% code title="ask.mjs" %}

```js
import { ConvaiClient } from '@convai/web-sdk/core';

const client = new ConvaiClient({
  apiKey: process.env.CONVAI_API_KEY,
  characterId: 'YOUR_CHARACTER_ID',
  interactionApiUrl: 'YOUR_INTERACTION_API_URL',
});

client.on('botTurnCompleted', async () => {
  const reply = client.chatMessages.filter((m) => m.type === 'bot-llm-text').at(-1);
  console.log(reply?.content);
  await client.disconnect();
});

await client.connect();
client.sendUserTextMessage('请用一句话总结你的角色。');
```

{% endcode %}

{% hint style="info" %}
你自己输入的消息不会作为 `chatMessages` 用户条目显示；转录内容会像其他传输方式一样由服务器发送的消息填充。请从你自己的状态中渲染用户输入。
{% endhint %}

### API 参考

| 属性 / 方法                               | 类型                                                             | 描述                                                                                           |
| ------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| `transport`                           | `"livekit" \| "websocket" \| "sse"`                            | 选择传输方式。解析为 `"sse"` ，在省略时为 `interactionApiUrl` ，并且已设置；否则为 `"livekit"`.                        |
| `interactionApiUrl`                   | `string`                                                       | 交互 API 端点，或将请求转发到它的服务器路由。SSE 必需。                                                             |
| `ttsEnabled`                          | `boolean`                                                      | 为每条回复生成语音。默认值为 `false` 在 SSE 上。                                                              |
| `toggleTts(enabled)`                  | `(enabled: boolean) => void`                                   | 为下一条消息打开或关闭语音。                                                                               |
| `sendUserTextMessage(text, options?)` | `(text: string, options?: SendUserTextMessageOptions) => void` | 将一条文本消息作为一个 `POST`.                                                                          |
| `sendInterruptMessage()`              | `() => void`                                                   | 停止正在进行的回复。                                                                                   |
| `characterSessionId` 事件               | `(id: string) => void`                                         | 发出服务器分配的会话 ID。可能会以相同值重复发出。                                                                   |
| `audioData` 事件                        | `(data: AudioDataEvent) => void`                               | 发出一个语音数据块： `音频` （base64 PCM）， `sample_rate`, `channels`, `includes_wav_header` （始终 `false`). |
| `botTurnCompleted` 事件                 | `(data: unknown) => void`                                      | 当回复完全送达时触发。                                                                                  |

### 后续步骤

如需在 SSE 与实时传输之间做出选择，并设置推荐的 SSE 聊天与实时语音模式，请参阅 [选择传输方式](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/choose-a-transport.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/sse-transport.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.
