我跑步跑了六七年,配速这事一直别扭。跑到一半想看眼配速,得停下,掏手机,解锁,点开跑步 APP,等它转完那一圈。冬天手指头冻得伸不直,摘手套那几秒是真难受。智能手表能显示数据,可要切歌、换个播客,还是得回到手机。有了 Rokid Glasses 之后我就想,能不能换个做法。戴上眼镜,说一句「开跑」,它自己记、自己报,手不用离开摆动节奏。于是就有了《悦跑教练》,一个跑在 Rokid Glasses 上的 AIUI 跑步智能体。

接下来讲讲它是怎么搭起来的,中间踩过的坑我一并写清楚。
image-20260918200311714

一、项目简介

《悦跑教练》解决的是一个很小的麻烦,跑步的时候不想掏手机。目标用户是每周跑三到五次的城市跑者。这类人不需要专业训练系统,也不想看复杂图表,要的就是一个不用掏手机就能报数、能顺手记下来的东西。眼镜在这个场景里挺合适。它一直戴在头上,抬眼就能看,开口就能用,手还在摆臂。这一点手机做不到,手表只做到一半。

功能上有四块。累计数据看主页,实时数据在跑步页,训练计划分三档,历史记录存最近二十次。就这些。我没往里塞社交,也没做训练指导,做多了反而用不上。

二、场景与用户意图

跑步这个场景有几个特点,直接决定了智能体该怎么设计。

唤醒的时机分两种。 一种是出门前站在楼下,说完「开始跑步」就要走,没耐心等页面转圈。另一种是跑到一半喘着气,只想说一两个字把事情办了。

用户嘴里说的不会标准。 「开始跑步」「开跑」「跑起来」是一个意思,「暂停」「停一下」「歇会儿」也是一个意思。更麻烦的是跑起来呼吸重,识别结果经常吞字或者串音。我实测过十几条含混的语音输入,「开跑」被正确识别的概率不到一半,倒是「开炮」出现过两次,还有一次识别成了「开跑吧」。

所以意图识别不能做精确匹配。我在 parseCommand 里按关键词分组,一共分了十一组,每组共享一个动作。挑几组主要的说。

  • 开始组用「开始、开跑、出发、跑起来、开练」

  • 暂停组用「暂停、歇一下、停一下、等一等」

  • 继续组用「继续、接着跑、恢复」

  • 结束组用「结束、跑完、不跑了、停跑、收工」

  • 查询拆成两组,问当前状态用「播报、报一下、多少公里、现在配速」,问历史用「记录、历史、上次、成绩、跑过」

「开始跑步」「暂停一下」这类完整口令写在 AGENTS.md 的意图声明里,把宿主和页面的职责分开。

/* ---------------- 指令解析 ---------------- */

// 顺序很重要:先匹配语义更明确的指令,避免"结束跑步"被"跑步"误判为开始
const RULES = [
  { command: 'finish', keywords: ['结束', '跑完', '不跑了', '停跑', '收工'] },
  { command: 'pause', keywords: ['暂停', '歇一下', '停一下', '等一等'] },
  { command: 'resume', keywords: ['继续', '接着跑', '恢复'] },
  { command: 'back', keywords: ['返回', '回去', '回首页', '上一页', '退回'] },
  { command: 'start', keywords: ['开始', '开跑', '出发', '跑起来', '开练'] },
  { command: 'report', keywords: ['播报', '报一下', '多少公里', '现在配速', '什么情况'] },
  { command: 'plan', keywords: ['计划', '训练', '课程', '方案'] },
  { command: 'history', keywords: ['记录', '历史', '上次', '成绩', '跑过'] },
  { command: 'confirm', keywords: ['就这个', '用这个', '确认', '选定', '选它'] },
  { command: 'next', keywords: ['下一个', '换一个', '切换', '后面'] },
  { command: 'prev', keywords: ['上一个', '前一个', '前面'] },
];

export function parseCommand(text) {
  if (!text) return 'unknown';
  const content = String(text);
  for (const rule of RULES) {
    for (const keyword of rule.keywords) {
      if (content.includes(keyword)) return rule.command;
    }
  }
  return 'unknown';
}

三、方案设计

智能体定义

名字叫「悦跑教练」,定位是随身的配速播报器加简易记录本。不做训练指导,不做社交,不做排行榜。

页面与结构

四个页面都在 .ink 单文件里写。

- 主页 累计里程、本周次数、当前训练计划入口

- 跑步中 实时距离、配速、步频、心率估算、卡路里

- 训练计划 5km 入门、10km 进阶、减脂燃脂三档

- 历史记录 最近 20 次

状态流转

待机 → 主页面
  ↓ 说「开始跑步」/ 点「开跑」
