具身交互智能,是让 AI 从"屏幕里的一段文字"走向"能看、能听、能开口应答的伙伴"。这一次,我把它请进了一个 AI 设计师的个人作品集——kiki’s space。访客不再只是滑动浏览海报与视频,而是能直接和主理人 kiki 面对面聊设计、聊灵感、聊合作。本文完整复盘这个站点的立意、视觉系统与数字人全链路的工程实现。

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

一、为什么一个作品集要做"具身交互智能"

传统作品集网站的问题不在于不好看,而在于"单向":它把作品陈列出来,却无法回答访客心里的问题——“这套视觉是怎么想的?”“能不能帮我做类似的?”“怎么联系?”

kiki’s space 的立意,是把作品集从"陈列馆"变成"会客厅"。我们引入具身交互智能——让一位名叫 kiki 的 AI 设计师数字人常驻页面:她有形象、有声音、能实时听懂访客的话并即时应答。访客浏览到"互动体验"区时,可以打字,也可以点亮麦克风直接说话,像和一位有品味的设计师面对面交流。

这背后有三个明确的产品决策:

  • 保留数字人为核心交互,而非装饰:数字人不是首屏的一个摆件,而是承载"和设计师对话"这一核心体验的主角。
  • 克制的高端气质优先:视觉上不追求炫技堆砌,而是"温暖、梦幻、克制、有科技感",像创意机构官网而非模板站。
  • 工程上复刻而非重造:数字人能力从既有的 Vue 项目迁移到 React,全链路(形象渲染、LLM 流式应答、语音识别、流式断句播报、实时打断)忠实复刻,保证交互质感不降级。

二、技术选型:用 React + Vite 复刻数字人全链路

站点是一个独立的 React 18 + Vite 5 子工程,面向 PC 端优先、响应式兼顾移动端。之所以从 Vue 迁移到 React,是为了统一后续创意类项目的技术栈,同时验证"数字人核心逻辑能否与框架解耦"。

迁移策略是分层的:

  • 框架无关的纯逻辑原样迁移:形象服务 avatar、大模型服务 llm、语音识别 asr、工具函数 utils、常量 constants、类型 types——这些不依赖任何框架,直接复用。
  • 有状态的控制核心重写为单例:原 Vue 的响应式 store 改写为一个框架无关的 AvatarController 单例 + 订阅机制,再用 React 的 useSyncExternalStore 对接。
  • 视图层用 React 函数组件重写:导航、首屏、角色介绍、作品案例、互动体验、联系方式各自成组件。

整体页面结构分为五个区块:首屏 Hero(全屏视频 + 导航)、角色介绍、作品案例、互动体验(数字人对话)、联系方式。

三、视觉系统:把"温暖·梦幻·克制·科技感"写进 CSS 变量

高端感来自约束,而非元素堆叠。我把四个气质关键词落成了一套设计令牌,集中在全局样式里:

:root {
  /* 版心 1700px,创意机构官网级的横向舒展 */
  --shell: 1700px;

  /* 暖色调色板:橙 / 暖黄 / 白 / 玻璃 */
  --bg: #fbf6ef;
  --ink: #241d18;
  --brand: #ff7a18;      /* 主橙 */
  --brand-2: #ffab40;    /* 暖黄 */

  /* 玻璃材质 */
  --glass: rgba(255, 255, 255, 0.55);
  --glass-brd: rgba(255, 255, 255, 0.8);
  --glass-shadow: 0 24px 70px rgba(150, 88, 24, 0.14);

  --radius-l: 28px;
}

三个关键手法支撑起整体气质:

  • 暖色梦幻底纹:用 body::before 铺一层固定的多点径向光晕(右上主橙、左侧暖橙、底部暖黄),让空白处也有柔光呼吸,而非死白。
  • 玻璃态卡片:统一的 glass 类用 backdrop-filter: blur(18px) saturate(1.4) 做磨砂玻璃,配合暖色投影,形成"克制的科技感"。
  • 滚动入场动画:用 IntersectionObserver 封装的 useReveal,让各区块进入视口时轻柔上浮淡入,节奏舒缓。

首屏则用一段全屏静音循环视频打底,缺省时以暖色渐变兜底,标题直接点明身份——具身交互智能的 AI 设计师:

<span className="hero__badge glass">✦ AI 设计师 · 具身交互智能</span>
<h1 className="hero__title">
  你好,我是 <span className="hl">kiki</span>
  <br />
  用设计,让想象<span className="hl">有温度地</span>发生
</h1>

四、前提准备:在魔珐星云控制台配置数字人形象

