前言:出门的小事,放到同一张卡上

取快递、买牛奶、送洗衣服,都是几分钟的小事,凑在一次出门里,就需要记住先后顺序。到了快递柜旁边,可能临时想先去超市;一件事办完后,又要知道下一件是什么。这样的安排适合做成一张随进度变化的行动卡片。

想到把这些小事放到眼镜里,我先问了一个很具体的问题:抬眼看过来,能不能立刻找到“现在办什么”。因此页面围绕当前事项和下一件事来排布,复杂操作放到后面的管理页。

这次我用 AIUI 做了“顺手办”:接收一组要办的事,先给出清单让用户核对;确认之后进入办理状态,把当前事项放大,显示下一件和完成进度。中途可以调整顺序、删除任务,也能撤销上一次保存的操作。

本文的运行图均来自 AIUI Studio 模拟器。下面先用按键入口演示一遍,方便在没有可用麦克风的环境里复现。语音入口的实际测试结果放在后面的验证部分,按键演示不代表语音识别已经通过。

1. 演示效果:清单、核对与办理

我选的例子是“下班后先取快递,再买牛奶,最后送洗衣服”。这句话包含三项任务和一个明确的先后关系。页面收到它以后,需要先把理解结果摆出来,给用户一次检查的机会。

这里有三个不同的时刻:刚收到安排时,清单还只是草稿;按下确认后,它才成为本机记录;开始办理后,页面再根据未完成任务推导出当前事项。把这三步拆开,才能让“取消”真的有意义。

例如原来已经有一份清单,这次又报了三个新任务。预览页会写明“将替换原清单”,同时完整列出新任务。用户选择取消,原来的顺序和完成状态都保留;用户确认,才执行替换。这里没有“识别到几个词就直接保存”的旁路。

这条流程落在三张不同的画面里:收到安排先列清单,确认后显示总览,开始办理再放大当前事项。取消停在保存之前;管理操作另开一页,办理时不用一直面对删除、排序和改名按钮。

当前场景

接收的意图或操作

页面反馈

新的安排

先取快递,再买牛奶

生成待确认的任务清单

调整顺序

把取快递移到最后

显示调整后的顺序,等待确认

改名字

把买牛奶改成买酸奶

显示旧名称、新名称和完整清单

办理一件

完成当前

标记当前项,再显示下一项

临时变动

稍后再办

预览将当前项移到最后的结果

操作有误

撤销上次

恢复最近一次操作前的状态

表里的自然语言是当前解析器支持的明确说法。自由表达仍要受任务边界和解析能力约束,例如“找个离我最近的超市,顺便看哪个快递柜方便”涉及位置和路线,这一版不会补出一个看似合理的结果。

宿主怎样把任务交给页面

工程里只有一个主页面 pages/board/board.ink。首页、预览、总览、办理和完成,是同一个页面里的不同状态。任务数据保存在独立模块里,页面根据数据组织展示内容。

errand-board/
├── AGENTS.md
├── app.json
├── app.js
├── assets/icon.png
├── modules/
│   ├── domain.js
│   ├── input.js
│   └── storage.js
└── pages/board/board.ink

AGENTS.md 告诉宿主什么情况下调起页面,以及怎样传递参数。普通打开只传 from=agent,页面读取已有清单;新安排可以传用户原话 text,也可以传已经明确的 tasks 数组。后者适合宿主已经完成语义理解的情况,页面仍会继续做校验和确认。

页面入口的声明放在 board.ink 的 <script type="application/json" def> 中。截图保留了实际文件里的声明写法,下面给出其中的 JSON 配置,方便复制后核对参数。

{
  "width": 480,
  "height": 352,
  "navigationBarTitleText": "顺手办",
  "description": "将办事安排变成行动清单;修改先预览并由用户确认。按任务阶段显示空状态、确认清单、当前任务、进度和完成反馈。",
  "schema": {
    "data": {
      "type": "object",
      "properties": {
        "text": {
          "type": "string",
          "description": "用户安排或调整任务的原话;不传则读取本机清单"
        },
        "tasks": {
          "type": "array",
          "items": {
            "type": "string",
            "maxLength": 18
          },
          "maxItems": 8,
          "description": "已明确的任务,每项最多18字。不编造或补充用户未说的内容"
        },
        "from": {
          "type": "string",
          "enum": [
            "agent",
            "nav"
          ]
        }
      },
      "required": []
    }
  }
}