跑步中 → 计时器启动,TTS 进入每 5 分钟播报节奏
  ↓ 说「暂停」/ 摸镜腿
已暂停 → 计时器挂起,数据冻结
  ↓ 说「继续」/ 再摸镜腿
跑步中(恢复)
  ↓ 说「结束跑步」/ 按返回键 / 点「结束」按钮
保存对话框 → 写入本地存储 → 回到主页面

能力调用关系

能力用途
语音识别(ASR)听懂开始、暂停、继续、结束、查询
语音合成(TTS)开跑提示、每 5 分钟播报、结束播报成绩
本地存储存最近 20 次跑步记录
页面事件接管唤醒、镜腿按键
屏幕渲染实时数据展示与按钮点击

四、AIUI 实现

项目结构

├── lib/
│   └── voice.js           # TTS 播报 + ASR 识别 + 指令解析
├── pages/
│   ├── index/index.ink    # 主页
│   ├── running/index.ink  # 跑步中
│   ├── plan/index.ink     # 训练计划
│   └── history/index.ink  # 历史记录
├── AGENTS.md              # 智能体说明文档
├── app.js                 # 应用入口脚本
└── app.json               # 应用配置(路由 + 权限声明)

voice.js 里的三个方法

speak(text) 负责播报,用的是 SpeechSynthesisUtterancespeechSynthesis.speak(utterance, 'enqueue')

第二个参数是关键。我第一版没传,结果播报和用户语音掐在一起。跑到一半它正在播「当前配速」,我说「暂停」,两个声音叠着出来。加上 'enqueue' 之后播报进队列,一条播完才播下一条。

createListener(onResult) 负责识别。AIUI 里 SpeechRecognition 有两种模式。简单模式的 start / stop / abort 由页面自己控制,会话模式走 SpeechRecognitionSession,结束点交给系统判断,适合长对话。跑步这种两个字就说完的场景,用简单模式就够。

parseCommand\(text, commands) 负责匹配。按上面说的关键词分组,命中哪组就返回哪个动作。

页面事件接管

这块是 AIUI 跟普通页面框架差别最大的地方。宿主会把语音唤醒和硬件按键事件分发给当前页面,接不接、怎么接,由页面自己决定。

每个页面都要写这三段。

// 语音唤醒,必须 preventDefault 接管,否则宿主自己处理掉了
onVoiceWakeup(event) {
  event.preventDefault()
  this.startListening()
}

// 镜腿按键
onKeyUp(event) {
  if (event.code === 'GlobalHook') {
    // 镜腿触摸,跑步页里做暂停切换
  } else if (event.code === 'Enter') {
    // 确认
  } else if (event.code === 'ArrowUp' || event.code === 'ArrowDown') {
    // 计划页切换焦点
  }
}

键码对应关系是这样的。镜腿触摸是 GlobalHook,确认是 Enter,上下导航是 ArrowUpArrowDown

app.json 权限声明

"permissions": ["RECORD_AUDIO"]

这条漏了就是静默失败。页面不报错,麦克风也没反应,排查起来最费时间。

AGENTS.md

按官方格式写,一级标题是 # Agent: 悦跑教练,下面分 ## System Prompts## Capabilities## Configuration## Dependencies。语音意图写在 System Prompts 里,把「开始跑步」「暂停一下」「结束跑步」这类完整口令声明清楚,让宿主和页面的职责边界明确。

# 悦跑教练 · Rokid AIUI 跑步智能体

一款运行在 Rokid Glasses 上的 AR 跑步助手智能体,按 AIUI 官方规范(`.ink` 单文件组件 + 页面级事件)编写,可直接导入使用。

## v1.1 更新:修复"点麦克风/镜腿无反应"

**原因**:v1.0 只实现了页面渲染,没有实现页面级事件回调,宿主不知道把语音/按键事件分发给谁。

**修复内容**:

| 修复项 | 说明 |
|--------|------|
| `onVoiceWakeup` 事件 | 每个页面都实现了该回调,唤醒后会 `event.preventDefault()` 接管交互并启动 ASR 识别 |
| ASR 语音识别 | 新增 `lib/voice.js` 封装 `SpeechRecognition`,唤醒后可说「开始跑步」「暂停一下」等指令 |
| TTS 语音播报 | 用 `speechSynthesis.speak()` 接入播报,含开跑提示、每 5 分钟数据播报、结束成绩播报 |
| `RECORD_AUDIO` 权限 | `app.json` 补充录音权限声明,否则识别无法启动 |
| 镜腿按键 | `onKeyUp` 处理 `GlobalHook`(镜腿触摸)/ `Enter` / `Backspace` / 方向键 |
| 点击交互 | 主按钮、卡片、麦克风条都支持 `bindtap`,Craft 调试面板可直接点 |

