前阵子家里小朋友睡前总要听故事,我一开始用 ChatGPT 生成文字再用语音朗读,结果他听了几句就跑掉了。原因很简单:单纯文本、预录视频都不存在具身交互智能,只能单向输出,无法实现人和 AI 双向实时共情互动;而市面上很多数字人方案存在延迟高、没法打断、成本高的原因,根本不适合这种实时交互场景。
于是我干脆自己搭了一个"故事大王陪伴官":前端用 Vue 3 + Vite,AI Coding 用 Trae Work 生成骨架,大脑接 DeepSeek/通义千问成故事推理创作,魔珐星云提供完整具身交互智能;一句话理清分工:大模型让 AI 学会思考,魔珐星云赋予 AI 可感知、可互动的 3D 躯体与实时拟人表达能力。小朋友说一句"我想听小兔子的故事",大模型生成故事,3D 卡通数字人实时口型、表情、动作一起播报,还能语音输入、切换形象、在手机上用。
整段链路端到端延迟大概在 500ms 左右,核心代码只有一套,屏幕端数字人跑通之后,想下沉到人形服务机器人也能复用。

一、项目效果与整体架构
最终效果是这样的:打开网页,左侧是 3D 数字人舞台,右侧是聊天面板。小朋友可以打字,也可以按住麦克风说话;说完之后,大模型生成一段中文故事,数字人立刻开始讲,嘴巴和表情会跟着内容动,讲完还能继续追问。
我把主要能力拆成了几块:
- 前端界面 :Vue 3 + Vite,
AvatarStage.vue负责数字人舞台,ChatPanel.vue负责聊天输入和消息展示。 - 语音输入 :
useSpeechRecognition.js封装浏览器 Web Speech API,按住说话、松开发送。 - 大脑 :
chat.js调用 OpenAI 兼容接口,支持 DeepSeek、通义千问、智谱、OpenAI 等多个模型动态切换。 - 身体 :
useAvatarSDK.js封装魔珐星云 XmovAvatar SDK,负责连接、播报、打断、音量控制。 - 配置 :
useMagicKeys.js管理多组 AppID/AppSecret 钥匙串,aiConfig.js管理大模型 API Key,都存在 localStorage 里。

整个调用链路很短:用户输入 -> 语音识别转文字 -> chat.js 调用大模型 -> 拿到故事文本 -> speakWithInterrupt 交给星云 SDK -> 数字人开始播报。链路中的关键耗时其实只有大模型生成时间和 SDK 响应时间,星云这边靠参数流 + 端侧渲染,能把延迟压到约 500ms。

二、用 Trae Work 搭骨架
这个项目不是我从零一行行敲出来的,而是完全用 Trae Work 从零来实现的。Trae IDE 想必大家应该都用过了,而 Trae Work 面向更广泛人群的 AI 原生工作台,提供网页 / 桌面 / 移动三端,支持 Work、Code、Design 三种模式。


我先用 Trae Work 把需求描述清楚:"一个 Vue 3 的 3D 数字人故事陪伴页面,左侧舞台右侧聊天,支持语音输入和多模型切换"。Trae Work 帮我生成了基础目录、组件结构和 composable 雏形,我再根据魔珐星云的 SDK 文档做接入和调优。
Trae Work 生成的核心文件大致如下:
src/components/AvatarStage.vue:3D 数字人舞台,负责未连接时的空状态和连接后的渲染容器。src/components/ChatPanel.vue:聊天面板,包含消息列表、输入框、语音按钮和音量控制。src/composables/useAvatarSDK.js:封装星云 SDK 的连接、播报、打断、音量等方法。src/utils/chat.js:封装大模型调用逻辑。src/composables/useSpeechRecognition.js:封装 Web Speech API 的语音识别。
我用 Trae Work 最多的地方是生成样板代码和边界处理。比如语音输入这个 composable,它要处理浏览器不支持、权限被拒绝、没有检测到语音、网络错误等场景,自己手写容易漏,Trae Work 能一次性给出比较完整的结构,我再根据业务微调。
javascript
// src/composables/useSpeechRecognition.js
export function useSpeechRecognition(options = {}) {
const SpeechRecognition =
window.SpeechRecognition || window.webkitSpeechRecognition;
const isSupported = ref(!!SpeechRecognition);
const isListening = ref(false);
const transcript = ref("");
const error = ref(null);
function start() {
// 每次创建新实例,避免状态混乱
if (recognition) {
try { recognition.abort(); } catch (e) {}
}
recognition = createRecognition();
transcript.value = "";
recognition.start();
}
function stop() {
if (recognition && isListening.value) {
try { recognition.stop(); } catch (e) {}
}
}
// ... onresult / onerror / onend 处理
}

