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

时间:2026-07-24 07:51:45 来源:互联网

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

img_6a62a9103c7a230.webp

@[toc]


两年前,我们面对聊天框输入文字,AI返回的是一段段文本;一年前,开始聆听AI的“语音消息”,仿佛在和一位不太熟悉的微信好友交流;而如今,你可以让一位具备表情、眼神和精准口型的3D数字人,在浏览器中与你进行实时对话,你话音刚落,她便能无缝接话。

本文中,我将借助两组API——蓝耘MaaS(负责大脑)与魔珐星云(负责身体)——带你完整实现这一过程,打造一个人人可复现、仅需一台普通笔记本即可运行的具身智能应用。

具体做什么呢?正好可以借用一个文旅圈的热门梗:“这么近,那么美,周末到河北。”

河北文旅近年来热度颇高,但想认真规划一次旅行,仍需费心搜索攻略:在社交平台翻半天,看到的多是广告;向大模型咨询,得到的则是冷冰冰的长篇文字。如果有一位温柔的河北导游小姐姐,能当面为你介绍正定古城的玩法、白石山玻璃栈道的刺激程度,还能一键生成周末两天的行程,那该多好?

因此,本文的主角诞生了——河北专属3D导游“冀小美”(冀为河北简称,美取自“那么美”)。她具备以下能力:

  1. 按住说话即可提问,无需打字,张嘴即达;
  2. 专注于河北文旅:询问其他省份景点时,她会温柔地引导你——“这么近,那么美,周末到河北呀”
  3. 一键生成专属攻略:选择出发地、天数和兴趣偏好,长篇行程便会流畅地显示在屏幕上;
  4. 延迟数据实时可见:页面右上角设有HUD,清晰展示大模型首字延迟、数字人开口延迟和生成吞吐量。

全部代码仅为一个HTML文件,现在开始构建。


一、这套组合凭什么能打?

首先明确架构,避免“调用了两个API却不知其运作原理”。

“身体”层面,魔珐星云已大幅降低了技术门槛。传统3D数字人采用“云端渲染+视频推流”模式,延迟高且对带宽敏感。魔珐星云则采用参数流+端渲染方式:云端仅下发面部肌肉参数、骨骼动画参数和PCM音频参数,浏览器SDK利用本地显卡在WebGL中实时渲染。由于传输数据量小,官方标称双向响应时间约为1200毫秒,普通笔记本的Chrome浏览器即可流畅运行,口型与音频在同一帧内下发,实现了天然同步。

“大脑”层面,智能模型并不稀缺,但快速稳定的推理服务至关重要。这正是蓝耘MaaS的定位:其模型广场提供了DeepSeek、GLM等主流旗舰模型,采用标准OpenAI兼容接口,按Token计费,注册充值后即可使用。

而决定整体体验的关键,在于连接大脑与身体的“神经”。以下是完整的交互链路:

 复制代码你的语音 → 浏览器ASR → 蓝耘MaaS(流式生成) → 前端断句 → 魔珐 speak()
                                                              ↓
        浏览器WebGL渲染数字人 ← TTSA参数流(面部肌肉 + 骨骼 + 音频)

请注意一个关键点:数字人开口的时刻约等于大模型首句生成完成的时刻。当魔珐将身体侧延迟压缩至1200毫秒级别后,整条链路的瓶颈便转移至推理服务——首字延迟每增加一秒,数字人形象再生动,也会显得像反应迟钝的木偶。

而“导游”这一场景,对推理服务提出了双重考验

  1. 短问答考验低延迟:“白石山值得去吗?”——当面提问,回答稍有延迟便会穿帮;
  2. 长攻略考验高吞吐:“帮我规划一个周末两天的行程”——六七百字的攻略,吞吐量不足将导致用户煎熬等待。

这正是我选择蓝耘的原因。根据AI Ping的实测数据,蓝耘MaaS上主流模型的首字延迟和吞吐量均表现优异:

img_6a62a9105d20031.webp

官方数据之外,本文的Demo还内置了一个延迟计时HUD,实时记录每一轮对话的首字延迟、开口延迟和生成吞吐量。蓝耘的速度究竟如何,让我们用秒表来验证。


