打开冰箱,挑几样食材,一位住在屏幕里的暖厨姐姐立刻帮你搭配菜谱,还能一步步语音教你做菜------这不是美食 App 的幻想,而是具身交互智能走进厨房的真实落地。本文以一道完整菜谱的上菜流程为线索,从魔珐星云控制台配置到 React + Vite 工程源码,拆解云厨 YunChef 如何让数字人「小厨」成为你的私人食谱顾问。
魔珐星云PC端官方链接:https://xingyun3d.com?utm_campaign=daily\&utm_source=CSDNwanfen3\&utm_medium=\&utm_term=\&utm_content=
一、开胃前菜 · 为什么厨房需要一位数字人厨师
1.1 冰箱里的食材,缺一个会说话的菜谱
每个人打开冰箱时都经历过同样的困境:西红柿、鸡蛋、一块豆腐、半颗洋葱------这些食材能做什么?搜索引擎给你一千条菜谱链接,每条都要你自己读完再判断。你真正需要的,不是另一篇图文教程,而是一个站在你旁边、看到你手里的食材、直接告诉你「先做这个、再做那个」的人。
具身交互智能解决的正是这个「最后一公里」。所谓「具身」,是指数字人不只是一段语音或一个头像,而是拥有完整形象、肢体动作和口型同步的虚拟实体;所谓「交互智能」,是指她能实时响应用户操作,将菜谱数据转化为自然语言讲解,配合手势和表情输出,形成「看得见人、听得到声、跟得上节奏」的沉浸式烹饪体验。
本项目中,数字人暖厨姐姐「小厨」会在用户点击任意菜谱时,主动播报完整的做法步骤和烹饪技巧------不是冷冰冰的文字列表,而是一位温暖的姐姐在手把手教你做菜。
1.2 核心能力一览
| 能力模块 | 说明 |
|---|---|
| 食材匹配 | 23 种常见食材库,勾选后智能匹配推荐菜谱 |
| 分类浏览 | 家常菜 / 快手菜 / 汤羹 / 主食 / 甜品,5 大分类 |
| 菜谱详情 | 12 道菜谱,含食材、步骤、技巧、标签 |
| 语音讲解 | 点击「小厨讲解」,数字人实时播报完整做法 |
| 实时打断 | 讲解过程中可随时切换菜谱,数字人立即响应 |
| 暖厨视觉 | 奶油基底 + 琥珀橙主题色,温暖的厨房氛围 |
1.3 技术栈
| 层级 | 技术选型 |
|---|---|
| 前端框架 | React 18 + Vite 6 |
| 图标库 | lucide-react |
| 数字人 SDK | 魔珐 XmovAvatar(参数流架构) |
| 样式系统 | 纯 CSS 令牌驱动(暖厨主题) |
| 开发端口 | localhost:5281 |
二、食材备料 · 魔珐星云控制台配置
做一道好菜,先把食材备齐。数字人项目的第一步,是在魔珐星云控制台完成形象配置。魔珐星云是具身交互智能的底座平台------数字人的形象、音色、动作都在这里定义,前端 SDK 只负责渲染和驱动。以下 5 个步骤完成后,你就拥有了一个可以在前端调用的数字人实例。
步骤1:创建驱动应用
登录魔珐星云控制台,创建一个新的驱动应用,获取 appId 和 appSecret。

步骤2:形象配置
选择或上传数字人形象,配置外观参数。云厨项目选择了一位温暖亲切的女性形象,契合「暖厨姐姐」的定位。

步骤3:场景配置
设置数字人渲染的场景背景,确保与项目的暖色视觉主题协调。

步骤4:音色配置
选择温暖、亲切的音色。小厨的开场白是:「欢迎来到云厨,我是你的暖厨姐姐小厨。告诉我你冰箱里有什么食材,我来帮你推荐好吃的菜谱。」

步骤5:表演配置
配置数字人的肢体动作和表情参数,确保播报菜谱时动作自然流畅。