把任务限制为八项、每项十八字,是这一版界面和交互的取舍。超过限制时提示用户重新整理,不会截掉后面的任务再显示“保存成功”。名称相同的两项也会拒绝,用户可以补充对象或地点,让后面的调整指令有明确目标。

本地解析只处理常见的顺序表达。下面是项目实际使用的完整 parseTasks 函数;“买牛奶和面包”会保留为一项,不会按每一个“和”字拆开。

export function parseTasks(input) {
  let list;
  if (Array.isArray(input)) list = input;
  else {
    const raw = String(input || '').trim();
    if (raw.length > 320) fail('安排太长,请分成八项以内');
    const text = raw.replace(/^(?:(?:请|帮我|给我|我想|我要|今天|下班后|出门后|出门前|安排一下|安排|要办的事是|要办))+/g,'');
    list = text.split(/[,,、;;\n]+|(?:然后|接着|最后|再(?=[买取送去拿还寄接交缴办打修洗]))/g);
  }
  if(list.some(v=>typeof v!=='string'))fail('任务需要是文字列表');
  const titles = list.map(v => clean(v).replace(/^(?:先|再|然后|接着|最后|顺便|以及)+/g,'')).filter(Boolean);
  if (!titles.length) fail('请说出要办的事,例如先取快递,再买牛奶');
  if (titles.length > MAX_ITEMS) fail('一次最多安排八件事');
  if (titles.some(t => Array.from(t).length > MAX_TITLE)) fail('每件事请控制在18字内');
  if (new Set(titles).size !== titles.length) fail('有重复任务,请补充地点或对象');
  return titles;
}

这里没有请求外部模型。宿主如果提供明确的任务数组,就直接校验数组;收到原话时,才走这段有限的文本规则。复杂安排应继续由宿主理解或请用户说清楚,页面不猜测用户没有提过的事。

任务副本与确认提交

数据模型里,我保留了两份不同的东西:plan 是已经提交的记录,pending 是这次待确认的修改。生成预览时复制 plan,所有改动都先发生在副本上。页面渲染的是副本里的完整任务,因此排序和删除的后果可以直接核对。

pending 还记着生成它时的 revision。确认时先检查这份预览是否仍对应当前记录,再读取文件核对已有数据。如果另一处已经改了清单,就要求重新载入,避免用旧预览覆盖新数据。这个检查适用于当前应用流程,没有提供跨进程原子比较交换,也没有实现多设备同步。

控制器里的提交代码在 modules/domain.js 中。截图里最关键的是第 104、105 行:先等待文件写入,再更新内存记录。read、validatePlan 和 snapshot 与它处在同一控制器闭包中,完整上下文随项目源码提供。

async function write(next,keepUndo) {
    if(writing)fail('正在保存,请勿重复操作');writing=true;
    try{
      await ensureFresh();
      const saved=validatePlan({...next,schema:1,revision:plan.revision+1,undo:keepUndo?{items:plan.items,started:plan.started}:next.undo});
      await storage.write(JSON.stringify(saved));
      plan=saved;return snapshot();
    }finally{writing=false;}
  }
async function confirm() {
    if(!pending)fail('没有待确认的修改');
    if(pending.base!==plan.revision)fail('预览已过期,请重新调整');
    const result=await write(pending.next,true);pending=null;return result;
  }

await storage.write(...) 放在更新 plan 前面,决定了界面什么时候可以说“已保存”。失败时,内存里的旧记录仍在,预览也保留,用户可以重试。页面在异步操作期间会阻止重复提交,控制器内部也会拒绝并发写入。

任务标识使用独立的 id。改名只改变标题,排序只改变数组顺序,二者都不会制造一条“新任务”。匹配调整目标时先找完整名称或编号,再允许唯一的包含匹配。如果“买”同时命中买牛奶和买面包,就要求说完整名称,不能随便选第一条。

办理页:当前这一件

确认清单后,可以先在总览里核对,再选择“开始办理”。办理页从未完成任务中取第一项作为当前任务,下一项放在下面,顶部显示已办数量和进度条。当前项、下一项和进度都从同一份数据推导,避免页面上各自维护一个数字。

export function progress(plan) {
  const done = plan.items.filter(v => v.done).length;
  return {done,total:plan.items.length,current:plan.items.find(v=>!v.done)||null,next:plan.items.filter(v=>!v.done)[1]||null};
}