二、第一组API:三分钟拿到蓝耘MaaS凭证

1. 注册登录

访问蓝耘控制台完成注册。

2. 创建API Key

登录后,在左侧导航栏找到API管理/密钥管理,点击“创建API Key”,生成后复制并妥善保存。

img_6a62a9107788a32.webp

3. 模型广场锁定deepseek-v4-flash

点击左侧【模型广场】,找到deepseek-v4-flash

img_6a62a9108a80633.webp

打开详情页,记录其在蓝耘平台的调用名称:

 复制代码deepseek-v4-flash

为何选择此模型?导游场景需满足三项要求:中文知识底蕴(能准确讲解河北景点典故)、长文规划能力(攻略需条理清晰)、流式输出速度。DeepSeek V4 Flash的官方定位为“低成本高速模型,适合大规模调用和高并发”——前两项有DeepSeek V4的基础支撑,第三项是其核心优势,与“面对面对话怕慢半拍”的导游场景高度契合。其价格更为亲民:输入1元/百万Tokens,输出2元/百万Tokens,完整运行本文Demo也花费甚微。当然,蓝耘模型广场的其他模型也可通过一个字段轻松切换,文末将提及。

至此,蓝耘侧所需的三件套已准备齐全:

配置项
API Keysk-xxxxxxxx(你自己的密钥)
接口地址(OpenAI兼容端点)
模型名称deepseek-v4-flash

三、第二组API:魔珐星云开户,请出「冀小美」本尊

第一步:注册账号

打开官网:

 复制代码https://xingyun3d.com/

点击右上角“登录/注册”。注册时“邀请码”一栏可填写JM8ABNY834,领取1000积分作为新人额度,足以体验本文Demo及更多功能。

第二步:创建具身智能应用

进入控制台,点击“创建应用”,依次进行选择:

img_6a62a910a512834.webp

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

img_6a62a910b616635.webp

创建完成后,进入应用详情页,获取以下两个关键字段:

  1. App ID — 应用唯一标识;
  2. App Secret — 应用密钥。

img_6a62a910c8f0836.webp

复制并妥善保存,App Secret需确保安全。

至此,两组API的凭证已全部获取,共计五个值:蓝耘的Key、接口地址、模型名称,以及魔珐的App ID和App Secret。接下来进入核心环节。


四、硬核实操:一个HTML文件请出河北专属导游

在编写代码之前,先说明本Demo的六个设计决策,每个都有其考量:

  1. 河北知识卡注入:在System Prompt中嵌入一份精选的河北文旅知识卡(涵盖正定、山海关、白洋淀、崇礼、白石山等景点,包含亮点及适合人群),以提升回答的“河北浓度”并减少幻觉;
  2. 拒答边界:Prompt中明确规定“只服务河北文旅”——用户询问外省景点时,她会先礼貌回应,再自然推荐河北的同类替代,并附带口头禅。通用大模型如何调教为“专属”角色,关键即在此处;
  3. 双负载分流:聊天模式下,大模型流式输出逐句驱动数字人开口(考验低延迟);攻略模式下,她先口述一句垫场词,长文仅显示在屏幕上(考验高吞吐)——六七百字的攻略若逐句念完,需耗时数分钟,正确做法是眼睛阅读全文、耳朵聆听重点,垫场词还能巧妙隐藏长文首字等待时间;
  4. 延迟HUD:记录三个时间点,实时显示大模型首字延迟、数字人开口延迟和生成吞吐量;
  5. 按住说话+随时打断:使用浏览器原生Web Speech中文识别,松手自动发送;打断按钮可随时停止她的发言;
  6. 凭证不硬编码:五个凭证从页面面板填写并存入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(接口地址和模型名称已预填),点击“保存并请出冀小美”。

img_6a62a910d9c2237.webp


五、效果实测:是骡子是马,正定遛遛

1. 冀小美就位

凭证保存后,资源下载进度条完成,温柔的冀小美出现在暖色调舞台上。左上角状态显示“冀小美已就位”,右上角延迟HUD处于待命状态:

