AB
AiBoss
Tutorials

魔珐星云

Tutorials

用魔珐星云 SDK 搭建可对话的 3D 数字人客服页面

面向前端开发者的实操教程:在 Vue 3 + TypeScript 项目中引入魔珐星云数字人 SDK,配置应用凭证与数字人形象,接入语音识别与大模型,让 3D 数字人把 AI 回复边说边演出来,并处理字幕、动作指令与连接释放。

魔珐星云是一个 3D 数字人开放平台,提供可嵌入网页的虚拟人 SDK:开发者传入文本,数字人就能同步输出语音、表情、手势与身体动作,并支持字幕回调、动作指令、中途打断等交互能力。本文要解决的问题是:把一个大模型客服接到一个会说话、会做动作的 3D 数字人身上,做成一个可以在浏览器里直接打开、输入问题就能得到语音回答的客服页面。适合已经会写前端代码、想给产品加一层具身交互界面的开发者阅读;不需要 3D 建模或动画绑定经验。站内也有对应的工具条目可参考:魔珐星云。

整条链路由四段组成:浏览器采集用户语音或文字 → 语音识别转成文本 → 大语言模型生成回复 → 数字人 SDK 把回复念出来并配上动作。本文以 Vue 3 + TypeScript + Vite 为宿主框架,语音识别用第三方 ASR 服务,大模型用兼容 OpenAI 接口的服务,数字人部分用魔珐星云 SDK。

准备工作

账号与凭证

先在魔珐星云平台注册账号并登录,进入控制台。控制台主要分三块:体验中心用于试用具身驱动、视频生成、语音生成等功能;应用管理用于创建应用、挑选或定制数字人形象;文档中心提供 SDK 接入说明、接口文档与示例代码。新注册账号会获得一定数量的赠送积分,可用于体验中心测试与 SDK 调用,具体额度、是否有时效、后续如何计费,请以官网当前信息为准。

需要拿到手的凭证有两组:

  • SDK App Id 与密钥:在应用管理中创建应用(填写应用名称、描述、所属行业),创建完成后进入应用详情即可复制。
  • 数字人形象配置:在应用详情里的数字人配置中选择形象风格(超写实、二次元、卡通、美型等),并可调整发型、服饰等细节,保存后该应用即绑定这套形象。

另外还需要两组外部服务的参数:

  • 语音识别(ASR):本文以腾讯云 ASR 为例,需要 ASR App ID、ASR Secret ID、ASR Secret Key。也可以换成其他提供实时语音转写能力的服务。
  • 大语言模型:本文以火山方舟系模型为例,需要 API Key 与接口地址;代码层通过 OpenAI 兼容 SDK 调用,因此任何兼容该协议的服务都能替换进来。

本地环境

  • Node.js 与 npm(或 pnpm、yarn),能运行 Vite 即可。
  • 一个现代浏览器。数字人采用端侧实时渲染,不依赖独立 GPU 服务器,普通笔记本与手机即可运行;素材提到入门级芯片(如 RK3566)也能流畅运行,实际帧率仍取决于设备与网络。
  • 麦克风权限:如果要做语音输入,页面必须运行在 HTTPS 或 localhost 下,否则浏览器不会授予录音权限。

先体验再开发

在写代码之前,建议先去体验中心跑一遍「具身驱动」:输入一段文本,观察数字人的语音、表情、手势是否同步,微表情(挑眉、点头)是否自然。这一步能帮你判断所选形象与音色是否符合业务调性,也便于后面排查问题时区分「SDK 没接好」和「形象配置不合适」。体验中心里还有视频生成(上传文档或输入文案,自动匹配场景、灯光与运镜生成数字人视频)和语音克隆(上传一段真人音频克隆音色,支持多语种与多情绪)两项能力,如果客服需要固定品牌音色,可以在这里先试。

操作步骤

第一步:创建项目骨架

用 Vite 的 vue-ts 模板初始化项目,然后安装依赖。除了 Vue 本身,还需要 OpenAI 兼容 SDK 用于调用大模型。

npm create vite@latest vue-xingyun-ai-customer-service -- --template vue-ts
cd vue-xingyun-ai-customer-service
npm install
npm install openai

建议的目录结构如下,把「服务层」和「组件层」分开,后面替换 ASR 或大模型供应商时只改服务层:

vue-xingyun-ai-customer-service/
├── index.html
├── package.json
├── vite.config.ts
├── .env.local
└── src/
    ├── main.ts
    ├── App.vue
    ├── styles/main.css
    ├── services/
    │   ├── xingyun.service.ts
    │   └── llm.service.ts
    ├── components/
    │   └── CustomerService.vue
    ├── config/
    └── utils/

第二步:在 index.html 中引入 SDK

