1. 项目简介
要解决的问题很单纯:走在路上、植物园里或自家阳台看到一株不认识的植物,想知道它叫什么,但为此掏手机、解锁、打开 App、对焦、等结果,这一串动作和「抬眼看一眼」的成本完全不成比例。眼镜的第一视角天然适合这件事——所见即所问,答案应该出现在眼前,而不是掌心的另一块屏幕上。
目标用户是戴 Rokid 眼镜、对身边植物有即时好奇心的人:户外散步与逛植物园的人、做自然观察的亲子用户、园艺爱好者。他们要的是一句话结论和几个关键鉴别特征,不是养护教程或百科长文。
这个智能体只做一件事:拍一帧 → 识别 → 报出名字 → 存下来。不需要账号、不需要后端、不需要联网服务(模型能力由 AIUI 运行时提供)。识别范围不限于开花植物,观叶、多肉、蕨类等同样在提示词范围内;辨认模糊时明确回答「没认出来」,而不编造物种。
2. 场景与用户意图
触发场景:用户在户外或室内看到一株植物,抬起手唤醒智能体。AIUI 智能体被唤醒后直接落在取景页(app.json 的 pages 首项是 pages/camera/camera),没有输入框、没有功能菜单,第一屏只有两个动作:_actions = ['拍照', '历史']。也就是说,用户不需要先「描述需求」——他看到什么,就是需求本身。
意图理解与承接:一次单击即代表「告诉我眼前这是什么」。智能体不再追问拍摄对象、不给二次确认弹窗,直接把整条链路走完:
| 用户动作 | 智能体的理解 | 承接方式 |
|---|---|---|
| 唤醒 | 准备取景 | 进入取景页,显示四角取景框与状态角标 |
| 单击 / Enter(选中「拍照」) | 识别眼前这一帧 | takePhoto() → 转码 → 自动跳结果页 → 调 LanguageModel |
| 等待几秒 | 希望直接用耳朵听到答案 | 左图右信息 + tts_content 语音播报 + 自动写入历史 |
| 上下键选「历史」 | 回看之前认过的植物 | 进入记录列表,双击返回 |
结果之所以是「中文正式名 / 拉丁学名 / 科属 / 生活型 / 花期 / 关键鉴别特征」这几个字段,是因为提示词把输出收敛到了「确认物种」这个意图上:key_feature 只写用于鉴别的形态特征,不写养护教程;tts_content 限 30 字以内,适配眼镜朗读。模型返回 code = -1 时,界面给出「未识别」而不是一段似是而非的猜测,这也是承接意图的一部分——诚实比凑一个答案更重要。
3. 方案设计
3.1 智能体定义
纯前端 AIUI 应用,三个页面,一项权限,无自定义后端服务:
{
"pages": [
"pages/camera/camera",
"pages/result/result",
"pages/history/history"
],
"permissions": ["CAMERA"]
}
运行时是 QuickJS,不是浏览器:没有 AbortController、不支持 CSS 伪元素、部分 CSS 属性会被静默忽略。交互约定为上下键移动选中项(到头即停、不循环)、Enter / 单击确认、双击返回。
3.2 页面与卡片设计
| 页面 | 职责 | 主要卡片 / 区块 |
|---|---|---|
camera | 取景、拍照、转码 | 四角取景框 + 准星、状态角标(就绪 / 取景 / 识别中 / 异常)、两个动作项、底部提示条 |
result | 识别与结果呈现 | 左侧照片预览卡,右侧信息卡(名称、拉丁名、科属、生活型、花期、鉴别特征),加载 / 未识别 / 错误三态 |
history | 记录回看 | 列表项(缩略信息 + 科属标签 + 时间)、详情卡(大图 + 完整字段)、空态 |
结果页采用左图右信息,是因为单绿屏能同时承载的信息有限:照片用来确认「认的是不是刚才那株」,文字只留能在余光里读完的几行。所有图标、取景角标、加载环都用真实 <view> 子元素拼接(原因见 7.3 坑 6)。
3.3 状态流转
三个页面各有一套 data.phase 状态机,界面按 phase 显隐,失败一律回到可见的 error:
camera idle ──单击/Enter──> shooting ──拍照完成──> processing ──navigateTo──> result
▲ │ │
└──── 超时/失败 ──── error <──────────────┘
result loading ──code=0────> success ──> 播报 + 存档
├────code=-1─────> notflower
└────异常/超时───> error ──单击──> 重新拍摄(回 camera)
history loading ──有记录───> list ──单击──> detail ──双击──> list
└────无记录──────> empty └──双击──> 退出
状态机不是 AIUI 的 API,是项目自己约定的 data 字段;ink:if 依据它切换模板区块。之所以每个异步边界都要有超时回退,是因为 QuickJS 下未捕获的 Promise 异常常表现为「页面没反应」(见 7.3 坑 5)。
3.4 能力调用关系
| 能力 | API / 指令 | 调用位置 | 作用 |
|---|---|---|---|
| 摄像头 | wx.media.createCameraContext().takePhoto() | camera.ink 的 _takePhoto() | 取一帧 JPEG ArrayBuffer |
| 转码 | wx.arrayBufferToBase64() | camera.ink | ArrayBuffer → base64 |
| 本地存储 | wx.setStorageSync / getStorageSync | 三个页面 + utils/storage.js | lastPhoto、flower_history |
| 多模态模型 | LanguageModel.availability() / create() / prompt() / destroy() | utils/recognizer.js | 图像识别与文本输出 |
| 语音 | speechSynthesis.speak() / cancel() | result.ink 的 _speak() / _stopSpeech() | 播报与离页打断 |
| 模板指令 | ink:for / ink:key / ink:if | 三个 .ink | 列表渲染、状态显隐 |
| 绘制 | wx.createCanvasContext() + requestAnimationFrame | camera.ink / result.ink | 扫描与装饰动画 |
| 路由 | wx.navigateTo / navigateBack | 三个页面 | 页面切换与返回 |
| 权限 | app.json 的 CAMERA | 全局 | 相机访问 |
4. AIUI 实现
4.1 项目结构
拍照识花/
├── app.json # 页面注册 + CAMERA 权限
├── pages/
│ ├── camera/camera.ink # 取景、拍照、转码、状态机与超时
│ ├── result/result.ink # 识别调用、结果卡片、语音播报
│ └── history/history.ink # 记录列表、详情、双击返回
└── utils/
├── recognizer.js # LanguageModel 调用、提示词、JSON 解析与降级
└── storage.js # 历史记录 CRUD
.ink 单文件里同时放 <script setup> 逻辑、<page> 模板与 <style>;两个 utils 模块负责模型调用和存储,页面不直接碰模型细节。
4.2 数据流
takePhoto() → photo.data : JPEG / ArrayBuffer
→ wx.arrayBufferToBase64() → 纯 base64 字符串
→ setStorageSync('lastPhoto') → 结果页 getStorageSync
→ 'data:image/jpeg;base64,' + base64 → session.prompt([...])
→ Promise<string> → JSON → setData / speak / saveRecord
整条链路里只有一种图片编码(JPEG)、只做一次转码(base64)。同一份字符串被三个地方消费:模型的 image_url、<image> 的 src、历史记录里的照片字段。前缀在消费处拼,不存进 Storage,这是后面能避开格式问题的主要原因。
4.3 拍照与转码
// pages/camera/camera.ink(节选,省略状态机与超时分支)
var cameraCtx = wx.media.createCameraContext()
var photo = await cameraCtx.takePhoto({
quality: 'high',
mode: 'default',
enableSystemPreview: true
})
var buf = photo.data
var base64
if (buf instanceof ArrayBuffer) {
base64 = wx.arrayBufferToBase64(buf)
} else if (ArrayBuffer.isView(buf)) {
base64 = wx.arrayBufferToBase64(buf.buffer)
} else {
throw new Error('unexpected photo.data: ' + Object.prototype.toString.call(buf))
}
wx.setStorageSync('lastPhoto', base64)
wx.navigateTo({ url: '/pages/result/result' })
三个细节:
takePhoto()返回 Promise,photo.data是 JPEG 编码的 ArrayBuffer,不是临时文件路径,也不需要任何格式转换。quality / mode / enableSystemPreview三个参数必须写全。缺参数时模拟器里的 Promise 不会 settle,页面直接挂起(见 7.3 坑 5)。- 显式兼容
ArrayBuffer与 TypedArray 两种返回类型,未知类型直接抛错,不把脏数据传到下一页。
4.4 跨页传递
一张 quality: 'high' 的照片转出的 base64 是几十万字符量级,不能塞进 navigateTo 的 query。用 wx.setStorageSync('lastPhoto', base64),结果页 onLoad 里取回:
// pages/result/result.ink
var photo = wx.getStorageSync('lastPhoto') || ''
this.setData({ photo: photo, phase: 'loading' })
页面里显示同一张图时补上前缀即可:
src="{{photo ? 'data:image/jpeg;base64,' + photo : ''}}"
image/jpeg 是刻意的:它与 takePhoto 的实际输出一致,是整条链路的格式契约。
4.5 调用 LanguageModel
utils/recognizer.js 导出的 recognize(base64Photo) 是识别的唯一入口,由 pages/result/result.ink 的 _init() 调用。下面是它的调用主干(省略 catch 分支与 timedOut 标志),SYSTEM_PROMPT、NOT_FLOWER_RESULT、extractJSON() 都定义在同一个文件里,不是运行时 API:
// utils/recognizer.js
import { LanguageModel } from 'language-model'
export async function recognize(base64Photo) {
var availability = await LanguageModel.availability()
if (availability !== 'available') {
return Object.assign({}, NOT_FLOWER_RESULT, { tts_content: '识别能力不可用' })
}
var session = await LanguageModel.create({
initialPrompts: [{ role: 'system', content: SYSTEM_PROMPT }]
})
var timeoutId = setTimeout(function () { session.destroy() }, 15000)
try {
var responseText = await session.prompt([{
role: 'user',
content: [
{ type: 'text', text: '请识别这张图片中的植物,严格按照规则输出 JSON。' },
{ type: 'image_url', image_url: { url: 'data:image/jpeg;base64,' + base64Photo } }
]
}])
clearTimeout(timeoutId)
var parsed = extractJSON(responseText)
if (!parsed) return Object.assign({}, NOT_FLOWER_RESULT, { tts_content: '识别结果解析失败' })
var complete = {}
var fields = ['code', 'cn_name', 'latin_name', 'family', 'genus', 'life_form', 'flower_period', 'key_feature', 'tts_content']
for (var i = 0; i < fields.length; i++) {
complete[fields[i]] = parsed[fields[i]] !== undefined ? parsed[fields[i]] : (fields[i] === 'code' ? 0 : '')
}
if (complete.code === 0 && (!complete.cn_name || !complete.key_feature)) {
complete.code = -1
complete.tts_content = '未能确认花卉种类'
}
return complete
} finally {
clearTimeout(timeoutId)
session.destroy()
}
}
调用形状值得单独记一下,这里最容易写错:
LanguageModel是模块导出,必须 import;当成运行时全局对象调用会得到not a function。prompt(input)的input是消息数组,消息的content是内容块数组:type: 'text'放指令,type: 'image_url'放图片,图片以 data URI 写在image_url.url。没有session.read()这个 API。prompt()返回Promise<string>,不是对象;也不接受signal参数。超时只能靠setTimeout+session.destroy(),用完的 session 在finally里销毁。- 系统提示词里把输出约束写死:用《中国植物志》正式中文名、有花看花无花看叶形与株型、辨认模糊返回
code = -1不许编造、tts_content限 30 字以内适合眼镜朗读、只输出 JSON。返回字段为cn_name / latin_name / family / genus / life_form / flower_period / key_feature / tts_content。
4.6 结果落地
extractJSON() 是 utils/recognizer.js 里的本地函数,和上面的 recognize() 同一个文件。模型返回的是字符串,偶尔还会带一句说明或 Markdown 代码块,所以解析要容错:
function extractJSON(text) {
var s = text.trim()
try { return JSON.parse(s) } catch (e) {}
var a = s.indexOf('{'), b = s.lastIndexOf('}')
if (a >= 0 && b > a) {
try { return JSON.parse(s.substring(a, b + 1)) } catch (e) {}
}
return null
}
上面 recognize() 里的 fields 循环和 code = -1 判断就是两道保险:缺字段补空串、关键字段为空时强制降级。宁可播报「未能确认花卉种类」,也不要在眼前显示一张空卡片。
结果页 pages/result/result.ink 的 _init() 拿到 code = 0 的返回值后,一次完成三件事:
// pages/result/result.ink · _init()
this.setData({ phase: 'success', result: result })
speechSynthesis.speak(new SpeechSynthesisUtterance(result.tts_content), 'immediate')
saveRecord({
photo: photo,
cn_name: result.cn_name,
latin_name: result.latin_name,
family: result.family,
genus: result.genus,
life_form: result.life_form,
flower_period: result.flower_period,
key_feature: result.key_feature,
tts_content: result.tts_content
})
saveRecord() 是 utils/storage.js 导出的方法,把记录写入 Storage key flower_history,最新在前,上限 50 条;每条的 id 是写入时的 Date.now(),列表的 ink:key="id" 用的就是它。
5. 交互与体验
5.1 操作路径
唤醒 → camera(idle) ──上下键选「拍照」── 单击/Enter ──> shooting → result(loading)
→ result(success / notflower / error) ──单击「重新拍摄」──> camera
└─上下键选「历史」──> history(list)
→ history(list) ──上下键选记录 ── 单击 ──> detail ──双击──> list ──双击──> 退出
方向键选择到头即停不循环,Enter 与单击等价,双击返回上一页或退出;根页的 idle / error 态不拦截返回键,交给宿主处理。
取景页:单绿屏 + 四角取景框,两个可切换动作
识别记录列表:选中项用背景层与侧条标识,避免动态 class
记录详情:照片与结构化信息一起落盘,可离线回看
5.2 反馈机制
- 状态可见:header 角标随
phase显示「就绪 / 取景 / 识别中 / 异常」,底部提示条随状态给出「正在识别 · 单击取消」「滑动选择 · 单击确认 · 返回退出」等操作提示。 - 等待可见:取景页用
requestAnimationFrame驱动 canvas 扫描动画,结果页有加载环;识别中单击可取消。 - 结果多通道:屏幕显示 +
speechSynthesis播报,离开页面时_stopSpeech()调cancel()打断,不把上一条结果念到下一页。 - 失败有明确文案:相机不可用、拍照超时、模型不可用、识别超时、解析失败、置信度不足各有对应提示,例如「相机不可用,请检查权限后重试」「未能确认花卉种类」。
- 防误触:
_isPhantom()在页面载入后 400800ms 内忽略按键,避免跳转瞬间的幽灵按键;双击窗口取 80800ms。
6. 调试与验证
验证环境是 AIUI Studio 模拟器,文中界面截图均来自模拟器预览。主要手段:
- 控制台日志定位调用点:
utils/recognizer.js在可用性检查、模型返回长度、JSON 解析失败、session异常等位置都有console.log/console.error;camera.ink对相机初始化、转码、拍照异常分别打日志。踩坑章节里的报错(not a function、AbortController is not defined、模板变量缺失警告)都是这样定位到具体方法的。 - 状态机可观察:每个
phase都有对应界面和文案,拍照超时 10 秒、识别超时 15 秒的兜底可以直接看是否被触发,而不必猜「是不是模型没返回」。 - 格式对照样本:用一张普通 JPEG 的南天竹原图做输入,确认 PNG 相关问题是数据格式而非提示词导致(见 7.3 坑 1)。
- 降级分支检查:代码为「相机不可用 / 拍照超时 / 模型不可用 / 识别超时 / JSON 解析失败 / 置信度低 / 空历史列表」分别准备了可显示的兜底状态,逐项走查过 UI 表现。
目前成果验证集中在模拟器:三个页面可运行,识别结果页、历史列表与详情页均有截图。真机调试尚未系统开展,取景质量、实际光照下的识别率、设备端语音播报效果还需要在眼镜上复测,这也是后续迭代第一项。
7. 成果与复盘
7.1 可运行成果
三个页面跑通了完整闭环:唤醒 → 单击拍照 → 多模态识别 → 左图右信息 + 语音播报 → 自动存档 → 历史回看。纯前端实现,没有后端服务;识别范围的提示词覆盖开花植物、观叶、多肉、蕨类等;失败路径都有可见反馈。
7.2遇到的问题与解决
坑 1|PNG 图片识别失败或返回空
- 位置:
recognize()里拼dataUri的那一行(见 4.5)。 - 当时:拿 PNG 素材调试,把 PNG 字节转成 base64 交给
recognize(),dataUri却仍按image/jpeg拼;反过来把前缀改成image/png也不行,因为字节其实来自takePhoto()的 JPEG。两种都是声明格式与真实编码对不上。 - 原因:模型的图像输入要求声明的 MIME 与实际字节一致,PNG 也不一定能被直接接受。
- 现在:不做任何格式转换,全链路只留一种编码:
// utils/recognizer.js · recognize()
var dataUri = 'data:image/jpeg;base64,' + base64Photo
base64Photo 的来历要连起来看:camera.ink 的 _takePhoto() 把 photo.data(JPEG)转成 base64 存进 lastPhoto,result.ink 的 _init() 读出来传给 recognize()。格式契约跨了三个文件,改动时不能只改一处。调试时我用一张普通 JPEG 原图做对照,确认问题不在提示词。
对照样本:一张普通 JPEG 原图
坑 2|recognize (utils/recognizer.js:92:32) not a function
- 位置:
utils/recognizer.js文件顶部。 - 当时:直接写
LanguageModel.create(...),把它当成运行时注入的全局对象,没有 import。报错里的recognize是调用栈中的方法名,不是出错的 API。 - 现在:文件第一行显式导入。
import { LanguageModel } from 'language-model'
LanguageModel 是模块导出,不是全局构造器;日志出现「全局」字样时也以实际 import 为准。
坑 3|session.read() is not a function
- 位置:
recognize()里LanguageModel.create()返回session之后。 - 当时 / 现在:
// 当时:照搬其它 SDK 的「读流」命名
var text = await session.read(input)
// 现在
var text = await session.prompt(input)
session 是 create() 返回的会话对象,主要可用的是 prompt() 和 destroy()。返回 Promise<string>,结构化数据得靠同文件的 extractJSON() 自己解析。
坑 4|AbortController is not defined
- 位置:
recognize()的超时处理。 - 当时:按浏览器习惯
new AbortController(),再把signal传给prompt()。 - 原因:AIUI 跑在 QuickJS 上,没有
AbortController,prompt()也不接受signal。 - 现在:
var timedOut = false
var timeoutId = setTimeout(function () {
timedOut = true
if (session) session.destroy() // 让 pending 的 prompt 结束
}, 15000)
// catch / finally 里按 timedOut 区分「超时」与「真实异常」,并 destroy()
timedOut 是 recognize() 内部的局部变量,用于超时后返回「识别超时,请重试」而不是笼统的「识别失败」;降级结果统一用文件顶部的 NOT_FLOWER_RESULT 常量展开。
坑 5|拍照后页面卡死、无日志、整个 tab 无响应
- 当时:
var photo = await cameraCtx.takePhoto(),参数为空。 - 原因:缺参数时模拟器里的 Promise 不会 settle。页面用
data.phase状态机渲染,一旦停在shooting,界面就永远是「处理中」,看起来像卡死,连返回键都没反应。 - 现在:
var photo = await cameraCtx.takePhoto({
quality: 'high',
mode: 'default',
enableSystemPreview: true
})
_takePhoto() 由 _activate()(方向键选中「拍照」后确认)或 onTapPhoto() 调用。除了参数写全,这个方法里还放了两层定时器:拍照前 10 秒、跳转前 15 秒,超时就 setData({ phase: 'error' }) 并显示原因;所有 await 都包 try/catch。phase 的取值 idle / shooting / processing / error 是项目自己约定的状态机,不是 AIUI 的 API。
这四条都不抛异常,只是「什么都不显示」,所以更要标明改在哪个元素上。
坑 6|::before / ::after 画的图标不显示
- 位置:三个
.ink的<style>与模板,例如结果页的重新拍摄图标、取景页的四角标记。 - 当时:样式里写
.icon::before { content: ''; ... },用伪元素画相机、花瓣、小圆点。 - 原因:AIUI 运行时不支持 CSS 伪元素。
- 现在:用真实
<view>子元素拼。取景页一个 L 形角标是这样写的:
<view class="vf-corner vf-corner-tl">
<view class="vf-ch-h"></view>
<view class="vf-ch-v"></view>
</view>
vf-corner-tl 表示左上角,vf-ch-h / vf-ch-v 分别是水平和垂直那两段,都是项目自定义的 class 名;四个角各写一份,相机图标、花瓣、加载环同理。