img_6a62a910ea78238.webp

2. 短问答:低延迟体感

点击快捷问题中的“正定古城怎么玩”,或直接按住麦克风进行语音提问。话音刚落,冀小美几乎立即回应——隆兴寺、荣国府、古城夜景等口语化短句娓娓道来,口型与语音完美同步。

观察右上角HUD:大模型首字延迟为1648毫秒,数字人开口延迟为1914毫秒。这个“开口延迟”是从点击发送到她开始说话的整体链路耗时——两个数字仅相差266毫秒,意味着首句刚生成完毕她便立即接话,印证了前文所述“数字人开口时刻约等于大模型首句完成时刻”。

img_6a62a9110e9a839.webp

3. 拒答彩蛋:她只忠于河北

关键测试来了。在输入框中输入“帮我推荐一下杭州的景点”(或按住麦克风直接询问),观察她的回应:

她先肯定你的想法,再用河北的同类目的地接续话题,最后以口头禅收尾。她不仅未被带偏,反而顺势进行了推广。这正是System Prompt中拒答边界规则的威力:一个通用大模型在此被成功调教为立场坚定的河北文旅推荐官。此外,本轮回答的生成吞吐量达到了205.0字/秒

img_6a62a91125fe8310.webp

4. 一键攻略:高吞吐展示位

选择“石家庄·周末两日·古建历史”,点击生成。冀小美先开口说出垫场词:“好嘞,这就为您定制河北专属攻略……”与此同时,屏幕上的攻略已流畅地滚动呈现:“这份石家庄古建两日攻略为您量身定制——第一天在正定古城触摸唐宋遗韵,第二天去赵州桥感受隋代工匠的智慧”,行程亮点、按天分时段的安排、高铁地铁接驳以及当地美食推荐等一应俱全。

这一轮的HUD数据尤为有趣:数字人开口延迟仅为1毫秒——个位数。垫场词无需等待大模型,点击瞬间她便开口,而大模型首字2771毫秒的等待时间被完美隐藏在她说话的这几秒内,用户体感是她回应一声后攻略即呈现。生成吞吐量定格在139.1字/秒——六七百字的完整攻略,约五秒内铺满屏幕,从点击到阅读完毕,几乎无等待感。


六、避坑指南

操作中若遇问题,可对照以下排查:

  1. 必须使用localhost或HTTPSwebkitSpeechRecognition仅在安全上下文中可用,本地开发务必通过python -m http.server 8000配合localhost:8000访问,禁用IP地址。

  2. 必须使用Chrome或Edge:Web Speech API在Firefox/Safari中支持不完整。代码已做降级处理——不支持时麦克风按钮自动禁用,仍可打字交互。

  3. 麦克风权限:首次点击麦克风时,Chrome会弹出权限窗口。若误点“阻止”,可前往chrome://settings/content/microphone手动放行。

  4. 蓝耘接口地址与模型名称: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

  5. 音色选择错误:若冀小美的中文发音听起来像“外国人念课文”,则是在创建应用时选择了非中文音色。音色一旦创建便与应用绑定,需重建一个应用并选择标准普通话女声。

  6. AppSecret泄漏防护:凭证存储在localStorage中仅供自用。若计划将页面部署给他人使用,务必把魔珐AppSecret和蓝耘Key迁移至后端中转,前端仅暴露短期令牌。


综上所述,魔珐星云通过“参数流+端渲染”技术,提供了低延迟、可在普通浏览器中运行的3D数字人躯壳;蓝耘MaaS凭借高吞吐、低首字延迟的推理服务,充当了反应迅速的大脑。两组API各司其职,以一个HTML文件为载体,整合成了一位有形象、有声音、有立场的河北专属数字导游。延迟HUD上的数据明确揭示:具身智能的“最后一公里”瓶颈已不在身体侧——当推理服务足够快速时,“面对面与AI对话”的体验便水到渠成。这正是该组合被称为“人人可用的具身智能”的原因:无需GPU服务器,无需复杂的工程架构,仅需两个密钥、一份代码和一台笔记本电脑即可实现。