不止陈列,更能对话:kiki‘s space 具身交互智能作品集的设计与工程全记录
具身交互智能,是让 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> = { '<': '<', '>': '>', "'": ''', '"': '"', '&': '&' }
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=
更多推荐




所有评论(0)