一、项目简介——为什么要做「随口记」

相信很多人都有过这样的经历:想记账,但每花一笔钱都要掏出手机、解锁、找到记账 App、选分类、填金额……流程还没走完,耐心先没了,于是记账计划坚持不到三天就放弃。如果我们戴的是 AR 眼镜,这件事本可以更简单——刚吃完饭拎着东西走出餐厅,随口说一句"午饭 28",账单就已经记好了

这就是我基于 Rokid AIUI(面向 AI + AR 的 AI 原生 GUI 本地智能体框架)开发的智能体应用「随口记」一句话语音记账本。它面向所有"想记账但嫌麻烦"的普通用户,目标是把一次记账的操作成本压缩到"一句话 + 一次单击"。

本项目的完整能力清单如下:

  • 语音记账:一句话报出消费,智能体自动抽取金额、自动匹配 8 大支出分类,弹出确认卡片;
  • 单击入账:眼镜按键单击确认、返回键取消,配合 TTS 语音播报,全程不用看屏幕也能完成;
  • 今日账单:首页展示今日支出总额、笔数与明细列表;
  • 本周统计:按分类聚合金额与占比,前滑/后滑即可在"今日/本周"之间切换;
  • 本地持久化:账单写入设备本地 Storage,关掉再打开数据不丢;

二、眼镜上怎么被"唤醒"

1、日常的使用场景

  • 场景一:饭后结账。吃完午饭,说一句"午饭花了 28",眼镜立刻弹出确认卡片并播报"记一笔,餐饮 28 元,单击确认,返回取消",单击镜腿即可入账。

  • 场景二:超市采购。拎着购物袋回家,说"超市买东西 69",智能体匹配到"购物"分类,确认后完成记录。

  • 场景三:睡前复盘。问一句"今天花了多少",今日账单页直接展开;再问"本周花销统计",本周各分类占比一目了然。

2、用户意图识别

在 AIUI 的开发模型里,开发者要做的不是写死流程,而是告诉智能体"用户可能会怎么说、你该调起哪个页面"。「随口记」只识别三类意图,边界清晰才不容易出错:

意图

典型说法

系统响应

记账(最高频)

"刚打车花了 23""买咖啡十五块五""话费充了五十"

抽取金额/分类/备注,调起确认页(confirm),由用户单击后入库

查今日账单

"今天花了多少""今日账单"

调起今日账单页(home)

看统计

"本周花了多少""分类统计"

调起统计页(stats),默认展示本周

3、两条防错设计

  1. 金额不明确绝不脑补。用户只说"午饭"没说金额时,智能体只反问一句"多少钱?",而不是猜一个数字。

  1. 确认页是最后一道人工关口。智能体只负责"识别和建议",必须用户单击之后才真正写入,识别错了按返回键就能丢弃,从交互设计上杜绝误记账。

三、方案设计

1、三层架构

我把「随口记」拆成清晰的三层:

  • 智能体描述层(AGENTS.md):写清智能体身份、三类意图的判定规则、金额与分类抽取规则,以及"什么情况下调起哪个页面"。运行时由宿主大模型完成自然语言理解。

  • 页面交互层(pages/):3 个 .ink 单文件页面。每个页面的 <script def> 里用 description + schema.data 声明自己的"入参契约",这份契约同时也是大模型调起页面时的工具参数说明(即 AIUI 的 Tool Rendering 机制)。

  • 数据层(modules/ + Storage)categories.js 提供分类表、关键词兜底匹配和中文数字归一化;store.js 负责本地存储的增删查与按日/按周聚合。

2、页面内暂时不调用大模型

这是本项目最重要的一个取舍:自然语言理解全部交给 AGENTS.md 定义的宿主 Agent,页面只接收结构化字段,做确定性的展示、存储与按键交互。好处非常直接:页面逻辑不依赖网络和模型稳定性,任何时候打开结果都可复现,在 Craft 里调试时不会因为模型"抽风"而误判问题出在自己代码里。即使智能体漏给了分类,页面还有一层关键词规则兜底(未来完善大模型实现)

3、数据模型

每一笔账单是一条结构固定的记录,全部账单以数组形式存在本地 Storage 的 voice_ledger_records_v1 键下:

{
  "id": "全局唯一 ID,由 crypto.randomUUID() 生成",
  "amount": 23,            // 金额,数字,单位元,统一保留两位小数
  "category": "transport", // 8 个分类 key 之一
  "note": "打车回家",       // 不超过 8 个字的备注,可为空
  "ts": 1756432100000      // 消费时间,毫秒时间戳
}

