在线咨询
专属客服在线解答,提供专业解决方案
工单支持
专业技术支持团队,随时响应服务需求

用 Next.js 搭建浏览器端语音智能体

一个 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.jsonnpm 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:3000127.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 必须先 loginsubscribe,然后才轮到 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,允许麦克风权限,点「开始对话」。


十三. 应该看到什么

  1. 页面显示「已连接,请开始说话」。
  2. 智能体主动播放中文开场白。
  3. 你和智能体的字幕持续刷新。
  4. 能顺畅进行多轮中文语音对话。
  5. 点「结束对话」后智能体退出,麦克风、RTC、RTM 和 Toolkit 全部释放。

十四. 故障排查

  • ERESOLVE:检查项目根目录的 .npmrc 里有没有 legacy-peer-deps=true
  • window is not defined:RTC、RTM、Toolkit 只能在客户端组件里 import,而且要动态导入。
  • DEVICE_NOT_FOUNDcan not find stream after getUserMedia:电脑没有可用输入设备,或者浏览器没拿到麦克风权限。
  • 智能体没有字幕:RTM 的 loginsubscribe 必须都在 Toolkit 初始化之前完成。
  • 有字幕但没声音:检查 user-published 回调里有没有调用 rtc.subscribe()audioTrack.play()
  • 回复很长,或者出现括号里的思考过程:检查 DeepSeek 参数里的 thinking: { type: "disabled" }
  • 接口返回 502:App ID 和 Certificate 是否同项目,DeepSeek、MiniMax 的 Key 是否有效、有没有余额。

十五. 上线前还要做什么

这份代码是能跑的最小实现,接业务前至少补上:接口鉴权、限流、Token 续期、模型调用重试、日志脱敏、监控告警、页面卸载时自动停止智能体,以及同频道 UID 冲突检查。


十六. 下一步

界面跑起来之后,第一个会被吐槽的通常是打断——它决定这个助手像不像人。

《语音智能体打断实战:VAD、关键词与不可打断》

在声网,连接无限可能

想进一步了解「对话式 AI 与 实时互动」?欢迎注册,开启探索之旅。

本博客为技术交流与平台行业信息分享平台,内容仅供交流参考,文章内容不代表本公司立场和观点,亦不构成任何出版或销售行为。