## 功能一览

| 模块 | 说明 |
|------|------|
| 🏠 主页 | 累计里程、本周次数、当前计划,开跑入口 |
| 🏃 跑步页 | 实时距离/用时/配速/步数/心率/卡路里,TTS 每 5 分钟播报 |
| 📋 训练计划 | 入门 5km / 进阶 10km / 减脂燃脂,语音或镜腿方向键切换选择 |
| 📊 历史记录 | 本地保存最近 20 次记录,语音查询最近成绩 |

## 语音指令(唤醒后说出)

- 「开始跑步」→ 跳转跑步页并开始
- 「暂停一下」/「继续」→ 暂停 / 恢复
- 「结束跑步」→ 保存并播报成绩
- 「播报一下」→ 立即播报当前距离配速心率
- 「看看计划」→ 计划页;「下一个」「就这个」→ 切换/选定
- 「跑步记录」→ 历史页并播报最近成绩

## 镜腿按键映射

| 按键 | 主页 | 跑步页 | 计划页 | 历史页 |
|------|------|--------|--------|--------|
| 触摸(GlobalHook) | 语音唤醒 | 暂停/继续 | 确认选定 | 语音播报 |
| Enter | 开跑 | 语音唤醒 | — | — |
| 方向键上/下 | — | — | 切换焦点 | 滚动 |
| Backspace | 重置状态 | 结束并保存 | 返回 | 返回 |

## 项目结构

run-coach/
├── AGENTS.md                  # 智能体描述(官方规范格式)
├── app.json                   # 页面路由 + RECORD_AUDIO 权限
├── app.js                     # 应用入口:存储初始化 + 数据读写
├── lib/
│   └── voice.js               # TTS 播报 + ASR 识别 + 指令解析(共享模块)
└── pages/
    ├── index/index.ink        # 主页
    ├── running/index.ink      # 跑步实时数据页
    ├── plan/index.ink         # 训练计划页
    └── history/index.ink      # 历史记录页

## 导入使用

### Craft Web IDE(推荐)

1. 打开 Craft AIUI Web IDE:https://js.rokid.com/craft?lang=zh-CN 并登录
2. 「导入本地文件夹」→ 选中 `run-coach` 文件夹
3. 点「运行智能体」→ 进入 Interactive InkView 预览
4. ****测试语音****:右侧面板点麦克风图标(或「语音识别」按钮)→ 说出指令 → 观察页面底部状态条和日志面板
5. ****测试镜腿****:右侧面板点「单击 / 双击 / 向前滑动 / 向后滑动」按钮

### AIUI Studio

1. 浏览器打开 https://aiui.rokid.com/ 并登录
2. 新建智能体,把本项目文件上传/粘贴到工作区
3. 真机验证 → 生成版本 → 提交上架

## 技术说明

- ****语音交互架构****:`onVoiceWakeup`(接管唤醒)→ `SpeechRecognition`(识别指令)→ `parseCommand`(关键词解析)→ 业务动作 → `speechSynthesis.speak`(TTS 反馈)
- ****页面规范****:`.ink` 单文件组件,`export default {}` 注册,`this.setData()` 更新状态
- ****权限****:`app.json` 中声明 `"permissions": ["RECORD_AUDIO"]`
- ****视觉规范****:单色绿设计系统,官方设计令牌 + 弹性布局
- ****数据模拟****:距离/步数/心率来自固定步频 170 步/分 × 步幅 0.75m 的模拟(`pages/running/index.ink` 顶部 `SIM_*` 常量),接入真实传感器时替换为传感器回调即可

## 常见问题排查

| 现象 | 原因与解决 |
|------|-----------|
| 点麦克风没反应 | 确认导入的是 v1.1 代码(页面里有 `onVoiceWakeup`);在 Craft 中重新运行智能体 |
| 说了指令没反应 | 打开右侧「日志」面板看 ASR 是否返回文本;确认说的内容命中指令关键词 |
| 无语音播报 | TTS 依赖运行时语音服务,Web 调试环境可能受限,以真机为准 |
| 想改指令词 | 编辑 `lib/voice.js` 里的 `RULES` 关键词表即可 |
{
  "pages": [
    "pages/index/index",
    "pages/running/index",
    "pages/plan/index",
    "pages/history/index"
  ],
  "window": {
    "navigationBarTitleText": "悦跑教练",
    "navigationBarTextStyle": "white"
  },
  "permissions": ["RECORD_AUDIO"]
}

五、交互与体验

整体思路是三路冗余,语音、镜腿,同一件事两条路都能到。

