一、为什么做这个应用
纸巾、键盘、显示器,这些东西每天都摆在眼前,可真要说出它们的日语名字,未必能立刻想起来。我想做的日语学习助手,就是让用户戴着眼镜拍下眼前的物品,看它用日语怎么写,再听一遍发音。对刚入门的日语学习者来说,身边熟悉的东西,随时都能成为学习材料。
第一版的操作很简单:拍照后生成词卡,左右滑动换词,轻触即可听发音。每张词卡保留日语词形、平假名、罗马音、词性和中文释义。跟读、评分和例句功能留到后续版本。
二、场景和用户意图
用户说 “打开日语学习助手” 或 “拍照学日语” 后,眼镜就会打开学习页面。语音唤醒和意图识别交给宿主处理,应用负责后续的拍照、查词和听音,用户沿用平时操作眼镜的习惯即可开始。
点下首页的 “开始使用” 后,应用先进入 “对准物品” 页面,准备好后再轻触 “开始拍照”。
我希望 “意图驱动” 能落在这些具体操作上:想学眼前的物品,就根据照片生成词卡;想听当前单词,轻触便播放这个词的发音;某个词的发音加载失败,就在这张卡上原地重试。页面结构用 Ink 编写,识别结果决定展示哪些词卡,任务进度决定显示什么提示。
这条流程既要让用户清楚下一步做什么,也要在出错后保留继续操作的途径。因此,拍照由用户主动触发,等待时区分相机和识别两种状态,失败后尽量在当前步骤恢复。
三、方案设计
对这个应用来说,更重要的是让用户能够自由拍摄 —— 桌面、厨房,或是路上遇到的东西,都不应该被预先限定。沿用乐奇宿主的识图能力,把这一版的重点放在查词、发音和交互上。
模型、词典和音频服务各自承担一部分工作:模型负责理解照片中的物品,词典提供词形、读音与词义之间的对应关系,音频服务再为选中的词准备发音。这样既能发挥模型理解场景的能力,也能让学习内容拥有稳定的词条依据。
能确定的词直接出卡,有歧义的则由模型结合物品说明继续选义。分析结束后仍无法判断的物品会被略过;遇到网络中断或请求超时,则保留重试入口。
词典没有收录时怎么办
有时模型已经认清了物品,也给出了日语名称,词典里却查不到。针对这种情况,应用保留了 AI 补充机制:把名称、假名、中英文含义、物品说明和准备好的音频存入单独的补充库。以后再次遇到同一物品,就可以复用这份资料,并继续显示 “AI 补充” 标记。复用前仍会比较含义,避免把同名的不同物品混在一起。
补充词的保存也有明确条件:词典查询正常完成且确实没有结果,文字资料完整,音频准备成功。如果查询超时,则进入重试流程。
架构设计:把交互、模型与可复用数据分开
系统由眼镜应用、乐奇宿主和自有服务器三部分组成。眼镜中的 AIUI 应用负责管理页面状态、任务和缓存;相机、LanguageModel 和 AudioPlayer 由乐奇宿主提供;词典、已有词音和补充词则保存在服务器上。照片交给宿主的视觉模型处理,后续的查词与发音准备只需要文字和词条信息。
终端代码按职责划分:index.ink 与 page.js 处理页面和镜腿操作,services.js 串联拍照、模型调用和网络请求,preparation.js 负责安排音频任务。音频缓存放在 services.js 中,准备队列决定哪些词该加载、同时加载几个。页面样式、识图和音频准备可以分别调整,减少改一处就牵动整条流程的情况。
服务端划分为三个职责清晰的服务。dictionary-service 的 /v1/lookup 用于查询完整 JMdict;lexicon-service 的 /v1/resolve 做确定性匹配,/v1/audio 提供已有词音,/v1/supplements 保存独立的 AI 补充;speech-service 的 /v1/speech 负责需要新合成的发音。普通词卡由客户端决定先取静态音频还是回退到合成,AI 补充服务则可以在内部请求动态词音。
把词典和词音放在服务器上,后续更新更方便,不同用户也能复用已有资源。但在线服务也带来了网络依赖,因此客户端保留了缓存、超时和单卡重试机制。识别出的词卡可以完整保留,发音则按需加载,避免用户还没开始学习,就要先等所有音频下载完。
四、AIUI 的关键实现
建立工程,打开全屏页面
项目使用官方脚手架创建。新建一个 AIUI 智能体工程,可以从下面这条命令开始:
npm create @yodaos-pkg/aiui-agent@latest my-agent
当然,也可以使用各种Agent工具进行开发,只需要安装Rokid AIUI的Skill即可
npx skills add https://github.com/jsar-project/AIUI/tree/main/skills/aiui-dev
与主流程有关的目录
app.json 智能体说明、页面和权限
app.js 应用入口
pages/index/index.ink 页面结构与 WXSS
modules/page.js 状态和镜腿交互
modules/services.js 拍照、模型与音频编排
modules/recognition.js 识图提示词及结果解析
modules/dictionary-learning.js 词典核对与文本选义
modules/preparation.js 音频准备队列
dictionary-service/ 完整 JMdict 查询服务
lexicon-service/ 词典与补充词服务
speech-service/ 动态发音服务
app.json 中的 agentPrompt 描述应用可以承接的意图,pages 声明页面路径。当用户提出 “拍照学日语” 时,宿主据此打开 pages/index/index。由于拍照、切卡和听音都需要交互,页面以 _blank 全屏方式打开。
Ink 文件把页面结构、脚本和样式组织在一起。状态和交互逻辑集中modules/page.js,再通过 createPage 创建页面对象,入口文件因此可以保持简洁。下面是省略了词典和音频初始化参数的入口结构。
入口结构示意,不是完整可独立运行示例
<script setup>
import wx from 'wx';
import { createPage } from '../../modules/page.js';
// 完整参数见项目 index.ink
const page = createPage(undefined, wx);
export default {
...page,
onKeyUp(e) { return page.onKeyUp.call(this, e); }
};
</script>
<page>
<view class="shell">
<!-- 通过绑定显示当前任务状态 -->
</view>
</page>
页面使用 view、text、button、scroll-view 组织内容,通过 this.setData () 更新状态。布局上,普通词卡左右切换,长内容在卡片内部滚动;底部提示区位置固定,这样无论词语多长,都不会把操作提示挤出画面。
拍照和识别分别给出提示
modules/services.js 节选,deadline 与 assertVision 为项目封装
const camera = deviceApis?.media?.createCameraContext();
if (typeof camera?.takePhoto !== 'function') {
knownReason = 'unavailable';
throw new Error();
}
const photo = await deadline(camera.takePhoto({
quality: 'high',
mode: 'wide',
enableSystemPreview: true,
}), 20000, '拍照等待较久,请检查相机后重试。');
assertVision(ticket);
takePhoto 由用户轻触触发。在 Promise 返回之前,页面提示 “正在拍照,请保持稳定”;收到照片后才开始识别,并提示 “拍摄完成,可以自然移动”。此时还不知道最终会得到几张词卡,加载数量暂时显示为 “—/—”。把两个阶段区分清楚,用户就不必一直保持拍照姿势,等到识别结束。参数中的 wide 用于请求广角模式,实际视角由设备宿主决定。
接收照片时,程序会检查数据是否为空以及文件大小。只要超过应用设置的 10 MiB 上限,或相机调用失败,都会停留在拍照页并说明原因。用户可以在这里重新尝试,不必退回首页再走一遍入口。
让模型说明它看见了什么
首轮识图返回九个字段:japanese、reading、chinese、meaning、objectDescription、visible、lookupNames、confidence 和 x。其中,meaning 用于匹配日英词典中的释义,objectDescription 保留物品的具体含义,x 用于按物品在照片中从左到右的顺序排列,lookupNames 最多放两个确切的同义名称以辅助查词。罗马音则在本地根据假名计算。
提示词主要约束几种容易出错的情况:同类物体去重,不拆分零件凑数;区分纸质笔记本和笔记本电脑;把现场实物与屏幕、图案中的内容分开。照片里出现的文字只作为图像内容处理。confidence 记录模型对当前判断的把握程度,供后续筛选使用。
modules/services.js 中的实际多模态消息,外围超时与解析代码省略
const answer = await askModel(active, [{
role: 'user',
content: [
{ type: 'text', text: '识别本图,按objects结构返回。' },
{ type: 'image_url', image_url: { url } },
],
}], ticket, options.modelTimeoutMs,
'识别时间较长,请重试。', 'vision');
选义时通过 openModel 新建一个文本会话,请求中只携带物品信息,以及候选的 key、词形、读音、英文释义、词性和领域。
模型输出通过 promptStreaming 逐次读取。它采用轮询式接口,读到空结果时短暂等待,直到响应完整结束,再解析 JSON、核对词条。整个读取过程设有超时和输出大小限制,避免异常响应让页面一直等待。
让音频准备跟上词卡
词典命中的词卡先查会话中的音频缓存,再查服务端静态资源:优先使用词形与读音都对应的真人录音,其次是预置合成音,之后还有随包资源和动态合成作为兜底。AI 补充词由独立的 supplements 服务处理文字和音频。动态合成的音频会检查格式、时长和全文转写;静态音频则直接复用,不会在每次播放时重新做 ASR。
日语发音采用现有录音、预置词音加服务端合成的组合。
词卡数量不设固定上限,但音频按需准备:先预载前 20 张,内存中最多保留 20 段,后面的词点到时再加载。网络音频最多同时准备两个。当等待达到 45 秒时,只要已有识别结果,就先进入词卡页,其余工作在后台继续;超时仍未完成的词可以单独重试。这样即使照片中的物品较多,也不用等所有词都准备好才开始学习。
五、交互和等待反馈
进入学习后,画面一次只突出一个词。日语词形放在最显眼的位置,下面依次显示平假名、罗马音、词性和中文释义。以 “ケース” 为例,先看词形,再往下找读音和中文,轻触就能听一次日语发音。左右滑动可切换词卡,右上角显示当前位置和总数,底部用实心页码标出当前项。
等待提示会跟着实际进度变化:还在识图时,显示 “已加载 —/—”;拿到结果后,只有词条和音频都准备好的卡片才计入已加载数量。取景框中的扫描细线表示任务仍在进行。比起一个不断增长的百分比,我更希望让用户知道应用正在进行哪一步。
发音没有加载好时,词条和页码仍然保留,用户可以在当前卡片上点选重试。已经匹配到词条的,只重新准备音频;查词时遇到网络中断的,则带着已有的物品说明继续请求。一个词失败,不应该让用户重新拍整张照片。离开词卡页前,还会确认是否返回首页,以减少误触造成的中断。
六、调试与使用中的问题
打包与导入
工程通过 npm run build:online 生成在线安装包。这个版本从服务器获取词典和日语词音,生成的 .aix 文件可以直接导入 AIUI Studio。
生成在线安装包
npm run build:online
# 正式安装包
dist/japanese-coach-1.0.0-online.aix
# 可查看的正式工程目录
dist/studio-online-1.0.0/
在 Studio 中调试页面
在 AIUI Studio 中展开 “新建智能体”,选择 “.aix 导入”。导入后可以在同一工程内查看代码,再切到模拟调试页操作镜腿、设置本地图片输入。页面布局和大部分操作可以先在这里调整好,再到眼镜上实际体验。
调试时,除了看页面能否正常显示,还要把整个操作串起来检查:从首页进入拍照,拍完进入词卡,再切换、听音,最后返回首页。轻触和双击是否容易混淆,切卡后会不会残留上一个词的提示,都是只看单张页面时容易忽略的问题。
分页、重拍和百词缓存
分页选中状态不明显,是实际使用中发现的一个问题。页面虽然显示了当前序号,底部分页却没有突出当前位置;两边本应显示箭头的位置也变成了方框,让人很难判断该往哪个方向切换。
分页问题来自样式覆盖:通用的 selected 样式被后面的 dot 样式盖住了。修复时为分页单独定义选中样式,用实心绿底加黑色数字突出当前位置。箭头显示成方框,则是因为字体缺少对应字形;改用 view 的边框和旋转来绘制后,就不再依赖箭头字符。切到第 2 张时,右上角的 02/05 与底部实心的 “2” 便能保持一致。
修复后:当前页高亮,左右方向清晰可见
另一个问题出现在第二次拍照时。用户返回首页后再次拍摄,上一轮相机或模型的结果可能刚好返回,干扰新一轮操作。为此,页面给每轮任务分配一个 ticket,回调只有在页面仍可见且 ticket 一致时才能更新界面。离开页面时,还会停止动画、音频和准备队列。拍照失败则停留在相机页,显示原因,等用户再次轻触后重试。
modules/page.js 中的生命周期保护节选
valid(ticket) {
return this._visible && ticket === this._ticket;
}
// cancelWork 中同时取消工作,并使旧回调失效
this._ticket += 1;
this._audioTicket += 1;
词卡多了以后,还要处理好预加载与缓存之间的关系。缓存只能保留 20 段音频,如果继续为后面所有词下载发音,前面的音频就会不断被挤出去。因此目前只预载前 20 张的词音,后面的词卡完整保留,点到哪张再加载哪张。这样既保证了学习顺序,也不会为了暂时还没看到的词而无谓等待。
让等待过程更清楚
拍照后的等待时间,是我比较在意的体验问题。要优化它,先得把拍照、识图、查词和准备音频这几个环节分开来看。相机还没返回照片,和模型正在识别图片,是完全不同的两个阶段,界面不能始终只显示一句 “加载中”。
程序会分别记录模型首段响应、完整响应、查词和音频准备的耗时,便于定位等待发生在哪一步。界面则显示用户能理解的状态:正在拍照、正在识别、正在准备发音。一旦已有词卡,就先展示结果,剩下的继续准备,避免用户只能盯着等待页。
在桌面图片的示例中,识别结果被整理成 7 张词卡,第一张是 “本”。用户可以逐张查看这些物品的日语,不需要再从一大段模型回答里翻找单词。右侧保留的本地图片输入,也方便在 Studio 中对照识别结果。
在眼镜上实际使用
下面两段是实机录像,记录了词卡切换、听音和返回首页的完整过程。
做这个应用,我最在意的始终是一个很简单的过程:看到东西,想知道它的日语,拍下来就能学。反复确认词义、等待时不知道能不能移动、返回后没法再拍,都会打断这个过程。功能可以慢慢增加,但这些日常操作应该先做顺手。
参考资料与第三方来源
AIUI Studio(中国站):AIUI Studio
AIUI 文档:AIUI 智能体开发框架与文档 | Rokid
AIUI Studio 快速入门:AIUI Studio 快速入门 | AIUI | Rokid;version=latest
AIUI GitHub项目:github.com/yodaos-proj…
AIUI Gitee项目:gitee.com/jsar-projec…
AIUI Skill:github.com/jsar-projec…
AIUI Demo案例:github.com/jsar-projec…
Awesome AIUI项目地址:github.com/yodaos-proj…
AIUI AIX-CLi:
AIX-CLI项目地址:GitHub - yodaos-project/aix: The AI Executable file format · GitHub
AIX-CLI网站:AIX
EDRDG JMdict 项目与许可:www.edrdg.org/wiki/JMdict…
EDRDG 许可说明:www.edrdg.org/edrdg/licen…
Tofugu 与 WaniKani 日语词汇发音库:github.com/tofugu/japa…
词典使用 EDRDG 的 JMdict,真人录音来自 Tofugu、WaniKani 的日语词汇发音库。相关许可说明保留在正式工程的 NOTICE 中,数据及改编内容遵循 CC BY-SA 4.0。录音库未覆盖的词使用合成音,部分中文释义和补充词由模型生成。AI 编程助手协助了项目实现、问题排查与文章整理。