1. 项目背景

还款日、体检、家人的生日,这类日子记进手机要经过一串动作:解锁,找应用,点加号,填标题,选日期。步骤一多,人就会想“等会儿再记”,然后忘掉。

我想试试压成一句:对眼镜说“记一个还款日12月20日”,镜片上多出一张卡片,名称还款日,目标 12 月 20 日,大字是还剩多少天,语音把同一个数字读出来。想看的时候问“还有几天到还款日”。

单页面的 Rokid AIUI 应用,不联网、不做云同步,数据全在本机一份存储里,录音权限只给页内收音用。这个应用解决的与其说是“记不住”,不如说是“懒得记”。

2. 事件的数据结构

事件入库后的形状是 {id, name, year, month, day, source, createdAt},其中 year 允许为空,这是我最先定的一条。

年份不存,显示那一刻才算:从当年起往后找,最多看 8 年,哪一年放得下这个月和日、又落在今天或未来,就用哪一年。12 月 20 日过了 12 月 20 日再看,自动落到下一年。跨年不需要迁移,也不需要在年根写定时任务。

2 月 29 日是这条规则顺出来的。平年 2 月只有 28 天,那一年被跳过,落到最近的闰年;带年份的 2026-2-29 会在构造时被月天数检查拦下,2028-2-29 合法。代码里没有一处写着“如果闰年”。

存储结构是 {version: 1, revision: 0, events: [], lastEventId: null},events 最多 20 条,revision 每次增删加一,lastEventId 记住上次看的那条,进页面按它定位,找不到就选最后一个,一个都没有就进 empty。

3. 剩余天数怎么算

天数不存,每次渲染重算:

export function countdown(date, now) {
  const today = localToday(now);
  const target = resolveTarget(date, today);
  if (!target) return {kind: 'invalid', days: 0, label: '日期无法计算', big: '—', unit: '', target: null, speech: ''};
  const diff = Math.round((new Date(target.year, target.month - 1, target.day)
    - new Date(today.year, today.month - 1, today.day)) / MS_PER_DAY);
  if (diff === 0) return {kind: 'today', days: 0, label: '就是今天', big: '今天', unit: '', target, speech: '就是今天'};
  if (diff > 0) return {kind: 'future', days: diff, label: '还有' + diff + '天', big: String(diff), unit: '天后', target, speech: '还有' + diff + '天'};
  return {kind: 'past', days: -diff, label: '已过' + -diff + '天', big: String(-diff), unit: '天前', target, speech: '已过' + -diff + '天'};
}

两边都用本地时区的年月日构造零点时刻,相减除以 86400000 再取整,取整是为了吸收时制偏移,同一自然日里任何时刻算出的天数一致。答案有三种:diff 为 0 大字是“今天”两个字,不是数字 0;正数“还有 N 天”;负数“已过 N 天”,过期不归零。

4. 添加与查询

约束宿主的部分写在 app.json 的 agentPrompt 里:仅打开不传参数;添加优先传 name 和 date,date 认 12月20日、12-20、2026-12-20 三种写法,不写年份由页面取最近未来的同月同日;拆不出字段就整句放进 text;不许编造事件名或日期,模糊说法不许换算成具体日期;写入本机成功才算添加成功。

页面参数在 count.ink 的 def 块里,四个全都不强制,只打开看看也合法:

"properties": {
  "name": {"type": "string", "maxLength": 24},
  "date": {"type": ["string", "number"]},
  "text": {"type": "string", "maxLength": 120},
  "from": {"type": "string", "enum": ["agent", "nav"]}
}

onLoad 接住参数后不立刻写,先读本机,读完定好模式,最后才执行暂存的命令,而且命令在执行前就被清掉了。这个顺序是有意的:读失败直接进错误页,暂存命令不再执行;命令先清后执行,写入失败后点“重新读取”也不会把同一条添加悄悄再跑一遍。

accept 收到话之后按有没有日期分流:带了 date,或原话里抽得出日期,走添加;否则走查询。判断“说了没有”用 provided,空串和纯空格不会伪装成一次有效输入。查询按名称找是双向包含,“生日”能同时命中“生日”和“妈妈生日”,命中多个取最后一个。

页面里另有两个收音按钮“记一个”“查一个”,它们不引入新逻辑,只是同一套识别流程的两个固定话术开头。识别中的状态回调会不停改写状态行,所以话术存在 _voiceTip 里,提示一直是“该怎么说”,不会被“正在听”盖掉。_voiceRun 是收音的代际号,onHide 或下一次操作时加一,旧回调就作废,上一次的识别结果落不到新一次操作上。识别成功交给 accept,和宿主传参走同一条解析路,页面只有一套规则。

5. 删除的两步确认

删除是唯一不可逆的操作,所以拆成两步。主界面的“删除”和使用说明页的“删除当前事件”都先进 confirm-delete,焦点默认落在“保留”上,直接按 Enter 执行的是保留。

确认页复用卡片区域,只换文案:标题问“要删除这个事件吗?”,倒数字仍然显示。用户确认的对象是哪条事件,看一眼就知道,不用回忆。

