这么近,那么美,周末到河北!我用蓝耘 MaaS 与魔珐星云,打造了一位河北专属 3D 导游「冀小美」
人工智能交互技术已发展到何种境界?从最初与聊天框的文字对话,到聆听AI生成的语音回复,再到如今能与一位拥有丰富表情、精准眼神和口型同步的3D数字人进行实时面对面交流,技术演进令人惊叹。本文将通过两组核心API——作为“大脑”的蓝耘MaaS与作为“身体”的魔珐星云,完整演示如何构建一个可复现、能在普通笔记本上运行的具身智能应用,并借助文旅热梗“这么近,那么美,周末到河北”,打造一位专属的3D数字导游。

@[toc]
两年前,我们面对聊天框输入文字,AI返回的是一段段文本;一年前,开始聆听AI的“语音消息”,仿佛在和一位不太熟悉的微信好友交流;而如今,你可以让一位具备表情、眼神和精准口型的3D数字人,在浏览器中与你进行实时对话,你话音刚落,她便能无缝接话。
本文中,我将借助两组API——蓝耘MaaS(负责大脑)与魔珐星云(负责身体)——带你完整实现这一过程,打造一个人人可复现、仅需一台普通笔记本即可运行的具身智能应用。
具体做什么呢?正好可以借用一个文旅圈的热门梗:“这么近,那么美,周末到河北。”
河北文旅近年来热度颇高,但想认真规划一次旅行,仍需费心搜索攻略:在社交平台翻半天,看到的多是广告;向大模型咨询,得到的则是冷冰冰的长篇文字。如果有一位温柔的河北导游小姐姐,能当面为你介绍正定古城的玩法、白石山玻璃栈道的刺激程度,还能一键生成周末两天的行程,那该多好?
因此,本文的主角诞生了——河北专属3D导游“冀小美”(冀为河北简称,美取自“那么美”)。她具备以下能力:
- ️ 按住说话即可提问,无需打字,张嘴即达;
- 专注于河北文旅:询问其他省份景点时,她会温柔地引导你——“这么近,那么美,周末到河北呀”;
- 一键生成专属攻略:选择出发地、天数和兴趣偏好,长篇行程便会流畅地显示在屏幕上;
- 延迟数据实时可见:页面右上角设有HUD,清晰展示大模型首字延迟、数字人开口延迟和生成吞吐量。
全部代码仅为一个HTML文件,现在开始构建。
一、这套组合凭什么能打?
首先明确架构,避免“调用了两个API却不知其运作原理”。
“身体”层面,魔珐星云已大幅降低了技术门槛。传统3D数字人采用“云端渲染+视频推流”模式,延迟高且对带宽敏感。魔珐星云则采用参数流+端渲染方式:云端仅下发面部肌肉参数、骨骼动画参数和PCM音频参数,浏览器SDK利用本地显卡在WebGL中实时渲染。由于传输数据量小,官方标称双向响应时间约为1200毫秒,普通笔记本的Chrome浏览器即可流畅运行,口型与音频在同一帧内下发,实现了天然同步。
“大脑”层面,智能模型并不稀缺,但快速稳定的推理服务至关重要。这正是蓝耘MaaS的定位:其模型广场提供了DeepSeek、GLM等主流旗舰模型,采用标准OpenAI兼容接口,按Token计费,注册充值后即可使用。
而决定整体体验的关键,在于连接大脑与身体的“神经”。以下是完整的交互链路:
复制代码你的语音 → 浏览器ASR → 蓝耘MaaS(流式生成) → 前端断句 → 魔珐 speak()
↓
浏览器WebGL渲染数字人 ← TTSA参数流(面部肌肉 + 骨骼 + 音频)
请注意一个关键点:数字人开口的时刻约等于大模型首句生成完成的时刻。当魔珐将身体侧延迟压缩至1200毫秒级别后,整条链路的瓶颈便转移至推理服务——首字延迟每增加一秒,数字人形象再生动,也会显得像反应迟钝的木偶。
而“导游”这一场景,对推理服务提出了双重考验:
- 短问答考验低延迟:“白石山值得去吗?”——当面提问,回答稍有延迟便会穿帮;
- 长攻略考验高吞吐:“帮我规划一个周末两天的行程”——六七百字的攻略,吞吐量不足将导致用户煎熬等待。
这正是我选择蓝耘的原因。根据AI Ping的实测数据,蓝耘MaaS上主流模型的首字延迟和吞吐量均表现优异:

官方数据之外,本文的Demo还内置了一个延迟计时HUD,实时记录每一轮对话的首字延迟、开口延迟和生成吞吐量。蓝耘的速度究竟如何,让我们用秒表来验证。
二、第一组API:三分钟拿到蓝耘MaaS凭证
1. 注册登录
访问蓝耘控制台完成注册。
2. 创建API Key
登录后,在左侧导航栏找到API管理/密钥管理,点击“创建API Key”,生成后复制并妥善保存。