语音 适合场景固定的动作。站定了说「开始跑步」,或者跑着说「暂停」,不用看屏幕。

镜腿 在手上没空的时候最好使。摸一下就是暂停和继续的切换,比说两个字还快。这个是跑步场景里我实际用得最多的。

四个页面各说一句。

主页 进来看累计里程和本周次数,底部一个「开跑」大按钮。不用说话也能开始。

跑步中 是核心。中间三行数字,距离、配速、步频。不用碰它,每 5 分钟自己播报一次,「当前配速 5 分 12 秒每公里,已跑 2.3 公里,继续保持」。喘气的时候听到这句,比低头看表方便。

image-20260918200956757

训练计划 用方向键上下切,Enter 确认。每个计划标了推荐配速区间,选完回主页,下次开跑按这个目标算。

历史记录 存最近 20 次。说「上次跑了多少」,播报最近一次的距离和配速。

image-20260918200943396

六、调试与验证

AIUI 预览

开发全程都在 AIUI 里做。改完 .ink 直接点「运行智能体」,右侧面板有麦克风图标和镜腿操作按钮那一排,语音和按键都能在浏览器里模拟。跑步页的 5 分钟播报周期太长,调试时我临时把间隔改成 10 秒,验证逻辑对了再改回去。
在这里插入图片描述

v1.0 到 v1.1 的排查

第一版做完,页面渲染正常,四个页面都能翻,麦克风没反应,按镜腿也没反应。

翻官方文档 page-events.md 才发现问题,我没实现任何页面级事件。宿主把语音唤醒事件分发出去,没有页面接,信号就丢了。我原来以为平台内置了语音功能,接上就能用,其实整条链路得页面自己搭。

第二版补了三件事,onVoiceWakeup 接管唤醒再启动识别、onKeyUp 处理镜腿按键、app.jsonRECORD_AUDIO 权限。这三点凑齐,麦克风才真正活过来。

image-20260918201140895

真机验证

戴上眼镜到小区门口那次,我说「开始跑步」,它回了「好的,开始跑步。当前是减脂燃脂计划,预计 25 分钟」。镜腿摸一下,播报「已暂停」。跑起来之后每 5 分钟自动报一次配速,声音从眼镜两侧出来,路过的大爷都多看我一眼。

真机上跟模拟器有几处不一样。

  • 户外风噪对识别影响明显。同一句「暂停」,室内成功率我体感八成以上,有风的时候明显掉下来。所以兜底按键不是可选项。

  • 跑超过 40 分钟,播报节奏得控住。太频繁会烦,5 分钟一次比较舒服。

  • 眼镜扬声器在户外音量偏小,播报内容要短。我后来把「当前配速 X 分 Y 秒每公里」简化成了「配速 X 分 Y 秒」。

image-20260918201053629

七、成果与复盘

成果

四个页面全部跑通,语音、镜腿、点屏三条路径都能走完整个跑步流程。关键词容错实测能覆盖大部分含混输入。历史上限 20 条,存本地,不依赖网络。

遇到的问题

问题原因解决
点麦克风无反应页面未实现 onVoiceWakeup补页面事件接管
镜腿按了没反应未监听 onKeyUp 与键码GlobalHook / Enter 分支
识别起不来app.jsonRECORD_AUDIO补权限声明
播报和语音打架speak 未传队列参数speak(utterance, 'enqueue')
提审被卡预览素材用了 AI 生成图,与实际不符换成模拟器真实截图和录屏

最后一条我想多说一句。预览素材这块我一开始想省事,让 AI 画了几张眼镜界面的效果图,自己看两眼就删了。风格跟实际跑出来的界面完全不是一回事,提审的时候这种图文不符最容易被卡。后来老老实实跑项目,用模拟器截了真实界面才过的。

后续迭代

接下来想做三件事。

1. 接真实传感器。 现在距离是步频乘步幅模拟出来的,接上 IMU 和 GPS 之后配速才准。

2. 加心率区间提醒。 现在心率是估算值,接上真实数据后可以在超区间时播报提示。

3. 训练计划自定义。 现在三档是写死的,想做成能自己填目标距离和配速区间。

一点感受

我用下来的感觉是,AIUI 更像一套让硬件开口的机制,页面只是它的外壳。把「功能」想成「页面」,就没了交互。把「交互」想成「页面加事件回调」,它才活过来。

你写了 onVoiceWakeup,它就接住麦克风,写了 onKeyUp,它就接住镜腿。没写,眼镜里就是一张冷着的图。

平台把「硬件信号分发到页面」这条管道铺好了,接不接是开发者的事。这件事我卡了整整一版才想明白。写给同样卡着的朋友,去翻 page-events 那一节,别只读 quickstart。

更多推荐