真删之后 lastEventId 指向剩余的最后一条,下次进页面落在那里,不会停在已被删掉的那个位置上。

没做撤销。两步确认加上焦点默认在“保留”,按错一次的代价是退回主界面,要连按错两次才会真的丢数据。为这个小概率再存一份删除前快照、再加一条恢复路径,账就不划算了,我到这里就停了。

6. 数据校验与写入保护

存储层只包两个方法,键名 rizi-countdown-v1。read 把读到的 undefined、null、空串统一归成 null,代表还没有数据;出错则抛错。write 在 setStorageSync 之后立刻回读一次,回读值和写入的字符串不相等就抛“回读不一致”——写成功的判定是“读得回来”,不只是“没报错”。

数据进来先过 decodeState:null 给空状态;不是字符串或超 200000 字符报“数据过大或格式不正确,原数据已保留”;JSON.parse 或逐项校验失败统一报“倒数数据异常,原数据已保留,请检查后重试”。校验查 version、revision、事件条数、每条的字段、id 唯一性、lastEventId 是否真实存在。任何一项不过,这份 JSON 就不会被当成有效数据,页面也不会拿空账本顶替它。

写入全在 commit 里,顺序是刻意排的:

function commit(next) {
  if (!ready) throw new Error('请先重新读取倒数数据');
  validateState(next);
  let current;
  try {
    current = storage.read();
  } catch (error) {
    ready = false;
    throw storageError('读取失败,本次操作未保存,请重新读取');
  }
  if (current !== raw) {
    ready = false;
    throw storageError('倒数数据已在其他页面变化,请重新读取后再操作');
  }
  const value = JSON.stringify(next);
  try {
    storage.write(value);
  } catch (error) {
    ready = false;
    throw storageError('保存失败,原有事件未改动,请重新读取');
  }
  state = next;
  raw = value;
  ready = true;
}

提交前重新读一遍存储,和上次留底的原串 raw 做全等比较,不是自己上次写出去的内容就拒绝提交。用字符串全等而不是解析后比较,因为写入和回读的就是同一个序列化结果,全等最严格也最便宜。

读不回、被人改过、写不进,三种失败的报错措辞不同,但处理相同:ready 置假,state 和 raw 都不动,存储里的旧数据原封不动,页面切到错误模式,只留“重新读取”和“退出”。界面上不会出现一半新一半旧的账本。

7. 页面状态与操作

页面有六个模式:loading、error、empty、rules、ready、confirm-delete。render 是唯一出口,每次都从模式和快照出发,把标题、卡片、按钮全部算好,一次 setData 交给模板。

动作栏按模式换:ready 两行六个窄按钮,“记一个”“查一个”“使用说明”在上,“上一个”“删除”“下一个”在下;empty 三个;rules 两个;error 两个;confirm-delete 两个。按钮宽度有处小算术:数量能被 3 整除时从 225px 换成 148px,3 × 148 加两道 6px 间距正好铺满 456px 的内容宽度,三个一行、六个两行。

切换事件不碰存储,直接从快照取数组用取模循环,最后一个的下一项绕回第一个,只有一个事件时提示“只有一个事件,无需切换”。切换有三个入口:“上一个”“下一个”、单击卡片。

按键只认 Enter 和上下方向键。Backspace 直接放行交给系统返回;event.repeat 挡掉长按连发;收音中按确认键不算数,免得被当成“说完了”。Enter 统一进 activate 按模式分发,点按钮则先把焦点挪过去再走同一个 activate,两条路不 fork。

两处小防守:go 切模式时顺手清掉旧的状态提示,上一步的报错不会留到下一步;notice 里只有 _alive 为真才 setData,onHide 之后迟到的提示写不进已经离开的界面。onShow 回来时重新 load,别处改过的数据能提前看到。

视觉上,480 × 352 黑底绿字,倒数字放在大字位置,四位数会把字号从 44px 降到 34px,避免顶出卡片;数字和单位同一行、基线对齐,字形不会被裁成一条横线。

rules 模式固定列七条使用说明:两个按钮的固定话术、三种日期写法、无年份顺延、“上一个”“下一个”与单击卡片两种切换方式、删除要再确认一次、只存本机且最多 20 条。这份说明和代码里的约束是同一套,改行为时说明得跟着改。

8. 后期迭代

我这个智能体虽然轻量,但值得升级的点还有很多,后面我想按下面这些进行升级一轮:

  • 中文数字日期。现在只认阿拉伯数字,“十二月二十日”这类说法解析不出来,用户得重说一遍。先把常见月份和日期的中文数字补进解析,和阿拉伯数字并到同一条出口。

  • 列表视图。20 条事件现在只能一张张翻,加一个按日期远近排序的总览页,翻页之前先看全局。

  • 到期提醒。数据只有打开页面才会被读到,任何日期都不会主动通知。要开口就得动后台能力,打破“不联网、只在本机”这条边界——做之前得先把产品定位重新想一遍。

更多推荐