openai/realtime-voice-component
GitHub: openai/realtime-voice-component
OpenAI 官方的 React/浏览器语音控制参考组件,为基于 Realtime API 的应用提供工具受限的语音驱动 UI 交互能力。
Stars: 873 | Forks: 108
# realtime-voice-component
为基于 OpenAI Realtime 构建的工具受限 UI 提供的 React/浏览器语音控制组件。
## 分发状态
本仓库旨在作为 GitHub 参考实现进行分享。目前尚未发布到 npm,`package.json` 仍标记为私有。
代码采用 Apache-2.0 许可。参见 [`LICENSE`](./LICENSE)。
## 本软件包是什么
本软件包适用于满足以下条件的应用:
- 你的应用定义了助手可以执行的确切操作
- 工具保持由应用拥有且范围受限
- UI 负责可见的状态变更
- 你需要一个对 React 友好的控制器和一个可选的启动器 widget
本软件包特意针对浏览器 UI 流程做出了特定设计。它不是一个通用的编排框架,也不能替代原始的 Realtime 传输层。
## 选择合适的层
当你想要为语音驱动的 UI 使用 React/浏览器层时,请使用本软件包:
- 带有 React 绑定的可重用控制器
- 打包好的启动器 widget
- 通过 ghost cursor(幽灵光标)进行可选的可见确认
- 以应用拥有的工具为中心的模式,而非自由形式的浏览器自动化
当你想要更底层的传输和会话控制时,请使用原始 Realtime:
- 自定义音频处理
- 非 React 运行时
- 从头开始构建你自己的 UI 界面和状态模型
当你需要更广泛的无头 SDK 时,请使用 [`openai-agents-js`](https://github.com/openai/openai-agents-js):
- 智能体编排与移交
- 更丰富的托管工具和 MCP 流程
- 超出浏览器 UI 软件包范围的服务端或多运行时智能体系统
## 演示应用
仓库的 [`demo/`](./demo) 应用是主要可运行的教学界面。它展示了:
- 一个基础的切换主题流程
- 一个多步表单流程
- 一个更丰富的共享状态国际象棋流程
- 跨多个屏幕的共享控制器重用
- 在运行时之上叠加的可选唤醒词实验
在本地运行:
```
cp demo/.env.example demo/.env.local
# 编辑 demo/.env.local 并设置 OPENAI_API_KEY
npm install
npm run demo
```
## 软件包结构
- `defineVoiceTool()` 将基于 Zod 的应用操作转化为 Realtime 函数工具。
- `createVoiceControlController()` 负责管理会话、传输、工具执行、对话记录组装以及连接生命周期。
- `useVoiceControl()` 将 React 绑定到外部控制器或内部自有的控制器。
- `VoiceControlWidget` 是位于控制器之上的启动器 UI。
- `useGhostCursor()` 和 `GhostCursorOverlay` 是可选的可见确认辅助工具。
## 推荐的默认流程
对于本仓库中的大多数浏览器应用,推荐的路径是:
1. 通过你自己的 `/session` endpoint 代理浏览器 SDP + 会话配置
2. 注册一个映射到一个真实应用操作的受限工具
3. 从主题演示或基于控制器的小型集成开始
4. 在可见变更后将当前 UI 状态发送回会话
## 轮次检测默认设置
控制器默认使用 Realtime 的 `server_vad`。对于纯文本和纯工具会话,它还会设置 `interrupt_response: false`,以便新的语音不会取消正在进行的文本响应或工具调用。当你的 UI 不向用户播放助手音频时,这非常重要。
如果你覆盖了 `audio.input.turnDetection`,请使用类似以下的服务器端 VAD 配置作为纯工具 UI 控制的起点:
```
{
type: "server_vad",
threshold: 0.5,
prefixPaddingMs: 300,
silenceDurationMs: 200,
createResponse: true,
interruptResponse: false,
}
```
## 与现有应用集成
最可靠的改造模式是:
- 保持你的应用作为事实来源
- 为一个语音界面创建一个显式的控制器
- 在工具和你的真实处理程序之间放置一个由应用拥有的小型包装器
- 保持 widget 以启动器为核心
在实践中,这避免了我们在将该软件包集成到更大的应用时遇到的大多数令人困惑的故障模式。
在连接任何内容之前,请选择所有权归属:
- 单屏幕所有权:控制器属于一个屏幕,并且可以立即使用该屏幕的工具创建
- 共享 shell 或 provider 所有权:控制器位于场景级 UI 之上,因为相同的会话应该在场景、标签页或路由更改期间保持活动状态
这种选择会影响控制器的位置、在创建时是否已知 `tools`,以及中立的 bootstrap 控制器是否是正确的形态。
### 分步指南
1. **像安装普通应用依赖一样安装本软件包。**
使用你的包管理器从本地检出路径安装 `realtime-voice-component`,并从你的应用中导入 `realtime-voice-component/styles.css`。优先使用正常的依赖安装,而不是直接从你的应用访问软件包的源码树。
2. **在你的应用后端添加一个 `/session` endpoint。**
让浏览器将 SDP 和会话配置发送到你的应用服务器,并让你的服务器将该请求转发给 `POST https://api.openai.com/v1/realtime/calls`。
除非你有意需要覆盖会话设置,否则请保持 multipart body 不变。
示例:
app.post("/session", async (request, response) => {
const contentType = request.header("content-type");
const realtimeResponse = await fetch("https://api.openai.com/v1/realtime/calls", {
method: "POST",
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
...(contentType ? { "Content-Type": contentType } : {}),
},
body: request,
duplex: "half",
});
response
.status(realtimeResponse.status)
.type(realtimeResponse.headers.get("content-type") ?? "application/sdp")
.send(await realtimeResponse.text());
});
3. **创建一个由应用拥有的小型语音包装器。**
围绕你现有的应用状态和处理程序构建一个包装器或适配器。
好的包装器方法类似于:
- `getState()`
- `setPrompt()`
- `setScenario()`
- `startRun()`
- `stopRun()`
- `sendToast()`
示例:
const stateRef = useRef({
prompt,
runStatus,
scenarioId,
});
stateRef.current = {
prompt,
runStatus,
scenarioId,
};
const voiceAdapter = useMemo(
() => ({
getState: () => stateRef.current,
sendToast: (message: string) => {
toast(message);
},
setPrompt,
setScenario: setScenarioId,
startRun,
stopRun,
}),
[setPrompt, setScenarioId, startRun, stopRun],
);
当工具在调用时需要最新状态时,优先使用 refs 或稳定的选择器。避免在每次状态更改时重新构建每个工具定义,仅仅是为了读取当前值。
此模式中的 `useMemo` 调用是为了引用稳定性,而不是通用的优化。如果你的包装器对象或工具数组在每次渲染时都改变标识,`controller.configure(...)` 也会在每次渲染时重新运行。
4. **针对包装器注册受限工具。**
工具应该调用包装器方法,而包装器方法应该调用你真正的应用逻辑。不要让工具的 `execute()` 成为第二个业务逻辑层。
示例:
const tools = useMemo(
() => [
defineVoiceTool({
name: "get_screen_state",
description: "Inspect the current app state before acting.",
parameters: z.object({}),
execute: () => ({
ok: true,
state: voiceAdapter.getState(),
}),
}),
defineVoiceTool({
name: "set_prompt",
description: "Replace the current prompt.",
parameters: z.object({
prompt: z.string().min(1),
}),
execute: ({ prompt }) => {
voiceAdapter.setPrompt(prompt);
return { ok: true, prompt };
},
}),
defineVoiceTool({
name: "start_run",
description: "Start the current run.",
parameters: z.object({}),
execute: async () => {
await voiceAdapter.startRun();
return { ok: true };
},
}),
defineVoiceTool({
name: "send_message",
description: "Show a short operator-facing message.",
parameters: z.object({
message: z.string().min(1),
}),
execute: ({ message }) => {
voiceAdapter.sendToast(message);
return { ok: true };
},
}),
],
[voiceAdapter],
);
5. **在拥有语音界面的层级提升控制器。**
如果某个屏幕、路由 shell 或 provider 拥有启用语音的界面,请在那里创建控制器。如果你将外部控制器传递给 `useVoiceControl(controller)` 或 `VoiceControlWidget`,则你的应用拥有该控制器的生命周期。
示例:
const [controller] = useState(() =>
createVoiceControlController({
activationMode: "vad",
auth: { sessionEndpoint: "/session" },
instructions:
"Use the provided tools to control the current screen. Prefer tools over free-form responses.",
outputMode: "tool-only",
tools,
}),
);
使用当前的 `tools` 进行初始化,可以避免文档中那种令人困惑的“先创建空控制器,稍后再加工具”的模式。当工具集发生更改时,你仍应重新同步外部控制器。
如果你的应用在屏幕级工具之上拥有控制器,中立的 bootstrap 仍然有效。本仓库中的共享演示会话以 `tools: []` 开始,并随着每个演示屏幕变为活动状态而重新配置。仅当控制器确实在最终工具集之前存在,或者当相同的会话需要在场景更改后存活时,才使用此模式。
6. **仅使用 Effect 来同步外部控制器。**
从 `useEffect` 中调用 `controller.configure(...)` 是合适的,因为控制器是一个外部对象,而不是 React 状态。不要使用 Effects 将 React 状态镜像到更多的 React 状态中。
示例:
useEffect(() => {
controller.configure({
activationMode: "vad",
auth: { sessionEndpoint: "/session" },
instructions:
"Use the provided tools to control the current screen. Prefer tools over free-form responses.",
outputMode: "tool-only",
tools,
});
}, [controller, tools]);
7. **将 `VoiceControlWidget` 作为启动器挂载,而不是作为你的状态模型。**
widget 应该保持精简。如果你想要可见的确认,可以添加 `GhostCursorOverlay`,但将实际的状态更改保留在你的应用处理程序中。
示例:
return (
<>
>
);
只有当父组件确实需要渲染运行时状态(如 `connected`、`activity` 或工具调用历史记录)时,才在该父组件中调用 `useVoiceControl(controller)`。`VoiceControlWidget` 内部已经绑定到控制器。
当集成变得更大时,将其拆分为显式的文件,而不是将所有内容都留在一个屏幕组件中。一个好的默认结构是:
voice/
voiceAdapter.ts
voiceTools.ts
useScreenVoiceController.ts
VoicePanel.tsx
保持适配器、工具、控制器连线和面板 UI 分离。本仓库中的演示代码遵循相同的通用模式。
8. **在可见更改后将应用状态发送回会话。**
如果模型需要新的上下文,请将当前应用状态推回会话中,以便模型能够基于屏幕上实际的内容保持状态同步。
9. **按以下顺序调试。**
- 如果 widget 停留在 `idle` 并且从未请求 `/session`,请首先检查控制器所有权、widget 挂载以及浏览器的 media/WebRTC 支持。
- 如果 widget 在到达 `/session` 之前变为 `error`,请首先检查浏览器控制台以及权限/支持问题。
- 如果已请求 `/session` 但失败,则调试后端代理、身份验证和 Realtime API 响应。
### 我们总结的集成避坑指南
- 不要从叶子组件的清理操作中销毁外部拥有的控制器。在 React 开发模式和 Strict Mode 中,重新挂载可能会导致已挂载的 widget 持有一个已失效的控制器,从而静默地永远无法连接。
- 如果 widget 一直没有离开 `idle` 状态,即使你的 `/session` 路由健康,问题也可能完全出在客户端。
- widget 只是一个启动器。如果你的交互模型需要更丰富的捕获控件、对话记录 UI 或更具主见的界面,请在控制器之上构建自定义 UI。
## 状态管理经验
最简洁的集成方式是将应用视为事实来源,而将语音运行时视为对该状态的受限调用者。
- 让你的应用拥有真正的状态变更;工具应该调用应用处理程序,而不是试图在语音层内部模拟状态
- 为一个语音界面使用一个显式的控制器;如果多个控件或屏幕实际上属于同一个界面,请重用相同的控制器
- 如果你将外部控制器传递给 `useVoiceControl(controller)` 或 `VoiceControlWidget`,你的应用将拥有该控制器的生命周期
- 小心 React 的开发重新挂载和 Strict Mode 清理;从叶子组件的清理操作中销毁外部拥有的控制器可能会导致已挂载的 widget 持有一个失效的控制器,从而静默地永远无法连接
- 优先使用稳定的工具定义;如果工具只需要最新状态,请通过 refs 或稳定的选择器读取它,而不是在每次状态更改时重新构建整个工具集
- 在包装器对象或工具数组馈送到控制器配置时,在此处使用 `useMemo` 是为了身份稳定性,而不是作为一项通用的性能规则
- 如果你希望 hook 负责创建和销毁,请优先使用 `VoiceControl(options)`,而不是手动创建控制器
- 如果 widget 停留在 `idle` 并且从未请求你的 `/session` endpoint,在假设后端损坏之前,请先检查控制器的生命周期和浏览器的 media 支持
在实践中,最可靠的模式是:
1. 在实际拥有语音界面的应用层创建或提升控制器
2. 将应用状态传递给工具执行和状态同步消息
3. 让 widget 保持精简并以启动器为核心
4. 在创建控制器的同一层级保留销毁决策
## 本地安装
本仓库目前仍针对本地开源使用进行优化,而不是为了 npm 发布做准备。
从本地检出路径安装:
```
npm install ../path/to/realtime-voice-component zod
```
然后从 `realtime-voice-component` 和 `realtime-voice-component/styles.css` 导入。
## 文档
- [文档概述](./docs/README.md)
- [快速开始](./docs/getting-started.md)
- [与现有应用集成](./docs/integrating-with-an-existing-app.md)
- [架构选择](./docs/architecture-choices.md)
- [控制器与运行时](./docs/controller-runtime.md)
- [Widget 与幽灵光标](./docs/widget-and-cursor.md)
- [身份验证](./docs/authentication.md)
- [展示演示架构](./docs/demo-architecture.md)
- [API 参考](./docs/api-reference.md)
标签:OpenAI Realtime, React, Syscalls, 前端组件, 用户界面, 自动化攻击, 语音交互