三、热锅起灶 · 数字人容器与 SDK 集成
灶台搭好,才能开火。控制台配置完成后,下一步是在前端搭建数字人的运行环境。核心架构是静态 HTML 容器 + IIFE 封装的数字人服务。
3.1 静态 HTML 容器
数字人的渲染容器必须在 index.html 中静态存在,确保 avatar.js 运行时能立即找到 DOM 节点。这是具身交互智能落地的第一个工程要点------SDK 需要挂载到一个已经存在的容器上。
js
<!-- 数字人舞台容器(静态 HTML,确保 avatar.js 运行时存在) -->
<div class="avatar-stage" id="avatar-stage-wrapper">
<div class="avatar-stage__halo"></div>
<div class="avatar-stage__frame">
<div class="avatar-stage__box">
<div id="xmov-avatar-container" class="avatar-stage__sdk"></div>
</div>
<div class="avatar-stage__loading" id="avatar-loading" style="display:none;">
<div class="avatar-stage__rings">
<span></span><span></span><span></span>
</div>
<p>小厨 准备中...</p>
</div>
</div>
<div class="avatar-stage__nameplate">
<span class="avatar-stage__name">小厨</span>
<span class="avatar-stage__role">暖厨姐姐 · 在线</span>
</div>
<div class="avatar-stage__subtitle" id="avatar-subtitle"></div>
</div>
<script type="module" src="/src/main.jsx"></script>
<!-- 魔珐 XmovAvatar SDK -->
<script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js"></script>
<script src="/avatar.js"></script>
关键设计:
avatar-stage__frame是数字人的毛玻璃框体,280x420 固定右下角avatar-stage__nameplate悬浮在框内顶部,显示数字人名字和角色avatar-stage__subtitle悬浮在框内底部,显示播报字幕- SDK 通过 CDN 加载,avatar.js 放在 public 目录,确保 Vite 构建后可访问
3.2 数字人服务核心(avatar.js)
avatar.js 采用 IIFE 封装,通过 window.AvatarService 暴露全局接口。这是具身交互智能的第二个工程要点------将 SDK 的复杂连接、播报、打断逻辑封装为简洁的 API,业务组件只需调用 speakText() 即可。
js
/* ============================================================
* 云厨 YunChef · 数字人暖厨姐姐服务(原生 JS 版)
* 复用魔珐 XmovAvatar SDK,提供连接/播报/字幕/打断能力
* ============================================================ */
(() => {
'use strict';
/* ============== 配置 ============== */
const AVATAR_CONFIG = {
appId: 'your_avatar_app_id',
appSecret: 'your_avatar_app_secret',
gatewayUrl: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
dataSource: '2',
customId: 'demo',
};
const AVATAR_IDENTITY = {
name: '小厨',
role: '暖厨姐姐',
greeting: '欢迎来到云厨,我是你的暖厨姐姐小厨。告诉我你冰箱里有什么食材,我来帮你推荐好吃的菜谱,一步步教你做出美味佳肴。',
};
/* ============== 状态 ============== */
const avatarState = {
connected: false,
connecting: false,
instance: null,
subtitle: '',
avatarState: '',
listeners: new Set(),
};
/* ============== 订阅机制 ============== */
function subscribe(listener) {
avatarState.listeners.add(listener);
return () => avatarState.listeners.delete(listener);
}
function notify() {
avatarState.listeners.forEach(l => l());
}
function patch(partial) {
Object.assign(avatarState, partial);
notify();
}
/* ============== DOM 引用 ============== */
const $ = (sel) => document.querySelector(sel);
const containerId = 'xmov-avatar-container';
function getSubtitleEl() { return $('#avatar-subtitle'); }
function getLoadingEl() { return $('#avatar-loading'); }
/* ============== 连接 ============== */
async function connect() {
if (avatarState.connected || avatarState.connecting) return;
const containerEl = document.getElementById(containerId);
if (!containerEl) {
console.error('[Avatar] 容器不存在:', containerId);
return;
}
patch({ connecting: true });
updateLoadingUI(true);
try {
const url = new URL(AVATAR_CONFIG.gatewayUrl);
url.searchParams.append('data_source', AVATAR_CONFIG.dataSource);
url.searchParams.append('custom_id', AVATAR_CONFIG.customId);
let resolveConnect;
let rejectConnect;
const connectPromise = new Promise((res, rej) => {
resolveConnect = res;
rejectConnect = rej;
});
const options = {
containerId: `#${containerId}`,
appId: AVATAR_CONFIG.appId,
appSecret: AVATAR_CONFIG.appSecret,
enableDebugger: false,
gatewayServer: url.toString(),
onProxyWidgetEvent: (event) => console.log('[Avatar SDK事件]', event),
onStateChange: (state) => {
patch({ avatarState: state });
},
onMessage: async (error) => {
if (!avatarState.connected) {
rejectConnect(new Error(error.message || 'SDK连接失败'));
}
},
onVoiceStateChange: (status) => {
if (status.includes('end')) {
patch({ avatarState: 'interactive_idle' });
}
},
};
const avatar = new window.XmovAvatar(options);
await new Promise(r => setTimeout(r, 3000));
await avatar.init({
onDownloadProgress: (progress) => {
if (progress >= 100) {
resolveConnect(true);
}
},
onClose: () => {
patch({ avatarState: '', connected: false });
},
});
const timeout = new Promise((_, rej) => setTimeout(() => rej(new Error('连接超时')), 15000));
try {
await Promise.race([connectPromise, timeout]);
} catch (e) {
console.warn('[Avatar] 连接等待结束:', e.message);
}
avatarState.instance = avatar;
patch({ connected: true, connecting: false });
updateLoadingUI(false);
console.log('[Avatar] 连接成功');
setTimeout(() => {
speakText(AVATAR_IDENTITY.greeting);
}, 800);
} catch (err) {
console.error('[Avatar] 连接失败:', err);
patch({ connected: false, connecting: false });
updateLoadingUI(false);
}
}
function disconnect() {
if (avatarState.instance) {
try {
avatarState.instance.stop();
avatarState.instance.destroy();
} catch (e) {
console.error('[Avatar] 断开失败:', e);
}
avatarState.instance = null;
patch({ connected: false, avatarState: '' });
}
}
/* ============== 播报 ============== */
function speakText(text) {
if (!avatarState.instance || !text) return;
const ssml = `<speak>${text}</speak>`;
avatarState.instance.speak(ssml, true, true);
patch({ avatarState: 'speak' });
}
function interrupt() {
if (!avatarState.instance) return;
try {
if (typeof avatarState.instance.interactiveidle === 'function') {
avatarState.instance.interactiveidle();
} else if (typeof avatarState.instance.interrupt === 'function') {
avatarState.instance.interrupt();
}
patch({ avatarState: 'interactive_idle' });
} catch (e) {
console.error('[Avatar] 打断失败:', e);
}
}
/* ============== 字幕 ============== */
function setSubtitle(text) {
patch({ subtitle: text });
const el = getSubtitleEl();
if (el) {
el.textContent = text;
el.classList.toggle('is-on', !!text);
}
}
/* ============== UI 更新 ============== */
function updateLoadingUI(show) {
const el = getLoadingEl();
if (el) {
el.style.display = show ? 'flex' : 'none';
}
}
/* ============== 初始化 ============== */
function init() {
console.log('[Avatar] 初始化数字人服务');
connect();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
/* ============== 暴露全局接口 ============== */
window.AvatarService = {
connect,
disconnect,
speakText,
interrupt,
setSubtitle,
subscribe,
getState: () => ({
connected: avatarState.connected,
connecting: avatarState.connecting,
subtitle: avatarState.subtitle,
avatarState: avatarState.avatarState,
}),
IDENTITY: AVATAR_IDENTITY,
};
})();
这段代码的设计要点:
- IIFE 单例模式 :整个服务包裹在立即执行函数中,避免全局污染,只通过
window.AvatarService暴露必要接口 - 订阅-通知机制 :
subscribe/notify/patch三件套,让 React 组件可以监听数字人状态变化 - 连接容错:15 秒超时 + Promise.race,避免 SDK 卡死导致页面白屏
- 播报与打断 :
speakText使用 SSML 格式,interrupt支持实时打断切换
四、调味配比 · 菜谱数据建模


一道好菜,调味是关键。数字人已经就位,接下来需要准备她讲解的内容------菜谱数据。云厨的菜谱数据采用结构化 JSON 建模,每道菜谱包含完整的烹饪信息。
4.1 分类与食材库
js
export const categories = [
{ id: 'all', name: '全部菜谱', icon: 'UtensilsCrossed' },
{ id: 'home', name: '家常菜', icon: 'Home' },
{ id: 'quick', name: '快手菜', icon: 'Zap' },
{ id: 'soup', name: '汤羹', icon: 'CupSoda' },
{ id: 'staple', name: '主食', icon: 'Wheat' },
{ id: 'dessert', name: '甜品', icon: 'Cherry' },
];
export const ingredientPool = [
'鸡蛋', '西红柿', '土豆', '黄瓜', '豆腐', '猪肉', '鸡肉', '牛肉',
'虾', '鱼', '白菜', '茄子', '青椒', '洋葱', '胡萝卜', '玉米',
'米饭', '面条', '面粉', '牛奶', '苹果', '香蕉', '芒果',
];
4.2 菜谱数据结构
以「西红柿炒鸡蛋」为例,每道菜谱包含 id、名称、分类、难度、时长、食材列表、标签、描述、步骤、技巧共 10 个字段:
js
{
id: 'tomato_egg',
name: '西红柿炒鸡蛋',
category: 'home',
difficulty: '简单',
time: 15,
ingredients: ['西红柿', '鸡蛋'],
tags: ['经典', '下饭', '新手友好'],
description: '国民家常菜,酸甜的西红柿配上嫩滑的鸡蛋,简单却百吃不厌。几乎每个中国家庭的餐桌上都少不了这道菜。',
steps: [
'西红柿切块,鸡蛋打散加少许盐搅匀',
'热锅凉油,倒入蛋液炒至凝固盛出',
'锅中再加少许油,放入西红柿翻炒出汁',
'加入炒好的鸡蛋,加盐、少许糖调味',
'翻炒均匀,撒葱花出锅',
],
tips: '鸡蛋不要炒太老,七分凝固就盛出。西红柿要炒出红油才香,可以加一点点糖提鲜。',
},
4.3 食材匹配算法
用户勾选食材后,系统自动匹配包含这些食材的菜谱,按匹配度排序:
js
export function matchRecipes(selectedIngredients) {
if (!selectedIngredients.length) return recipes;
return recipes
.map((recipe) => {
const matchCount = recipe.ingredients.filter((i) =>
selectedIngredients.includes(i)
).length;
return { ...recipe, matchCount };
})
.filter((r) => r.matchCount > 0)
.sort((a, b) => b.matchCount - a.matchCount);
}
4.4 播报文案生成
将菜谱数据转化为自然语言播报文案,供数字人语音输出:
js
export function generateRecipeBrief(recipe) {
let brief = `${recipe.name},${recipe.difficulty}难度,大约需要${recipe.time}分钟。`;
brief += recipe.description;
brief += `需要准备的食材有:${recipe.ingredients.join('、')}。`;
brief += `做法步骤:${recipe.steps.join(',然后')}。`;
if (recipe.tips) {
brief += `小厨提醒你:${recipe.tips}`;
}
return brief;
}
五、大火翻炒 · 前端组件架构
食材备好了,灶台也热了,现在把所有元素组合到一起。前端组件负责将菜谱数据呈现给用户,并在关键交互节点触发数字人播报------它是数据层与具身交互智能之间的桥梁。
5.1 主页面 App.jsx
App 组件管理三个核心状态:当前分类、已选食材、当前菜谱。通过 useMemo 实现响应式过滤:
js
export default function App() {
const [activeCategory, setActiveCategory] = useState('all');
const [selectedIngredients, setSelectedIngredients] = useState([]);
const [selectedRecipe, setSelectedRecipe] = useState(null);
const toggleIngredient = (item) => {
setSelectedIngredients((prev) =>
prev.includes(item) ? prev.filter((i) => i !== item) : [...prev, item]
);
};
const filteredRecipes = useMemo(() => {
let result = activeCategory === 'all'
? recipes
: recipes.filter((r) => r.category === activeCategory);
if (selectedIngredients.length > 0) {
result = matchRecipes(selectedIngredients);
if (activeCategory !== 'all') {
result = result.filter((r) => r.category === activeCategory);
}
}
return result;
}, [activeCategory, selectedIngredients]);
return (
<div className="app">
<header className="app-header">
<div className="app-header__brand">
<ChefHat size={28} />
<div>
<h1>云厨 YunChef</h1>
<p>具身交互智能食谱顾问 · 暖厨姐姐小厨</p>
</div>
</div>
<CategoryNav categories={categories} active={activeCategory} onChange={setActiveCategory} />
</header>
<main className="app-main">
<IngredientInput selected={selectedIngredients} onToggle={toggleIngredient} />
{selectedIngredients.length > 0 && (
<div className="match-banner">
<span>🍳 根据你选择的 <strong>{selectedIngredients.join('、')}</strong>,小厨为你推荐了 {filteredRecipes.length} 道菜谱</span>
</div>
)}
<div className="recipe-grid">
{filteredRecipes.map((recipe) => (
<RecipeCard key={recipe.id} recipe={recipe} matchCount={recipe.matchCount || 0} onClick={setSelectedRecipe} />
))}
</div>
{selectedRecipe && (
<RecipeDetail recipe={selectedRecipe} onClose={() => setSelectedRecipe(null)} />
)}
</main>
</div>
);
}
5.2 菜谱详情与语音联动
RecipeDetail 组件是具身交互智能的核心交互点------点击「小厨讲解」按钮,触发数字人语音播报:
js
export default function RecipeDetail({ recipe, onClose }) {
if (!recipe) return null;
const diff = difficultyConfig[recipe.difficulty] || difficultyConfig['简单'];
const handleSpeak = () => {
const brief = generateRecipeBrief(recipe);
if (window.AvatarService) {
window.AvatarService.interrupt?.();
setTimeout(() => window.AvatarService.speakText(brief), 300);
}
};
return (
<div className="recipe-detail">
<div className="recipe-detail__header">
<div className="recipe-detail__title-row">
<span className="recipe-detail__difficulty" style={{ color: diff.color, background: diff.bg }}>
{recipe.difficulty}
</span>
<h2 className="recipe-detail__name">{recipe.name}</h2>
<span className="recipe-detail__time">
<Clock size={14} /> {recipe.time}分钟
</span>
</div>
<div className="recipe-detail__actions">
<button className="recipe-detail__speak-btn" onClick={handleSpeak} title="让小厨讲解菜谱">
<Volume2 size={16} />
<span>小厨讲解</span>
</button>
<button className="recipe-detail__close-btn" onClick={onClose}>
<X size={18} />
</button>
</div>
</div>
<p className="recipe-detail__desc">{recipe.description}</p>
<div className="recipe-detail__ingredients">
<h4>🧄 所需食材</h4>
<div className="recipe-detail__ingredient-list">
{recipe.ingredients.map((ing) => (
<span key={ing} className="recipe-detail__ingredient">{ing}</span>
))}
</div>
</div>
<div className="recipe-detail__steps">
<h4>👩🍳 做法步骤</h4>
<ol className="recipe-detail__step-list">
{recipe.steps.map((step, i) => (
<li key={i} className="recipe-detail__step">
<span className="recipe-detail__step-num">{i + 1}</span>
<p>{step}</p>
</li>
))}
</ol>
</div>
{recipe.tips && (
<div className="recipe-detail__tips">
<h4>💡 小厨提醒</h4>
<p>{recipe.tips}</p>
</div>
)}
<div className="recipe-detail__tags">
{recipe.tags?.map((tag) => (
<span key={tag} className="recipe-detail__tag">{tag}</span>
))}
</div>
</div>
);
}
交互流程:
- 用户点击菜谱卡片 → 左侧滑入详情面板
- 点击「小厨讲解」→ 先调用
interrupt()打断当前播报 - 等待 300ms → 调用
speakText(brief)开始新播报 - 数字人同步口型、手势,右下角框体内实时显示
六、精致摆盘 · 暖厨视觉设计系统
一道好菜,色香味俱全。功能已经跑通,最后一步是用视觉系统为整个项目定调------暖色奶油基底 + 琥珀橙主题色,营造温馨的厨房氛围。
6.1 CSS 令牌体系
js
:root {
--bg-base: #faf6f0;
--bg-card: #ffffff;
--bg-warm: #fff8f0;
--bg-input: #fef3e2;
--text-primary: #2d1b0e;
--text-secondary: #6b4c35;
--text-muted: #9c7a5f;
--text-light: #c4a88a;
--accent: #e67e22;
--accent-light: #f59e0b;
--accent-soft: rgba(230, 126, 34, 0.12);
--accent-glow: rgba(230, 126, 34, 0.25);
--green: #22c55e;
--green-soft: rgba(34, 197, 94, 0.12);
--red: #ef4444;
--red-soft: rgba(239, 68, 68, 0.12);
--border: rgba(205, 170, 130, 0.25);
--border-strong: rgba(205, 170, 130, 0.45);
--shadow-sm: 0 1px 3px rgba(139, 90, 43, 0.06);
--shadow-md: 0 4px 12px rgba(139, 90, 43, 0.08);
--shadow-lg: 0 8px 32px rgba(139, 90, 43, 0.12);
--radius-sm: 8px;
--radius-md: 12px;
--radius-lg: 16px;
--radius-xl: 20px;
--font: 'Inter', 'PingFang SC', 'Microsoft YaHei', sans-serif;
}
6.2 数字人舞台样式
数字人框体采用毛玻璃效果,固定右下角,与菜谱详情面板(左侧)互不遮挡:
js
.avatar-stage {
position: fixed;
right: 32px;
bottom: 32px;
width: 280px;
height: 420px;
z-index: 1000;
pointer-events: none;
}
.avatar-stage__frame {
position: relative;
width: 100%;
height: 100%;
border-radius: 20px;
background: rgba(255, 248, 240, 0.6);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid rgba(230, 126, 34, 0.18);
box-shadow:
0 20px 60px -12px rgba(230, 126, 34, 0.12),
0 8px 24px -8px rgba(0, 0, 0, 0.3);
overflow: hidden;
z-index: 1;
pointer-events: auto;
}
.avatar-stage__box {
position: absolute;
inset: 0;
overflow: visible;
}
.avatar-stage__sdk {
width: 100%;
height: 100%;
overflow: visible;
}
/* SDK canvas 样式 */
.avatar-stage__sdk canvas,
.avatar-stage__sdk video {
display: block !important;
max-width: 130% !important;
max-height: 100% !important;
width: auto !important;
height: auto !important;
margin: 0 auto !important;
position: relative !important;
z-index: 3 !important;
left: 0 !important;
right: 0 !important;
}
.avatar-stage__sdk > div {
overflow: visible !important;
}
关键设计:
overflow: visible贯穿 box、sdk、内部 div,避免裁剪数字人肢体动作max-width: 130%为手臂抬起等动态动作预留显示空间- 详情面板在左侧滑入,数字人在右下角,布局互不干扰
七、上菜收尾 · 项目复盘与工程总结
菜已上桌,趁热复盘。云厨 YunChef 是一个基于 React 18 + Vite 6 构建的具身交互智能食谱顾问应用,从魔珐星云控制台配置数字人形象,到前端 IIFE 封装 SDK 服务,再到菜谱数据建模与语音播报联动,完整走通了具身交互智能在厨房场景的落地全链路------用户不再面对冷冰冰的菜谱列表,而是有一位温暖的暖厨姐姐站在旁边,看你冰箱里有什么就推荐什么,一步步教你做菜。回顾整个项目,具身交互智能在厨房场景的价值可以归纳为以下几点:
- 从「搜索」到「对话」:用户不再需要在海量菜谱中筛选,告诉数字人你有什么食材,她直接推荐并讲解
- 从「文字」到「陪伴」:菜谱不再是冷冰冰的步骤列表,而是一位温暖的姐姐在手把手教你做菜
- 从「工具」到「伙伴」:数字人不是一次性使用的工具,而是一位可以反复请教、随时打断、永远耐心的烹饪伙伴
- 食材驱动推荐:23 种食材库,勾选后智能匹配 12 道菜谱,按匹配度排序
- 数字人语音讲解:点击「小厨讲解」,暖厨姐姐实时播报做法步骤,支持打断切换
- 暖厨视觉系统:奶油基底 #faf6f0 + 琥珀橙 #e67e22,毛玻璃框体,温暖的厨房氛围
魔珐星云PC端官方链接:https://xingyun3d.com?utm_campaign=daily\&utm_source=CSDNwanfen3\&utm_medium=\&utm_term=\&utm_content=