SDK 通过 CDN 脚本引入,加载完成后会在全局注册 XmovAvatar 类。脚本地址中的 @latest 表示始终取最新版本;如果生产环境需要版本可复现,应改为固定版本号,具体可用版本以官网当前信息为准。脚本放在 <body> 内、应用挂载点之后,确保 DOM 已就绪。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>数字人客服</title>
</head>
<body>
  <div id="app"></div>
  <script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js"></script>
  <script type="module" src="/src/main.ts"></script>
</body>
</html>

第三步:写入环境变量

所有密钥都走环境变量,不要硬编码进源码。Vite 只暴露以 VITE_ 开头的变量给前端,因此变量名统一加前缀。注意:前端环境变量会被打包进产物,生产环境应改为由自己的后端签发临时凭证或做代理转发,不要把长期密钥直接暴露在浏览器里。

# .env.local
VITE_XINGYUN_APPID=你的应用ID
VITE_XINGYUN_SECRET=你的应用密钥
VITE_OPENAI_API_KEY=你的大模型APIKey
VITE_OPENAI_BASE_URL=你的大模型接口地址
VITE_ASR_APPID=你的ASR应用ID
VITE_ASR_SECRET_ID=你的ASR SecretId
VITE_ASR_SECRET_KEY=你的ASR SecretKey

第四步:封装数字人服务

把 SDK 的初始化、说话、动作、销毁都收进一个服务类,组件只调用方法,不直接碰 SDK 实例。初始化时传入的参数包括:渲染容器选择器、appId、appSecret、网关地址,以及若干回调。

// src/services/xingyun.service.ts
class XingYunService {
  private sdkInstance: any = null;
  private isInitialized = false;
  private containerId = 'avatar-container';

  async initSDK(config: {
    appId: string;
    appSecret: string;
    onProgress?: (p: number) => void;
    onStateChange?: (s: string) => void;
    onSubtitle?: (t: string) => void;
    onSubtitleEnd?: () => void;
    onError?: (e: unknown) => void;
  }) {
    if (!(window as any).XmovAvatar) {
      await this.loadSDKScript();
    }

    this.sdkInstance = new (window as any).XmovAvatar({
      containerId: `#${this.containerId}`,
      appId: config.appId,
      appSecret: config.appSecret,
      gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
      onStateChange: (state: string) => {
        config.onStateChange?.(state);
      },
      onVoiceStateChange: (status: string) => {
        console.log('语音状态:', status);
      },
      onWidgetEvent: (data: any) => {
        if (data.type === 'subtitle_on') config.onSubtitle?.(data.text);
        else if (data.type === 'subtitle_off') config.onSubtitleEnd?.();
      },
      enableLogger: import.meta.env.DEV,
    });

    await this.sdkInstance.init({
      onDownloadProgress: (progress: number) => config.onProgress?.(progress),
      onError: (error: unknown) => config.onError?.(error),
      onClose: () => console.log('连接已关闭'),
    });

    this.isInitialized = true;
    return true;
  }

  private loadSDKScript(): Promise<void> {
    return new Promise((resolve, reject) => {
      const script = document.createElement('script');
      script.src = 'https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js';
      script.onload = () => resolve();
      script.onerror = () => reject(new Error('SDK 加载失败'));
      document.head.appendChild(script);
    });
  }

  speak(text: string, isStart = true, isEnd = true) {
    if (!this.isInitialized || !this.sdkInstance) {
      throw new Error('SDK 未初始化');
    }
    this.sdkInstance.speak(text, isStart, isEnd);
  }

  speakWithAction(text: string, action = 'Hello') {
    const ssml = `<speak><ue4event><type>ka</type><data><action_semantic>${action}</action_semantic></data></ue4event>${text}</speak>`;
    this.speak(ssml, true, true);
  }

  disconnect() {
    if (this.sdkInstance) {
      this.sdkInstance.stop();
      this.sdkInstance.destroy();
      this.sdkInstance = null;
      this.isInitialized = false;
    }
  }

  getSupportedActions() {
    return ['Hello', 'Goodbye', 'Agree', 'Disagree', 'Think', 'Explain'];
  }
}

export default new XingYunService();

几个关键点:speak 的第二个参数表示「一段语音的开始」,第三个参数表示「一段语音的结束」。流式输出大模型回复时,第一块用 speak(chunk, true, false),中间块用 speak(chunk, false, false),最后一块用 speak(chunk, false, true),这样数字人才能连续说完一整段而不被打断成碎片。动作指令通过 SSML 的 ue4event 节点下发,action_semantic 填动作标识。

第五步:封装大模型服务

用 OpenAI 兼容 SDK 指向你的模型服务,开启 stream: true,把回复做成异步生成器逐块吐出,方便边生成边让数字人开口。

