【魔珐星云 | 给语音智能体写一套状态机:按钮什么时候该亮?】

魔珐星云 | 给语音智能体写一套状态机:按钮什么时候该亮?

具身交互智能体开发笔记 · 第 02 篇

页面上只有四个按钮:发送、开始收音、停止收音、打断表达。

最开始,这件事很容易做:点哪个按钮,就调用哪个方法。

麻烦往往出在第二步。初始化还没完成,发送按钮先亮了;智能体明明还在说话,页面却显示"空闲";用户想让它停下来,点完以后才发现,只是关掉了麦克风。

链路还在跑,界面却已经跟不上了。沿着代码往回找,经常会发现:几个含义不同的"完成",最后都改了同一个变量。

上一篇聊过回调、播报和打断的区别。这一篇就把它们落到页面上:什么时候能发送,什么时候该收音,想打断时,按钮又该听谁的?

先把 isBusy 放到一边

假设页面只有一个变量:

js 复制代码
let isBusy = false;

开始识别时设成 true,请求完成时改回 false。听起来很合理,直到出现这个场景:

智能体正在说话,麦克风也正在收音。

它到底忙不忙?

对于"开始收音",它已经在收音,不能再启动一次;对于"停止收音",这个按钮应该可用;对于"打断表达",用户也应该能点。

写到这里,isBusy 就有点为难了。它只能回答"忙不忙",却回答不了"正在忙什么,还能做什么"。

先把状态拆开,按钮的逻辑反而会简单下来。

这一篇继续用魔珐星云的 XingyunAvatarAgent 来写。魔珐星云做的是端到端具身交互智能,感知、智脑和多模态表达可以通过这套 SDK 接进 Web 页面。对于想做接待助手、屏幕交互应用的人来说,这样更容易先把交互跑起来,再往里面加自己的业务。我们今天要补的,就是页面上这几个看似不起眼的按钮。

想边看边试,可以先打开官方的 SDK 教学与 Demo。代码、执行入口和日志都在里面,填好自己的 App ID、App Secret,完成初始化,就能顺着"表达""感知&大脑"里的示例试下去。特别值得留意的是:点下按钮以后,日志里的状态什么时候变了,画面又什么时候才真正结束。

还没有账号的话,注册魔珐星云时可以填邀请码 JSUAY3IS96 ,领取 1000 积分。有兴趣跟着动手的朋友,可以顺手用上。

再看这三条状态线,各自管的事情就清楚了:

状态来源 页面需要知道什么 本文会用到的示例值
Agent 当前能否发起交互 running、reconnecting
ASR 麦克风与识别处于什么阶段 idle、requesting-permission、starting、listening、stopping、failed
Conversation 对话最近进入了哪个阶段 asking、speaking、completed、interrupted

先记住表里这几个常见值就够了。asking 要多留个心眼:开始 ASR 时也可能收到它,仅凭这个名字,还不能断定模型已经在生成回答。

页面自己也有事要记。比如初始化 Promise 还没返回,或者用户刚点了开始收音,浏览器正在等授权。这些过程归应用自己管理,和 SDK 回传的状态分开存。

一份状态快照,别让回调各改各的按钮

先给这些状态找个统一的落脚点:

js 复制代码
function initialState() {
  return {
    phase: "empty",       // 应用生命周期,非 SDK 状态
    agent: "idle",        // SDK Agent 状态
    asr: "idle",          // SDK ASR 状态
    conversation: "idle", // 初始占位值,后续记录 SDK 回调
    pending: null,        // 应用正在提交的命令
    error: ""
  };
}
let state = initialState();

phase 记页面走到了哪一步:有没有实例,初始化是否完成,是不是正在清理。pending 记刚点下去的命令,比如"开始收音"还在等浏览器授权。

命令还没返回,与一轮对话还没结束,是两件事。

await agent.ask(text) 返回了,后面还可能有一长段话要说。模型发来文字的 done,也不等于声音和动作已经结束。这里如果抢先写入 completed,页面就会比智能体提前"下班"。

接下来,把所有状态变化收进 reduce。给它一个旧快照和一个新事件,让它算出下一份快照。先把最常走的几条路径写出来:

js 复制代码
function reduce(state, event) {
  switch (event.type) {
    case "INIT_BEGIN":
      return { ...initialState(), phase: "initializing" };
    case "INIT_OK":
      return { ...state, phase: "active" };
    case "SDK_AGENT":
      return { ...state, agent: event.value };
    case "SDK_ASR":
      return { ...state, asr: event.value };
    case "SDK_CONVERSATION":
      return { ...state, conversation: event.value };
    case "COMMAND_BEGIN":
      return { ...state, pending: event.name, error: "" };
    case "COMMAND_END":
      return { ...state, pending: null };
    case "ERROR":
      return { ...state, error: event.message };
    default:
      return state;
  }
}

