摘要:具身交互智能是当下AI应用的重要方向,本文详细介绍如何基于魔珐星云XmovAvatar SDK的参数流架构,使用Qoder AI编程工具从零搭建一个面向MBTI性格分析场景的具身交互智能数字人应用------枕月・MBTI人格类型分析师。文章涵盖环境搭建、SDK配置、LLM大模型对接、ASR语音识别集成等完整开发流程,并深入解析流式对话、首句即播报、实时打断、字幕跟随等核心交互机制的实现原理。通过本文,读者可以快速掌握企业级数字人应用的开发技巧,打造出一个响应延迟 < 500ms、支持双模交互(文字+语音)、深紫色星空主题风格的智能MBTI分析师系统。
魔珐星云PC端官方链接:https://xingyun3d.com?utm_campaign=daily\&utm_source=CSDNwanfen3\&utm_medium=\&utm_term=\&utm_content=
一、环境搭建:从0到1跑通参数流交互链路
1.1 下载并启动Demo项目
步骤1:下载项目****源码
GitHub仓库:https://github.com/publicize0828/XmovLiteAvatarJSDemo
Gitee仓库:https://gitee.com/xmovmaster/XmovLiteAvatarJSDemo

步骤2:安装依赖
解压缩后,用Qoder打开项目,在终端执行:
bash
# 使用 pnpm 安装(推荐,速度更快)
pnpm i
# 或使用 npm 安装
npm i

步骤3:启动开发服务器
bash
npm run dev

步骤4:浏览器访问
打开浏览器访问:http://localhost:5173

1.2 配置魔珐星云SDK密钥
步骤5:创建驱动应用
登录魔珐星云官网,进入「应用管理」创建新应用:

配置驱动应用名称和备注:

步骤6:配置人物形象
- 形象配置:选择灵犀机器人形象

- 场景配置:选择合适的展示场景

- 音色配置:选择AI语音音色

- 表演配置:设置动作风格和交互行为

步骤7:在线调试
在官网进行实时调试,验证配置效果:

步骤8:获取密钥
复制App ID和App Secret,配置到本地项目中:

步骤9:SDK配置说明
虚拟人SDK采用参数流架构,关键配置项:

1.3 配置腾讯云ASR语音识别
步骤10:创建腾讯云密钥
登录腾讯云ASR控制台:https://console.cloud.tencent.com/asr
创建访问密钥:

新建密钥,获取SecretKey并妥善保存:


步骤11:配置ASR参数
在项目中配置三项关键参数:
- ASR App ID
- ASR Secret ID
- ASR Secret Key

1.4 配置火山引擎大语言模型
步骤12:创建API KEY
进入API接入页面:

创建API KEY:

步骤13:开通模型
选择并开通豆包大模型(推荐 doubao-1-5-pro-32k):

步骤14:配置到项目
复制示例代码中的API KEY参数,配置到Demo项目的LLM配置中:


1.5 验证连接成功
步骤15:点击连接测试
完成所有配置后,点击「连接」按钮,显示连接成功即可:

二、枕月・MBTI人格类型分析师数字人搭建
2.1 创建具身驱动应用
登录魔珐星云控制台,进入「具身驱动对话流」模块,创建新的驱动应用:

2.2 配置枕月・虚拟偶像角色信息
填写应用名称为"枕月・MBTI人格类型分析师",配置角色定位和相关信息:

2.3 选择MBTI人格类型分析师
在角色模板中选择"MBTI人格类型分析师",主打人格解读功能:

2.4 数字人四要素配置(核心步骤)
更改数字人相关配置,完成形象、场景、音色、模型四大核心配置:
- 角色名称定位:MBTI 人格类型分析师,主打人格解读功能
- 角色形象规格:3D 超写实数字人风格
- 适用使用场景:生活娱乐类场景
- 交互语种:仅支持中文对话
- 人设简介:MBTI 解读助手,擅长拆解人格特质,帮助用户自我认知、改善人际交往
- 语音音色配置:知心姐姐音色,可选 Pro 高级语音版本、基础语音版本,当前选中 Pro
- 功能开关:AI 动作生成功能处于开启状态
- 底层全局模型配置
- ASR 语音识别模型:选用 doubao
- 核心大语言模型:选用豆包

2.5 获取SDK密钥并接入终端
创建同款应用,获取 SDK 密钥(App ID + App Secret),可接入任意终端:

2.6 同款应用信息配置
配置同款应用的相关信息,确保与枕月角色定位一致:

2.7 人物形象-场景-音色-表演配置
完成人物形象、展示场景、AI音色、表演动作四大维度配置(此处可以不做修改默认即可,有特殊需求可以更改场景):

2.8 接入SDK到项目
接入SDK,复制App密钥,并参考文档将数字人应用接入到你的网页、App 或任意终端中:

三、效果展示与核心代码讲解

3.1 constants/index.ts - MBTI分析师角色配置
枕月的人格定义是整个应用的"灵魂"。系统提示词采用Markdown格式结构化编写,明确了角色身份、专业能力、工作原则三大维度,确保大模型输出符合MBTI分析师的专业定位。
bash
// constants/index.ts
export const LLM_CONFIG = {
BASE_URL: 'https://ark.cn-beijing.volces.com/api/v3',
DEFAULT_MODEL: 'doubao-1-5-pro-32k-250115',
SYSTEM_PROMPT: `你是枕月,一位专业而温柔的MBTI人格类型分析师。
## 你的身份
- 名称:枕月
- 身份:MBTI人格类型分析师,性格解读专家
- 性格:温柔细腻、洞察敏锐、善于共情、富有智慧
- 语言风格:温暖亲切、深入浅出、富有启发性、善用比喻
## 你的专业能力
1. **MBTI测试引导**:通过趣味对话帮助用户了解自己的MBTI类型
2. **性格深度解析**:详细解读16种人格类型的特征、优势与盲点
3. **人际关系分析**:分析不同人格类型之间的相处模式与契合度
4. **成长建议**:针对不同人格类型提供个性化的成长路径建议
## 工作原则
- 以温暖包容的态度引导对话,让用户感到被理解
- 用生动的比喻和案例解释抽象的心理学概念
- 避免刻板印象,强调人格的多样性和发展潜力
- 结合认知功能理论(如Ni、Ne、Si、Se等)进行深度分析
- 尊重每个类型的独特价值,不评判优劣`,
} as const
角色设计要点:
- 枕月角色定位明确:MBTI人格类型分析师,性格解读专家
- 四大专业能力覆盖MBTI核心场景:测试引导、深度解析、关系分析、成长建议
- 语言风格温暖亲切,善用比喻,降低心理学概念的理解门槛
- 强调认知功能理论(Ni、Ne、Si、Se),确保分析深度
3.2 avatar.ts - 数字人SDK服务
这是项目的核心模块,负责魔珐星云XmovAvatar SDK的初始化、连接和生命周期管理。采用Promise管理模式处理异步连接流程,支持超时控制和状态监控。参数流技术的关键在于:云端只传输驱动参数(骨骼、表情、口型坐标),客户端本地解算渲染。
js
import type { AvatarConfig } from '../types'
import { generateContainerId, getPromiseState } from '../utils'
import { SDK_CONFIG, APP_CONFIG } from '../constants'
interface AvatarCallbacks {
onSubtitleOn: (text: string) => void
onSubtitleOff: () => void
onStateChange: (state: string) => void
onVoiceStateChange?: (status: string) => void
}
class AvatarService {
private containerId: string
constructor() {
this.containerId = generateContainerId()
}
getContainerId(): string {
return this.containerId
}
async connect(config: AvatarConfig, callbacks: AvatarCallbacks): Promise<any> {
const { appId, appSecret } = config
const { onSubtitleOn, onSubtitleOff, onStateChange, onVoiceStateChange } = callbacks
// 检查容器是否存在
const containerEl = document.getElementById(this.containerId)
if (!containerEl) {
console.error(`[AvatarService] 容器 #${this.containerId} 不存在!`)
throw new Error(`容器 #${this.containerId} 不存在,请确保 AvatarRender 组件已渲染`)
}
console.log(`[AvatarService] 容器 #${this.containerId} 已找到,尺寸: ${containerEl.clientWidth}x${containerEl.clientHeight}`)
// 构建网关URL
const url = new URL(SDK_CONFIG.GATEWAY_URL)
url.searchParams.append('data_source', SDK_CONFIG.DATA_SOURCE)
url.searchParams.append('custom_id', SDK_CONFIG.CUSTOM_ID)
console.log('[AvatarService] 开始创建 SDK 实例...')
// 连接Promise管理
let resolve: (value: boolean) => void
let reject: (reason?: any) => void
const connectPromise = new Promise<boolean>((res, rej) => {
resolve = res
reject = rej
})
// SDK构造选项
const constructorOptions = {
containerId: `#${this.containerId}`,
appId,
appSecret,
enableDebugger: false,
gatewayServer: url.toString(),
// 字幕代理组件事件 - 仅打印日志
onProxyWidgetEvent: (event: any) => {
console.log('SDK事件:', event)
},
onStateChange,
onMessage: async (error: any) => {
const state = await getPromiseState(connectPromise)
const plainError = new Error(error.message)
if (state === 'pending') {
reject(plainError)
}
},
onVoiceStateChange: (status: string) => {
console.log('onVoiceStateChange=========', status)
// 当状态为 'end' 时,表示数字人停止说话
if (status.includes('end')) {
onVoiceStateChange?.(status)
}
},
}
// 创建SDK实例
console.log('[AvatarService] new XmovAvatar()...')
const avatar = new window.XmovAvatar(constructorOptions)
// 等待初始化
await new Promise(resolve => {
setTimeout(resolve, APP_CONFIG.AVATAR_INIT_TIMEOUT)
})
// 初始化SDK
console.log('[AvatarService] 调用 avatar.init()...')
await avatar.init({
onDownloadProgress: (progress: number) => {
console.log(`初始化进度: ${progress}%`)
if (progress >= 100) {
resolve(true)
}
},
onClose: () => {
onStateChange('')
console.log('SDK连接关闭')
}
})
console.log('[AvatarService] avatar.init() 完成')
// 等待连接完成(设置超时避免永久挂起)
const connectTimeout = new Promise<boolean>((_, rej) => {
setTimeout(() => rej(new Error('SDK连接超时')), 15000)
})
try {
await Promise.race([connectPromise, connectTimeout])
console.log('[AvatarService] 连接成功')
} catch (error) {
console.warn('SDK连接等待结束:', error)
// 超时不抛错,可能已经初始化完成但没触发100%回调
}
// 连接成功后,注入CSS隐藏SDK字幕
this.injectSubtitleKiller()
return avatar
}
/**
* 注入全局CSS:精准隐藏SDK字幕元素,不影响数字人渲染
*/
private injectSubtitleKiller(): void {
// 暂时完全禁用字幕隐藏,先确保数字人能正常显示
console.log('[AvatarService] 字幕隐藏已禁用,排查数字人渲染问题')
return
}
disconnect(avatar: any): void {
if (!avatar) return
// 移除注入的CSS
const killerStyle = document.getElementById('xmov-subtitle-killer')
if (killerStyle) killerStyle.remove()
try {
avatar.stop()
avatar.destroy()
} catch (error) {
console.error('断开连接时出错:', error)
}
}
}
export const avatarService = new AvatarService()
代码讲解:
- connect 方法是核心异步函数,建立参数流通道的关键
- 构建网关URL是参数流的入口地址------魔珐星云的参数流架构在这里体现:不传输视频,只传输驱动参数
- new window.XmovAvatar() 创建SDK实例,启动参数流通道
- avatar.init() 开始下载3D模型资源,进度到100%时通道就绪
- Promise.race 设置15秒超时,防止连接永久挂起
- onVoiceStateChange 回调监听数字人说话状态,end 表示停止说话
- 关键优势:整个连接过程只传输配置参数,不产生视频流带宽消耗,低延时、高并发
3.3 llm.ts - 大语言模型服务
负责与火山引擎豆包大模型交互,生成智能回复。这是数字人的"大脑"。提供普通对话和流式对话两种模式,其中流式模式是实现低延迟播报的核心。
js
import OpenAI from 'openai'
import type { LlmConfig, ChatMessage } from '../types'
import { LLM_CONFIG } from '../constants'
class LlmService {
private openai: OpenAI | null = null
private currentApiKey: string = ''
private initClient(config: LlmConfig): void {
if (this.currentApiKey === config.apiKey && this.openai) {
return
}
const baseURL = config.baseURL || LLM_CONFIG.BASE_URL
console.log('初始化LLM客户端:', { baseURL, model: config.model })
this.openai = new OpenAI({
apiKey: config.apiKey,
dangerouslyAllowBrowser: true,
baseURL: baseURL,
// 确保使用 fetch API 支持流式
fetch: (url, init) => {
console.log('LLM请求URL:', url)
console.log('LLM请求配置:', {
method: init?.method,
headers: init?.headers,
body: init?.body
})
return fetch(url, init)
}
})
this.currentApiKey = config.apiKey
}
async sendMessage(config: LlmConfig, userMessage: string): Promise<string | null> {
this.initClient(config)
if (!this.openai) {
throw new Error('LLM客户端未初始化')
}
const messages: ChatMessage[] = [
{ role: 'system', content: LLM_CONFIG.SYSTEM_PROMPT },
{ role: 'user', content: userMessage }
]
try {
console.log('发送LLM请求:', { model: config.model, message: userMessage })
const completion = await this.openai.chat.completions.create({
messages,
model: config.model
})
const response = completion.choices[0]?.message?.content
console.log('LLM响应:', response)
return response || null
} catch (error) {
console.error('LLM请求失败:', error)
throw error
}
}
async sendMessageWithStream(config: LlmConfig, userMessage: string): Promise<AsyncIterable<string>> {
this.initClient(config)
if (!this.openai) {
throw new Error('LLM客户端未初始化')
}
const messages: ChatMessage[] = [
{ role: 'system', content: LLM_CONFIG.SYSTEM_PROMPT },
{ role: 'user', content: userMessage }
]
console.log('发送流式LLM请求:', {
baseURL: config.baseURL || LLM_CONFIG.BASE_URL,
model: config.model,
stream: true,
message: userMessage
})
try {
const stream = await this.openai.chat.completions.create({
messages,
model: config.model,
stream: true
})
console.log('流式请求已创建,开始接收数据...')
return (async function* () {
let chunkCount = 0
for await (const part of stream) {
chunkCount++
const content = part.choices[0]?.delta?.content
if (content) {
yield content
}
}
})()
} catch (error) {
console.error('流式请求失败:', error)
throw error
}
}
}
export const llmService = new LlmService()
代码讲解:
- LlmService 类封装了与大语言模型的交互逻辑
- 懒初始化模式:只在首次调用时创建客户端,currentApiKey 对比避免重复初始化
- dangerouslyAllowBrowser: true 允许浏览器端直接使用(生产环境建议后端代理)
- sendMessageWithStream 方法返回 AsyncIterable,使用 async function* 异步生成器
- stream: true 启用流式输出,服务端逐块返回数据
- yield content 逐个返回文本片段,实现逐字输出效果
- 端到端约500ms的秘诀:LLM流式输出(约200ms)+ 参数流传输(约100ms)+ 端渲渲染(约200ms)
3.4 action-manager.ts - 动作队列管理器
管理数字人的语音播报队列,支持SSML格式文本。采用异步队列处理机制,确保多段流式文本按序播报。这是实现"边生成边播报"的关键组件。
js
import type { Ref } from 'vue'
import type { ActionQueueItem } from '../types'
import { generateSSML } from '../utils'
interface SpeakOptions {
/** 是否为流式对话起始 */
isStart?: boolean
/** 是否为流式对话结束 */
isEnd?: boolean
}
interface ActionManagerOptions {
instanceRef: Ref<any | null>
onVoiceReady?: () => void
onVoiceEnd?: () => void
}
export class ActionManager {
private queue: ActionQueueItem[] = []
private isSpeaking = false
private instanceRef: Ref<any | null>
private onVoiceReady?: () => void
private onVoiceEnd?: () => void
constructor(options: ActionManagerOptions) {
this.instanceRef = options.instanceRef
this.onVoiceReady = options.onVoiceReady
this.onVoiceEnd = options.onVoiceEnd
}
speak(text: string, options: SpeakOptions = {}) {
const ssml = generateSSML(text.replace(/\n+/g, '\n'))
this.queue.push({
ssml,
isStart: options.isStart ?? false,
isEnd: options.isEnd ?? false
})
this.processQueue()
}
reset() {
this.queue = []
this.isSpeaking = false
}
private async processQueue() {
if (this.isSpeaking) return
if (!this.queue.length) return
const instance = this.instanceRef.value
if (!instance) return
this.isSpeaking = true
while (this.queue.length) {
const item = this.queue.shift()
if (!item) break
this.onVoiceReady?.()
instance.speak(item.ssml, item.isStart, item.isEnd)
// 如果是流式中间段,等待下一个片段
if (!item.isEnd) {
continue
}
// 等待 speak 完成
await new Promise(resolve => setTimeout(resolve, 50))
}
this.isSpeaking = false
this.onVoiceEnd?.()
}
}
代码讲解:
- speak 方法将文本转换为SSML格式后入队,isStart/isEnd 标记流式对话的起止
- processQueue 是核心逻辑:异步循环消费队列,逐段调用SDK的 speak 方法
- isStart 标记流式对话起始段,触发数字人进入"speak"状态
- isEnd 标记流式对话结束段,触发数字人返回"interactive_idle"状态
- 中间段落 !item.isEnd 时 continue 立即处理下一段,确保流畅播报不中断
- reset 方法用于打断时清空队列,停止当前播报
3.5 app.ts - 核心业务逻辑(流式分句播报)
应用状态管理和业务流程编排。这是整个项目最核心的文件,集成了SDK连接、LLM对话、字幕回调、打断播报、流式分句播报等全部核心功能。
js
import { reactive, ref, watch } from 'vue'
import type { AppState } from '../types'
import { LLM_CONFIG, API_KEYS } from '../constants'
import { validateConfig } from '../utils'
import { avatarService } from '../services/avatar'
import { llmService } from '../services/llm'
import { ActionManager } from '../services/action-manager'
// 应用状态 - 使用默认密钥(如果已配置)
export const appState = reactive<AppState>({
avatar: {
appId: API_KEYS.AVATAR.appId || '',
appSecret: API_KEYS.AVATAR.appSecret || '',
connected: false,
instance: null
},
asr: {
provider: 'tx',
appId: API_KEYS.ASR.appId || '',
secretId: API_KEYS.ASR.secretId || '',
secretKey: API_KEYS.ASR.secretKey || '',
isListening: false
},
llm: {
model: LLM_CONFIG.DEFAULT_MODEL,
apiKey: API_KEYS.LLM.apiKey || ''
},
ui: {
text: '',
subTitleText: ''
}
})
// 中文标点符号正则
const cnSplitSign = /[。?!;... ,:]/
// 英文标点符号正则
const enSplitSign = /[.?!;:,]/
// 虚拟人状态
export const avatarState = ref('')
const avatarInstance = ref<any>(null)
// 用于等待 onVoiceStateChange 'end' 状态的 Promise 解析器
let voiceEndResolver: (() => void) | null = null
const actionManager = new ActionManager({
instanceRef: avatarInstance,
onVoiceReady: () => {
avatarState.value = 'speak'
},
onVoiceEnd: () => {
avatarState.value = 'interactive_idle'
}
})
// Store类 - 业务逻辑处理
export class AppStore {
async connectAvatar(): Promise<void> {
console.log('[AppStore] connectAvatar 开始')
const { appId, appSecret } = appState.avatar
if (!validateConfig({ appId, appSecret }, ['appId', 'appSecret'])) {
throw new Error('appId 或 appSecret 为空')
}
try {
const avatar = await avatarService.connect({
appId,
appSecret
}, {
onSubtitleOn: (text: string) => {
appState.ui.subTitleText = text
},
onSubtitleOff: () => {
appState.ui.subTitleText = ''
},
onStateChange: (state: string) => {
avatarState.value = state
},
onVoiceStateChange: (status: string) => {
if (status === 'end') {
avatarState.value = 'interactive_idle'
if (voiceEndResolver) {
voiceEndResolver()
voiceEndResolver = null
}
}
}
})
appState.avatar.instance = avatar
avatarInstance.value = avatar
appState.avatar.connected = true
} catch (error) {
appState.avatar.connected = false
throw error
}
}
async sendMessage(): Promise<string | undefined> {
const { llm, ui, avatar } = appState
if (!validateConfig(llm, ['apiKey']) || !ui.text || !avatar.instance) {
return
}
try {
// 如果数字人正在说话,先打断并等待停止
await this.interrupt()
if (avatarState.value === 'speak') {
try {
await this.waitForAvatarIdle()
} catch (error) {
console.warn('等待数字人停止说话超时,继续发送:', error)
}
}
actionManager.reset()
// 发送到LLM获取回复
const stream = await llmService.sendMessageWithStream({
provider: 'openai',
model: llm.model,
apiKey: llm.apiKey
}, ui.text)
if (!stream) return
// 缓存一定数量文本再进行 speak 调用,采用如下策略:
// 1. 持续缓存字符直到遇到任意标点符号,检查【缓存的可读字符是否 >= minimum】
// 1.1 如果是,将缓存组装为 ssml 并调用 speak
// 1.2 如果否,继续缓存
// 2. 将【汉字、英文字母、阿拉伯数字】视为【可读字符】进行匹配
const minimum = 20
const context = {
/** 缓存文本 */
cache: '',
/** 缓存的可读字符数 */
chars: 0,
/** 是否已经发送过首句 */
firstSpeakSend: false,
/** 缓存的空格数 */
spaceCount: 0
}
// 创建一个 Promise,在第一句发送后立即 resolve
let firstSentenceResolved = false
const firstSentencePromise = new Promise<void>((resolve) => {
// 在后台继续处理流式数据
;(async () => {
try {
// 流式播报响应内容
for await (const content of stream) {
if (typeof content !== 'string') continue // 防御编程
context.cache += content // 将该段文本加入缓存
// 英文以空格开头加一个计数器做分割推送
if (content.startsWith(' ')) {
context.spaceCount += 1
}
const chars = content.match(/[\u4e00-\u9fa5a-zA-Z0-9]/g)?.length ?? 0 // 统计段内可读字符数
let shouldSend = false
if (!context.firstSpeakSend) {
// 首句:需要达到最小字符数且遇到标点符号
shouldSend = context.spaceCount
? context.spaceCount > minimum - 1 && enSplitSign.test(content)
: context.chars > minimum && cnSplitSign.test(content)
} else {
// 后续句子:遇到标点符号即可发送
shouldSend = context.spaceCount ? enSplitSign.test(content) : cnSplitSign.test(content)
}
if (!shouldSend) {
context.chars += chars
continue
}
// 发送缓存的文本
actionManager.speak(context.cache, {
isStart: !context.firstSpeakSend,
isEnd: false
})
// 如果是第一句,立即 resolve Promise,让 sendMessage 返回
if (!context.firstSpeakSend && !firstSentenceResolved) {
firstSentenceResolved = true
context.firstSpeakSend = true
resolve() // 第一句发送后立即返回成功信号
} else if (context.firstSpeakSend) {
context.firstSpeakSend = true
}
context.cache = ''
context.chars = 0
context.spaceCount = 0
}
// 处理剩余的缓存文本
if (context.cache.length > 0) {
actionManager.speak(context.cache, {
isStart: !context.firstSpeakSend,
isEnd: true
})
// 如果首句还没发送就结束了(短回复),也要resolve
if (!firstSentenceResolved) {
firstSentenceResolved = true
resolve()
}
} else if (context.firstSpeakSend) {
// 如果已经发送过内容但没有剩余文本,发送结束标记
actionManager.speak('', {
isStart: false,
isEnd: true
})
} else {
// 流结束但没有任何内容,也要resolve避免卡死
if (!firstSentenceResolved) {
firstSentenceResolved = true
resolve()
}
}
} catch (error) {
console.error('流式处理错误:', error)
// 如果第一句还没发送就出错了,也要 resolve
if (!firstSentenceResolved) {
firstSentenceResolved = true
resolve()
}
}
})()
})
// 等待第一句发送完成,然后立即返回
await firstSentencePromise
return 'success'
} catch (error) {
console.error('发送消息失败:', error)
throw error
}
}
interrupt(): void {
if (!appState.avatar.instance) {
return
}
try {
// 重置动作管理器队列
actionManager.reset()
// 调用虚拟人实例的打断方法
// SDK 的 interactive_idle() 方法用于打断当前说话
if (typeof appState.avatar.instance.interactiveidle === 'function') {
appState.avatar.instance.interactiveidle()
} else {
// 备用方案:尝试其他可能的打断方法
if (typeof appState.avatar.instance.interrupt === 'function') {
appState.avatar.instance.interrupt()
}
}
} catch (error) {
console.error('打断失败:', error)
avatarState.value = 'interactive_idle'
}
}
private async waitForAvatarIdle(timeout: number = 5000): Promise<void> {
if (avatarState.value === 'interactive_idle' || avatarState.value === '') {
return
}
return new Promise((resolve, reject) => {
let resolved = false
voiceEndResolver = () => {
if (!resolved) {
resolved = true
clearTimeout(timeoutId)
resolve()
}
}
// 同时使用 watch 监听状态变化作为备用方案
const stopWatcher = watch(avatarState, (newState) => {
if ((newState === 'interactive_idle' || newState === '') && !resolved) {
resolved = true
stopWatcher()
voiceEndResolver = null
clearTimeout(timeoutId)
resolve()
}
}, { immediate: false })
const timeoutId = setTimeout(() => {
if (!resolved) {
resolved = true
stopWatcher()
voiceEndResolver = null
reject(new Error('等待虚拟人停止说话超时'))
}
}, timeout)
// 立即检查一次状态
if (avatarState.value === 'interactive_idle' || avatarState.value === '') {
if (!resolved) {
resolved = true
stopWatcher()
voiceEndResolver = null
clearTimeout(timeoutId)
resolve()
}
}
})
}
}
export const appStore = new AppStore()
核心交互流程:
- 用户输入文字/语音
- 检查数字人状态,如正在说话则打断
- 等待数字人进入空闲状态
- 流式调用LLM获取MBTI分析回复
- 首句达到20字符+标点即播报(降低首字延迟)
- 后续句子遇到标点即播报
- 流式输出结束,发送结束标记
- 字幕回调实时将文本显示在数字人脚下
双保险等待机制:
- voiceEndResolver:通过 onVoiceStateChange 的 end 状态触发Promise解析
- watch 监听:同时监听 avatarState 状态变化作为备用方案
- 超时兜底:5秒超时自动resolve,避免永久阻塞
3.6 AvatarRender.vue - 数字人渲染组件(透明背景叠加)
负责数字人的可视化渲染。采用fixed定位将数字人悬浮在页面右下角,透明背景叠加在页面内容之上,支持字幕跟随显示在数字人脚下。
js
<template>
<div class="avatar-render">
<!-- SDK 渲染容器 - 透明背景 -->
<div :id="containerId" class="sdk-container" />
<!-- 字幕显示 - 数字人脚下 -->
<div v-if="appState.ui.subTitleText" class="subtitle-area">
<div class="subtitle-text">{{ appState.ui.subTitleText }}</div>
</div>
<!-- 语音输入动画 -->
<div v-show="appState.asr.isListening" class="voice-animation">
<img :src="siriIcon" alt="语音输入" />
</div>
<!-- 加载状态 -->
<div v-if="!appState.avatar.connected" class="loading-placeholder">
<div class="loading-spinner"></div>
<div class="loading-text">枕月准备中...</div>
</div>
</div>
</template>
关键CSS:透明背景 + 右下角悬浮 + 字幕跟随
js
.avatar-render {
position: fixed;
right: 120px;
bottom: 0;
width: 500px;
height: 85vh;
max-height: 900px;
z-index: 100;
pointer-events: none; /* 不阻挡页面鼠标事件 */
overflow: visible; /* 允许肢体动作超出边界 */
}
/* SDK容器 - 透明背景 */
.sdk-container {
background: transparent !important;
overflow: visible;
}
.sdk-container :deep(canvas),
.sdk-container :deep(video) {
max-width: none; /* 不限制canvas尺寸 */
background: transparent !important;
}
/* 字幕区域 - 数字人脚下 */
.subtitle-area {
position: absolute;
bottom: 20px;
left: 60%;
transform: translateX(-40%);
max-width: 360px;
z-index: 102;
}
.subtitle-text {
padding: 12px 20px;
background: rgba(15, 12, 41, 0.85); /* 深紫色半透明 */
backdrop-filter: blur(12px); /* 毛玻璃效果 */
border: 1px solid rgba(167, 139, 250, 0.25);
border-radius: 16px;
color: #e9d5ff;
font-size: 14px;
text-align: center;
box-shadow: 0 4px 20px rgba(139, 92, 246, 0.2);
animation: subtitleFadeIn 0.3s ease;
}
代码讲解:
- position: fixed + right: 120px:数字人固定在右下角,向左偏移避免边缘裁剪
- pointer-events: none:数字人容器不阻挡页面鼠标事件,用户可正常操作底层页面
- overflow: visible:允许数字人肢体动作(如抬手)超出容器边界,不被裁剪
- background: transparent !important:SDK容器和canvas均透明,实现叠加效果
- .subtitle-area 使用 bottom: 20px 定位在数字人脚下
- 字幕采用深紫色毛玻璃背景(rgba(15, 12, 41, 0.85) + backdrop-filter: blur(12px)),与整体主题统一
3.7 ExhibitionPanel.vue - MBTI主题全屏页面
MBTI主题全屏内容页面,包含粒子背景、品牌区、介绍卡片、MBTI四维卡片、热门话题快捷按钮、对话交互区。采用深紫色星空渐变主题,营造神秘优雅的氛围。
js
<template>
<div class="exhibition-panel">
<!-- 动态背景粒子效果 -->
<div class="particles-bg">
<div v-for="i in 20" :key="i" class="particle" :style="getParticleStyle(i)"></div>
</div>
<div class="main-content">
<!-- 顶部品牌区 -->
<header class="panel-header">
<div class="brand-area">
<div class="brand-icon">🌙</div>
<div class="brand-text">
<h1 class="main-title">枕月</h1>
<p class="sub-title">MBTI人格类型分析师</p>
</div>
</div>
<div class="status-badge" :class="{ 'online': appState.avatar.connected }">
<span class="status-dot"></span>
<span class="status-text">{{ connectionStatus }}</span>
</div>
</header>
<!-- MBTI四维卡片 -->
<section class="dimensions-section">
<div class="dim-grid">
<div class="dim-card" v-for="dim in dimensions" :key="dim.label" @click="selectDimension(dim)">
<div class="dim-icon">{{ dim.icon }}</div>
<div class="dim-content">
<div class="dim-label">{{ dim.label }}</div>
<div class="dim-desc">{{ dim.desc }}</div>
</div>
</div>
</div>
</section>
<!-- 快捷问题区 -->
<section class="quick-questions">
<div class="question-chips">
<button v-for="q in quickQuestions" :key="q" class="question-chip" @click="askQuestion(q)">
{{ q }}
</button>
</div>
</section>
<!-- 对话交互区 -->
<section class="control-section">
<div class="input-wrapper">
<input v-model="appState.ui.text" type="text"
placeholder="向枕月提问,探索你的性格..."
class="message-input" @keydown.enter="handleSendMessage" />
<button @click="handleSendMessage" class="ctrl-btn ctrl-btn-send">发送</button>
</div>
<div class="action-row">
<button @mousedown.prevent="startVoiceRecord" @mouseup.prevent="stopVoiceRecord"
class="ctrl-btn ctrl-btn-voice">
{{ appState.asr.isListening ? '松开结束' : '按住说话' }}
</button>
<button @click="handleInterrupt" class="ctrl-btn ctrl-btn-interrupt">打断</button>
</div>
</section>
</div>
</div>
</template>
MBTI四维数据和快捷问题配置:
js
// MBTI四维数据
const dimensions = [
{ icon: '🔋', label: 'E / I', desc: '能量方向:外向 vs 内向' },
{ icon: '👁', label: 'S / N', desc: '信息获取:感觉 vs 直觉' },
{ icon: '⚖️', label: 'T / F', desc: '决策方式:思考 vs 情感' },
{ icon: '📋', label: 'J / P', desc: '生活态度:判断 vs 知觉' }
]
// 快捷问题
const quickQuestions = [
'我是INTJ,适合什么职业?',
'INFP和INFJ有什么区别?',
'如何判断自己是I还是E?',
'ENTP的恋爱观是怎样的?',
'ISTP适合做什么工作?',
'ENFJ如何发挥领导优势?'
]
深紫色星空主题CSS核心样式:
js
/* 全屏背景渐变 */
.app-container {
width: 100vw;
height: 100vh;
background: linear-gradient(135deg, #0f0c29 0%, #302b63 50%, #24243e 100%);
}
/* 粒子动画 - 20个随机粒子营造星空氛围 */
.particle {
position: absolute;
bottom: -10px;
background: rgba(167, 139, 250, 0.6);
border-radius: 50%;
animation: floatUp linear infinite;
box-shadow: 0 0 6px rgba(167, 139, 250, 0.4);
}
@keyframes floatUp {
0% { transform: translateY(0) translateX(0); opacity: 0; }
10% { opacity: 1; }
90% { opacity: 0.8; }
100% { transform: translateY(-100vh) translateX(30px); opacity: 0; }
}
/* 品牌标题渐变 */
.main-title {
font-size: 32px;
font-weight: 700;
background: linear-gradient(135deg, #a78bfa 0%, #c4b5fd 50%, #e9d5ff 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
letter-spacing: 3px;
}
/* 发送按钮渐变 */
.ctrl-btn-send {
background: linear-gradient(135deg, #8b5cf6 0%, #a78bfa 100%);
color: #ffffff;
border-radius: 24px;
}
代码讲解:
- 全屏沉浸式布局,深紫→靛蓝→深灰三段渐变背景(#0f0c29 → #302b63 → #24243e)
- 20个粒子元素通过 getParticleStyle 随机生成大小、位置、速度,营造星空氛围
- 品牌标题使用紫色渐变文字(#a78bfa → #c4b5fd → #e9d5ff)
- MBTI四维卡片采用2x2网格布局,点击可快速填入提问
- 热门话题以chips标签形式展示,一键发送
- 交互控制区包含文字输入、按住说话、打断三大核心功能
四、总结与展望
本文基于魔珐星云参数流 SDK,使用 Qoder 从零搭建了一个深紫色星空主题的 MBTI 人格分析师数字人。通过 SDK 端侧解算实现 < 500ms 响应延迟,配合首句 20 字符即播报的流式分句策略将首字延迟压至 < 1s;采用回调 + watch 双保险状态监听保障交互稳定性,透明背景 fixed 定位实现数字人右下角悬浮叠加,字幕实时跟随显示在数字人脚下。整套方案服务层分离、状态集中管理,代码精简,开发者可直接复用源码,1 天内即可搭建出可交付的 MBTI 人格分析师产品。
魔珐星云PC端官方链接:https://xingyun3d.com?utm_campaign=daily\&utm_source=CSDNwanfen3\&utm_medium=\&utm_term=\&utm_content=