从零搭建枕月・MBTI人格分析师:魔珐星云具身交互智能数字人机身交互智能开发实战

摘要:具身交互智能是当下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

登录火山引擎控制台:https://console.volcengine.com/ark/region:ark+cn-beijing/experience/chat?modelId=doubao-1-5-lite-32k-250115

进入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()

核心交互流程:

  1. 用户输入文字/语音
  2. 检查数字人状态,如正在说话则打断
  3. 等待数字人进入空闲状态
  4. 流式调用LLM获取MBTI分析回复
  5. 首句达到20字符+标点即播报(降低首字延迟)
  6. 后续句子遇到标点即播报
  7. 流式输出结束,发送结束标记
  8. 字幕回调实时将文本显示在数字人脚下

双保险等待机制:

  • 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=

相关推荐
西安老张(AIGC&ComfyUI)6 天前
第021章:ComfyUI文生音频Qwen3-TTS模型数字人音色设计(一)
aigc·音视频·数字人·comfyui
RTC实战笔记10 天前
实时互动数字人怎么做,才不是一个只会说话的视频?
音视频·数字人·rtc·数字人接入
兜里只有三分钱~23 天前
快递索赔场景评测|魔珐星云职场法务数字人线下落地实测
数学建模·数字人·数字
wei_shuo1 个月前
基于魔珐星云打造的心理咨询师数字人:情绪疏导、压力管理、语音随时交互
数字人
lipku1 个月前
LiveTalking 更新:集成 vLLM-Omni TTS服务
python·开源·数字人·vllm·实时数字人
wei_shuo1 个月前
基于魔珐星云打造的金融理财顾问数字人:投资理财、财务规划、语音随时交互
数字人
武子康1 个月前
调查研究-155 Open-LLM-VTuber 本地部署与互动实战指南
人工智能·python·深度学习·ai·数字人
平头哥~1 个月前
健康饮食新范式!魔珐星云智能膳食规划AI助手
数字人
七夜zippoe2 个月前
重构数字人交互体验:魔珐星云+DeepSeek打造情绪陪伴数字人全流程实战测评
重构·交互·数字人·deepseek·魔珐星云