// src/services/llm.service.ts
import OpenAI from 'openai';

const client = new OpenAI({
  apiKey: import.meta.env.VITE_OPENAI_API_KEY,
  baseURL: import.meta.env.VITE_OPENAI_BASE_URL,
  timeout: 60000,
  dangerouslyAllowBrowser: true, // 仅本地演示;生产环境请走后端代理
});

async function* sendMessageStream(
  userMessage: string,
  systemPrompt = '你是一名专业、简洁的客服助手,回答控制在三句话以内。'
) {
  const stream = await client.chat.completions.create({
    model: '你的模型名称',
    messages: [
      { role: 'system', content: systemPrompt },
      { role: 'user', content: userMessage },
    ],
    stream: true,
  });

  for await (const chunk of stream) {
    const content = chunk.choices[0]?.delta?.content || '';
    if (content) yield content;
  }
}

export default { sendMessageStream };

系统提示词里限制回答长度很重要:数字人念长文本时用户等待感明显,客服场景下短句更自然。模型名称请填你实际开通的模型,不要照抄示例。

第六步:组装客服组件

组件负责三件事:挂载时初始化数字人、把用户输入交给大模型、把回复交给数字人念出来。同时维护聊天记录、加载进度、字幕文本三个状态。

<script setup lang="ts">
import { ref, onMounted, onBeforeUnmount, nextTick } from 'vue';
import XingYunService from '../services/xingyun.service';
import LLMService from '../services/llm.service';

const chatHistory = ref<{ type: string; content: string; time: string }[]>([]);
const userInput = ref('');
const selectedAction = ref('');
const isLoading = ref(false);
const progress = ref(0);
const currentSubtitle = ref('');
const chatContainer = ref<HTMLElement | null>(null);

const quickReplies = [
  '你能帮我做什么?',
  '如何修改个人信息?',
  '订单查询流程是怎样的?',
  '退换货政策是什么?',
];

onMounted(async () => {
  try {
    await XingYunService.initSDK({
      appId: import.meta.env.VITE_XINGYUN_APPID,
      appSecret: import.meta.env.VITE_XINGYUN_SECRET,
      onProgress: (v) => (progress.value = v),
      onStateChange: (s) => {
        if (s === 'ready') addMessage('system', '数字人已就绪,请问有什么可以帮您?');
      },
      onSubtitle: (t) => (currentSubtitle.value = t),
      onSubtitleEnd: () => (currentSubtitle.value = ''),
    });
  } catch (e) {
    console.error(e);
    addMessage('system', '数字人服务初始化失败,请刷新重试。');
  }
});

onBeforeUnmount(() => XingYunService.disconnect());

async function sendMessage() {
  const text = userInput.value.trim();
  if (!text || isLoading.value) return;
  addMessage('user', text);
  userInput.value = '';
  isLoading.value = true;

  try {
    let first = true;
    let buffer = '';
    for await (const chunk of LLMService.sendMessageStream(text)) {
      buffer += chunk;
      if (first) {
        XingYunService.speak(chunk, true, false);
        first = false;
      } else {
        XingYunService.speak(chunk, false, false);
      }
    }
    XingYunService.speak('', false, true);
    addMessage('ai', buffer);
  } catch (e) {
    console.error(e);
    addMessage('system', '获取回复失败,请重试。');
  } finally {
    isLoading.value = false;
    selectedAction.value = '';
  }
}

function addMessage(type: string, content: string) {
  const now = new Date();
  const time = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
  chatHistory.value.push({ type, content, time });
  nextTick(() => {
    if (chatContainer.value) chatContainer.value.scrollTop = chatContainer.value.scrollHeight;
  });
}
</script>

模板部分只需要一个固定 id 为 avatar-container 的容器(与初始化参数一致)、一个消息列表、一个输入框和一组快捷问题按钮。容器必须有明确的宽高,否则数字人渲染出来是空白。

<template>
  <div class="cs-layout">
    <div id="avatar-container" class="avatar"></div>
    <div class="panel">
      <p v-if="progress < 100">资源加载中:{{ progress }}%</p>
      <p v-if="currentSubtitle" class="subtitle">{{ currentSubtitle }}</p>
      <div ref="chatContainer" class="messages">
        <div v-for="(m, i) in chatHistory" :key="i" :class="m.type">
          <span>{{ m.time }}</span> {{ m.content }}
        </div>
      </div>
      <div class="quick">
        <button v-for="q in quickReplies" :key="q" @click="userInput = q; sendMessage()">{{ q }}</button>
      </div>
      <input v-model="userInput" @keyup.enter="sendMessage" placeholder="请输入问题" />
      <button :disabled="isLoading" @click="sendMessage">发送</button>
    </div>
  </div>
