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

这就是我基于 Rokid AIUI(面向 AI + AR 的 AI 原生 GUI 本地智能体框架)开发的智能体应用「随口记」一句话语音记账本。它面向所有"想记账但嫌麻烦"的普通用户,目标是把一次记账的操作成本压缩到"一句话 + 一次单击"。
本项目的完整能力清单如下:
- 语音记账:一句话报出消费,智能体自动抽取金额、自动匹配 8 大支出分类,弹出确认卡片;
- 单击入账:眼镜按键单击确认、返回键取消,配合 TTS 语音播报,全程不用看屏幕也能完成;
- 今日账单:首页展示今日支出总额、笔数与明细列表;
- 本周统计:按分类聚合金额与占比,前滑/后滑即可在"今日/本周"之间切换;
- 本地持久化:账单写入设备本地 Storage,关掉再打开数据不丢;
二、眼镜上怎么被"唤醒"
1、日常的使用场景
- 场景一:饭后结账。吃完午饭,说一句"午饭花了 28",眼镜立刻弹出确认卡片并播报"记一笔,餐饮 28 元,单击确认,返回取消",单击镜腿即可入账。

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

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

2、用户意图识别
在 AIUI 的开发模型里,开发者要做的不是写死流程,而是告诉智能体"用户可能会怎么说、你该调起哪个页面"。「随口记」只识别三类意图,边界清晰才不容易出错:
| 意图 | 典型说法 | 系统响应 |
| 记账(最高频) | "刚打车花了 23""买咖啡十五块五""话费充了五十" | 抽取金额/分类/备注,调起确认页(confirm),由用户单击后入库 |
| 查今日账单 | "今天花了多少""今日账单" | 调起今日账单页(home) |
| 看统计 | "本周花了多少""分类统计" | 调起统计页(stats),默认展示本周 |
3、两条防错设计
- 金额不明确绝不脑补。用户只说"午饭"没说金额时,智能体只反问一句"多少钱?",而不是猜一个数字。


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

三、方案设计
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 个坑(供大家参考)
- navigateTo 的 query 全是字符串。"23" 不会自动变成数字,备注经过 URL 还会被编码。解决:onLoad 中统一走 normalizeAmount 归一化、decodeURIComponent 还原备注。
- 返回键的默认行为只能在 onKeyUp 拦截。起初写在 onKeyDown 里发现页面依然被关掉,查阅文档后改为在 onKeyUp 中
event.preventDefault()才生效。 - 入账后返回首页数据不刷新。页面返回不会触发 onLoad,把刷新逻辑挪到 onShow 后解决。
- SpeechRecognition 必须由用户手势激活,且部分 Craft 版本没有 ASR。因此保留"模拟记一笔"保底入口,并在开麦失败时给出明确文字提示,保证任何环境都能完整演示。
- 演示数据绝不能覆盖用户数据。seedDemoIfEmpty 仅在存储为空时写入,另外提供"重置演示"按钮显式清空再重建。
- 两种页面打开方式对应两种关闭方式。Agent 调起用 this.finish() 把焦点交回对话流,页面跳转打开用 wx.navigateBack(),代码里按 from 字段分别处理并互为兜底。
2、后续迭代计划
- 真机联调:在 Rokid 眼镜上验证真实语音唤醒、抬显显示效果与真机 TTS;
- 可视化升级:用内置 chart 组件为统计页增加饼图;
- 预算能力:增加月度预算与超支语音提醒;
- 数据导出与多端同步:支持账单导出 CSV,探索云端备份。
九、写在最后
这次实践最大的体会是:AIUI 把"听懂用户"这件事交给了智能体,我们只需要使用语言描述,就能用很少的代码做出一个完整的眼镜应用;而 Craft 的 Web 预览让"没有真机"不再是动手的障碍。希望这篇教程能给想入门 AIUI 的朋友一点参考,也欢迎大家一起交流。
相关资源:
- AIUI 官网:js.rokid.com/AIUI
- AIUI Studio(中国站):aiui.rokid.com
- AIUI GitHub:github.com/jsar-project/AIUI
- AIUI Gitee:gitee.com/jsar-project/AIUI
- Craft 工作台:js.rokid.com/craft
- 程序源码:https://gitcode.com/aasd23/voice-ledger
更多推荐


所有评论(0)