ARTICLE · 1266911
从源码拆解一个 AIUI 每日一词智能体:页面、离线索引与硬件交互
前言
本篇文章主要讲解词境应用的开发过程中涉及到的问题以及相关的思路方法。先看一下项目相关截图。


项目运行结构
项目的主要运行关系可以概括成:
app.json
├─ 注册 pages/daily/index
└─ 打开智能体时触发 workers/bootstrap.js
app.js
└─ 初始化全局状态与会话 ID
pages/daily/index.ink
├─ lib/wordIndex.js 本地词库索引与取词
├─ lib/focus.js 焦点、键盘配对、镜腿触摸保护
├─ lib/speech.js 朗读与宿主能力降级
├─ lib/session.js 最近查看日期
└─ services/pushService.js 可选缓存消息
词库资产位于 assets/words.js,统计为 4 套教材、40 个册次、322 个单元、4423 条词。页面取词不依赖网络。tools/daily-push.mjs 则是另一条服务端路径:它需要外部调度器按时运行,不是眼镜端 Worker 的定时任务。

app.json:声明页面与 Worker
app.json 只注册一个页面,并配置一个由打开事件触发的前台 Worker:
{
"pages": [
"pages/daily/index"
],
"window": {
"navigationBarTitleText": "词镜"
},
"agentWorkers": [
{
"name": "bootstrap",
"script": "workers/bootstrap.js",
"trigger": {
"type": "open"
},
"lifetime": "foreground"
}
]
}
pages 决定页面路由;agentWorkers 声明 Worker 的脚本、触发条件和生命周期。这里的 trigger.type 是 open,不是定时器。若需要到点推送,必须由服务端或 CI 定时执行推送脚本,不能把 open 触发解释成后台每天运行。
当前配置没有声明权限。词库是包内资产;朗读调用宿主提供的 TTS 能力。不要仅因为测试桩里出现相机或语音识别 API,就推断正式页面具备这些功能。
app.js:应用生命周期与会话标识
应用入口保持轻量,只创建一个会话 ID,并记录进入、离开前台的生命周期:
functionnewSessionId() {
try {
if (
typeof crypto !== 'undefined' &&
typeof crypto.randomUUID === 'function'
) {
return crypto.randomUUID();
}
} catch (err) {
// 宿主不支持时继续使用时间戳方案
}
return'cijing-' + Date.now().toString(36);
}
exportdefault {
globalData: {
agentName: CONFIG.agentName,
sessionId: '',
wordReady: false,
},
onLaunch() {
this.globalData.sessionId = newSessionId();
},
onShow() {},
onHide() {},
};