8 个固定分类为:餐饮 food、交通 transport、购物 shopping、娱乐 entertainment、居家 home、医疗 medical、学习 study、其他 other。

四、Rokid创建智能体

1、第一步,创建一个属于你的智能体 Rokid AIUI Studio,填写智能体信息(例如:智能体名称、类别、功能介绍等等)

完成之后我们点击智能体进入,第二步,下载开发包 https://gitee.com/jsar-project/AIUI,下载之后我们按照AIUI的文档进行查看,开发自己的应用https://js.rokid.com/AIUI/guide/quickstart?version=0.16.1&lang=zh-CN,这里教大家一个小技巧,如果没有自己应用ide,可以直接与AI UI对话,给我们提供一些参考思路建议。

五、代码实现

1、初始化工程与目录结构

使用官方脚手架生成工程,然后按下面的结构组织文件:

npm create @yodaos-pkg/aiui-agent@latest voice-ledger
voice-ledger/
├── AGENTS.md              # 智能体描述:身份、意图、抽取与调页规则
├── app.js                 # 应用入口,启动时保证存储已初始化
├── app.json               # 页面路由注册
├── package.json
├── modules/
│   ├── categories.js      # 分类表 + 关键词兜底 + 金额归一化
│   └── store.js           # Storage 增删查、按日/周聚合、演示数据
└── pages/
    ├── home/home.ink      # 今日账单页(默认落地页)
    ├── confirm/confirm.ink# 记账确认页(核心)
    └── stats/stats.ink    # 今日/本周统计页

2、app.json

AIUI 要求所有页面都必须在 app.json 的 pages 数组中注册(不带后缀),数组第一项就是默认落地页:

{
  "pages": [
    "pages/home/home",
    "pages/confirm/confirm",
    "pages/stats/stats"
  ],
  "window": {
    "navigationBarTitleText": "随口记"
  }
}

3、AGENTS.md

AGENTS.md 是整个应用的大脑。下面是核心片段,注意我特意要求它"不要自己声称已经记好",而是必须调起确认页——入库动作只能由用户在页面上完成:

# Agent: 随口记 - 一句话语音记账助手
- **Version**: 1.0.0
- **Description**: 通过对话帮助用户用一句话记录日常开销,查看今日账单与本周分类统计;记账时渲染确认页面,用户单击后入账。
- **Author**: Tom

## System Prompts
你是「随口记」,一个运行在 AR 眼镜上的极简记账助手。全部使用中文,回答简短口语化,单句不超过 20 个字,不使用 emoji。

你能识别三类用户意图:

### 意图一:记账(最高频)
当用户的话里包含消费行为与金额(例如"午饭28"、"刚打车花了23块"、"买咖啡15块5"、"话费充了五十"):
1. 抽取字段:
   - amount:数字,单位统一为"元"。"块/块钱"就是元;"15块5"=15.5;中文数字(五十=50)要换算;没有金额时不要猜测,反问一次"多少钱?"。
   - category:从下列 8 个分类中选一个,依据句中关键词:
     food 餐饮(饭/早餐/午餐/晚餐/外卖/奶茶/咖啡/零食/水果/吃/喝)
     transport 交通(打车/滴滴/地铁/公交/加油/停车/车票/骑行)
     shopping 购物(买/淘宝/京东/拼多多/超市/衣服/鞋)
     entertainment 娱乐(电影/游戏/会员/演出/KTV/酒吧)
     home 居家(房租/水费/电费/话费/网费/日用/家电)
     medical 医疗(药/医院/挂号/体检)
     study 学习(书/课程/学费/文具)
     other 其他(无法判断时兜底)
   - note:不超过 8 个字的消费事项摘要(如"打车""午饭"),没有就给空字符串。
   - ts:当前时间戳(毫秒)。
2. 立即调用确认页(pages/confirm/confirm)工具,把 amount、category、note、ts 作为入参传入,由页面完成确认与入库,你不要自己声称"已经记好了"。
3. 同时只说一句引导语,例如:"记一笔,餐饮28元,单击确认。"

### 意图二:查今日账单
当用户问"今天花了多少/今日账单/今天记了什么"时,调用 pages/home/home 打开今日账单页,并用一句话报出你已知的概况(不知道金额就不说数字)。

