一、写在前面

你有没有想过,一次完美的旅行规划应该是什么样的?

不是打开攻略网站面对几百篇文章发呆,不是在地图App里反复标记对比,而是——站在一颗地球仪前,转动它,目光落在某个城市上,然后一个懂旅行的AI伙伴告诉你:「这里3月樱花最美,5天行程这样安排,当地人有你不知道的吃法。」

这就是我们做「云游 YunTrip」的初衷:把全球旅行目的地装进一颗可交互的3D地球仪,让AI数字人成为你的私人旅行顾问——这就是具身交互智能的魅力所在。

**魔珐星云PC端官方**:https://xingyun3d.com?utm_campaign=daily&utm_source=CSDNwanfen3&utm_medium=&utm_term=&utm_content=

二、从「Demo」到「产品」:一次关键的形态进化

2.1 起点:一个能对话的数字人

项目最初的样子,和市面上大多数数字人Demo差不多——屏幕右边站着一个数字人,左边是一些配置面板,用户可以打字或说话跟它对话。功能完整,但缺少「场景感」。

坦白说,用户看到一个数字人站在面板旁边,第一反应不是「好酷」,而是「所以呢?它能帮我做什么?」

我们缺的不是技术能力,而是一个让用户一眼就懂的使用场景。

2.2 转折:如果数字人住在地球仪里呢?

灵感来自一次很随意的讨论:

「如果首页不是面板,而是一颗地球仪呢?」

「用户点哪个国家,数字人就讲哪里的旅行攻略。」

「像真正的旅行顾问一样,指着地图给你推荐。」

这个想法瞬间把整个产品逻辑串通了:地球仪 = 内容载体,数字人 = 具身交互智能入口,点击 = 需求触发。 三者形成一个完整闭环,用户不需要思考「我该干什么」,看到地球仪就想点,点了就想听。

2.3 方案确定:3D地球仪 + 弹出卡片 + 悬浮气泡

经过几轮讨论,我们锁定了最终的产品形态:

模块 功能 交互方式
3D地球仪 展示全球26个热门目的地 拖拽旋转、缩放、点击标记
目的地卡片 展示景点、美食、最佳季节 点击标记弹出,右侧滑入
数字人气泡 AI旅行规划师对话交互 悬浮按钮,点击展开
导航栏 按区域筛选目的地 点击区域标签切换视角

三、创建魔珐星云驱动应用

3.1 创建驱动应用

1、登录魔珐星云控制台,应用管理-创建驱动应用-横屏模式

3.2 形象配置

选择适合旅行顾问场景的数字人形象,建议阳光、亲和的风格。

3.3 场景配置

配置数字人的展示场景,推荐使用简洁干净的背景,与地球仪的深色调形成协调。

3.4 音色配置

旅行规划师的音色很关键——我们选择了温暖、有活力的音色,让用户感觉像在和一个见多识广的朋友聊天。

3.5 表演配置

配置数字人的待机动作和说话时的肢体语言,让交互更自然。

四、核心代码深度解析

4.1 经纬度转3D坐标:地球仪的数学基础

整个地球仪交互的起点,是把地理坐标(经纬度)映射到Three.js的3D空间坐标。这个转换函数虽然只有7行,但它是所有标记点定位、相机飞行、区域导航的基石。

// src/components/WorldMap.vue
// 经纬度转 3D 坐标
function latLngToVector3(lat: number, lng: number, radius: number): THREE.Vector3 {
  const phi = (90 - lat) * (Math.PI / 180)
  const theta = (lng + 180) * (Math.PI / 180)
  return new THREE.Vector3(
    -radius * Math.sin(phi) * Math.cos(theta),
    radius * Math.cos(phi),
    radius * Math.sin(phi) * Math.sin(theta)
  )
}

逐行解析

  • 第1行:纬度转余角(phi),因为Three.js的球体坐标系以Y轴为极点,而地理纬度以赤道为0度,需要90度偏移
  • 第2行:经度加180度后转弧度,Three.js的theta从X轴负方向开始逆时针计算,与地理经度的本初子午线起点不同
  • 第3-6行:球坐标转直角坐标的标准公式,-x是因为Three.js的X轴方向与地理经度方向相反