INIT_OK、SDK_AGENT 是应用自己起的事件名。以后翻日志,一眼就能分清:这次是初始化走完了,还是 SDK 又报来一条状态。

更省心的是,回调从此只管更新快照。button.disabled = ... 不必散落在三个回调里,按钮也不会因为某条消息晚到一步,就被改回错误的样子。

按钮是否可用,交给一个函数

有了这份快照,四个按钮就可以从同一个地方拿答案:

js 复制代码
export function controls(s) {
  const ready = s.phase === "active" && s.agent === "running";
  const free = s.pending === null;
  const micIdle = ["idle", "failed"].includes(s.asr);
  return {
    connect: s.phase === "empty",
    ask: ready && free && micIdle,
    startASR: ready && free && micIdle,
    stopASR: ready && free && s.asr === "listening",
    interrupt: ready,
    destroy: ["active", "init-failed"].includes(s.phase) && free
  };
}

ready 同时检查两件事:应用已经等到初始化完成,SDK 当前也处于 running。

这个"双重检查"专门挡住一种时序:running 回调已经到了,init() 的 Promise 却还没返回。SDK 报告了进展,应用自己的初始化流程还得等它走完。

剩下几个按钮,我在这里做了这样的取舍:

  • 收音期间,暂停手动文本发送,减少两个输入入口同时提交的机会。
  • ASR 处于 idle 或 failed 时,才允许尝试开始收音;授权、启动和停止的过程中不重复启动。
  • 本例只在 listening 时开放"停止收音",不实现等待授权过程中的取消操作。
  • "打断表达"只要求整体可交互,不受麦克风命令的 pending 阻塞。

最后一条是有意留的。浏览器还在等麦克风授权,用户也可能突然想让智能体停下来。这个时候,不能连"打断"一起锁掉。

所以这里只要整体还能交互,就保留打断入口,不靠最近一条对话回调去猜服务端是否还有内容待播。没有正在表达的内容时,点击它也可能听不出变化。

"发送"亮起来,则只说明它过了眼前这几道检查,并不代表上一轮已经结束。如果你的产品必须严格一问一答,还要加一层轮次调度。按钮能不能点,和下一轮该不该开始,得各自想清楚。

算完以后,再一次性更新界面:

js 复制代码
function render(state, enabled) {
  for (const [name, canUse] of Object.entries(enabled)) {
    document.getElementById(name).disabled = !canUse;
  }
  document.getElementById("error").textContent = state.error;
}

function dispatch(event) {
  state = reduce(state, event);
  render(state, controls(state));
}

这里用原生 DOM 把关系写得直白一点。换成 React 或 Vue,前面的状态和判断函数照样能用,渲染交给框架就好。

SDK 回调只报告事实

现在回头接 SDK,回调里反而没多少事情要做了:收到什么,就记下什么。

js 复制代码
agentCallbacks: {
  onAgentStateChange(value) {
    dispatch({ type: "SDK_AGENT", value });
  },
  onASRStateChange(value) {
    dispatch({ type: "SDK_ASR", value });
  },
  onConversationChange(event) {
    dispatch({ type: "SDK_CONVERSATION", value: event.state });
  },
  onError(error) {
    dispatch({ type: "ERROR", message: error.message });
  }
}

报错时也别急着把整个页面判成"坏了"。一次模型请求失败,不等于 Agent 已经掉线。错误信息先展示出来,Agent 和 ASR 是否变了,继续看它们各自的回调。

至于初始化,就老老实实等 await 走完,再记下完成:

js 复制代码
await agent.init();
dispatch({ type: "SDK_AGENT", value: agent.getAgentState() });
dispatch({ type: "SDK_ASR", value: agent.getASRState() });
dispatch({ type: "INIT_OK" });

结束时再读一遍 SDK 当前状态,页面就能从一个对齐的快照开始工作。

后面遇到断线,Agent 进入 reconnecting,ready 自然变成 false,按钮随之变灰。等 SDK 恢复到 running,再重新算一遍。这里让 SDK 处理自动重连,页面负责告诉用户"现在还在恢复",别再叠一个定时器不停创建新实例。

点击以后,还要再检查一次

按钮禁用了,不等于调用入口就安全了。表单提交、快捷键和其他业务代码,都可能绕过按钮的点击事件。

所以,真正调用 SDK 的地方,还得看一眼当前快照:

js 复制代码
  async function command(name, invoke) {
    // UI 禁用只是提示;真正执行前仍要检查一次。
    if (!agent || !controls(state)[name]) return false;
    dispatch({ type: "COMMAND_BEGIN", name });
    try {
      await invoke(agent);
      return true;
    } catch (error) {
      report(error);
      return false;
    } finally {
      dispatch({ type: "COMMAND_END" });
    }
  }