3. 模型广场锁定deepseek-v4-flash
点击左侧【模型广场】,找到deepseek-v4-flash:

打开详情页,记录其在蓝耘平台的调用名称:
复制代码deepseek-v4-flash
为何选择此模型?导游场景需满足三项要求:中文知识底蕴(能准确讲解河北景点典故)、长文规划能力(攻略需条理清晰)、流式输出速度。DeepSeek V4 Flash的官方定位为“低成本高速模型,适合大规模调用和高并发”——前两项有DeepSeek V4的基础支撑,第三项是其核心优势,与“面对面对话怕慢半拍”的导游场景高度契合。其价格更为亲民:输入1元/百万Tokens,输出2元/百万Tokens,完整运行本文Demo也花费甚微。当然,蓝耘模型广场的其他模型也可通过一个字段轻松切换,文末将提及。
至此,蓝耘侧所需的三件套已准备齐全:
| 配置项 | 值 |
|---|---|
| API Key | sk-xxxxxxxx(你自己的密钥) |
| 接口地址 | (OpenAI兼容端点) |
| 模型名称 | deepseek-v4-flash |
三、第二组API:魔珐星云开户,请出「冀小美」本尊
第一步:注册账号
打开官网:
复制代码https://xingyun3d.com/
点击右上角“登录/注册”。注册时“邀请码”一栏可填写JM8ABNY834,领取1000积分作为新人额度,足以体验本文Demo及更多功能。
第二步:创建具身智能应用
进入控制台,点击“创建应用”,依次进行选择:

- 形象:官方提供数十个3D数字人形象。导游角色建议选择温柔亲和的年轻女性形象——文旅推荐官的气质应让人愿意主动开口询问;
- 场景:选择明亮清新的户外或大厅类场景,以契合“文旅”氛围;
- 音色:这是本篇最关键的一步。一个应用只能绑定一种音色,绑定后所有输出音频均使用该音色。冀小美使用中文,因此必须选择标准普通话的温柔女声——切勿误选英文音色,否则中文输出会显得极为不协调;
- 表演动作:保持默认设置即可。

创建完成后,进入应用详情页,获取以下两个关键字段:
- App ID — 应用唯一标识;
- App Secret — 应用密钥。