这个函数被调用于三个场景:标记点放置(radius = EARTH_RADIUS * 1.01)、相机飞行目标(radius = EARTH_RADIUS + 8)、区域导航(radius = EARTH_RADIUS + 12)。不同的radius值决定了标记点贴在球面、相机停在球外、还是飞到更远的视角。

4.2 地球渲染:程序化纹理 + NASA高清贴图的双保险策略

地球渲染是整个项目视觉效果的核心。我们采用了一个「双保险」策略:先用Canvas程序化生成一个基础纹理确保页面加载就有内容,再异步加载NASA Blue Marble高清贴图覆盖上去。

// src/components/WorldMap.vue - createEarth()核心逻辑
function createEarth() {
  const geometry = new THREE.SphereGeometry(EARTH_RADIUS, 64, 64)

  // 第一步:程序化生成备用纹理(确保首屏有内容)
  const canvas = document.createElement('canvas')
  canvas.width = 2048
  canvas.height = 1024
  const ctx = canvas.getContext('2d')!

  // 海洋渐变背景
  const oceanGrad = ctx.createLinearGradient(0, 0, 0, canvas.height)
  oceanGrad.addColorStop(0, '#0a1628')
  oceanGrad.addColorStop(0.5, '#0d1f3c')
  oceanGrad.addColorStop(1, '#0a1628')
  ctx.fillStyle = oceanGrad
  ctx.fillRect(0, 0, canvas.width, canvas.height)

  // 用椭圆近似七大洲轮廓
  ctx.fillStyle = '#1a3a5c'
  ctx.strokeStyle = '#2a5a8c'
  const continents = [
    { x: 300, y: 250, w: 300, h: 220, rot: -0.1 },  // 北美
    { x: 480, y: 520, w: 140, h: 280, rot: 0.15 },   // 南美
    { x: 1020, y: 230, w: 180, h: 140, rot: 0.1 },   // 欧洲
    { x: 1050, y: 420, w: 200, h: 300, rot: 0 },     // 非洲
    { x: 1280, y: 250, w: 400, h: 250, rot: -0.05 }, // 亚洲
    { x: 1550, y: 580, w: 180, h: 140, rot: 0.1 },   // 澳洲
    { x: 1024, y: 940, w: 800, h: 80, rot: 0 },      // 南极
  ]
  continents.forEach(c => {
    ctx.save()
    ctx.translate(c.x, c.y)
    ctx.rotate(c.rot)
    ctx.beginPath()
    ctx.ellipse(0, 0, c.w / 2, c.h / 2, 0, 0, Math.PI * 2)
    ctx.fill()
    ctx.stroke()
    ctx.restore()
  })

  const fallbackTexture = new THREE.CanvasTexture(canvas)
  const material = new THREE.MeshPhongMaterial({
    map: fallbackTexture,
    specular: new THREE.Color(0x222244),
    shininess: 15,
  })

  earth = new THREE.Mesh(geometry, material)
  scene.add(earth)

  // 第二步:异步加载NASA Blue Marble真实卫星影像覆盖
  const loader = new THREE.TextureLoader()
  loader.load(
    'https://unpkg.com/three-globe@2.41.12/example/img/earth-blue-marble.jpg',
    (texture) => {
      texture.colorSpace = THREE.SRGBColorSpace
      material.map = texture
      material.needsUpdate = true  // 触发材质重新渲染
    },
    undefined,
    () => { console.log('Using fallback procedural earth texture') }
  )

  // 第三步:叠加夜间灯光作为自发光贴图
  loader.load(
    'https://unpkg.com/three-globe@2.41.12/example/img/earth-night.jpg',
    (nightTexture) => {
      nightTexture.colorSpace = THREE.SRGBColorSpace
      material.emissiveMap = nightTexture
      material.emissive = new THREE.Color(0x444444)
      material.emissiveIntensity = 0.8
      material.needsUpdate = true
    }
  )
}

设计思路解析

为什么需要「双保险」?因为NASA高清贴图有2MB+,在网络不佳时可能需要数秒加载。如果等贴图加载完才渲染地球,用户会看到一个空白页面。程序化纹理在1毫秒内就能生成,确保用户打开页面就看到一颗蓝色星球,然后高清贴图无缝覆盖。

夜间灯光贴图(earth-night.jpg)作为emissiveMap叠加后,地球上的城市区域会微微发光——东京、纽约、伦敦的灯光在暗面清晰可见,这个效果远超预期。