### 意图三:看统计
当用户问"本周花了多少/分类统计/花销汇总"时,调用 pages/stats/stats,入参 range 取 "week";问"今天的统计"时 range 取 "today"。

### 边界
- 收入、转账、借钱不属于支出记账范围,礼貌说明你只记日常支出。
- 一句话里出现多笔消费时,只处理金额最明确的一笔,并提示用户其余的可以逐笔说。
- 不要编造金额、时间或分类;信息不足就追问,一次只问一个问题。

## Capabilities
- storage.read/write:读写本地账单记录(key: voice_ledger_records_v1)
- audio.tts:关键操作语音播报

## Dependencies
- Host LLM:宿主提供的默认大模型
- Pages:pages/confirm/confirm、pages/home/home、pages/stats/stats(页面入参契约以各页面 <script def> 中的 schema.data 为准)

讲解:这段提示词同时承担了"意图分类 + 实体抽取 + 工具路由"三件事。把分类关键词直接写进提示词,是为了让大模型的分类口径和页面里的规则兜底口径保持一致,避免出现"模型说是餐饮、规则算成其他"的。

4、categories.js

分类表同时服务于模型提示词和页面兜底。normalizeAmount 负责把各种口语金额变成数字:阿拉伯数字优先正则提取,其次支持"二十三""五十"这类两位数中文口语:

export function guessCategory(text) {
  const t = String(text || '').toLowerCase();
  for (const cat of CATEGORIES) {
    if (cat.keywords.some((k) => t.includes(k.toLowerCase()))) return cat.key;
  }
  return 'other';
}

export function normalizeAmount(raw) {
  if (typeof raw === 'number' && isFinite(raw)) return Math.round(raw * 100) / 100;
  const s = String(raw ?? '').trim();
  const arabic = s.match(/\d+(\.\d+)?/);
  if (arabic) return Math.round(parseFloat(arabic[0]) * 100) / 100;
  if (s.includes('百') || s.includes('千')) return null; // 超出范围,交回上层追问
  // 省略"十"位中文数字解析:二十三=23、五十=50
}

讲解:函数在无法解析时返回 null 而不是 0,这是刻意的——0 元是一个"合法值"会被当成有效金额,而 null 可以让确认页明确进入"没听清金额"的错误态并语音提示用户重说。

5、store.js

存储层全部使用 AIUI 兼容小程序的同步存储 API。入账时新记录 unshift 到数组头部(最新在前),所有金额计算都做两位小数校正:

const KEY = 'voice_ledger_records_v1';

export function getRecords() {
  try {
    const v = wx.getStorageSync(KEY);
    return Array.isArray(v) ? v : [];
  } catch (e) { return []; }
}

export function addRecord({ amount, category, note, ts }) {
  const records = getRecords();
  const record = {
    id: crypto.randomUUID(),
    amount: Math.round(Number(amount) * 100) / 100,
    category: category || 'other',
    note: note || '',
    ts: ts || Date.now()
  };
  records.unshift(record);
  wx.setStorageSync(KEY, records);
  return record;
}

// 最近 7 天(含今天)
export function recordsOfWeek(records, ts = Date.now()) {
  const d = new Date(ts); d.setHours(0, 0, 0, 0);
  const end = d.getTime() + 24 * 3600 * 1000;
  const start = end - 7 * 24 * 3600 * 1000;
  return records.filter((r) => r.ts >= start && r.ts < end);
}

统计页使用的 summarize 用 Map 按分类累加,再算出每个分类的金额与百分比,最后按金额降序返回,页面拿到即可直接渲染。

6、home.ink:今日账单页

首页承担三件事:展示今日汇总与明细、提供无需语音的演示入口、提供一条页内语音识别通道。有两个细节值得说:

细节一:用 onShow 而不是 onLoad 刷新数据。从确认页返回首页时页面不会重新 onLoad,只有 onShow 会再次触发,在这里重新读取 Storage 才能让新入账的记录立刻出现:

onShow() {
  this.reload(); // 每次页面显示都重新聚合今日数据
},
reload() {
  const records = getRecords();
  const today = recordsOfDay(records);
  const todayList = today.map((item) => ({
    id: item.id,
    leftText: categoryName(item.category) + (item.note ? ' ' + item.note : ''),
    amountText: '¥' + item.amount,
    timeText: formatTime(item.ts)
  }));
  this.setData({
    todayTotalText: String(sum(today)),
    countText: String(todayList.length),
    todayList,
    isEmpty: todayList.length === 0
  });
}

