> 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/vanilla-typescript/building-a-custom-ui-typescript.md).

# 在 TypeScript 中构建自定义 UI

在原生 TypeScript 中基于 Convai Web SDK 构建你自己的聊天界面，包含连接状态、消息、角色语音和媒体控制。

在……之上构建你自己的聊天界面 `ConvaiClient` 而不是使用预构建的小部件。当小部件不符合你的设计时，请使用此页面。最终，你的页面会连接、显示对话、播放角色的声音，并切换麦克风。

### 先决条件

* `@convai/web-sdk` 已安装。参见 [从原生 TypeScript 开始](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/vanilla-typescript.md).
* 一个 Convai API 密钥或身份验证令牌，以及一个角色 ID。

### 添加标记

{% code title="index.html" %}

```html
<div id="status">未连接</div>
<button id="connect">连接</button>
<button id="mic" disabled>取消静音</button>

<div id="chat"></div>

<input id="input" type="text" placeholder="输入消息" disabled />
<button id="send" disabled>发送</button>

<script type="module" src="/src/main.ts"></script>
```

{% endcode %}

### 连接客户端

下面的脚本会在点击时连接，让控件与……保持同步， `stateChange`，渲染来自……的对话， `messagesChange`，并通过……播放角色的声音。 `AudioRenderer`.

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

```typescript
import {
  AudioRenderer，
  ConvaiClient，
  类型 ChatMessage，
  类型 ConvaiClientState，
} from '@convai/web-sdk/vanilla';

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

const statusEl = document.getElementById('status') as HTMLDivElement;
const connectBtn = document.getElementById('connect') as HTMLButtonElement;
const micBtn = document.getElementById('mic') as HTMLButtonElement;
const chatEl = document.getElementById('chat') as HTMLDivElement;
const inputEl = document.getElementById('input') as HTMLInputElement;
const sendBtn = document.getElementById('send') as HTMLButtonElement;

let audioRenderer: AudioRenderer | null = null;

client.on('stateChange', (state: ConvaiClientState) => {
  statusEl.textContent = state.isConnected ? state.agentState : 'disconnected';
  connectBtn.disabled = state.isConnected || state.isConnecting;
  micBtn.disabled = !state.isConnected;
});

client.on('botReady', () => {
  inputEl.disabled = false;
  sendBtn.disabled = false;
});

client.on('messagesChange', (messages: ChatMessage[]) => {
  const shown = messages.filter(
    (m) =>
      (m.type === 'user-llm-text' ||
        m.type === 'user-transcription' ||
        m.type === 'bot-llm-text' ||
        m.type === 'bot-output') &&
      m.content.trim(),
  );
  chatEl.replaceChildren(
    ...shown.map((m) => {
      const div = document.createElement('div');
      div.className = m.type.startsWith('user') ? 'user' : 'bot';
      div.textContent = m.content;
      return div;
    }),
  );
  chatEl.scrollTop = chatEl.scrollHeight;
});

// 在默认的 WebRTC 传输上播放角色的声音。
client.on('connect', () => {
  audioRenderer ??= new AudioRenderer(client.room);
});

client.on('disconnect', () => {
  audioRenderer?.destroy();
  audioRenderer = null;
  inputEl.disabled = true;
  sendBtn.disabled = true;
});

connectBtn.addEventListener('click', async () => {
  try {
    await client.connect();
  } catch (error) {
    statusEl.textContent = error instanceof Error ? error.message : '连接失败';
  }
});

function sendMessage() {
  const text = inputEl.value.trim();
  if (!text) return;
  client.sendUserTextMessage(text);
  inputEl.value = '';
}

sendBtn.addEventListener('click', sendMessage);
inputEl.addEventListener('keydown', (e) => {
  if (e.key === 'Enter') sendMessage();
});

micBtn.addEventListener('click', async () => {
  try {
    await client.audioControls.toggleAudio();
    micBtn.textContent = client.audioControls.isAudioMuted ? '取消静音' : '静音';
  } catch (error) {
    console.error('麦克风切换失败：', error);
  }
});
```

{% endcode %}

在默认的 WebRTC 传输上，麦克风默认处于关闭状态，除非你设置 `startWithAudioOn: true`，因此第一次点击会请求浏览器授予麦克风权限。媒体控制方法是异步的，并且在浏览器拒绝该设备时会拒绝，所以要将它们保留在 `尝试`.

{% hint style="warning" %}
没有 `AudioRenderer`的情况下，WebRTC 传输上的自定义 UI 可以连接并接收文本，但角色的声音永远不会播放。聊天小部件会为你创建一个；自定义 UI 必须自己创建一个。
{% endhint %}

### 在 WebSocket 传输上播放音频

使用 `transport: 'websocket'`，传输会播放声音；无需任何接线。Pipecat 的 WebSocket 传输会通过其自己的 Web Audio 播放器播放角色的音频，因此自定义 UI 不需要 `AudioRenderer` 且没有 `botAudioTrack` 监听器：

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

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

await client.connect(); // 角色的声音会在没有额外代码的情况下播放
```

这里使用的 `botAudioTrack` 该事件不会在此传输上触发，因此为其 `AudioRenderer` 创建的对象会保持静默，不会造成影响。

参见 [使用 WebSocket 传输](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/websocket-transport-layer.md) 用于其余设置。

### 添加摄像头和屏幕共享控件

摄像头和屏幕共享需要视频连接。设置 `enableVideo: true` 在客户端配置中，然后调用这些控件：

```typescript
await client.videoControls.toggleVideo();
await client.screenShareControls.toggleScreenShare();
```

读取 `client.videoControls.isVideoEnabled` 并且 `client.screenShareControls.isScreenShareActive` 来标记按钮，或订阅 `videoStateChange` 并且 `screenShareStateChange` 在相同的控件对象上。

### 验证 UI

点击 **连接**。状态显示 `已连接`，输入框会在角色准备就绪后启用，输入的消息会在聊天中和口头回复中都得到回应。点击麦克风按钮并说话：你的话语会以转写形式出现，角色会回复。

### 后续步骤

{% content-ref url="/pages/ffc5fb138ba8c77242a24832c33e0e453dfb2b0f" %}
[在 TypeScript 中处理事件和消息](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/vanilla-typescript/events-and-message-handling.md)
{% endcontent-ref %}

{% content-ref url="/pages/f1fc7386520b3ebeeafa719a8d9efb554e1d7024" %}
[ConvaiClient 参考](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/vanilla-typescript/convaiclient-core-api.md)
{% endcontent-ref %}

{% content-ref url="/pages/e842433c402c6a1f2b7938d74b7fd4adf7da7152" %}
[在原生 TypeScript 中添加口型同步](/api-docs/zh/cha-jian-yu-ji-cheng/web-plugins/convai-web-sdk/vanilla-typescript/real-time-lipsync.md)
{% endcontent-ref %}


---

# 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/vanilla-typescript/building-a-custom-ui-typescript.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.