4.3 大气层Shader:一行公式的视觉魔法

地球边缘那圈蓝色光晕,是整个视觉效果中最「值回票价」的部分。实现它只需要一个自定义Shader:

// src/components/WorldMap.vue - 大气层片段着色器
varying vec3 vNormal;
void main() {
  float intensity = pow(0.65 - dot(vNormal, vec3(0.0, 0.0, 1.0)), 2.0);
  gl_FragColor = vec4(0.3, 0.6, 1.0, 1.0) * intensity;
}

原理拆解

  • dot(vNormal, viewDirection) 计算法线与视线的夹角余弦值
  • 球体边缘的法线垂直于视线,点积接近0;球心法线平行于视线,点积接近1
  • 0.65 - dot(…) 反转后,边缘值大、中心值小
  • pow(…, 2.0) 做二次方衰减,让光晕更集中在边缘
  • 最终效果:球体中心几乎透明,边缘渐变为蓝色光晕,完美模拟大气散射

配合 AdditiveBlending(加法混合)和 BackSide(背面渲染),这个光晕自然地叠加在地球边缘之外。

4.4 相机飞行:三次缓动让交互有「重量感」

点击目的地后相机飞行动画的核心,是一个自定义缓动函数。不是简单的linear插值,而是模拟物理运动的「先加速后减速」:

// src/components/WorldMap.vue
function flyCameraTo(target: THREE.Vector3) {
  autoRotate = false
  if (autoRotateTimer) clearTimeout(autoRotateTimer)

  const start = camera.position.clone()
  const duration = 1500
  const startTime = Date.now()

  function animate() {
    const elapsed = Date.now() - startTime
    const t = Math.min(elapsed / duration, 1)
    // 三次缓动函数:前半程加速,后半程减速
    const ease = t < 0.5
      ? 4 * t * t * t                    // 加速段:三次方增长
      : 1 - Math.pow(-2 * t + 2, 3) / 2  // 减速段:三次方衰减

    camera.position.lerpVectors(start, target, ease)
    camera.lookAt(0, 0, 0)
    controls.update()

    if (t < 1) requestAnimationFrame(animate)
  }
  animate()

  // 交互结束后8秒恢复自转
  autoRotateTimer = setTimeout(() => { autoRotate = true }, 8000)
}

为什么用三次缓动而不是线性? 线性插值会让相机匀速移动,看起来像机械臂。三次缓动让起步有「推力感」,到达有「刹车感」,整个飞行有了物理世界的重量感。用户感知到的不是「画面在动」,而是「视角在飞」。

4.5 流式播报引擎:首句即发声的核心算法

这是整个项目中逻辑最复杂的模块。目标是将LLM的流式输出实时转化为具身交互智能数字人,实现同步语音、表情、肢体多模态播报,让用户在1秒内就能听到回复。

// src/stores/app.ts - sendMessage()核心流式播报逻辑
async sendMessage(): Promise<string | undefined> {
  // ... 前置校验省略

  // 如果数字人正在说话,先打断
  await this.interrupt()
  if (avatarState.value === 'speak') {
    await this.waitForAvatarIdle()
  }

  actionManager.reset()
  const stream = await llmService.sendMessageWithStream({
    provider: 'openai', model: llm.model, apiKey: llm.apiKey
  }, ui.text)

  if (!stream) return

  // 核心策略:缓存文本直到遇到标点且达到最小可读长度
  const minimum = 20
  const context = {
    cache: '',           // 缓存文本
    chars: 0,            // 可读字符计数
    firstSpeakSend: false, // 首句是否已发送
    spaceCount: 0        // 英文空格计数
  }

  // 中英文标点正则
  const cnSplitSign = /[。?!;… ,:]/
  const enSplitSign = /[.?!;:,]/

  let firstSentenceResolved = false
  const firstSentencePromise = new Promise<void>((resolve) => {
    ;(async () => {
      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) {
          // 首句策略:可读字符 >= 20 且遇到标点才发送
          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,让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 })
        if (!firstSentenceResolved) { firstSentenceResolved = true; resolve() }
      }
    })()
  })

  await firstSentencePromise
  return 'success'
}