randomUUID() 可用时提供更强的唯一性;回退 ID 只用于缺少该能力时继续启动。这个回退不应被当成密码学令牌,也不适合做安全鉴权。
入口没有导入云端或模型模块,目的是让核心启动不被可选能力拖住。另一个需要准确描述的细节是:wordReady 当前初始化为 false,仓库代码没有将它改成 true。它不能作为“词库已就绪”的可靠状态信号。
bootstrap Worker:打开时做什么、不做什么
Worker 的入口把异步任务注册到事件生命周期:
onOpen(event) {
event.waitUntil(this.ensure());
}
ensure() {
if (!this.pending) {
this.pending = this.verify().finally(() => {
this.pending = null;
});
}
returnthis.pending;
}
event.waitUntil() 告诉宿主这次打开事件仍有异步工作;pending 则合并同一个 Worker 实例中同时到来的自检请求,完成后清空,允许后续再次检查。它不跨 Worker 重启持久化,也不负责页面状态。
verify() 调用 getStats() 和 getByDate(),检查索引是否能产出当天词并打印日志。这里有两个边界:统计不一致当前只记录日志,不会中止应用;Worker 与页面不在同一个 JavaScript 上下文,Worker 构建的索引不会被页面直接复用。页面仍会在自己的上下文调用 getByDate()。
所以这个 Worker 是启动自检,不是共享索引预热,也不是严格的启动门禁。写监控或启动状态时,不能把日志“自检通过”误当成强校验结果。
唯一页面:pages/daily/index.ink
AIUI 页面集中在一个 .ink 单文件中,当前页面由四个区块组成:
<scriptdef>
{
"navigationBarTitleText": "词镜 · 每日一词",
"description": "从本地词库按当天日期取词并支持朗读。",
"schema": {
"data": {
"type": "object",
"properties": {
"date": { "type": "string" }
},
"required": []
}
}
}
</script>
<scriptsetup>
exportdefault {
data: {
pReady: false,
pError: false,
word: '',
meaning: ''
},
onLoad(query) {
// 初始化页面状态并读取当天词条
}
};
</script>
<page>
<viewink:if="{{pReady}}">
<text>{{word}}</text>
<text>{{meaning}}</text>
</view>
</page>
<style>
/* 页面视觉样式 */
</style>
这是结构示意,不是可直接替换的完整页面。真实页面还包含推送条、音标、分类、出处、焦点按钮和错误态。script def 声明页面元信息与可选 date 入参;script setup 放状态、生命周期和方法;page 使用 WXML 风格模板;style 放页面样式。
页面状态怎么组织
页面将正常态和错误态分开:
pReady控制词卡是否显示;pError控制错误信息与重试按钮;focusIdx和focusCount表示焦点位置及可选项数量;record保存当前完整词条,data中的word、meaning等字段用于模板显示;closed用于阻止页面卸载后的延迟动作。
正常态有两个操作:朗读和换一个。错误态将焦点数量收敛到一个重试操作。这个状态划分让输入处理不必猜“当前按钮是否存在”,只需根据当前态更新焦点范围。
onLoad() 与当前异步顺序
加载时页面创建输入守卫、读取日期、读取词库统计,然后请求缓存推送。当前代码在推送 Promise 完成后才调用 render():
consumeCachedMessage().then((push) => {
if (push && push.content) {
self.setData({ pushText: push.content, showPush: true });
} else {
self.setData({ pushText: '', showPush: false });
}
self.render();
});
服务层会把 API 异常转换为 null,所以正常失败最终仍会进入本地取词。但如果请求一直 pending,本地词卡也会一直等。这说明“错误可降级”和“网络请求永不阻塞”是两件事。更稳妥的改法是立即执行本地 render(),再异步更新推送状态,并在异步回调开始处检查 closed;否则页面卸载后回调仍可能先调用 setData()。
同样要注意,onUnload() 虽然会设置 closed = true 并取消镜腿计时器,但这并不会自动保护所有异步回调。每个回调在触碰页面实例前都需要做生命周期检查。
render():把词条映射到 UI
页面根据日期和“换一个”的偏移构造取词键:
render() {
if (this.closed) return;
try {
const key = this.shift > 0
? this.dateKey + '#' + this.shift
: this.dateKey;
const record = getByDate(key, scopeFilter());
if (!record) {
this.setError('今天暂时取不到词');
return;
}
this.record = record;
this.setData({
pReady: true,
pError: false,
word: record.word,
phonetic: record.phonetic ? '/' + record.phonetic + '/' : '',
showPhonetic: !!record.phonetic,
meaning: record.meaning,
category: record.category,
source: record.versionName + ' · ' + record.grade +
' · Unit ' + record.unitNo
});
this.setFocus(0);
} catch (err) {
this.setError(err && err.message ? err.message : '取词失败');
}
}
try/catch 将索引构建或数据读取异常转为页面错误态,而不是让方法异常退出后留下空屏。音标同时保存展示文本和 showPhonetic 布尔值,模板据此隐藏整行;这比在模板表达式中重复处理空值更清楚。
错误态通过 setError() 清除当前记录、设置错误标志并将焦点切到重试按钮。activate() 检测到错误态时重新调用 render(),因此重试沿用同一个取词路径,不复制一份初始化逻辑。
当天访问提示的顺序问题
当前 render() 中先执行 markDailySeen(dateKey),然后再读 lastDailySeen() 决定显示“今天已看过”还是“今天第一次打开”。由于读之前已经写入,当天首次打开也会读到当天日期,首次访问分支因此无法按预期工作。
正确的判断顺序应当是先读旧值、算出提示,再写入新值:
const seenBefore = lastDailySeen();
const seenNote = seenBefore === this.dateKey
? '今天已看过'
: '今天第一次打开';
markDailySeen(this.dateKey);
这是根据当前代码提出的修正示例,不是仓库现有实现。它也展示了一个常见状态 bug:读写 API 单独看都正确,顺序错误仍会让业务判断失效。
本地词库:从层级资产到扁平索引
源数据按教材、册次、单元、词条组织。lib/wordIndex.js 的 buildIndex() 遍历层级结构,生成适合筛选和页面展示的扁平记录:
const record = {
id: version.code + '/' + gradeKey + '/' + unit.unitNo + '/' +
normalizeWord(w.word),
word: safeText(w.word, ''),
phonetic: normalizePhonetic(w.phonetic),
meaning: safeText(w.meaning, ''),
category: normalizeCategory(w.category),
versionCode: version.code,
versionName: version.shortName || version.name,
grade: grade.grade,
gradeKey,
unitNo: unit.unitNo,
unitTopic: safeText(unit.topic, '')
};
records.push(record);
cachedIndex 让同一个页面上下文内的索引只构建一次。数据仍保留教材、册次、单元等来源信息,因此筛选与出处展示使用同一份规范化记录,不用回头猜数组嵌套位置。
当前导出的 listVersions()、listGrades()、listUnits()、findWord()、getById()、getByMeaning() 等是库 API;唯一页面主要调用 getStats() 和 getByDate()。博客应区分“模块提供的能力”和“产品已经有的页面功能”。
归一化解决的是比较问题
lib/normalize.js 将常见源数据差异在索引入口处理:
exportfunctionnormalizePhonetic(value) {
if (value === null || value === undefined) returnnull;
const text = String(value).trim();
return text.length > 0 ? text : null;
}
exportfunctionnormalizeCategory(value) {
const text = safeText(value, '');
if (text.length === 0) return'单词';
const cleaned = text.replace(/^[一二三四五六七八九十]+、/, '').trim();
return cleaned.length > 0 ? cleaned : '单词';
}
exportfunctiongradeKeyOf(grade) {
const text = toArabicDigits(grade);
const numMatch = text.match(/([1-9])/);
if (!numMatch) return safeText(grade, '');
const num = numMatch[1];
if (text.indexOf('上') >= 0) return num + '上';
if (text.indexOf('下') >= 0) return num + '下';
return num;
}
音标空值归一为 null,让模板能够明确隐藏;分类去掉编号前缀,避免同一分类出现多种展示;年级名转成短键,解决“ 一年级上 ”和“ 一年级上册 ”写法不同导致筛选失败的问题。原始 grade 仍保留用于展示,gradeKey 用于比较,二者不要混为一谈。
稳定 ID 与适用边界
记录 ID 由教材代码、年级键、单元号和规范化单词组成,适合在词条顺序变化时引用记录,比数组下标稳定。但如果同一教材、同一册、同一单元内允许同词异义,这些字段未必足以唯一标识词条;届时应把源数据 ID 或释义纳入标识。稳定 ID 是数据模型承诺,不是只要拼接字符串就自然成立。
确定性每日取词
普通 Math.random() 每次打开都可能换词,不满足同一天稳定的产品要求。项目将日期和筛选范围转为稳定种子,再映射到候选词池:
exportfunctiongetByDate(dateKey, filter) {
const key = dateKey || dateKeyOf(newDate());
let pool = selectRecords(filter);
// 产品策略:指定范围无记录时退回全库
if (pool.length === 0) pool = selectRecords(null);
if (pool.length === 0) returnnull;
const seed = hashString(
key + '|' + (filter ? JSON.stringify(filter) : 'all'),
);
const offset = (seed + dayNumberOf(key)) % pool.length;
return pool[offset];
}
同一日期、同一筛选对象的序列化结果和同一词库顺序会给出同一条记录。范围先过滤、再取模,因此正常命中时不会跑出范围。空范围回退全库是产品选择,不是普遍正确:严格授权或合规场景不应悄悄扩大范围。
项目的 dateKeyOf() 使用设备本地年月日,符合“本地今天”的交互预期;dayNumberOf() 再用 Date.UTC() 计算日序号,避免当前时刻和夏令时变化混入偏移计算。传入页面的自定义 date 目前只按字符串使用,没有严格格式校验,不能宣称任意输入都已验证。
“换一个”在日期键后加 #1、#2 等偏移,再调用同一个函数。它保证相同偏移可复现,但不保证连续换词绝不重复。需要严格不重复时,要维护当天已展示 ID,并在剩余集合中选择。
还有一个可复现性细节:JSON.stringify(filter) 受属性构造顺序影响。当前 scopeFilter() 固定按 versionCode、grade、unitNo 创建字段,主路径稳定;若未来从动态输入生成过滤对象,应先规范字段顺序再生成种子。
focus.js:把硬件输入变成页面动作
焦点模块输出固定数量的 f0 到 f11 布尔字段,页面模板只需绑定布尔值,不需要执行复杂动态表达式。moveFocus() 做循环索引,focusPatch() 负责生成焦点状态。对于只有两个按钮的页面,向上越过第一个会回到第二个,向下越过第二个会回到第一个。
键盘按下和抬起配对
宿主可能在一次物理操作中同时上报 keydown 和 keyup。如果两种事件都执行确认,一次 Enter 就会触发两次。项目的 createKeyOnce() 记录待配对状态:
exportfunctioncreateKeyOnce() {
const pending = {};
return {
down(code) {
pending[code] = true;
returntrue;
},
up(code) {
const wasPending = !!pending[code];
pending[code] = false;
return !wasPending;
}
};
}
收到 down 时放行;配对 up 返回 false,只清理状态;若宿主只上报 keyup,没有待配对的 down,该 up 仍会触发。
这和时间窗口去抖不同。固定 100ms 窗口可能吞掉用户快速连续按键;配对模型只抑制同一次按下/抬起造成的重复执行。它解决的是事件重复,不是传感器电气抖动,两类问题不要用同一套参数处理。
GlobalHook 延迟确认
项目收到的镜腿 GlobalHook 不携带可靠的“单击/滑动”分类。单个信号先等待约 200ms,期间又收到信号则取消待执行动作,并进入约 350ms 冷却:
push(run) {
const now = Date.now();
if (now - state.lastAt < settleMs) {
clearTimer();
state.lastAt = now + cooldownMs;
returnfalse;
}
state.lastAt = now;
clearTimer();
state.timer = setTimeout(() => {
state.timer = null;
run();
}, settleMs);
returntrue;
}
这是启发式过滤,不是手势识别。代价是单击延迟,快速双击可能被判为滑动,不同设备也可能需要不同间隔。onUnload() 调用 cancel() 清除待执行计时器;用户设备仍误触时,可通过 CONFIG.input.templeTap = false 关闭镜腿确认,只保留明确的 Enter 输入。
speech.js:有限的 TTS 降级链
页面不直接操作语音 API,而调用 speakWord(word)。模块按顺序尝试指定英文音色、默认音色,再尝试宿主 wx.speech.playTTS();全局路径默认在 immediate 和 enqueue 两种模式间回退。
exportfunctionspeak(text, options) {
const opts = options || {};
const content = String(text == null ? '' : text).trim();
if (!content) returnfalse;
const modes = opts.mode ? [opts.mode] : ['immediate', 'enqueue'];
if (ttsAvailable()) {
for (const voice of [opts.voice || EN_VOICE, null]) {
for (const mode of modes) {
try {
globalSpeak(content, {
lang: opts.lang || 'en-US',
voice,
volume: opts.volume === undefined ? 8 : opts.volume,
mode
});
returntrue;
} catch (err) {
// 当前音色或模式失败,尝试下一条路径
}
}
}
}
return hostSpeak(content);
}
上面为控制流程的缩写示例;项目真实实现将指定音色、默认音色、播放模式和宿主 TTS 分开处理。这里展示的重点是:语音兼容逻辑集中在一处,页面不承担宿主差异分支。
ttsAvailable() 只检查全局 speechSynthesis 和 SpeechSynthesisUtterance 是否存在,不检测 wx.speech.playTTS()。speak() 返回 true 也只说明某个调用路径被接受,不代表设备已经发声。当前页面用播放序号和单词回显,让点击后有可观察反馈;如需确认真实播放完成,应依赖宿主提供的完成事件,不能从同步返回值推断。
降级层数也不宜无限增加。每条回退路线都应有明确目的,并保留足够诊断信息;否则错误会被吞掉,最终只剩“偶尔没声音”这种难以复现的问题。
session.js:存储失败时保住页面可用性
最近查看日期优先使用宿主同步存储,异常时写入模块内存对象:
functionwriteKey(key, value) {
memory[key] = value;
try {
wx.setStorageSync(key, value);
} catch (err) {
// 持久化不可用,本次运行仍保留内存态
}
}
读写两边都用 try/catch,使权限或运行时存储异常不至于让取词页面整体失败。内存回退只保证当前模块实例存活期间可用,不保证应用重启后保留。把“有回退”准确描述为“本会话降级”,不要说成持久化已经成功。
推送:客户端消费与服务端发送是两条路径
pushService.js:客户端读取缓存
客户端调用 createOpenAPI() 后请求 getAIUICacheMessage()。无数据是正常空态,异常会记录错误并返回 null,页面只在内容非空时显示推送条。页面实际只使用 content;path、customData 目前没有参与业务逻辑。
源码注释把接口描述为“原子取走、取走即删除”,但客户端只能证明自己调用并解析了 API 响应,不能从本地代码证明服务端删除语义。文档应把它表述为接口契约,而不是客户端已验证的实现事实。
daily-push.mjs:服务端发起通知
服务端脚本从环境变量读取 ROKID_SK、ROKID_ACCOUNT_ID、CIJING_AGENT_ID,然后发送跳转到 pages/daily/index 的通知。可选的临时缓存还需要 ACCOUNT_ACCESS_TOKEN 和 AIUI_ENDPOINT。
const sk = requireEnv('ROKID_SK');
const accountId = requireEnv('ROKID_ACCOUNT_ID');
const agentId = requireEnv('CIJING_AGENT_ID');
const cloud = new CloudIntegration({ token: sk });
await cloud.sendNotification({
messageId: 'cijing-daily-' + today,
accountId,
message: {
agentId,
content: '今日单词已更新,抬手看一眼',
tool: {
name: 'pages/daily/index',
parameters: { type: 'object', properties: {} }
}
}
});
此处的 today 在实际代码中由 new Date().toISOString().slice(0, 10) 生成,是 UTC 日期;页面默认日期由本地时区生成。靠近 UTC 日界线时二者可能不是同一天,若推送内容需要和设备当天严格一致,应定义统一时区策略。
密钥只从服务端环境变量读取,不应写入页面、配置文件或日志。daily-push.mjs 需要外部调度器触发;它不因位于 tools/ 目录就自动定时运行。
gen-words.mjs:构建期数据裁剪
生成器把层级源 JSON 转成 assets/words.js,只保留运行时需要的教材、册次、单元与词条字段,并计算词数统计。缺失音标被输出为 null,缺失分类也保持 null,之后由索引归一化逻辑统一处理。
生成文件是机器产物,不应手工修改。当前生成脚本若不传参数,会使用开发者机器上的默认源文件位置,所以在新环境不能假设直接运行 npm run gen:words 就能成功。脚本支持传入源 JSON 和输出 JS 路径;分享项目时应提供合法的数据来源与路径配置方式,而不是把本机路径写进教程。
check 与 smoke:各自能证明什么
package.json 提供:
npm run check | |
npm run smoke | |
npm run verify | |
npm run gen:words |
tools/smoke.mjs 覆盖了同日稳定、日期变化、范围筛选、空范围回退、按键配对、镜腿连发抑制、TTS 降级和无缓存推送等行为。这些测试能验证纯逻辑和代码分支,但 Node 桩不能证明真机的 GlobalHook 时序、音色是否启用、扬声器是否出声或界面是否裁切。设备集成测试仍要单独做。
验证脚本不是设备模拟器;测试通过也不能替代真机验收。反过来,真机手测也不适合替代确定性算法的自动回归测试,两者覆盖的问题不同。
当前实现值得继续处理的边界
render() | ||
render() 前直接 setData() | closed,避免触碰已销毁页面 | |
markDailySeen() 再 lastDailySeen() | ||
ttsAvailable() | ||
wordReady |
这些边界都能从当前代码路径直接看出。把它们写出来比把项目包装成“所有异常都已处理”的范例更有参考价值:读者可以区分已有保证、接口契约和仍需改进的设计。
总结
这个项目的核心实现不是单独一张单词卡,而是一条从配置、启动、索引构建到页面交互的链路:app.json 注册路由和 Worker,app.js 处理应用生命周期,.ink 页面编排状态,wordIndex 提供可复现的本地结果,focus 和 speech 适配穿戴设备输入与宿主能力,服务层把推送保持为可选增强。















































