这里有一个细节:Web Speech API 在 Chrome 下表现不错,但 Safari 和移动端兼容性参差不齐,所以我做了 isSupported 判断,不支持的时候按钮会自动隐藏,避免用户点完没反应。
三、大模型接入:不只是调接口
故事里的大脑我选了 DeepSeek 和通义千问做主力。项目里我把多个模型做成了可配置,避免以后某个模型不稳定或者价格上涨就要改代码。
模型配置写在 src/constants/aiModels.js 里,所有接口都是 OpenAI 兼容格式:
javascript
// src/constants/aiModels.js
export const AI_PROVIDERS = [
{
id: "qwen",
name: "通义千问(阿里云百炼)",
defaultModel: "qwen-plus",
proxyPrefix: "/proxy-api/qwen",
target: "https://dashscope.aliyuncs.com/compatible-mode",
},
{
id: "deepseek",
name: "DeepSeek",
defaultModel: "deepseek-chat",
proxyPrefix: "/proxy-api/deepseek",
target: "https://api.deepseek.com",
},
// ...
];
本地开发时,Vite 的 proxy 把这些前缀转发到对应的官方地址,避免前端直接暴露真实 API 域名:
javascript
// vite.config.js
server: {
proxy: {
"/proxy-api/deepseek": {
target: "https://api.deepseek.com",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/proxy-api\/deepseek/, ""),
},
},
}
运行时配置优先从 localStorage 读,没有才回退到 .env:
javascript
// src/utils/aiConfig.js
export function getActiveAiConfig() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
const parsed = JSON.parse(saved);
const provider = findProvider(parsed.providerId);
if (provider && parsed.apiKey) {
return {
providerId: provider.id,
apiKey: parsed.apiKey,
model: parsed.model || provider.defaultModel,
proxyPrefix: provider.proxyPrefix,
};
}
}
return {
providerId: "deepseek",
apiKey: import.meta.env.VITE_AI_API_KEY || "",
model: import.meta.env.VITE_AI_MODEL || "deepseek-chat",
proxyPrefix: "/proxy-api/deepseek",
};
}
调用大模型的地方 chat.js 就很简单了,动态读取当前配置,拼一个 system prompt 让模型用儿童友好的语气讲故事:
javascript
// src/utils/chat.js
export async function chat(prompt) {
const config = getActiveAiConfig();
const res = await fetch(`${config.proxyPrefix}/v1/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${config.apiKey}`,
},
body: JSON.stringify({
model: config.model,
messages: [
{
role: "system",
content:
"你是一位亲切可爱的故事大王陪伴官,专门为小朋友讲故事...",
},
{ role: "user", content: prompt },
],
}),
});
// ...
}
这里踩过一个坑:通义千问的某个模型返回时 content 是空的,真正的故事内容藏在 reasoning 字段里,而且 reasoning 前面往往有一段英文思考过程。我不得不写了一个 extractStoryFromReasoning 函数,按几种模式去提取中文正文,比如匹配 *(Story Body)* 标记、匹配 "Drafting the Story" 后的中文字符、或者直接找第一个连续中文段落。
javascript
function extractStoryFromReasoning(reasoning) {
const sectionMatch = reasoning.match(
/(?:\*\(Greeting\)\*|\*\(Story Body\)\*)\s*([\s\S]*?)(?=\n\s*\*\(|$)/
);
if (sectionMatch) return sectionMatch[1].trim();
const draftingMatch = reasoning.match(
/(?:Drafting the Story)[\s\S]*?\n\s*([\u4e00-\u9fa5][\s\S]*?)(?=\n\s*\*\*Check|$)/
);
if (draftingMatch) return draftingMatch[1].trim();
const chineseBlockMatch = reasoning.match(/[\u4e00-\u9fa5]{10,}[\s\S]*$/);
if (chineseBlockMatch) return chineseBlockMatch[0].trim();
return reasoning;
}
这个坑耗了我小半天,因为一开始我以为是自己 prompt 写错了,后来打开浏览器控制台看到原始响应才发现问题。

四、魔珐星云具身驱动 SDK:把故事"演"出来
大模型只能输出文字,要让数字人把故事"讲"出来,需要一层"身体"。我接入的是魔珐星云 XmovAvatar SDK,也就是魔珐星云具身驱动 SDK。

魔珐星云并非单纯 3D 形象生成工具,而是AI 终端时代全域具身交互底层开放平台,核心价值是为所有大模型、Agent 落地标准化具身交互智能。平台以自研 LAM 文生 3D 多模态大模型为底座,搭建「多模态感知层 - 大模型智能体层 - LAM3D 具身表达层」三层闭环架构,依托自研参数流架构 + AI 端渲和解算,不用预录视频,输入文本即可实时驱动 3D 形象同步语音、眼神、微表情、全身肢体,真正实现能感知、能反馈、可实时打断的完整具身交互智能。

接入流程大致分三步:在魔珐星云开放平台注册应用,拿到 AppID 和 AppSecret;前端创建 XmovAvatar 实例并完成 init() 初始化,等待资源下载和网关会话确认;之后调用 speak() 方法就能让数字人开口。
下面是我在项目中的几个重点代码片段:
SDK 初始化封装在 useAvatarSDK.js 里:
javascript
// src/composables/useAvatarSDK.js
function createSdkInstance({ appId, appSecret }) {
sdk.value = new window.XmovAvatar({
containerId: "#avatar-container",
appId,
appSecret,
gatewayServer: "https://nebula-agent.xingyun3d.com/user/v1/ttsa/session",
onMessage: (msg) => {
if (msg && msg.error_code && _sessionReject) {
_sessionReject(msg.error_reason || "认证失败");
}
},
onWidgetEvent: (eventName, data) => {
handleWidgetEvent(eventName, data);
},
});
}
连接时先 init 下载资源,再等待网关会话确认,最后把状态置为 connected:
javascript
async function doConnect({ appId, appSecret }) {
if (sdk.value) {
console.warn("[SDK] 已有实例,先清理");
destroySdk();
}
status.value = "connecting";
createSdkInstance({ appId, appSecret });
await sdk.value.init({
onDownloadProgress: (p) => {
progress.value = p;
},
});
// 等待网关会话确认,5s 超时兜底
await new Promise((resolve, reject) => {
_sessionReject = reject;
setTimeout(() => {
if (_sessionReject) {
_sessionReject = null;
resolve();
}
}, 5000);
});
status.value = "connected";
isConnected.value = true;
}
这里有个小踩坑:如果连接成功后不清理旧实例直接再次连接,SDK 会报错或出现两个渲染层。所以我在
doConnect开头加了判断,有旧实例就先destroySdk()。
播报故事时,为了让用户可以随时打断,我用的是 speakWithInterrupt:
javascript
function speakWithInterrupt(text, isStart = true, isEnd = true) {
if (!sdk.value || !isConnected.value) return;
sdk.value.interactiveidle();
setTimeout(() => {
sdk.value.speak(text, isStart, isEnd);
}, 150);
}
interactiveidle 会让数字人先进入可打断状态,然后再说新内容,即时中断,这是具身交互智能的关键能力。这样小朋友在听故事的时候突然问新问题,数字人会立刻停下来回答,而不是把旧故事念完。
为了让界面不那么单调,我还做了多形象切换:小猴子、小熊熊、小兔子等,每个形象有对应的 emoji、主题色和空状态渐变背景。配置存在 useMagicKeys.js 里,爸爸妈可以在设置面板里新增、编辑、切换不同的钥匙串。

javascript
// src/composables/useMagicKeys.js
export function useMagicKeys() {
const configs = ref([]);
const activeId = ref("");
const activeConfig = computed(() =>
configs.value.find((c) => c.id === activeId.value)
);
function addConfig(config) {
const item = { id: generateId(), ...config };
configs.value.push(item);
activeId.value = item.id;
persist();
}
// ...
}
五、体验已部署的故事大王应用
如果你暂时不想本地跑代码,可以直接打开我已经部署好的线上版本:
打开后,左侧是 3D 数字人舞台,右侧是聊天面板。第一次进入时,数字人会显示"还在睡觉"的空状态,需要爸爸妈妈先帮忙完成两步初始化。
使用步骤
-
连接魔珐星云魔法钥匙
点击页面上的设置入口(桌面端在右上角,移动端在卡片顶部),进入"爸爸妈妈的魔法钥匙串",填入你在魔珐星云开放平台申请到的 AppID 和 AppSecret,然后点击"召唤故事大王"。稍等几秒,等资源下载和网关会话确认完成,数字人就会从睡眠状态醒来,出现在舞台上。

-
设置大模型 API key
同样在设置面板里选择你喜欢的大模型,比如 DeepSeek 或通义千问,填入对应的 API Key。这个 Key 只保存在浏览器本地 localStorage 里,不会传到我的服务器上。配置好后,数字人就有"大脑"了。

-
开始聊天或随机故事
现在可以让小朋友开口了。点击输入框旁边的麦克风按钮,按住说话,松开后文字会自动填入输入框;也可以直接打字。说一句"我想听小兔子的故事",大模型生成故事,数字人立刻开始播报。如果不确定想听什么,面板里还有"故事骰子"按钮,点一下就能收获一个惊喜。
-
切换形象和调节音量(可选)
设置面板里可以切换小猴子、小熊熊、小兔子等不同形象,每个形象都有自己的主题色。聊天面板顶部还能独立调节数字人音量,避免和系统音量混在一起。
建议在 Chrome 浏览器里体验,Web Speech API 的识别效果最稳定;Safari 和移动端如果语音识别按钮没出现,说明当前环境不支持,也可以直接打字互动。
魔珐星云新用户可以用邀请码 JMFASQDPXB 注册,能拿到 1000 积分,能有很长时间体验。
儿童友好设计
既然是给小朋友用的,技术术语必须藏起来。我把所有面向孩子的文案都集中到了 src/constants/kidsText.js 里,比如 AppID 叫"魔法钥匙编号",AppSecret 叫"魔法咒语",断开连接叫"让大王休息"。
javascript
// src/constants/kidsText.js
export const KIDS_TEXT = {
CONFIG_TITLE: "🔑 爸爸妈妈的魔法钥匙串",
BTN_CONNECT: "✨ 召唤故事大王",
BTN_DISCONNECT: "💤 让大王休息",
KEY_APP_ID_LABEL: "魔法钥匙编号",
KEY_APP_SECRET_LABEL: "魔法咒语",
};
交互上我也尽量贴近儿童习惯,并支持自适应布局:
- 按住说话:按钮按住开始识别,松开自动把文字填进输入框,不需要小朋友打字。
- 自动关闭聊天面板:移动端收到 AI 回复后,聊天面板会延迟 2 秒自动收起,让小朋友把注意力放回数字人身上。
- 消息状态保留 :切换聊天窗口时不会清空历史记录,这个是通过把
messages状态从ChatPanel.vue提升到App.vue实现的。 - 音量独立控制:在聊天面板顶部可以调节数字人音量,不会和系统音量混在一起。
PC端UI:



移动端UI:


六、延伸
真正跑通之后我才意识到,数字人对小朋友有没有吸引力,关键不是大模型多聪明,而是它能不能像真人一样即时回应。小朋友不会耐心等一个聊天框慢慢打字,他们要的是"面对面"的互动感。
当下 AI 行业正在完成从纯文本对话,到全域具身交互智能的转型,只靠聊天框的 AI 很难走进教育、门店、机器人等线下真实场景,魔珐星云提供标准化底层能力,让任何开发者都能低成本给大模型装上具备完整具身交互智能的 3D 躯体。
我现在只做了网页版,但这件事让我觉得,未来如果把同样的交互逻辑放进服务机器人、AR 眼镜,甚至家里的人形机器人里,可能会打开更多有意思的场景。对我个人来说,故事大王更像是一个起点:它让我看到,大模型不只是用来回答问题的工具,还可以变成一个能被看见、能互动、有情绪的小角色。
接下来我还想试试能不能让数字人识别小朋友的语音情绪,或者根据对话内容自动切换表情和动作。这些能力现在看起来已经没那么远了。
七、总结
整个项目做下来,我最大的感受是:Trae Work 负责把工程骨架快速搭起来,降低前端工程开发门槛。大模型负责生成内容,赋予 AI 思考创作能力,魔珐星云 SDK 负责把内容"演"出来,提供整套标准化具身交互智能底层能力。三者分工很明确。如果没有 Trae Work,我可能还在纠结目录结构和组件划分;如果没有星云 SDK,故事大王就只是一个文字聊天机器人,对小朋友吸引力会差很多。
如果你也想试试,可以用我的邀请码 JMFASQDPXB 注册魔珐星云开放平台,新用户能拿到 1000 积分,足够跑很多轮测试。
最后想跟大家讨论个问题:
你会把 3D 数字人用在什么场景?教育陪伴、客服导览,还是人形机器人?
欢迎在评论区交流。