把 agent、state 和 dispatch 放在同一个控制器里,三个入口就都能经过这道检查:

js 复制代码
await command("ask", current => current.ask(text));
await command("startASR", current => current.startASR());
await command("stopASR", current => current.stopASR());

COMMAND_BEGIN 在调用 SDK 前同步发生。即使短时间又进来一次点击,也会先看见 pending,不会重复启动收音。

finally 只负责解除命令锁 ,不会把对话状态改为空闲。后续是否进入 speaking、是否来到 completed,继续等待对话回调。

打断走独立入口,检查 controls(state).interrupt 后调用 agent.interrupt("speak"),并捕获可能的异常。它不共用麦克风命令锁,也不顺手调用 stopASR()。

到这里,"停止收音"和"打断表达"终于从界面到代码都分开了:一个控制语音输入,一个控制当前交互的表达。用户想做哪个动作,就触发哪个入口。

别只测"顺着点一遍"

连上以后,发一句话,再关掉页面,通常看不出这里的问题。稍微给事件顺序添点乱,才知道前面的判断有没有漏。

这一步先不用对着麦克风反复说话。用模拟回调把事件一条条送进去,看按钮是不是按预期变化;这一层过了,再用真实应用检查收音和播报。

输入序列或场景 应当看到的结果
running 已到,init() 仍未完成 "发送"继续禁用
初始化完成,Agent 为 running,ASR 为 idle 可发送,也可开始收音
ASR 为 listening,对话为 speaking 可停止收音,也可打断表达
Agent 变成 reconnecting 交互按钮禁用,页面保留各条状态
ask() 返回,或收到模型文字 done 不擅自写入对话 completed
某个命令抛出异常 显示错误,并释放命令锁
初始化失败,但实例尚未释放 不允许再叠加创建一个实例

第一个场景尤其适合写成断言。用一个暂不完成的 init Promise,先触发 running 回调:

js 复制代码
const connecting = controller.connect(options);
callbacks.onAgentStateChange("running");

assert.equal(controls(controller.getState()).ask, false);

resolveInit(); // 测试桩:现在才让 init() 完成
await connecting;

assert.equal(controls(controller.getState()).ask, true);

resolveInit 来自测试桩,用来控制模拟的 init() 什么时候完成。故意把 Promise 拖后一步,就是要看看:页面会不会一听见 running,就忘了自己还在初始化。

清理实例时也一样,得等它真的清理成功,再回到可以重新连接的 empty。清理报错就先留住引用,别在 finally 里一把清空,转头又创建一个新实例。这部分还有不少细节,后面聊失败重试时再拆开。

用户看见的,就是这几个按钮

做到这里,再回头看开头那四个按钮,事情就清楚多了。

智能体一边说、一边听,"停止收音"和"打断表达"可以同时亮着。网络正在恢复,页面知道该等一等。ask() 返回了,界面也不会急着宣布"本轮结束"。

用户未必关心这些回调叫什么,但一定会在意:我现在能点什么,点完有没有用。

把状态放回同一份快照,后面再加字幕、流式回复或业务卡片,也就有了一个能接着往下写的地方。

下一篇,我们处理另一个一眼就能看见的问题:语音字幕为什么会越识别越重复?


接口参考

相关推荐
承渊政道2 个月前
给云原生排障助手装上“大脑“和“身体“:蓝耘元生代MaaS×魔珐星云数字人实战
云原生·数字人·魔珐星云·glm-5.2·蓝耕元生代·播报
七夜zippoe5 个月前
重构数字人交互体验:魔珐星云+DeepSeek打造情绪陪伴数字人全流程实战测评
重构·交互·数字人·deepseek·魔珐星云
wei_shuo5 个月前
基于魔珐星云打造的3D智能数字人:语音随时打断、毫秒级AI流式对话、WebGL2.0实时渲染
人工智能·魔珐星云
熊文豪5 个月前
Agent 认知破局:从具象表象到交互本质
魔珐星云
2的n次方_5 个月前
健身 Agent:不止视频,更有 AI 人物实时跟练交互
人工智能·音视频·交互·魔珐星云
池央5 个月前
给自己的官网装上魔珐星云 Agent:24 小时在线的具身交互助手
交互·具身智能·魔珐星云
七夜zippoe5 个月前
企业大屏升级AI智能体:魔珐星云+DeepSeek打造BI数据讲解数字人
人工智能·数字人·bi·deepseek·魔珐星云
CodingPioneer5 个月前
智屏问数 · AI数字人驱动的车间数字大屏
人工智能·信息可视化·数字人·魔珐星云·星云具身
落798.6 个月前
从 LLM 到具身 Agent:魔珐星云 SDK 与端侧参数流渲染架构深度实践
数字人·魔珐星云