明细列表使用 <scroll-view scroll-y="true"> 固定高度滚动,空数据时用内置的 <error-state> 组件提示"还没有记录,试试说:午饭花了 28"。

细节二:首页除了"开启麦克风"(使用 Web 标准 SpeechRecognition,识别到最终文本后走与 Agent 相同的归一化逻辑再跳转确认页),还提供了"模拟记一笔""载入演示数据""重置演示"三个按钮,保证在任何 Craft 版本、甚至麦克风不可用时,完整流程都能用鼠标/模拟器按键走通。

8、stats.ink:今日/本周统计页

统计页在 onLoad 时一次性算好"今日"和"本周",前滑/后滑只做切换、不重复计算

function decorateGroups(groups) {
  return (groups || []).map((g) => ({
    name: g.name,
    amountText: String(g.total),
    percent: g.percent,
    barWidth: g.percent + '%'   // 直接绑定到内层 view 的 width
  }));
}

onKeyUp(event) {
  if (event.code === 'ArrowDown' || event.code === 'ArrowUp') {
    event.preventDefault();
    this.applyTab(this.data.tabIndex === 0 ? 1 : 0); // 今日 ↔ 本周
  }
}

六、交互与体验

1、显示

AIUI 面向单绿色显示设备有明确的设计约束,本项目严格遵守:应用基准宽度 480px、单页内容高度不超过 352px,纯黑底(#000000)、主色荧光绿(#40FF5E),卡片统一 2px 边框、12px 圆角;不用 emoji、不用大面积色块,只靠文字和透明度区分层级,保证在眼镜小屏上一眼能看清重点。

2、按键映射与焦点系统

Craft 右侧模拟器提供的按键,与页面事件 code 的对应关系如下,三个页面的交互全部基于这套映射实现:

模拟器按键

事件 code

在本项目中的作用

返回

Backspace(onKeyUp)

确认页=取消入账并拦截默认退出;统计页=退出

单击

Enter / GlobalHook

执行当前获得焦点的操作(确认、切换页面等)

前滑/后滑

ArrowUp / ArrowDown

确认页切换"确认/取消"焦点;统计页切换今日/本周;首页循环切换 5 个按钮焦点

七、运行效果

Craft 是面向 AIUI 与 Ink 工程的一体化 Web 工作台,地址为 js.rokid.com/craft,导入本地文件夹后点"运行智能体"。

1、展示效果:

2、打包上架

这样我们就能运行了(鼓掌~)

八、总结

1、开发中踩过的 6 个坑(供大家参考)

  1. navigateTo 的 query 全是字符串。"23" 不会自动变成数字,备注经过 URL 还会被编码。解决:onLoad 中统一走 normalizeAmount 归一化、decodeURIComponent 还原备注。
  2. 返回键的默认行为只能在 onKeyUp 拦截。起初写在 onKeyDown 里发现页面依然被关掉,查阅文档后改为在 onKeyUp 中 event.preventDefault() 才生效。
  3. 入账后返回首页数据不刷新。页面返回不会触发 onLoad,把刷新逻辑挪到 onShow 后解决。
  4. SpeechRecognition 必须由用户手势激活,且部分 Craft 版本没有 ASR。因此保留"模拟记一笔"保底入口,并在开麦失败时给出明确文字提示,保证任何环境都能完整演示。
  5. 演示数据绝不能覆盖用户数据。seedDemoIfEmpty 仅在存储为空时写入,另外提供"重置演示"按钮显式清空再重建。
  6. 两种页面打开方式对应两种关闭方式。Agent 调起用 this.finish() 把焦点交回对话流,页面跳转打开用 wx.navigateBack(),代码里按 from 字段分别处理并互为兜底。

2、后续迭代计划

  • 真机联调:在 Rokid 眼镜上验证真实语音唤醒、抬显显示效果与真机 TTS;
  • 可视化升级:用内置 chart 组件为统计页增加饼图;
  • 预算能力:增加月度预算与超支语音提醒;
  • 数据导出与多端同步:支持账单导出 CSV,探索云端备份。

九、写在最后

这次实践最大的体会是:AIUI 把"听懂用户"这件事交给了智能体,我们只需要使用语言描述,就能用很少的代码做出一个完整的眼镜应用;而 Craft 的 Web 预览让"没有真机"不再是动手的障碍。希望这篇教程能给想入门 AIUI 的朋友一点参考,也欢迎大家一起交流。

相关资源:

更多推荐