一个 Next.js 应用就能把浏览器端该做的事全做完:发临时 Token、启停智能体、发布麦克风、播放智能体音频、展示智能体状态和实时字幕。服务端这条链路是 凤鸣 ASR → DeepSeek V4 Flash → MiniMax TTS,客户端用 RTC、RTM 和 Agora Voice AI Toolkit 三件套。
跑通之后你得到的是一个能直接改成客服或陪练场景的网页应用,类型检查和生产构建都能过。
一. 整体架构
浏览器 Client Component
├─ RTC 4.24.6:加入频道、发布麦克风、播放智能体音频
├─ RTM 2.2.4:接收实时字幕和智能体状态
└─ Voice AI Toolkit 2.9.0:统一解析状态、错误和字幕事件
│
├─ GET /api/get-config
├─ POST /api/start-agent
└─ POST /api/stop-agent
│
└─ Area.CN 对话式 AI 云服务
二. 准备工作
- Node.js 22 或更高版本。
- 开通了对话式 AI 引擎的声网项目。
- 四个凭证:App ID、App Certificate、DeepSeek API Key、MiniMax API Key。
- 一台带麦克风的电脑,浏览器用 Chrome、Edge、Safari 新版本都行。
三. 项目结构
nextjs-agent/
├─ .env.local
├─ .npmrc
├─ next.config.ts
├─ package.json
├─ tsconfig.json
├─ lib/
│ └─ env.ts
└─ app/
├─ layout.tsx
├─ page.tsx
├─ style.css
├─ voice-demo.tsx
└─ api/
├─ get-config/route.ts
├─ start-agent/route.ts
└─ stop-agent/route.ts
下面把每个文件的完整源码都给出来,照着建就行。package-lock.json 由 npm install 自动生成,不用手写。
四. 固定依赖版本
创建 package.json:
{
"name": "shengwang-convoai-nextjs-quickstart",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"agora-agent-client-toolkit": "2.9.0",
"agora-agents": "2.4.0",
"agora-rtc-sdk-ng": "4.24.6",
"agora-rtm": "2.2.4",
"next": "16.2.6",
"react": "19.1.0",
"react-dom": "19.1.0"
},
"devDependencies": {
"@types/node": "22.15.3",
"@types/react": "19.1.2",
"@types/react-dom": "19.1.2",
"typescript": "5.8.3"
}
}
照这份依赖直接 npm install 会失败:agora-rtm@2.2.4 声明的 peer dependency 是 RTC 4.23.0,和我们固定的 4.24.6 对不上,npm 会抛 ERESOLVE。加一个 .npmrc 跳过这项检查:
legacy-peer-deps=true
创建 tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"plugins": [{ "name": "next" }],
"incremental": true
},
"include": [
"next-env.d.ts",
".next/types/**/*.ts",
"**/*.ts",
"**/*.tsx",
".next/dev/types/**/*.ts"
],
"exclude": ["node_modules"]
}
本地调试时 localhost:3000 和 127.0.0.1:3000 经常混着用,为了两个地址都能正常工作,创建 next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
allowedDevOrigins: ["127.0.0.1"],
};
export default nextConfig;
五. 配置环境变量
创建 .env.local:
NEXT_PUBLIC_AGORA_APP_ID=REPLACE_WITH_APP_ID
AGORA_APP_CERTIFICATE=REPLACE_WITH_APP_CERTIFICATE
DEEPSEEK_API_KEY=REPLACE_WITH_DEEPSEEK_API_KEY
MINIMAX_API_KEY=REPLACE_WITH_MINIMAX_API_KEY
AGENT_UID=1001
# 留空时使用本文的 Next.js API。
# 运行上一篇 Python Quickstart 时改为 http://localhost:8000。
NEXT_PUBLIC_AGENT_API_BASE=
App ID 可以给浏览器,App Certificate 和两个模型 Key 只能留在服务端。判断方法很简单,凡是加了 NEXT_PUBLIC_ 前缀的变量都会被打进前端产物,密钥绝不能带这个前缀。
顺便说明一下 AGENT_UID=1001:它只是本示例给智能体分配的频道内 UID,不是控制台里的什么凭证,不需要申请。生产环境只要保证它不和同频道其他 UID 撞车就行。
六. 服务端环境变量工具
创建 lib/env.ts:
export function requireEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`缺少服务端环境变量: ${name}`);
return value;
}
export const agentUid = process.env.AGENT_UID ?? "1001";
一个几行的小工具,好处是变量缺失时在启动阶段就报错,而不是等到调用模型时才收到一个语焉不详的 502。
七. 生成临时加入配置
创建 app/api/get-config/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { generateConvoAIToken } from "agora-agents";
import { requireEnv } from "../../../lib/env";
export async function GET(request: NextRequest) {
try {
const appId = requireEnv("NEXT_PUBLIC_AGORA_APP_ID");
const appCertificate = requireEnv("AGORA_APP_CERTIFICATE");
const uidParam = Number(request.nextUrl.searchParams.get("uid"));
const uid = Number.isInteger(uidParam) && uidParam > 0
? uidParam
: Math.floor(Math.random() * 9_000_000) + 1_000_000;
const channel = request.nextUrl.searchParams.get("channel")
?? `shengwang-ai-${Date.now()}`;
const token = generateConvoAIToken({
appId,
appCertificate,
channelName: channel,
uid,
tokenExpire: 3600,
privilegeExpire: 3600,
});
return NextResponse.json({ appId, channel, uid: String(uid), token });
} catch (error) {
console.error("get-config failed", error);
return NextResponse.json({ error: "生成临时加入配置失败" }, { status: 500 });
}
}
Token 默认一小时有效。做长会话的话记得监听 RTC/RTM 的 Token 即将过期事件并续期,别等它过期断线。
八. 启动智能体
创建 app/api/start-agent/route.ts:
import { NextResponse } from "next/server";
import {
Agent,
AgoraClient,
Area,
DeepSeekLLM,
ExpiresIn,
FengmingSTT,
MiniMaxCNTTS,
} from "agora-agents";
import { agentUid, requireEnv } from "../../../lib/env";
type StartBody = { channel: string; userUid: string };
export async function POST(request: Request) {
try {
const { channel, userUid } = await request.json() as StartBody;
if (!channel || !userUid) {
return NextResponse.json(
{ error: "channel 和 userUid 必填" },
{ status: 400 },
);
}
const client = new AgoraClient({
area: Area.CN,
appId: requireEnv("NEXT_PUBLIC_AGORA_APP_ID"),
appCertificate: requireEnv("AGORA_APP_CERTIFICATE"),
});
const agent = new Agent({
client,
turnDetection: {
language: "zh-CN",
config: {
start_of_speech: {
mode: "vad",
vad_config: {
interrupt_duration_ms: 160,
prefix_padding_ms: 300,
},
},
end_of_speech: {
mode: "vad",
vad_config: { silence_duration_ms: 480 },
},
},
},
advancedFeatures: { enable_rtm: true },
parameters: {
audio_scenario: "chorus",
data_channel: "rtm",
enable_error_message: true,
enable_metrics: true,
},
})
.withStt(new FengmingSTT())
.withLlm(new DeepSeekLLM({
apiKey: requireEnv("DEEPSEEK_API_KEY"),
url: "https://api.deepseek.com/chat/completions",
model: "deepseek-v4-flash",
maxHistory: 20,
systemMessages: [
{ role: "system", content: "你是声网中文语音助手,回答简洁、准确。" },
],
greetingMessage: "你好,我是声网语音助手。想聊什么?",
failureMessage: "抱歉,我刚才没有处理成功,请再说一次。",
params: {
temperature: 0.6,
max_tokens: 512,
thinking: { type: "disabled" },
},
}))
.withTts(new MiniMaxCNTTS({
key: requireEnv("MINIMAX_API_KEY"),
model: "speech-01-turbo",
voiceSetting: {
voice_id: "female-shaonv",
speed: 1,
vol: 1,
pitch: 0,
},
audioSetting: { sample_rate: 16000 },
languageBoost: "Chinese",
}));
const session = agent.createSession({
name: `shengwang-web-${Date.now()}`,
channel,
agentUid,
remoteUids: [userUid],
idleTimeout: 120,
expiresIn: ExpiresIn.hours(1),
debug: false,
});
const agentId = await session.start();
return NextResponse.json({ agentId, agentUid });
} catch (error) {
console.error("start-agent failed", error);
return NextResponse.json({ error: "启动智能体失败" }, { status: 500 });
}
}
thinking: { type: "disabled" } 这行别删。不显式关掉 DeepSeek 的 thinking,模型的思考过程会跟着进回复、进字幕、进语音合成。用户会听到智能体自言自语一段推理,再回答问题。
九. 停止智能体
创建 app/api/stop-agent/route.ts:
import { NextResponse } from "next/server";
import { AgoraClient, Area } from "agora-agents";
import { requireEnv } from "../../../lib/env";
export async function POST(request: Request) {
try {
const { agentId } = await request.json() as { agentId?: string };
if (!agentId) {
return NextResponse.json({ error: "agentId 必填" }, { status: 400 });
}
const client = new AgoraClient({
area: Area.CN,
appId: requireEnv("NEXT_PUBLIC_AGORA_APP_ID"),
appCertificate: requireEnv("AGORA_APP_CERTIFICATE"),
});
await client.stopAgent(agentId);
return NextResponse.json({ ok: true });
} catch (error) {
console.error("stop-agent failed", error);
return NextResponse.json({ error: "停止智能体失败" }, { status: 500 });
}
}
十. 页面与样式
创建 app/layout.tsx:
import type { Metadata } from "next";
import "./style.css";
export const metadata: Metadata = {
title: "声网对话式 AI Next.js Quickstart",
description: "使用声网对话式 AI、凤鸣、DeepSeek 和 MiniMax 搭建浏览器语音智能体。",
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return <html lang="zh-CN"><body>{children}</body></html>;
}
创建 app/page.tsx:
import VoiceDemo from "./voice-demo";
export default function Home() {
return <VoiceDemo />;
}
页面本身直接引用客户端组件就行。重点在下一步。RTC、RTM 和 Toolkit 都是在用户点击「开始对话」之后才动态 import 的,这几个大块头 SDK 因此不会阻塞首屏。
创建 app/style.css:
:root { color-scheme: light; font-family: system-ui, sans-serif; }
body { margin: 0; background: #f6f7fb; color: #16181d; }
main { max-width: 760px; margin: 64px auto; padding: 32px; background: white; border-radius: 16px; }
button { margin-right: 12px; padding: 10px 18px; border: 0; border-radius: 8px; cursor: pointer; }
button:first-of-type { background: #0b6cff; color: white; }
button:disabled { opacity: .45; cursor: not-allowed; }
pre { min-height: 180px; padding: 16px; overflow: auto; background: #111827; color: #d1fae5; border-radius: 10px; white-space: pre-wrap; }
十一. 完整的浏览器语音组件
创建 app/voice-demo.tsx:
"use client";
import { useRef, useState } from "react";
import type {
IAgoraRTCClient,
IMicrophoneAudioTrack,
} from "agora-rtc-sdk-ng";
import type { RTMClient } from "agora-rtm";
import type { AgoraVoiceAI as AgoraVoiceAIClient } from "agora-agent-client-toolkit";
type JoinConfig = {
appId: string;
channel: string;
uid: string;
token: string;
agentUid?: string;
};
const pythonApiBase = (process.env.NEXT_PUBLIC_AGENT_API_BASE ?? "").replace(/\/$/, "");
async function getJoinConfig(): Promise<JoinConfig> {
const response = await fetch(
pythonApiBase ? `${pythonApiBase}/get_config` : "/api/get-config",
);
if (!response.ok) throw new Error("获取配置失败");
if (!pythonApiBase) return response.json() as Promise<JoinConfig>;
const payload = await response.json() as {
code: number;
data: {
app_id: string;
channel_name: string;
uid: string;
token: string;
agent_uid: string;
};
};
return {
appId: payload.data.app_id,
channel: payload.data.channel_name,
uid: payload.data.uid,
token: payload.data.token,
agentUid: payload.data.agent_uid,
};
}
async function startAgent(config: JoinConfig): Promise<string> {
const response = await fetch(
pythonApiBase ? `${pythonApiBase}/startAgent` : "/api/start-agent",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(pythonApiBase ? {
channelName: config.channel,
rtcUid: Number(config.agentUid),
userUid: Number(config.uid),
} : {
channel: config.channel,
userUid: config.uid,
}),
},
);
if (!response.ok) throw new Error("启动智能体失败");
const payload = await response.json() as {
agentId?: string;
data?: { agent_id?: string };
};
const agentId = payload.agentId ?? payload.data?.agent_id;
if (!agentId) throw new Error("启动接口没有返回 agentId");
return agentId;
}
async function stopAgent(agentId: string): Promise<void> {
const response = await fetch(
pythonApiBase ? `${pythonApiBase}/stopAgent` : "/api/stop-agent",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agentId }),
},
);
if (!response.ok) throw new Error("停止智能体失败");
}
export default function VoiceDemo() {
const rtcRef = useRef<IAgoraRTCClient | null>(null);
const micRef = useRef<IMicrophoneAudioTrack | null>(null);
const rtmRef = useRef<RTMClient | null>(null);
const aiRef = useRef<AgoraVoiceAIClient | null>(null);
const agentIdRef = useRef<string | null>(null);
const [running, setRunning] = useState(false);
const [status, setStatus] = useState("未连接");
const [lines, setLines] = useState<string[]>([]);
const [events, setEvents] = useState<string[]>([]);
function recordEvent(kind: string, detail: string) {
const at = new Date().toISOString();
setEvents((current) => [...current, `${at} ${kind} ${detail}`].slice(-100));
}
async function start() {
let rtc: IAgoraRTCClient | null = null;
let mic: IMicrophoneAudioTrack | null = null;
let rtm: RTMClient | null = null;
let ai: AgoraVoiceAIClient | null = null;
try {
setEvents([]);
setStatus("正在加载实时音视频 SDK…");
const [
{ default: AgoraRTC },
{ default: AgoraRTM },
{ AgoraVoiceAI, AgoraVoiceAIEvents, TranscriptHelperMode },
] = await Promise.all([
import("agora-rtc-sdk-ng"),
import("agora-rtm"),
import("agora-agent-client-toolkit"),
]);
setStatus("正在获取配置…");
const config = await getJoinConfig();
rtc = AgoraRTC.createClient({ mode: "rtc", codec: "vp8" });
(AgoraRTC as typeof AgoraRTC & {
setParameter?: (key: string, value: unknown) => void;
}).setParameter?.("ENABLE_AUDIO_PTS", true);
rtc.on("user-published", async (user, mediaType) => {
await rtc?.subscribe(user, mediaType);
if (mediaType === "audio") user.audioTrack?.play();
});
await rtc.join(config.appId, config.channel, config.token, Number(config.uid));
mic = await AgoraRTC.createMicrophoneAudioTrack();
await rtc.publish(mic);
rtm = new AgoraRTM.RTM(config.appId, config.uid);
await rtm.login({ token: config.token });
await rtm.subscribe(config.channel);
ai = await AgoraVoiceAI.init({
rtcEngine: rtc,
rtmConfig: { rtmEngine: rtm },
renderMode: TranscriptHelperMode.TEXT,
enableLog: true,
});
ai.on(AgoraVoiceAIEvents.TRANSCRIPT_UPDATED, (items) => {
setLines(items.map((item) => `${item.uid}: ${item.text}`));
const latest = [...items].reverse().find((item) => item.text);
if (latest) recordEvent("transcript", `${latest.uid}: ${latest.text}`);
});
ai.on(AgoraVoiceAIEvents.AGENT_STATE_CHANGED, (_, event) => {
setStatus(`智能体状态:${event.state}`);
recordEvent("state", String(event.state));
});
ai.on(AgoraVoiceAIEvents.AGENT_METRICS, (_, metric) => {
recordEvent("metric", `${metric.type}/${metric.name}: ${metric.value}ms`);
});
ai.on(AgoraVoiceAIEvents.AGENT_ERROR, (_, error) => {
setStatus(`错误:${error.type} / ${error.message}`);
recordEvent("error", `${error.type}/${error.code}: ${error.message}`);
});
ai.on(AgoraVoiceAIEvents.MESSAGE_ERROR, (_, error) => {
recordEvent("error", `message/${error.code}: ${error.message}`);
});
ai.subscribeMessage(config.channel);
const agentId = await startAgent(config);
rtcRef.current = rtc;
micRef.current = mic;
rtmRef.current = rtm;
aiRef.current = ai;
agentIdRef.current = agentId;
setRunning(true);
setStatus("已连接,请开始说话");
} catch (error) {
ai?.unsubscribe();
ai?.destroy();
mic?.stop();
mic?.close();
await rtc?.leave().catch(() => undefined);
await rtm?.logout().catch(() => undefined);
setRunning(false);
setStatus(String(error));
recordEvent("client-error", String(error));
}
}
async function stop() {
const agentId = agentIdRef.current;
if (agentId) await stopAgent(agentId);
aiRef.current?.unsubscribe();
aiRef.current?.destroy();
micRef.current?.stop();
micRef.current?.close();
await rtcRef.current?.leave();
await rtmRef.current?.logout();
rtcRef.current = null;
rtmRef.current = null;
micRef.current = null;
aiRef.current = null;
agentIdRef.current = null;
setRunning(false);
setStatus("已停止");
}
return (
<main>
<h1>声网中文语音智能体</h1>
<p>{status}</p>
<button disabled={running} onClick={() => void start()}>
开始对话
</button>
<button
disabled={!running}
onClick={() => void stop().catch((error) => setStatus(String(error)))}
>
结束对话
</button>
<h2>实时字幕</h2>
<pre>{lines.join("\n") || "字幕将显示在这里"}</pre>
<h2>事件时间线</h2>
<pre>{events.join("\n") || "状态、性能和错误事件将显示在这里"}</pre>
</main>
);
}
这个文件是全篇最值得细看的部分。
- 三个 SDK 用
Promise.all在点击之后才并行加载。放在模块顶层 import 会有两个后果:开发模式首屏变慢,SSR 阶段直接抛window is not defined。 - 顺序不能乱。RTM 必须先
login再subscribe,然后才轮到AgoraVoiceAI.init()。Toolkit 要靠已经订阅好的 RTM 通道收字幕和状态,顺序反了就是「智能体在说话但页面一片空白」。 start()里用局部变量逐个持有已创建的资源,任何一步失败都按已创建的顺序逐项释放。少了这段回滚,用户点一次失败再点一次,就会留下没关掉的 RTC 连接和还在占用的麦克风。
字幕和状态都走 Toolkit 的事件回调:TRANSCRIPT_UPDATED 给双方字幕,AGENT_STATE_CHANGED 给智能体状态,AGENT_METRICS 给各环节耗时,调延迟的时候特别有用。
十二. 安装、检查与运行
npm install
npm run typecheck
npm run build
npm run dev
打开 http://localhost:3000,允许麦克风权限,点「开始对话」。
十三. 应该看到什么
- 页面显示「已连接,请开始说话」。
- 智能体主动播放中文开场白。
- 你和智能体的字幕持续刷新。
- 能顺畅进行多轮中文语音对话。
- 点「结束对话」后智能体退出,麦克风、RTC、RTM 和 Toolkit 全部释放。
十四. 故障排查
ERESOLVE:检查项目根目录的.npmrc里有没有legacy-peer-deps=true。window is not defined:RTC、RTM、Toolkit 只能在客户端组件里 import,而且要动态导入。DEVICE_NOT_FOUND或can not find stream after getUserMedia:电脑没有可用输入设备,或者浏览器没拿到麦克风权限。- 智能体没有字幕:RTM 的
login和subscribe必须都在 Toolkit 初始化之前完成。 - 有字幕但没声音:检查
user-published回调里有没有调用rtc.subscribe()和audioTrack.play()。 - 回复很长,或者出现括号里的思考过程:检查 DeepSeek 参数里的
thinking: { type: "disabled" }。 - 接口返回 502:App ID 和 Certificate 是否同项目,DeepSeek、MiniMax 的 Key 是否有效、有没有余额。
十五. 上线前还要做什么
这份代码是能跑的最小实现,接业务前至少补上:接口鉴权、限流、Token 续期、模型调用重试、日志脱敏、监控告警、页面卸载时自动停止智能体,以及同频道 UID 冲突检查。
十六. 下一步
界面跑起来之后,第一个会被吐槽的通常是打断——它决定这个助手像不像人。
《语音智能体打断实战:VAD、关键词与不可打断》