算法设计要点

  1. 首句门槛高:必须积累20个可读字符(汉字/字母/数字)且遇到标点才发送。这是为了避免LLM开头输出「好的,让我来帮你」这种无意义前缀被立即播报
  2. 后续句门槛低:首句发出后,遇到任何标点就发送。因为后续内容都是有效信息
  3. 中英文****分治:英文以空格为词边界,需要积累更多空格(相当于词数)才触发;中文直接按标点断句
  4. Promise竞速:sendMessage在第一句发出后就返回,不等整个流处理完。后台异步继续处理后续段落
  5. 兜底机制:流结束时的残余文本、短回复、异常情况,都有resolve兜底,防止Promise永远挂起

4.6 组件编排:App.vue的三组件协同

整个应用只有三个核心组件,通过App.vue编排,事件驱动协作:

// src/App.vue
<script setup lang="ts">
import { provide, ref, onMounted, nextTick } from 'vue'
import WorldMap from './components/WorldMap.vue'
import DestinationCard from './components/DestinationCard.vue'
import AvatarBubble from './components/AvatarBubble.vue'
import { appState, appStore } from './stores/app'
import type { Destination } from './data/destinations'

// 提供全局状态和方法
provide('appState', appState)
provide('appStore', appStore)

// 选中的目的地
const selectedDestination = ref<Destination | null>(null)

function onSelectDestination(dest: Destination) {
  selectedDestination.value = dest
}

function onCloseCard() {
  selectedDestination.value = null
}

function onPlan(dest: Destination) {
  // 关闭卡片,展开数字人气泡
  selectedDestination.value = null
  // 自动发送消息(由 AvatarBubble 内的 appState.ui.text 驱动)
  setTimeout(() => {
    appStore.sendMessage()
  }, 500)
}

// 点击地图空白处关闭卡片
function onMapClick() {
  selectedDestination.value = null
}
</script>

<template>
  <div class="app-container" @click="onMapClick">
    <!-- 3D 地球仪主内容 -->
    <WorldMap @select-destination="onSelectDestination" />

    <!-- 目的地信息卡片 -->
    <DestinationCard
      :destination="selectedDestination"
      @close="onCloseCard"
      @plan="onPlan"
    />

    <!-- 数字人悬浮气泡 -->
    <AvatarBubble />
  </div>
</template>

交互链路:WorldMap点击标记 → emit select-destination → App设置selectedDestination → DestinationCard滑入展示 → 用户点击「让云游帮我规划」 → emit plan → App关闭卡片并调用sendMessage → LLM流式返回 → AvatarBubble自动展开播报。

整条链路零耦合,每个组件只关心自己的状态和事件。

4.7 数字人气泡:SDK容器与UI展示的分离设计

数字人气泡的交互设计解决了一个核心矛盾:数字人需要SDK容器始终存在于DOM中才能连接,但UI上不需要一直展示。

我们把「SDK容器」和「UI展示」分离——容器始终渲染(保证连接成功),UI层通过opacity控制显隐:

// src/components/AvatarBubble.vue
// 数字人渲染层 - 始终存在于DOM,通过CSS控制显隐
// <div class="avatar-render-layer" :class="{ expanded: isExpanded }" @click.stop>
//   <AvatarRender />
// </div>