按下“完成当前”,会将当前任务的 done 改成 true,提交后再重新计算页面。这个完成状态表示用户手动标记已办;应用不会替用户购物、取件或付款,也不会因为显示了完成卡片就声称现实里的事情已经发生。

“稍后再办”没有直接把当前项扔到队尾。它会先打开预览,让用户看到完整的新顺序,确认后才切换当前事项。只剩一件未完成任务时,会提示不需要暂缓,避免围着同一件事打转。

下面的连续操作包含暂缓、确认、完成一件、重新运行和撤销。重新运行后仍保留进度,随后撤销的是最近一次“完成”,而不是把整份清单清空。

删除预览与一次撤销

总览里的“逐项调整”按一次只看一项的方式展示任务,提供上一项、下一项、向前移动、移到最后和删除。这样不需要在小画面里同时塞下八行任务和一排细小的操作按钮。

删除会把剩余清单完整展示出来。示例里删除买牛奶后,预览只剩送洗衣服和取快递;选择取消,原任务马上回到办理流程,完成数量也不会变化。

撤销采用一层快照:每次需要支持撤销的提交,把上一次的任务数组和办理状态一起写入 undo;执行撤销后清掉这份快照,避免反复来回切换。开始办理只改变展示阶段,会保留已有撤销记录;没有改变名称或位置的空操作也不会消耗撤销机会。

进度与撤销写在同一个文件里,因此重新运行以后仍能撤销最近一次已提交的操作。这里没有承诺恢复任意历史版本,一层撤销的范围会直接写在操作提示中。

重跑后清单丢了:定位存储问题

最初我用普通键值接口保存记录。同一次运行里,确认、完成和撤销都正常;点击模拟器的“重新运行智能体”,清单却回到了空状态。只检查页面切换,会漏掉这个问题。

随后我分别写入了一个键值标记和一个 OPFS 文件,再重新运行。当前测试环境里,键值标记没有读回,OPFS 文件能够读回上一轮写入的内容。于是这一版改用 Agent 私有文件保存清单,写入前核对旧记录,文件提交后再回读验证。这个结果只描述本次环境里的对照测试,没有据此推断所有宿主上的键值存储都有相同行为。

另一个问题出现在内容变短时。比如 started 从 false 改成 true,整段 JSON 会少一个字符。当前文件写入行为会留下旧内容的尾部,导致下次解析失败。修复后,每次写入完整快照之前先截断缓冲区并定位到开头,提交后再比较读回的文本。

在 modules/storage.js 中,这次修复对应第 19~23 行:先清空写入缓冲区,再定位、写入、关闭,最后回读比较。截图用来找这几步的位置,下面保留完整模块。getFileHandle 的第二个参数也显式传入;当前运行时省略它会报参数数量错误。

const FILE='shunshouban-plan-v1.json';
export function createFileStorage(provider){
  async function root(){return await provider.getDirectory();}
  async function readFile(handle){return new TextDecoder().decode(await (await handle.getFile()).arrayBuffer());}
  return {
    async read(){
      try{
        const directory=await root();
        let handle;
        try{handle=await directory.getFileHandle(FILE,{create:false});}
        catch(e){if(e&&e.name==='NotFoundError'||/^NotFoundError\b/.test(String(e)))return null;throw e;}
        return await readFile(handle);
      }catch(e){console.warn('清单文件读取失败',String(e));throw Error('读取失败,请重试;请保留已有清单文件');}
    },
    async write(value){
      try{
        const directory=await root(),handle=await directory.getFileHandle(FILE,{create:true});
        const writer=await handle.createWritable();
        await writer.truncate(0);
        await writer.seek(0);
        await writer.write(value);
        await writer.close();
        if(await readFile(handle)!==value)throw Error('保存后核对不一致,请重新读取');
      }catch(e){console.warn('清单文件保存失败',String(e));throw Error('保存未确认,请重试或重新读取');}
    }
  };
}
export const storage=createFileStorage({getDirectory(){
  if(typeof navigator==='undefined'||!navigator.storage)throw Error('宿主暂不支持持久化文件存储');
  return navigator.storage.getDirectory();
}});