数字人的形象、音色与驱动能力由魔珐星云平台提供。开始编码前,需要在控制台完成以下配置并拿到接入密钥。

步骤 1:创建数字人应用

登录魔珐星云控制台,新建一个数字人应用,作为 kiki 的形象与能力载体。

步骤 2:配置数字人形象

为应用选择或上传数字人形象,这里选用契合 AI 设计师气质的形象。

步骤 3:配置场景为透明全身

场景选择"透明全身",让数字人以透明背景渲染,方便与网页的暖色玻璃态背景无缝融合,不出现突兀的底色块。

步骤 4:配置音色

挑选温暖、亲和的音色,与 kiki 的人设一致,用于后续 SSML 语音播报。

步骤 5:配置表演风格

设置数字人的表演(动作、表情)风格,让应答时的肢体语言自然、有温度。

步骤 6:获取 App 密钥并接入 SDK

复制应用的 App 密钥(appId / appSecret),参考平台文档将数字人接入网页、App 或任意终端。

拿到密钥后,把它连同 ASR、LLM 的密钥填入项目常量。注意:仓库中应使用占位符,切勿提交真实密钥。

// src/constants/index.ts —— 均为占位符,请替换为你自己的密钥
export const API_KEYS = {
  AVATAR: {
    appId: 'your_avatar_app_id',
    appSecret: 'your_avatar_app_secret'
  },
  ASR: {
    appId: 'your_asr_app_id',
    secretId: 'your_secret_id',
    secretKey: 'your_secret_key'
  },
  LLM: {
    apiKey: 'your_llm_api_key'
  }
} as const

// 网关与数据源配置
export const SDK_CONFIG = {
  GATEWAY_URL: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
  DATA_SOURCE: '2',
  CUSTOM_ID: 'demo'
} as const

// 腾讯云 ASR 引擎参数
export const ASR_CONFIG = {
  ENGINE_MODEL_TYPE: '16k_zh',
  VOICE_FORMAT: 1,
  NEEDVAD: 1
  // ...其余过滤与转换参数
} as const

五、核心代码讲解:数字人全链路是怎样跑起来的

这一节全部使用项目真实源码,按"渲染容器 → 连接 → 流式断句播报 → 实时打断 → 语音输入 → 状态订阅"的链路逐段拆解。

5.1 渲染容器的时序:id 必须先于连接存在

魔珐 SDK 需要一个已存在的 DOM 容器来挂载渲染。所以舞台组件要先把容器 div 渲染出来,其 id 由 avatarService 统一生成,连接时再据此 getElementById。下面是舞台组件的核心结构:

// src/components/AvatarStage.tsx
export default function AvatarStage({ containerId, connected, connecting, listening, subtitle }: Props) {
  return (
    <div className="stage">
      <div className="stage__halo" />
      <div className="stage__frame glass">
        {/* SDK 渲染容器:id 必须在 connect 前存在 */}
        <div className="stage__box">
          <div id={containerId} className="stage__sdk" />
        </div>
        {/* 语音监听态、字幕、加载态叠加层... */}
      </div>
    </div>
  )
}

容器 id 的生成用了加密随机数,保证多实例不冲突:

// src/utils/index.ts
export function generateContainerId(): string {
  const bytes = crypto.getRandomValues(new Uint8Array(8))
  let randomID = ''
  for (let i = 0; i < bytes.length; i++) {
    randomID += bytes[i].toString(16).padStart(2, '0')
  }
  return `${APP_CONFIG.CONTAINER_PREFIX}${randomID}`
}

这里有一个曾踩过的坑:魔珐 SDK 会在容器内层注入 overflow: hidden,导致数字人抬手动作被裁切。解决办法是在样式里强制覆盖内层容器的溢出行为,并让 canvas / video 以 contain 方式自适应:

/* src/components/AvatarStage.css */
.stage__sdk > div { overflow: visible !important; }
.stage__sdk canvas,
.stage__sdk video {
  width: auto; height: auto;
  max-width: none; max-height: 100%;
  object-fit: contain;
  background: transparent !important;
}

5.2 流式断句播报引擎:让具身交互智能"边想边说"

这是整个具身交互智能体验的心脏。如果等大模型把整段话生成完再播报,访客会经历一段尴尬的沉默;正确做法是"边生成、边断句、边说"。控制器里的 sendUserText 一边消费 LLM 的流式输出、一边按标点断句,把可读的句子交给播报队列:

// src/services/avatar-controller.ts
const cnSplitSign = /[。?!;… ,:]/   // 中文断句标点
const enSplitSign = /[.?!;:,]/            // 英文断句标点