// 关键交互:数字人说话时自动展开气泡
watch(() => appState.ui.subTitleText, (newText) => {
  if (newText && !isExpanded.value) {
    isExpanded.value = true  // 自动展开
  }
})
/* 默认隐藏但DOM存在 */
.avatar-render-layer {
  position: fixed;
  right: 100px;
  bottom: 300px;
  width: 320px;
  height: 55vh;
  max-height: 600px;
  z-index: 99;
  pointer-events: none;
  opacity: 0;
  transform: scale(0.8) translateY(20px);
  transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
/* 展开时显示 */
.avatar-render-layer.expanded {
  opacity: 1;
  transform: scale(1) translateY(0);
  pointer-events: auto;
}

4.8 目的地卡片:信息密度与视觉美感的平衡

点击标记点后,右侧滑入的信息卡片承载了核心内容。我们在卡片设计上遵循了「三层递进」原则:

  1. 第一眼:封面图 + 标签(文化/美食/自然)→ 建立第一印象
  2. 第二眼:一句话描述 + 热门景点 → 激发兴趣
  3. 第三眼:最佳旅行时间 + 「让云游帮我规划」按钮 → 触发行动

最后那个按钮是关键——它不只是个CTA,而是连接「浏览模式」和「对话模式」的桥梁。点击后,系统自动生成一条旅行规划请求,发送给AI数字人,用户在气泡中看到个性化的行程方案。

五、技术架构:魔珐星云参数流驱动的三条数据流

整个系统的技术架构可以概括为三条并行的数据流,它们以魔珐星云XmovAvatar SDK的参数流架构为核心枢纽协同工作:

5.1 视觉流:地球仪渲染(前端自包含)

Three.js场景 → 地球纹理(NASA Blue Marble) → 大气层Shader → 标记点(CSS2D) → 星空粒子

视觉流完全由前端Three.js驱动,不依赖魔珐星云。地球仪采用Three.js构建,加载NASA Blue Marble高清卫星影像作为主纹理,叠加夜间灯光作为自发光贴图,bump map提供地形凹凸感。半透明云层作为地球子节点跟随旋转。

这条流独立运行的好处是:即使数字人SDK未连接,地球仪依然可以正常展示和交互,用户不会看到空白页面。

5.2 对话流:魔珐星云参数流架构深度解析

用户输入 → LLM流式返回 → 标点断句 → 首句优先发送 → ActionManager队列 → XmovAvatar.speak() → 魔珐云端参数流解算 → 数字人口型/动作同步

这是整个项目的核心交互链路,也是魔珐星云参数流架构的价值所在。

什么是参数流?

传统数字人方案(视频流)的工作方式是:云端渲染好完整的数字人视频,编码为H.264/H.265视频流,通过网络传输到浏览器播放。这种方案的致命问题是延迟高达2-3秒——因为必须等云端渲染完整帧才能开始传输。

魔珐星云的参数流架构完全不同:云端不渲染视频,而是解算文本对应的「参数」——口型参数、面部表情参数、肢体动作参数——将这些参数流式推送到客户端,由客户端SDK在本地驱动3D模型渲染。传输的数据量从视频流的几Mbps降低到参数流的几Kbps,延迟从2-3秒降低到约500ms。

SDK加载:三级容错机制
// src/utils/sdk-loader.ts
export async function ensureSDKsLoaded(): Promise<boolean> {
  try {
    // 并行加载三个SDK
    await Promise.all([
      loadSDK('/cryptojs.js').catch(() => {
        // 本地失败则回退CDN
        return loadSDK('https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.1.1/crypto-js.js')
      }),
      loadSDK('/speechrecognizer.js'),
      loadSDK('https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js')
    ])

    // 轮询等待SDK初始化完成
    await Promise.all([
      waitForSDK('cryptoJS'),
      waitForSDK('speechRecognizer'),
      waitForSDK('xmovAvatar')
    ])
    return true
  } catch (error) {
    console.error('SDK加载失败:', error)
    return false
  }
}

三个SDK各司其职:CryptoJS负责ASR语音识别的签名加密,SpeechRecognizer封装WebAudio API实现实时语音转文字,XmovAvatar是魔珐星云的核心驱动SDK负责数字人渲染和参数流解算。

连接流程:从网关握手到参数流就绪
// src/services/avatar.ts - connect()核心流程
async connect(config: AvatarConfig, callbacks: AvatarCallbacks): Promise<any> {
  const { appId, appSecret } = config

  // 1. 构建魔珐星云网关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)

  // 2. 创建SDK实例,传入容器ID和密钥
  const avatar = new window.XmovAvatar({
    containerId: `#${this.containerId}`,
    appId,
    appSecret,
    gatewayServer: url.toString(),  // 指向魔珐云端参数流服务
    onStateChange,                  // 数字人状态变化回调
    onVoiceStateChange: (status) => {
      if (status.includes('end')) {
        onVoiceStateChange?.(status) // 说话结束回调
      }
    }
  })

  // 3. 初始化:下载数字人模型资源
  await avatar.init({
    onDownloadProgress: (progress) => {
      if (progress >= 100) resolve(true) // 模型下载完成
    }
  })

  // 4. 等待网关连接建立(15秒超时保护)
  await Promise.race([connectPromise, connectTimeout])

  return avatar
}

连接过程的关键节点:

  1. 网关****握手:SDK通过gatewayServer URL与魔珐云端建立WebSocket连接,协商参数流协议
  2. 模型下载:根据appId对应的数字人形象,下载3D模型、贴图、动作数据
  3. 状态机就绪:onStateChange回调开始工作,数字人进入interactive_idle待机状态
ActionManager:流式播报的队列调度器

魔珐星云的speak()接口支持流式调用——通过isStart/isEnd标记告诉SDK当前是流式的第几段。ActionManager封装了这个队列调度逻辑:

// src/services/action-manager.ts
class ActionManager {
  private queue: ActionQueueItem[] = []
  private isSpeaking = false
  private instanceRef: Ref<any | null>

  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()
  }

  private async processQueue() {
    if (this.isSpeaking) return  // 防止并发
    const instance = this.instanceRef.value
    if (!instance) return  // SDK未连接则跳过

    this.isSpeaking = true
    while (this.queue.length) {
      const item = this.queue.shift()
      if (!item) break

      this.onVoiceReady?.()  // 通知UI:开始说话
      // 调用魔珐SDK的speak,传入SSML和流式标记
      instance.speak(item.ssml, item.isStart, item.isEnd)
      if (!item.isEnd) continue  // 流式中间段,不等
    }
    this.isSpeaking = false
    this.onVoiceEnd?.()  // 通知UI:说完
  }
}

