这么近,那么美,周末到河北!我用蓝耘 MaaS × 魔珐星云,打造了一位河北专属 3D 导游「冀小美」

@toc
现在的 AI 交互,到底做到什么程度了?
两年前,我们对着聊天框打字,AI 回一堆文字;一年前,我们开始听 AI 的"语音条",像微信里那个不太熟的好友;而现在------你可以让一位有表情、有眼神、口型精准的 3D 数字人,在浏览器里跟你面对面聊天,你话音刚落,她就接上了话。
这篇文章,我就用两组 API------蓝耘 MaaS (大脑)和魔珐星云(身体)------带你把这件事完整做一遍,打造一个人人可复现、一台普通笔记本就能跑的具身智能应用。
做什么呢?正好蹭一个文旅圈的顶流热梗:"这么近,那么美,周末到河北。"
河北文旅这两年是真出圈了,但想认真玩一趟,攻略还是得自己刨:小红书翻半天全是广告,问大模型得到的是冷冰冰的长篇文字。要是有一位温柔的河北导游小姐姐,能当面给你讲正定古城怎么逛、白石山玻璃栈道刺不刺激,还能一键给你排好周末两天的行程呢?
于是就有了本文的主角------河北专属 3D 导游 「冀小美」 (冀,河北简称;美,取自"那么美")。她能做到:
- 🗣️ 按住说话就能问,张嘴即达,不用打字;
- 🏯 只忠于河北:你问她杭州,她会温柔地把你拉回来------"这么近,那么美,周末到河北呀";
- 🧾 一键生成专属攻略:选好出发地、天数、兴趣,长篇行程哗哗地流式写到屏幕上;
- ⚡ 延迟数据实时可见:页面右上角一个 HUD,大模型首字延迟、数字人开口延迟、生成吞吐,三个数字亮给你看。
全部代码就一个 HTML 文件。开整。
一、这套组合凭什么能打?
先把架构讲明白,避免"调了俩 API 但不知道自己在干嘛"。
"身体"这一侧,魔珐星云已经把难度打下来了。 传统 3D 数字人走的是"云端渲染 + 视频推流",延迟高、带宽敏感。魔珐星云走的是参数流 + 端渲染:云端只下发面部肌肉参数、骨骼动画参数和 PCM 音频参数,浏览器 SDK 用本地显卡在 WebGL 里实时渲染。传输量小,官方标称双向响应 1200ms 左右,普通笔记本的 Chrome 就能跑,嘴型和音频在同一帧里下发、天然对齐。
"大脑"这一侧,从来不缺聪明的模型,缺的是又快又稳的推理服务。 这就是蓝耘 MaaS 的位置:模型广场里 DeepSeek、GLM 等主流旗舰整整齐齐,标准 OpenAI 兼容接口,Token 按量计费,注册充值即用。
而真正决定体验的,是把大脑和身体连起来的那条 "神经" 。看一眼整条交互链路:
scss
你的语音 → 浏览器 ASR → 蓝耘 MaaS(流式生成) → 前端断句 → 魔珐 speak()
↓
浏览器 WebGL 渲染数字人 ← TTSA 参数流(面部肌肉 + 骨骼 + 音频)
注意一个关键事实:数字人开口的时刻 ≈ 大模型首句生成完成的时刻。 魔珐把身体侧延迟压到了 1200ms 级之后,整条链路的瓶颈就转移到了推理服务上------首字延迟慢一秒,数字人形象再灵动,看起来也像个慢半拍的木偶。
而"导游"这个场景,恰好对推理服务是双重考验:
- 短问答考低延迟:"白石山值得去吗?"------当面问导游,回答慢半拍立刻穿帮;
- 长攻略考高吞吐:"帮我排个周末两天行程"------六七百字的攻略,吞吐不够就是煎熬式等待。
这正是我选蓝耘的原因。根据 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(你自己的密钥) |
| 接口地址 | https://maas-api.lanyun.net/v1(OpenAI 兼容端点) |
| 模型名称 | deepseek-v4-flash |
三、第二组 API:魔珐星云开户,请出「冀小美」本尊
第一步:注册账号
打开官网👇
arduino
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
<!DOCTYPE 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">
<!-- 顶部凭证配置面板(可折叠 + localStorage 持久化) -->
<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">魔珐星云 AppID</label>
<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">魔珐星云 AppSecret</label>
<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 Key</label>
<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">localStorage</code> 中,不会上传到任何第三方。生产环境请把密钥迁移到后端中转。
</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]">
<!-- 左:3D 数字人 + 状态 + 延迟 HUD -->
<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>
<!-- 延迟 HUD:蓝耘快不快,秒表说话 -->
<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> ms</div>
<div>🗣️ 数字人开口 <span id="hud-speak" class="text-amber-400 font-bold">--</span> ms</div>
<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 += `<span class="text-rose-400"> [连接异常: ${err.message}]</span>`;
} 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):
bash
python -m http.server 8000
用 Chrome 或 Edge 访问 http://localhost:8000,在顶部面板填入魔珐的 AppID/AppSecret 和蓝耘的 API Key(接口地址和模型名已预填好),点击"保存并请出冀小美"。 
五、效果实测:是骡子是马,正定遛遛
1. 冀小美就位
凭证保存后,资源下载进度条走完,温柔的冀小美出现在暖色调的舞台上,左上角状态变为"冀小美已就位",右上角延迟 HUD 待命: 
2. 短问答:低延迟体感
点快捷问题"正定古城怎么玩",或者直接按住麦克风开口问。话音刚落,冀小美几乎立刻接话------隆兴寺、荣国府、古城夜景,口语化短句娓娓道来,口型和语音严丝合缝。
看右上角 HUD:大模型首字 1648 ms 、数字人开口 1914 ms 。这个"开口延迟"就是从点下发送到她张嘴说话的完整链路耗时------两个数字只差 266ms,也就是说首句刚生成完的瞬间她就接上了话,这就是第一节说的"数字人开口时刻 ≈ 大模型首句完成时刻"。 
3. 拒答彩蛋:她只忠于河北
重头戏来了。在输入框里打一句"帮我推荐一下杭州的景点"(或者按住麦克风直接问),看她怎么接:
"杭州的西湖和灵隐寺确实很美呢。不过如果您喜欢水乡古韵,我们河北的白洋淀荷花季和广府古城北方水城也很有韵味。这么近,那么美,周末到河北!"
先肯定你的想法,再用河北的同类目的地接住话头,最后口头禅收尾------不但没被带跑,还顺势营业。这就是 System Prompt 里那条拒答边界规则的威力:一个通用大模型,就这样被调教成了立场坚定的河北文旅推荐官。顺带一提,这轮回答的生成吞吐飙到了 205.0 字/秒 。 
4. 一键攻略:高吞吐展示位
选择"石家庄 · 周末两日 · 古建历史",点击生成。冀小美先开口垫场:"好嘞,这就为您定制河北专属攻略......"与此同时,屏幕上的攻略已经哗哗地流式铺开:"这份石家庄古建两日攻略为您量身定制------第一天在正定古城触摸唐宋遗韵,第二天去赵州桥感受隋代工匠的智慧",行程亮点、按天分时段的安排、高铁地铁接驳、真定府马家鸡这样的当地美食,一气呵成。
这一轮的 HUD 最有意思:数字人开口 1 ms ------没错,个位数。垫场词不需要等大模型,点击的瞬间她就开口了,而大模型首字的 2771 ms 等待被完整藏进了她说话的这几秒里,用户体感就是"她答应了一声,攻略就出来了"。生成吞吐定格在 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/proxy/v1。 -
音色选错了:如果冀小美说中文像"外国人念课文",是创建应用时音色选成了非中文音色------音色创建后跟应用绑定,重建一个应用换中文普通话女声即可。
-
AppSecret 泄漏防护 :凭证存
localStorage自用没问题;如果要把页面部署出去给别人用,务必把魔珐 AppSecret 和蓝耘 Key 挪到后端中转,前端只暴露短期 token。
总结
回看整个实验:魔珐星云 用"参数流 + 端渲染"提供了一具低延迟、可跑在普通浏览器里的 3D 躯壳,蓝耘 MaaS 用高吞吐、低首字延迟的推理服务提供了随叫随到的大脑,两组 API 各司其职,一个 HTML 文件把它们缝合成了一位有形象、有声音、有立场的河北专属导游。
延迟 HUD 上的数字则说明了一件事:具身智能的"最后一公里"已经不在身体侧了------当推理服务足够快,"面对面和 AI 说话"的体验就水到渠成。这也是为什么说这套组合是"人人可用的具身智能":没有 GPU 服务器,没有复杂工程,两个 Key、一份代码、一台笔记本,就够了。
这套架子还有很多可以继续折腾的方向:把知识卡换成实时接口(景区客流、高铁余票),让攻略真正"活"起来;给各地市做一排"文旅推荐官"矩阵;或者干脆把它部署到网页上,发给周末不知道去哪儿的朋友------
毕竟,这么近,那么美,周末到河北。