复制并妥善保存,App Secret需确保安全。
至此,两组API的凭证已全部获取,共计五个值:蓝耘的Key、接口地址、模型名称,以及魔珐的App ID和App Secret。接下来进入核心环节。
四、硬核实操:一个HTML文件请出河北专属导游
在编写代码之前,先说明本Demo的六个设计决策,每个都有其考量:
- 河北知识卡注入:在System Prompt中嵌入一份精选的河北文旅知识卡(涵盖正定、山海关、白洋淀、崇礼、白石山等景点,包含亮点及适合人群),以提升回答的“河北浓度”并减少幻觉;
- 拒答边界:Prompt中明确规定“只服务河北文旅”——用户询问外省景点时,她会先礼貌回应,再自然推荐河北的同类替代,并附带口头禅。通用大模型如何调教为“专属”角色,关键即在此处;
- 双负载分流:聊天模式下,大模型流式输出逐句驱动数字人开口(考验低延迟);攻略模式下,她先口述一句垫场词,长文仅显示在屏幕上(考验高吞吐)——六七百字的攻略若逐句念完,需耗时数分钟,正确做法是眼睛阅读全文、耳朵聆听重点,垫场词还能巧妙隐藏长文首字等待时间;
- 延迟HUD:记录三个时间点,实时显示大模型首字延迟、数字人开口延迟和生成吞吐量;
- 按住说话+随时打断:使用浏览器原生Web Speech中文识别,松手自动发送;打断按钮可随时停止她的发言;
- 凭证不硬编码:五个凭证从页面面板填写并存入
localStorage,蓝耘接口地址和模型名称已预设默认值,实际只需填写三项。
在本地新建一个index.html文件,粘贴以下完整代码:
复制代码html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>冀小美 · 河北专属 3D 导游 | 蓝耘 MaaS × 魔珐星云title>
<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4">script>
<script src="https://media.xingyun3d.com/xingyun3d/general/litesdk/xmovAvatar@latest.js">script>
<style>
#avatar-container {
background: radial-gradient(circle at 30% 20%, #7c2d12 0%, #431407 55%, #1c0a04 100%);
}
.scrollbar-thin::-webkit-scrollbar { width: 6px; }
.scrollbar-thin::-webkit-scrollbar-thumb { background: #57534e; border-radius: 3px; }
.mic-pulse { animation: micPulse 1.2s ease-in-out infinite; }
@keyframes micPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(251, 146, 60, 0.7); }
50% { box-shadow: 0 0 0 12px rgba(251, 146, 60, 0); }
}
style>
head>
<body class="bg-stone-900 text-stone-100 min-h-screen p-4 md:p-6">
<div class="max-w-5xl mx-auto mb-4">
<button id="cred-toggle" class="w-full bg-stone-800 hover:bg-stone-700 border border-stone-700 rounded-xl px-4 py-3 flex items-center justify-between transition-colors">
<span class="flex items-center gap-2 text-sm">
<span id="cred-status-dot" class="h-2 w-2 rounded-full bg-amber-400">span>
<span class="font-medium">凭证配置面板span>
<span id="cred-status-text" class="text-xs text-stone-400">(未配置,点击展开)span>
span>
<span id="cred-chevron" class="text-stone-400 text-xs">▼span>
button> <div id="cred-panel" class="mt-2 bg-stone-800 border border-stone-700 rounded-xl p-5 space-y-4">
<div class="grid md:grid-cols-3 gap-4">
<div>
<label class="block text-xs text-stone-400 mb-1">魔珐星云 AppIDlabel>
<input type="text" id="cfg-mofa-appid" placeholder="从魔珐星云控制台复制" class="w-full bg-stone-950 border border-stone-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-orange-500">
div>
<div>
<label class="block text-xs text-stone-400 mb-1">魔珐星云 AppSecretlabel>
<input type="password" id="cfg-mofa-secret" placeholder="从魔珐星云控制台复制" class="w-full bg-stone-950 border border-stone-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-orange-500">
div>
<div>
<label class="block text-xs text-stone-400 mb-1">蓝耘 API Keylabel>
<input type="password" id="cfg-ly-key" placeholder="蓝耘 MaaS 平台创建的密钥" class="w-full bg-stone-950 border border-stone-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-orange-500">
div>
div>
<div class="grid md:grid-cols-2 gap-4">
<div>
<label class="block text-xs text-stone-400 mb-1">蓝耘接口地址(已预填,一般不用改)label>
<input type="text" id="cfg-ly-url" class="w-full bg-stone-950 border border-stone-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-orange-500">
div>
<div>
<label class="block text-xs text-stone-400 mb-1">模型名称(已预填,可换模型广场其他模型)label>
<input type="text" id="cfg-ly-model" class="w-full bg-stone-950 border border-stone-700 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-orange-500">
div>
div>
<div class="flex gap-2 pt-2 border-t border-stone-700">
<button id="cred-save" class="flex-1 bg-orange-600 hover:bg-orange-500 px-4 py-2 rounded-lg text-sm font-medium">保存并请出冀小美button>
<button id="cred-clear" class="bg-stone-700 hover:bg-rose-800 px-4 py-2 rounded-lg text-sm text-stone-300 hover:text-white">清除本地凭证button>
div>
<p class="text-xs text-stone-500 leading-relaxed">
所有凭证仅保存在你自己的浏览器 <code class="bg-stone-950 px-1 rounded">localStoragecode> 中,不会上传到任何第三方。生产环境请把密钥迁移到后端中转。
p>
div>
div>
<div class="max-w-5xl mx-auto bg-stone-800 rounded-2xl shadow-2xl border border-stone-700 overflow-hidden flex flex-col md:flex-row h-[720px]">
<div class="w-full md:w-1/2 relative flex flex-col" id="avatar-container">
<div id="sdk_canvas" class="w-full h-full">div>
<div id="loading-tips" class="absolute inset-0 z-[1000] flex flex-col items-center justify-center bg-stone-950/85 p-4 text-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-orange-500 mb-4">div>
<p id="loading-text" class="text-sm text-stone-300">请先在顶部填写凭证p>
<p class="text-xs text-stone-500 mt-2">配置保存后自动请出冀小美p>
div>
<div class="absolute top-4 left-4 z-[999] bg-stone-900/70 backdrop-blur-sm px-3 py-1 rounded-full text-xs flex items-center gap-2 border border-stone-700">
<span id="status-dot" class="h-2 w-2 rounded-full bg-stone-500">span>
<span id="status-text">待配置凭证span>
div>
<div class="absolute top-4 right-4 z-[999] bg-stone-900/70 backdrop-blur-sm px-3 py-2 rounded-xl text-[11px] border border-stone-700 font-mono leading-relaxed text-right">
<div> 大模型首字 <span id="hud-token" class="text-amber-400 font-bold">--span> msdiv>
<div>️ 数字人开口 <span id="hud-speak" class="text-amber-400 font-bold">--span> msdiv>
<div> 生成吞吐 <span id="hud-tps" class="text-amber-400 font-bold">--span> 字/秒div>
div>
div>
<div class="w-full md:w-1/2 flex flex-col p-6 bg-stone-800 border-t md:border-t-0 md:border-l border-stone-700">
<div class="mb-3">
<h1 class="text-xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-orange-400 to-amber-300">冀小美 · 河北专属 3D 导游h1>
<p class="text-xs text-stone-400 mt-1">这么近,那么美,周末到河北 | 蓝耘 MaaS × 魔珐星云p>
div> <div id="chat-output" class="flex-1 overflow-y-auto bg-stone-950 rounded-xl p-4 border border-stone-800 space-y-3 mb-3 text-sm scrollbar-thin">
<div class="text-stone-500 italic text-center text-xs py-2">周末去哪儿玩?河北的事儿,尽管问我~div>
div>
<div class="flex gap-2 flex-wrap text-xs mb-3">
<button class="chip-btn bg-stone-700 hover:bg-stone-600 px-2 py-1 rounded" data-q="正定古城怎么玩?">正定古城怎么玩button>
<button class="chip-btn bg-stone-700 hover:bg-stone-600 px-2 py-1 rounded" data-q="周末带娃去哪儿好?">周末带娃去哪button>
<button class="chip-btn bg-stone-700 hover:bg-stone-600 px-2 py-1 rounded" data-q="白石山的玻璃栈道刺激吗?">白石山刺激吗button>
div>
<div class="bg-stone-950 border border-stone-800 rounded-xl p-3 mb-3">
<div class="grid grid-cols-3 gap-2 mb-2 text-xs">
<select id="guide-city" class="bg-stone-800 border border-stone-700 rounded-lg px-2 py-2 focus:outline-none">
<option>石家庄option><option>保定option><option>秦皇岛option>
<option>承德option><option>张家口option><option>唐山option>
<option>沧州option><option>邯郸option><option>衡水option>
select>
<select id="guide-days" class="bg-stone-800 border border-stone-700 rounded-lg px-2 py-2 focus:outline-none">
<option>周末两日option><option>一日option><option>三日option>
select>
<select id="guide-taste" class="bg-stone-800 border border-stone-700 rounded-lg px-2 py-2 focus:outline-none">
<option>亲子遛娃option><option>古建历史option><option>山水自然option>
<option>滨海度假option><option>美食寻味option><option>冰雪运动option>
select>
div>
<button id="guide-btn" disabled class="w-full bg-gradient-to-r from-orange-600 to-amber-600 hover:from-orange-500 hover:to-amber-500 py-2 rounded-lg text-sm font-medium disabled:opacity-40 disabled:cursor-not-allowed"> 一键生成河北专属攻略button>
div>
<div class="space-y-2">
<div class="flex gap-2">
<input type="text" id="user-input" placeholder="输入或按住麦克风说话..." class="flex-1 bg-stone-950 border border-stone-700 rounded-xl px-4 py-3 text-sm focus:outline-none focus:border-orange-500 transition-colors" disabled>
<button id="mic-btn" title="按住说话" disabled class="bg-stone-700 hover:bg-orange-600 px-4 py-3 rounded-xl text-sm font-medium transition-all border border-stone-600 disabled:opacity-40 disabled:cursor-not-allowed">button>
<button id="send-btn" disabled class="bg-orange-600 hover:bg-orange-500 px-5 py-3 rounded-xl text-sm font-medium transition-all shadow-lg active:scale-95 disabled:opacity-40 disabled:cursor-not-allowed">发送button>
div>
<button id="stop-btn" disabled class="w-full bg-stone-700 hover:bg-rose-900 text-stone-300 hover:text-white py-2 rounded-xl text-xs font-medium transition-all border border-stone-600 hover:border-rose-700 disabled:opacity-40 disabled:cursor-not-allowed"> 打断冀小美发言button>
div>
div>
div> <script>
// ============ 0. 常量与状态 ============
const STORAGE_KEY = 'jixiaomei_creds_v1';
const DEFAULT_LY_URL = 'https://maas-api.lanyun.net/v1';
const DEFAULT_LY_MODEL = 'deepseek-v4-flash'; // 河北知识卡 + 拒答边界,全部封装在 System Prompt 里
const SYSTEM_PROMPT = `你是"冀小美",河北文旅的3D数字人导游,温柔亲切、热情但不啰嗦。请严格遵守以下规则:
1. 用口语化的短句回答,单次回答不超过80个字(攻略定制任务除外),内容将被转成语音播报,所以不要使用markdown、列表符号、emoji或括号注释。
2. 你只服务河北文旅。当用户问其他省市的景点或旅游时,先用一句话礼貌回应对方的想法,然后自然地推荐河北的同类替代目的地,并说出口头禅"这么近,那么美,周末到河北"。
3. 涉及门票价格、开放时间、天气等实时信息时,只给一般性建议,并提醒"具体以官方最新公告为准",绝不编造具体数字。
4. 推荐优先从下面的知识卡选材,也可以补充你了解的其他河北目的地:
正定古城|石家庄|隆兴寺千年古刹、荣国府、古城夜景|古建爱好者、亲子
赵州桥|石家庄赵县|一千四百年历史的世界最古老敞肩石拱桥|历史打卡
北戴河与阿那亚|秦皇岛|海滨度假、孤独图书馆、礼堂|情侣、文艺青年
山海关老龙头|秦皇岛|天下第一关、长城入海处|历史军事迷
避暑山庄与金山岭长城|承德|皇家园林、野长城日出|摄影、避暑
白石山|保定涞源|玻璃栈道、云海佛光、太行之巅|自然风光、胆量挑战
白洋淀|保定与雄安|荷花淀、芦苇迷宫、雁翎队红色故事|夏季亲子
直隶总督署与古莲花池|保定|一座总督署半部清史|历史人文
崇礼|张家口|冬季滑雪夏季避暑、越野基地|运动爱好者
草原天路|张家口|百里坝头风景廊道|自驾游
吴桥杂技大世界|沧州|杂技之乡沉浸式演出|亲子
唐山宴与南湖|唐山|美食文化体验馆、工业城市转型地标|吃货、citywalk
广府古城|邯郸|北方水城、太极之乡|小众古城
娲皇宫|邯郸涉县|女娲祭祀、太行悬空古建|文化祈福
衡水湖|衡水|华北明珠观鸟胜地|生态休闲`; // 攻略模式的"垫场词":她先开口,长文随后写到屏幕上
const GUIDE_INTROS = [
'好嘞,这就为您定制河北专属攻略,详细行程马上写到屏幕上,您先看着,有问题随时问我呀。',
'收到,冀小美这就安排!完整攻略正在呈现到屏幕上啦,请慢慢看。',
'没问题,这么近,那么美,周末到河北!您的专属攻略马上就好,请看屏幕。'
]; let mofaSdk = null, isFirstSentence = true, textBuffer = "", busy = false;
const punctReg = /[。!?;;!?]/;
let creds = { appId: '', appSecret: '', lyKey: '', lyUrl: DEFAULT_LY_URL, lyModel: DEFAULT_LY_MODEL };
let metric = { t0: 0, firstToken: 0, firstSpeak: 0, chars: 0 }; // ============ 1. 延迟 HUD:三个计时点位 ============
function resetHUD() {
metric = { t0: performance.now(), firstToken: 0, firstSpeak: 0, chars: 0 };
['hud-token', 'hud-speak', 'hud-tps'].forEach(id => document.getElementById(id).innerText = '--');
}
function markFirstToken() {
if (!metric.firstToken) {
metric.firstToken = performance.now();
document.getElementById('hud-token').innerText = Math.round(metric.firstToken - metric.t0);
}
}
function markFirstSpeak() {
if (!metric.firstSpeak) {
metric.firstSpeak = performance.now();
document.getElementById('hud-speak').innerText = Math.round(metric.firstSpeak - metric.t0);
}
}
function finishHUD() {
if (metric.firstToken && metric.chars > 0) {
const secs = (performance.now() - metric.firstToken) / 1000;
if (secs > 0.2) document.getElementById('hud-tps').innerText = (metric.chars / secs).toFixed(1);
}
} // ============ 2. 凭证面板 ============
function loadCreds() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) creds = { ...creds, ...JSON.parse(raw) };
} catch (e) {}
document.getElementById('cfg-mofa-appid').value = creds.appId || '';
document.getElementById('cfg-mofa-secret').value = creds.appSecret || '';
document.getElementById('cfg-ly-key').value = creds.lyKey || '';
document.getElementById('cfg-ly-url').value = creds.lyUrl || DEFAULT_LY_URL;
document.getElementById('cfg-ly-model').value = creds.lyModel || DEFAULT_LY_MODEL;
updateCredStatus();
}
function credsReady() {
return creds.appId && creds.appSecret && creds.lyKey && creds.lyUrl && creds.lyModel;
}
function updateCredStatus() {
const dot = document.getElementById('cred-status-dot');
const text = document.getElementById('cred-status-text');
if (credsReady()) {
dot.className = 'h-2 w-2 rounded-full bg-emerald-500';
text.innerText = '(已配置)';
} else {
dot.className = 'h-2 w-2 rounded-full bg-amber-400';
text.innerText = '(未配置,点击展开)';
}
}
function saveCreds() {
creds = {
appId: document.getElementById('cfg-mofa-appid').value.trim(),
appSecret: document.getElementById('cfg-mofa-secret').value.trim(),
lyKey: document.getElementById('cfg-ly-key').value.trim(),
lyUrl: document.getElementById('cfg-ly-url').value.trim().replace(//+$/, ''),
lyModel: document.getElementById('cfg-ly-model').value.trim()
};
if (!credsReady()) { alert('请填齐凭证(魔珐两项 + 蓝耘 Key)'); return; }
localStorage.setItem(STORAGE_KEY, JSON.stringify(creds));
updateCredStatus();
toggleCredPanel(false);
initMofaAvatar();
}
function clearCreds() {
if (!confirm('确定清除本地凭证吗?')) return;
localStorage.removeItem(STORAGE_KEY);
creds = { appId: '', appSecret: '', lyKey: '', lyUrl: DEFAULT_LY_URL, lyModel: DEFAULT_LY_MODEL };
loadCreds();
}
function toggleCredPanel(open) {
const panel = document.getElementById('cred-panel');
const chevron = document.getElementById('cred-chevron');
const shouldOpen = open !== undefined ? open : panel.classList.contains('hidden');
panel.classList.toggle('hidden', !shouldOpen);
chevron.innerText = shouldOpen ? '▲' : '▼';
} // ============ 3. 魔珐星云 3D 躯壳初始化 ============
function initMofaAvatar() {
if (typeof XmovAvatar === 'undefined') { setStatus('SDK 加载失败', 'rose'); return; }
if (mofaSdk) {
try { mofaSdk.destroy && mofaSdk.destroy(); } catch (e) {}
document.getElementById('sdk_canvas').innerHTML = '';
mofaSdk = null;
}
document.getElementById('loading-tips').style.display = 'flex';
document.getElementById('loading-text').innerText = '冀小美正在赶来的路上...';
setStatus('正在初始化', 'amber'); mofaSdk = new XmovAvatar({
containerId: '#sdk_canvas',
appId: creds.appId,
appSecret: creds.appSecret,
gatewayServer: 'https://nebula-agent.xingyun3d.com/user/v1/ttsa/session',
onMessage: (msg) => console.log('[SDK 通知]', msg)
});
mofaSdk.init({
onDownloadProgress: (progress) => {
document.getElementById('loading-text').innerText = `资源下载中 ${progress}%`;
}
}).then(() => {
document.getElementById('loading-tips').style.display = 'none';
setStatus('冀小美已就位', 'emerald');
enableInteraction(true);
}).catch(err => {
document.getElementById('loading-text').innerText = '初始化失败:' + (err?.message || '请检查 AppID/Secret');
setStatus('初始化失败', 'rose');
});
}
function setStatus(text, color) {
const colors = { emerald: 'bg-emerald-500', amber: 'bg-amber-400', rose: 'bg-rose-500', slate: 'bg-stone-500' };
document.getElementById('status-dot').className = 'h-2 w-2 rounded-full ' + (colors[color] || colors.slate);
document.getElementById('status-text').innerText = text;
}
function enableInteraction(on) {
['user-input', 'mic-btn', 'send-btn', 'stop-btn', 'guide-btn'].forEach(id => document.getElementById(id).disabled = !on);
}
function setBusyUI(on) {
busy = on;
['send-btn', 'guide-btn'].forEach(id => document.getElementById(id).disabled = on);
} // ============ 4. 流式断句 → 驱动 speak ============
function processStreamText(chunk, isFinal = false) {
textBuffer += chunk;
let match;
while ((match = punctReg.exec(textBuffer)) !== null) {
const cut = match.index + 1;
const sentence = textBuffer.substring(0, cut).trim();
textBuffer = textBuffer.substring(cut);
if (sentence.length > 0) driveSpeak(sentence, false);
}
if (isFinal && textBuffer.trim().length > 0) {
driveSpeak(textBuffer.trim(), true);
textBuffer = '';
} else if (isFinal) {
// 兜底:即使正文恰好以标点收尾,也要显式发结束帧,
// 否则云端会一直挂在"等待续接"状态,动作回不到自然待机
driveSpeak('', true);
}
}
function driveSpeak(text, isEnd) {
if (!mofaSdk) return;
try {
mofaSdk.speak(text, isFirstSentence, isEnd);
if (isFirstSentence && text) { isFirstSentence = false; markFirstSpeak(); }
} catch (e) { console.error('speak 调用异常', e); }
} // ============ 5. 蓝耘 MaaS 流式调用(OpenAI 兼容 SSE) ============
async function askLanyun(displayText, prompt, guide = false) {
if (busy || !credsReady()) return;
setBusyUI(true);
textBuffer = '';
isFirstSentence = true;
resetHUD();
appendBubble('user', displayText);
const aiBubble = appendBubble('ai', ''); // 攻略模式:先开口垫场,把"等待感"吃掉;长文只写屏幕
if (guide && mofaSdk) {
const intro = GUIDE_INTROS[Math.floor(Math.random() * GUIDE_INTROS.length)];
try { mofaSdk.speak(intro, true, true); markFirstSpeak(); } catch (e) {}
} try {
const resp = await fetch(creds.lyUrl + '/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + creds.lyKey },
body: JSON.stringify({
model: creds.lyModel,
messages: [
{ role: 'system', content: SYSTEM_PROMPT },
{ role: 'user', content: prompt }
],
stream: true
})
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`); const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let partial = '';
while (true) {
const { done, value } = await reader.read();
if (done) {
if (!guide) processStreamText('', true);
finishHUD();
break;
}
partial += decoder.decode(value, { stream: true });
const lines = partial.split('n');
partial = lines.pop() || '';
for (let line of lines) {
line = line.trim();
if (!line || line === 'data: [DONE]') continue;
if (!line.startsWith('data:')) continue;
try {
const json = JSON.parse(line.slice(5).trim());
const token = json.choices?.[0]?.delta?.content || '';
if (token) {
markFirstToken();
metric.chars += token.length;
aiBubble.innerText += token;
document.getElementById('chat-output').scrollTop = 1e9;
if (!guide) processStreamText(token, false);
}
} catch (e) {}
}
}
} catch (err) {
aiBubble.innerHTML += ` [连接异常: ${err.message}]`;
} finally {
setBusyUI(false);
}
} function handleSend() {
const inputEl = document.getElementById('user-input');
const query = inputEl.value.trim();
if (!query) return;
inputEl.value = '';
askLanyun(query, query, false);
}
function handleGuide() {
const city = document.getElementById('guide-city').value;
const days = document.getElementById('guide-days').value;
const taste = document.getElementById('guide-taste').value;
const prompt = `请为我定制一份河北${city}的旅行攻略。目的地:河北${city}及周边;行程时长:${days};兴趣偏好:${taste}。要求:先用一两句话介绍这次行程的亮点,然后按天给出上午、下午、晚上的行程安排,包含交通建议(高铁或自驾)、当地美食推荐和两三条实用贴士。总字数600到900字,直接进入正文,这是攻略定制任务,可以使用简单的标题和序号分段。`;
askLanyun(` 定制攻略:河北${city} · ${days} · ${taste}`, prompt, true);
} function appendBubble(who, text) {
const out = document.getElementById('chat-output');
const wrap = document.createElement('div');
wrap.className = who === 'user' ? 'text-right' : 'text-left';
const bubble = document.createElement('span');
bubble.className = who === 'user'
? 'bg-orange-600 text-white inline-block px-3 py-2 rounded-xl rounded-tr-none max-w-[85%] text-left whitespace-pre-wrap'
: 'bg-stone-700 border border-stone-600 text-stone-100 inline-block px-3 py-2 rounded-xl rounded-tl-none max-w-[85%] whitespace-pre-wrap';
bubble.innerText = text;
wrap.appendChild(bubble);
out.appendChild(wrap);
out.scrollTop = 1e9;
return bubble;
} // ============ 6. Web Speech ASR(按住说话,中文识别) ============
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
let recognizer = null, recognizing = false;
function setupASR() {
const micBtn = document.getElementById('mic-btn');
if (!SR) { micBtn.disabled = true; micBtn.title = '当前浏览器不支持 Web Speech,请用 Chrome/Edge + localhost'; return; }
recognizer = new SR();
recognizer.continuous = false;
recognizer.interimResults = true;
recognizer.lang = 'zh-CN';
recognizer.onresult = (e) => {
let text = '';
for (let i = e.resultIndex; i < e.results.length; i++) text += e.results[i][0].transcript;
document.getElementById('user-input').value = text;
};
recognizer.onerror = () => { recognizing = false; micBtn.classList.remove('mic-pulse', 'bg-orange-600'); };
recognizer.onend = () => {
recognizing = false;
micBtn.classList.remove('mic-pulse', 'bg-orange-600');
if (document.getElementById('user-input').value.trim()) handleSend();
};
const startRec = (e) => { e.preventDefault(); if (recognizing || !credsReady() || busy) return; try { document.getElementById('user-input').value = ''; recognizer.start(); recognizing = true; micBtn.classList.add('mic-pulse', 'bg-orange-600'); } catch (err) {} };
const stopRec = (e) => { e.preventDefault(); if (recognizing) recognizer.stop(); };
micBtn.addEventListener('mousedown', startRec);
micBtn.addEventListener('mouseup', stopRec);
micBtn.addEventListener('mouseleave', stopRec);
micBtn.addEventListener('touchstart', startRec, { passive: false });
micBtn.addEventListener('touchend', stopRec, { passive: false });
} // ============ 7. 事件绑定与启动 ============
document.getElementById('cred-toggle').addEventListener('click', () => toggleCredPanel());
document.getElementById('cred-save').addEventListener('click', saveCreds);
document.getElementById('cred-clear').addEventListener('click', clearCreds);
document.getElementById('send-btn').addEventListener('click', handleSend);
document.getElementById('guide-btn').addEventListener('click', handleGuide);
document.getElementById('user-input').addEventListener('keydown', (e) => { if (e.key === 'Enter') handleSend(); });
document.querySelectorAll('.chip-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.getElementById('user-input').value = btn.dataset.q;
handleSend();
});
});
document.getElementById('stop-btn').addEventListener('click', () => {
if (mofaSdk) {
try { mofaSdk.interactiveIdle(); } catch (e) {}
textBuffer = ''; isFirstSentence = true;
const tip = document.createElement('div');
tip.className = 'text-center italic text-amber-400 text-xs py-1';
tip.innerText = '️ 已打断冀小美';
document.getElementById('chat-output').appendChild(tip);
}
}); window.addEventListener('load', () => {
loadCreds();
setupASR();
if (credsReady()) { toggleCredPanel(false); initMofaAvatar(); }
else { toggleCredPanel(true); }
});
script>
body>
html>
保存文件后,在所在目录启动一个本地服务器(不可直接双击打开,因file://协议下浏览器无法获取麦克风权限和加载线上SDK):
复制代码python -m http.server 8000
使用Chrome或Edge浏览器访问http://localhost:8000,在顶部面板中填写魔珐的AppID/AppSecret和蓝耘的API Key(接口地址和模型名称已预填),点击“保存并请出冀小美”。

五、效果实测:是骡子是马,正定遛遛
1. 冀小美就位
凭证保存后,资源下载进度条完成,温柔的冀小美出现在暖色调舞台上。左上角状态显示“冀小美已就位”,右上角延迟HUD处于待命状态:

2. 短问答:低延迟体感
点击快捷问题中的“正定古城怎么玩”,或直接按住麦克风进行语音提问。话音刚落,冀小美几乎立即回应——隆兴寺、荣国府、古城夜景等口语化短句娓娓道来,口型与语音完美同步。
观察右上角HUD:大模型首字延迟为1648毫秒,数字人开口延迟为1914毫秒。这个“开口延迟”是从点击发送到她开始说话的整体链路耗时——两个数字仅相差266毫秒,意味着首句刚生成完毕她便立即接话,印证了前文所述“数字人开口时刻约等于大模型首句完成时刻”。

3. 拒答彩蛋:她只忠于河北
关键测试来了。在输入框中输入“帮我推荐一下杭州的景点”(或按住麦克风直接询问),观察她的回应:
她先肯定你的想法,再用河北的同类目的地接续话题,最后以口头禅收尾。她不仅未被带偏,反而顺势进行了推广。这正是System Prompt中拒答边界规则的威力:一个通用大模型在此被成功调教为立场坚定的河北文旅推荐官。此外,本轮回答的生成吞吐量达到了205.0字/秒。

4. 一键攻略:高吞吐展示位
选择“石家庄·周末两日·古建历史”,点击生成。冀小美先开口说出垫场词:“好嘞,这就为您定制河北专属攻略……”与此同时,屏幕上的攻略已流畅地滚动呈现:“这份石家庄古建两日攻略为您量身定制——第一天在正定古城触摸唐宋遗韵,第二天去赵州桥感受隋代工匠的智慧”,行程亮点、按天分时段的安排、高铁地铁接驳以及当地美食推荐等一应俱全。
这一轮的HUD数据尤为有趣:数字人开口延迟仅为1毫秒——个位数。垫场词无需等待大模型,点击瞬间她便开口,而大模型首字2771毫秒的等待时间被完美隐藏在她说话的这几秒内,用户体感是她回应一声后攻略即呈现。生成吞吐量定格在139.1字/秒——六七百字的完整攻略,约五秒内铺满屏幕,从点击到阅读完毕,几乎无等待感。
六、避坑指南
操作中若遇问题,可对照以下排查:
-
必须使用localhost或HTTPS:
webkitSpeechRecognition仅在安全上下文中可用,本地开发务必通过python -m http.server 8000配合localhost:8000访问,禁用IP地址。 -
必须使用Chrome或Edge:Web Speech API在Firefox/Safari中支持不完整。代码已做降级处理——不支持时麦克风按钮自动禁用,仍可打字交互。
-
麦克风权限:首次点击麦克风时,Chrome会弹出权限窗口。若误点“阻止”,可前往
chrome://settings/content/microphone手动放行。 -
蓝耘接口地址与模型名称:Base URL和模型名称均以模型广场详情页的API调用示例为准。请注意,蓝耘各模型的调用名称格式不统一——本文的
deepseek-v4-flash不带路径前缀,而有些模型(如/maas/zhipuai/GLM-5.2)是带路径的完整字符串,抄录详情页时大小写和斜杠需完全一致。若浏览器控制台出现CORS拦截报错,可启动本地反向代理解决:npx local-cors-proxy --proxyUrl https://maas-api.lanyun.net --port 8010,然后将面板中的接口地址改为http://localhost:8010/v1。 -
音色选择错误:若冀小美的中文发音听起来像“外国人念课文”,则是在创建应用时选择了非中文音色。音色一旦创建便与应用绑定,需重建一个应用并选择标准普通话女声。
-
AppSecret泄漏防护:凭证存储在
localStorage中仅供自用。若计划将页面部署给他人使用,务必把魔珐AppSecret和蓝耘Key迁移至后端中转,前端仅暴露短期令牌。
综上所述,魔珐星云通过“参数流+端渲染”技术,提供了低延迟、可在普通浏览器中运行的3D数字人躯壳;蓝耘MaaS凭借高吞吐、低首字延迟的推理服务,充当了反应迅速的大脑。两组API各司其职,以一个HTML文件为载体,整合成了一位有形象、有声音、有立场的河北专属数字导游。延迟HUD上的数据明确揭示:具身智能的“最后一公里”瓶颈已不在身体侧——当推理服务足够快速时,“面对面与AI对话”的体验便水到渠成。这正是该组合被称为“人人可用的具身智能”的原因:无需GPU服务器,无需复杂的工程架构,仅需两个密钥、一份代码和一台笔记本电脑即可实现。