这里的核心设计:

  • isStart=true:告诉魔珐云端「这是第一段,开始新的播报会话」
  • isEnd=false:告诉云端「后面还有内容,不要关闭会话」
  • isEnd=true:告诉云端「这是最后一段,可以结束播报」
  • 队列机制:即使LLM流式返回很快,speak调用也是串行的,避免多段内容同时播报导致口型混乱
实时打断:参数流的双工优势

传统视频流方案无法实现真正的「打断」——因为视频已经预渲染了一段,中途停止会看到数字人嘴巴突然闭合的突兀画面。参数流架构下,云端实时解算参数,打断时只需清空参数队列,数字人立即进入idle状态,过渡自然。

// src/stores/app.ts - interrupt()方法
interrupt(): void {
  if (!appState.avatar.instance) {
    return
  }

  try {
    // 重置动作管理器队列
    actionManager.reset()

    // 调用虚拟人实例的打断方法
    // SDK 的 interactive_idle() 方法用于打断当前说话
    // SDK 会通过 onStateChange 回调通知状态变化为 'interactive_idle'
    if (typeof appState.avatar.instance.interactiveidle === 'function') {
      appState.avatar.instance.interactiveidle()
      console.log('已调用 interactive_idle() 打断方法,等待 SDK 通过 onStateChange 回调更新状态')
    }
  } catch (error) {
    console.error('打断失败:', error)
    // 如果打断失败,直接设置状态为交互空闲,确保逻辑继续执行
    avatarState.value = 'interactive_idle'
  }
}

5.3 交互流:地球仪 ↔ 卡片 ↔ 气泡 ↔ 魔珐云端

点击标记 → 地球飞向目的地 → 弹出信息卡片 → 点击规划 → 关闭卡片 → 气泡自动展开 → LLM流式返回 → ActionManager队列 → XmovAvatar.speak() → 魔珐云端参数流解算 → 数字人口型同步

三条流在App.vue中汇聚,通过provide/inject共享状态,各组件独立但协同。魔珐星云参数流架构是连接「文本理解」和「视觉呈现」的核心枢纽——LLM返回的文字经过断句策略切分后,通过ActionManager队列调度,以流式参数的形式推送到魔珐云端,云端实时解算出驱动数字人口型、表情、动作的参数流,回传到客户端SDK渲染。

六、写在最后

回顾整个项目,最大的收获不是技术实现,而是一个产品认知:

具身交互智能的价值不在于「数字人能说话」,而在于「在正确的场景中,用正确的方式触发交互」。

一个站在空白页面上的数字人,用户不知道跟它说什么。但一个站在地球仪旁边、当你点击东京时自动开始讲解日本旅行的数字人,用户会觉得「它懂我」。

这就是场景的力量。技术是基础,场景是灵魂。

云游YunTrip的旅程才刚刚开始。下一步,我们计划加入更多目的地、支持行程一键导出、以及基于用户偏好的智能推荐。如果你也对具身交互智能感兴趣,欢迎交流。

**魔珐星云PC端官方**:https://xingyun3d.com?utm_campaign=daily&utm_source=CSDNwanfen3&utm_medium=&utm_term=&utm_content=
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