const stream = await llmService.sendMessageWithStream({
  provider: 'openai',
  model: LLM_CONFIG.DEFAULT_MODEL,
  apiKey: API_KEYS.LLM.apiKey
}, trimmed)

if (!stream) return

const minimum = 20
const context = { cache: '', chars: 0, firstSpeakSend: false, spaceCount: 0 }

for await (const content of stream) {
  if (typeof content !== 'string') continue

  // 累积到最后一条 assistant 气泡用于文字展示
  this.appendToLastAssistant(content)

  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
  }

  this.actionManager.speak(context.cache, { isStart: !context.firstSpeakSend, isEnd: false })
  context.firstSpeakSend = true
  context.cache = ''
  context.chars = 0
  context.spaceCount = 0
}

// 处理剩余缓存:末段标记 isEnd=true,触发数字人自然收尾
if (context.cache.length > 0) {
  this.actionManager.speak(context.cache, { isStart: !context.firstSpeakSend, isEnd: true })
} else if (context.firstSpeakSend) {
  this.actionManager.speak('', { isStart: false, isEnd: true })
}

关键逻辑有三点:

  • 中英文双断句、首句优先:中文按累计可读字数超过 minimum(20)、英文按空格(词)数超过阈值,再加对应的断句标点才触发首句播报,避免开口就是半个词;首句发出后遇标点即播,让节奏跟上语流。
  • 文字与语音双通道:appendToLastAssistant 把每个字符实时追加到对话气泡,访客既能听、也能看,两条通道并行。
  • 末段收尾:流结束后把剩余缓存以 isEnd=true 播报,触发数字人自然收尾,避免最后一句被吞掉。

而真正把文本变成"开口说话"的,是 ActionManager——一个串行的 SSML 播报队列。它保证即便流式片段陆续到达,数字人也严格按顺序、不抢话地逐句播报:

// src/services/action-manager.ts
private async processQueue() {
  if (this.isSpeaking) return
  if (!this.queue.length) return
  const instance = this.getInstance()
  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)   // 调用 SDK 播报

    if (!item.isEnd) continue          // 流式中间段,继续取下一段
    await new Promise(r => setTimeout(r, 50))
  }
  this.isSpeaking = false
  this.onVoiceEnd?.()
}

每段文本在入队前都会被包装成 SSML,并转义特殊字符,避免破坏语音合成协议:

// src/utils/index.ts
export function generateSSML(text: string, options: {
  pitch?: number
  speed?: number
  volume?: number
} = {}): string {
  const { pitch = 1, speed = 1, volume = 1 } = options
  const encodeMap: Record<string, string> = { '<': '&lt;', '>': '&gt;', "'": '&apos;', '"': '&quot;', '&': '&amp;' }
  const processedText = text
    .replace(/\n+/g, '\n')
    .replace(/[<>'"&]/g, (str) => encodeMap[str] || str)
  return `<speak pitch="${pitch}" speed="${speed}" volume="${volume}">${processedText}</speak>`
}

5.3 实时打断与等待空闲:让对话可以被"插话"

真人对话是可以被打断的。当访客在 kiki 还在说话时又发了新消息,必须先让她停下,再应答新问题。interrupt 负责清空队列并调用 SDK 的打断接口:

// src/services/avatar-controller.ts
interrupt(): void {
  if (!this.instance) return
  try {
    this.actionManager.reset()
    if (typeof this.instance.interactiveidle === 'function') {
      this.instance.interactiveidle()
    } else if (typeof this.instance.interrupt === 'function') {
      this.instance.interrupt()
    }
  } catch (error) {
    console.error('打断失败:', error)
    this.patch({ avatarState: 'interactive_idle' })
  }
}

打断之后不能立刻抢话,需要等数字人真正进入空闲态。这里用一个可被回调兑现的 Promise + 超时兜底:

private waitForAvatarIdle(timeout = 5000): Promise<void> {
  if (this.state.avatarState === 'interactive_idle' || this.state.avatarState === '') {
    return Promise.resolve()
  }

  return new Promise((resolve, reject) => {
    let resolved = false

    const timeoutId = setTimeout(() => {
      if (!resolved) {
        resolved = true
        this.voiceEndResolver = null
        reject(new Error('等待虚拟人停止说话超时'))
      }
    }, timeout)

    // 由 SDK 的 onVoiceStateChange('end') 回调兑现
    this.voiceEndResolver = () => {
      if (!resolved) {
        resolved = true
        clearTimeout(timeoutId)
        resolve()
      }
    }
  })
}

5.4 语音输入:点亮麦克风直接说话

具身交互智能的"听"由腾讯云实时语音识别承担。React 版把它封装成 useAsr Hook,识别到整句结束(OnSentenceEnd)就回调,交给控制器发送:

// src/hooks/useAsr.ts(节选)
recognizer.OnRecognitionResultChange = (res) => {
  const currentText = res.result?.voice_text_str
  if (currentText) setAsrText(currentText)     // 实时中间结果,用于字幕
}
recognizer.OnSentenceEnd = (res) => {
  const resultText = res.result?.voice_text_str
  if (resultText) { setAsrText(resultText); callbacks.onFinished(resultText) }
}

在互动区里,麦克风按钮先打断当前播报、再启动识别;识别到完整一句后停止并把文本作为用户输入发送:

// src/components/Interactive.tsx(节选)
const toggleMic = () => {
  if (!connected) return
  if (asr.isListening) { asr.stop(); return }
  interrupt()
  asr.start({
    onFinished: (finalText) => { asr.stop(); if (finalText.trim()) sendUserText(finalText.trim()) },
    onError: () => { setError('语音识别出错,请检查麦克风权限'); asr.stop() }
  })
}

5.5 框架无关的状态核心:单例 + useSyncExternalStore

为了让数字人逻辑不绑死 React,控制器自己维护状态与订阅者集合,patch 时通知所有订阅者:

// src/services/avatar-controller.ts
subscribe = (listener: () => void) => {
  this.listeners.add(listener)
  return () => this.listeners.delete(listener)
}
getSnapshot = () => this.state
private patch(partial: Partial<AvatarStoreState>) {
  this.state = { ...this.state, ...partial }
  this.listeners.forEach(l => l())
}

React 侧只需一个 Hook 就能把这个外部 store 接进组件的渲染,天然并发安全:

// src/hooks/useAvatar.ts
export function useAvatar() {
  const state = useSyncExternalStore(
    avatarController.subscribe,
    avatarController.getSnapshot,
    avatarController.getSnapshot
  )
  return {
    ...state,
    connect: () => avatarController.connect(),
    disconnect: () => avatarController.disconnect(),
    sendUserText: (text: string) => avatarController.sendUserText(text),
    interrupt: () => avatarController.interrupt(),
    getContainerId: () => avatarService.getContainerId()
  }
}

这样一来,无论未来换到什么框架,只要能订阅这个单例、把容器 id 渲染出来,数字人就能跑起来。

六、作品区:让"精选作品与创意实验"看得见

作品区分海报与视频两类。海报类作品使用设计感封面图;视频类作品在封面之上叠加 CSS 流光扫过动效与居中播放角标,即便暂无真实 mp4 也有动态视觉;视频路径是可选字段,一旦放入真实文件即自动播放:

// src/components/Works.tsx(节选)
<div className="works__cover" style={{ background: w.gradient }}>
  {w.video ? (
    <video className="works__cover-video" poster={w.cover} autoPlay muted loop playsInline>
      <source src={w.video} type="video/mp4" />
    </video>
  ) : (
    <img className="works__cover-img" src={w.cover} alt={w.title} loading="lazy" />
  )}

  {/* 视频作品:流光动效 + 播放角标 */}
  {w.type === 'video' && (
    <>
      <span className="works__sheen" />
      <span className="works__play" aria-hidden="true">▶</span>
    </>
  )}
</div>

数据层用一个可选的 video 字段承接这套"有则播放、无则动态封面"的降级策略,让作品区无论素材是否就位都不空场。

七、总结:一次具身交互智能的轻量落地

kiki’s space 证明了一件事:具身交互智能不必是重型系统,它可以是一个个人作品集里的一位"会客厅主理人"。我们做的其实是三件事:

  • 用一套暖色玻璃态设计系统,把"温暖、梦幻、克制、科技感"落成可复用的 CSS 令牌;
  • 用分层迁移策略,把数字人全链路从 Vue 无损搬到 React,核心逻辑与框架彻底解耦;
  • 用流式断句播报 + 实时打断 + 语音输入,让访客与 kiki 的对话接近真人交流的节奏。

对访客而言,作品不再是静止的陈列,而是可以随时被追问、被回应的对话入口——这正是具身交互智能带来的体验跃迁。

如果你也想让自己的作品、品牌或空间"开口说话",欢迎把这套思路带走:让具身交互智能,在更多创意场景里成为那位懂你、也会表达的伙伴。

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

Logo

电影级数字人,免显卡端渲染SDK,十行代码即可调用,工业级demo免费开源下载!

更多推荐