读取失败时,首页会变成重试入口,禁止继续修改;不能把读取失败当成一份空清单再覆盖保存。若宿主未提供 OPFS,同样会提示读取未完成,不静默降为临时内存。这部分使用的是 AIUI 官方文档中的 OPFS 能力,需要在实际运行宿主上验证可用性。

按键备用入口与语音测试

这一轮测试中,页内 SpeechRecognition 返回了 network / Failed to open browser media stream。失败发生在浏览器媒体流打开阶段,因此这次没有完成真实麦克风输入和语音唤起整条链路的验收。平台接收了操作播报请求,也不能由此推断眼镜扬声器的实际效果。

备用入口做成了“按键选事项”:选择常见任务,加入清单,最后统一预览。它沿用同一套确认和存储逻辑,不会绕开保护;按钮也明确写着按键选择和载入示例,不让读者把演示数据误认为识别结果。自由任务与改名仍可以通过宿主的页面参数进入,页内语音需要可用的宿主通道。

收音流程还有几个限制:用户主动发起才开始;启动等待超时后恢复操作;自动接收最终识别结果,手动结束才允许使用已经收到的临时结果;页面隐藏或退出时停止本轮识别,并让过期回调失效。这样即使晚到一个识别结果,也不会在用户离开后偷偷生成新预览。

按键也不能只写一个“方向键加一”。一次滑动可能出现成对方向事件,所以同方向的短时间重复会去重,方向事件之后留一个短暂的确认间隔,GlobalHook 不参与确认。配合办理页的单一高亮项,能降低滑一下又误触一次确认的概率。

export function keyAction(page,event) {
  const code=event&&(event.code||event.key);
  const dir=code==='ArrowUp'||code==='ArrowLeft'?-1:code==='ArrowDown'||code==='ArrowRight'?1:0;
  if(!dir&&code!=='Enter'&&code!=='Backspace')return '';
  if(event.preventDefault)event.preventDefault();
  if(event.repeat)return '';
  const now=Date.now();
  if(dir){
    if(page._lastDirection===dir&&now-page._lastSlideAt<240)return '';
    page._lastDirection=dir;page._lastSlideAt=now;page._confirmAfter=now+120;
    return dir>0?'next':'prev';
  }
  if(code==='Backspace')return 'back';
  return now<(page._confirmAfter||0)?'':'confirm';
}

39项检查之后,还需要验证什么

最终保留了 39 项本地检查,覆盖解析边界、重复和含糊任务、预览与取消、文件提交失败、并发提交、撤销、重新载入以及识别回调失效等情况。文件测试还特意模拟了“覆盖短内容保留旧尾部”的行为,避免又被一个过于理想的存储替身掩盖。

平台按键流程单独跑了一遍。两段动图分别展示创建清单,以及调整顺序、完成与撤销;都保留实际操作停顿,连续采样平均约23帧/秒。语音是否识别成功,另看上一节的日志。

验证项目

本次结果

本地逻辑与异步存储检查

39 项通过

按键建清单、确认、取消

模拟器通过

开始办理、暂缓、完成、撤销

模拟器通过

删除预览与取消

模拟器通过

重新运行后恢复进度、撤销和完成状态

模拟器通过

页内真实麦克风识别、宿主语音唤起

当前环境未验收

真机佩戴体验、商店审核与上架

尚未验收

项目在 AIUI Studio 中使用独立智能体保存,并配置了专用图标。平台打包成功表示生成了调试版本,距离商店上架还需要审核。

最后核对包信息时,还发现了一个声明格式的兼容差异:简写的 <script def> 能在当前预览器里显示页面,平台却没有导出页面工具。改成 <script type="application/json" def> 重新打包后,包标题、页面路径和 text、tasks、from 入参才完整出现。预览结果与工具导出结果需要分别检查,宿主调起页面还依赖后者。

9. 总结:三件小事,逐项办完

“顺手办”这次跑通了从三件小事到逐项办理的过程:暂缓取快递,先去买牛奶;完成一件后,进度和下一件一起更新;发现点错,还能撤销最近一次提交。后续要优先补齐的是真实收音和眼镜联调,清单本身的确认、取消和恢复规则继续保留。

完整项目源码:https://gitcode.com/aasd23/errand-board

仓库包含页面、解析与存储模块、独立图标和 39 项检查脚本。导入时请新建智能体,避免覆盖已有项目。

资料与素材

工程源码:errand-board。技术接口:AIUI OPFS接口。

更多推荐