</template>

第七步:接入语音识别(可选)

如果希望用户直接说话而不是打字,在组件里加一个录音按钮:采集麦克风音频,按 ASR 服务的要求分片上传或建立实时流,拿到转写文本后调用 sendMessage。ASR 的鉴权参数走环境变量,签名逻辑放在服务层。这一步不改变数字人侧的代码,只是把输入源从输入框换成语音。

第八步:本地运行与验证

npm run dev

打开浏览器控制台,按顺序确认四件事:SDK 脚本是否加载成功、init 的下载进度是否走到 100、onStateChange 是否收到 ready、输入一句话后是否同时出现语音、字幕与动作。任何一环断了,先看控制台报错,再回到对应步骤检查凭证与参数。

一个完整示例

下面把最小可运行链路串起来,假设你只想验证「输入文字 → 数字人说出来」这一段,不接大模型、不接 ASR。

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>数字人最小示例</title>
  <style>
    #avatar-container { width: 480px; height: 640px; background: #111; }
  </style>
</head>
<body>
  <div id="avatar-container"></div>
  <input id="txt" value="你好,我是数字人客服,很高兴为您服务。" />
  <button id="say">说话</button>

  <script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js"></script>
  <script>
    const avatar = new XmovAvatar({
      containerId: '#avatar-container',
      appId: '你的应用ID',
      appSecret: '你的应用密钥',
      gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
      onStateChange: (s) => console.log('state:', s),
      onWidgetEvent: (d) => {
        if (d.type === 'subtitle_on') console.log('字幕:', d.text);
      },
      enableLogger: true,
    });

    avatar.init({
      onDownloadProgress: (p) => console.log('加载:', p + '%'),
      onError: (e) => console.error('初始化失败:', e),
      onClose: () => console.log('连接关闭'),
    }).then(() => {
      document.getElementById('say').onclick = () => {
        const text = document.getElementById('txt').value;
        avatar.speak(text, true, true);
      };
    });
  </script>
</body>
</html>

把这个文件放在任意静态服务器下打开(直接双击本地文件可能因跨域与权限限制失败),等控制台打印加载进度到 100、状态变为 ready,点击「说话」按钮,数字人应当念出输入框里的内容并同步口型与表情。跑通这一段之后,再按前面的步骤把大模型与 ASR 接上,就是完整的客服应用。

如果要验证动作指令,把 avatar.speak(text, true, true) 换成带 SSML 的调用:

const ssml = `<speak><ue4event><type>ka</type><data><action_semantic>Hello</action_semantic></data></ue4event>您好,欢迎咨询。</speak>`;
avatar.speak(ssml, true, true);

动作标识需要与平台侧该形象实际支持的动作对应,示例中的 Hello、Goodbye、Agree、Disagree、Think、Explain 只是常见取值,具体可用动作以平台文档与形象配置为准。

注意事项

  • 密钥不能进前端:VITE_ 前缀的变量会被打包进浏览器产物,任何人打开开发者工具都能看到。生产环境应由后端持有 appSecret 并签发临时凭证,或通过代理转发请求。
  • 流式语音要分段标记:把整段回复一次性交给 speak 会等模型全部生成完才开口;逐块调用时必须正确设置开始与结束标志,否则会出现重复朗读或说到一半被截断。
  • 容器尺寸:渲染容器没有宽高时数字人不可见,这是最常见的「初始化成功但看不到人」的原因。
  • 资源加载耗时:首次进入需要下载数字人资源,务必用 onDownloadProgress 做进度提示,并在 ready 之前禁用输入,避免用户提前提问。
  • 及时释放连接:组件卸载时调用 stop() 与 destroy(),否则会残留会话与音频资源,单页应用里反复切换路由时尤其明显。
  • 中途打断:数字人支持用户插话时停止当前内容并切换话题,实现上需要在检测到新的用户输入时先停止当前播报,再发起新一轮对话,否则两段语音会叠在一起。
  • 延迟预期:语音合成延迟与所选模型规模有关,小模型明显快于大模型;端到端体感还叠加了 ASR 与大模型的耗时,客服场景建议把模型回复控制在短句。
  • 多终端适配:SDK 覆盖手机、电脑、平板、电视等多终端环境,但不同设备的渲染性能与麦克风权限策略不同,上线前需在目标机型上实测。
  • 计费与额度:积分赠送额度、调用计费方式、并发上限等均可能调整,接入前请以官网当前信息为准,不要按本文示例中的任何数字做容量规划。
  • 合规:语音克隆需使用已获授权的音频素材;客服场景涉及用户个人信息时,转写文本与对话记录的存储需符合所在地区的隐私要求。