给 AI 眼镜开发应用有多难?我从零做了一个陪练,还上架了

0 阅读13分钟

我用 Rokid AIUI 做了一个自我介绍陪练,叫“说清楚”。用户先说自己的经历,模型根据上下文追问;卡住时换成句式补全,时间紧时缩短练习,结束后再单独生成整轮总结和介绍成稿。

我想解决的是开口以后接不上下一句的问题。比如说自己会用AI,接下来该讲哪个例子?这个例子又能给眼前的听众提供什么帮助?因此,我把身份、经历和帮助设成三个内容目标,把具体追问交给模型。

这次用 AIUI Studio 开发,就算没有实体眼镜,你也可以在模拟器中验证真实模型调用和页面流程。

图片

1. 在平台建工程

先打开工作台,点击左侧“新建智能体”,选择“对话创建”,进入AIUI CODING。我先读了 AIUI 0.18.0 快速开始,再让平台启用官方aiui-dev Skill,按框架规范创建工程。

图片

创建需求需要同时交代场景、页面和验收动作。把这个项目的要求归纳起来,可以这样描述:

创建中文自我介绍陪练“说清楚”。
按身份、具体经历、能提供的帮助组织练习。
每屏放一个问题、一条提示和少量操作。
支持语音回答、镜腿选择、暂停、重说和调整难度。
用LanguageModel结合上下文追问,最后独立生成总结。
请实际创建工程文件,并在模拟器跑完一轮。

实际创建没有一次完成。第一轮平台反复读资料、写计划,代码区还是空的。然后我把目标缩小为先创建四个文件、跑起欢迎页和基本跳转,再逐项补语音、模型和异常处理。这样每次修改都有可以检查的页面。

切到上方“代码”,先核对文件树,再打开具体文件编辑。创建目录以后,还要展开pages/index确认页面真的存在,app.json里的路径也要与它一致。只有应用入口、没有页面文件,仍然跑不出欢迎页。最后这份工程的主要文件是:

AGENTS.md               身份、场景和行为边界
app.json                页面入口、权限和全局配置
app.js                  应用生命周期
pages/index/index.ink   页面逻辑、结构和样式

平台生成最小工程后,我把代码写回同一个AIUI项目。后面的效果始终在它的模拟器里检查。

补功能时,每次只给一个可检查的目标:先让欢迎页能进入第一题,再让回答产生模型请求,最后加入重说和整轮总结。一次把所有要求塞进去,出问题时很难分清是页面没有生成、事件没有触发,还是模型没有返回。

下面代码整理了当前工程的关键分支,部分清理、日志和错误处理放在文字里解释。函数之间共用页面状态,照着实现时需要放回同一个页面对象里。

2. 配好入口和权限

app.json先声明页面路径和录音权限。下面是当前配置的关键部分:

{
  "name": "说清楚",
  "version": "1.2.1",
  "permissions": ["RECORD_AUDIO"],
  "pages": ["pages/index/index"],
  "window": {"navigationBarTitleText": "说清楚"}
}

最早生成的权限写过microphone、network,后来按文档改成RECORD_AUDIO。这个名称需要用平台支持的值。工程没有申请摄像头、定位或通讯录,也没有配置外部模型密钥。

AGENTS.md写清职责:只练内容和结构,不评价发音、语速或人格,不编造经历、数字和结果。这些要求还要放进页面实际发送给模型的系统提示,不能写完说明文件就认为每个请求都有约束。

AIUI CODING负责开发时生成和修改工程,LanguageModel负责产品运行时的追问与总结。两者的提示词分开:前者说明要做什么页面,后者说明怎样分析当前回答。运行时每次创建会话,都通过initialPrompts明确传入系统要求。

index.ink把页面配置、JavaScript、界面结构和样式放在一起。逻辑使用export default,页面状态通过this.setData更新,结构里读取对应字段。例如当前问题和操作按钮分别绑定question和actions,模型返回以后再一起更新。

具体编辑时,导入和页面方法放在script setup,显示问题与按钮的结构放在page,字号、边框和布局放在style。data里先给显示字段初值,再让事件改变状态。之前遇到过字段未定义的运行错误,所以我会先检查模板引用有没有在data中初始化。

页面还区分两个状态:stage表示练到身份、经历还是帮助,phase表示欢迎、回答、等待模型、查看反馈或阅读总结;收音另用voicePanel控制。处理普通回答后,页面先进入review供用户确认,再把下一问切成正式问题;这也给“这句重说”留出了位置。

问题和操作的绑定可以直接在page里看到,下面保留回答区域及按钮循环:

<page>
  <view ink:if="{{!paused && phase === 'answer'}}">
    <text>{{goalTitle}}</text>
    <text>{{question}}</text>
  </view>
  <view ink:for="{{actions}}" ink:key="key"
        data-key="{{item.key}}" bindtap="onAction">
    <text>{{item.label}}</text>
  </view>
</page>

按钮把自己的key交给onAction,再转给_act。切换页面状态时,_refresh重新生成actions,因此收音时显示“说完了”,反馈时显示“说下一句”,总结时显示“下一页”。界面文字和按键选择使用同一份数据,减少两边操作不一致的问题。

图片

3. 接入语音和按键

首页允许先说听众和用途,也可以直接开始。练习页只保留“语音回答”“给点提示”等操作;镜腿滑动切换选项,单击确认,双击暂停或继续。

图片

收音用平台提供的SpeechRecognition。创建识别实例后设置中文、单次识别和中间结果;中间文字只显示,最终结果才进入练习。下面摘出结果处理的主要分支,active和commit分别负责当前会话检查、提交及清理:

import { SpeechRecognition } from 'speech';
const recognition = new SpeechRecognition();
recognition.lang = 'zh-CN';
recognition.continuous = false;
recognition.interimResults = true;
recognition.onresult = e => {
  if (!active()) return;
  let finalText = '', interim = '';
  for (let i = 0; i < (e.results || []).length; i++) {
    const r = e.results[i], value = r && r[0] && r[0].transcript;
    if (!value) continue;
    if (r.isFinal) finalText += value;
    else interim += value;
  }
  this._voiceFinal = finalText;
  this._voiceInterim = interim;
  this.setData({ speechText: cut(finalText + interim).slice(-52) });
  if (finalText && !interim) commit(finalText);
};
recognition.start();

commit会先释放识别实例,再调用_route处理文字。onend也可能收到结束通知,因此要核对当前实例是否仍然有效,防止同一句话提交两次。页面离开时同样要解除回调、释放会话。

图片

这部分踩过一个坑:主动取消收音也显示“语音暂不可用”,旧会话的错误回调还会覆盖新状态。修复时先把旧实例回调置空,再abort;每个回调检查自己是否仍是当前实例。主动取消正常返回,权限、麦克风和网络错误分别提示。

识别文字进入_route后,先判断“暂停”“继续”“重说”等命令,再区分调整练法和普通回答。按键入口最终也调用同一组操作,避免语音和镜腿维护两套练习逻辑。

收音界面使用voicePanel单独切换,voiceState区分开启、正在听、等待最终确认和重试。用户单击“说完了”时调用stop,继续等待最终文字;取消则解除回调并abort。

镜腿事件映射为Enter、ArrowUp、ArrowDown和Backspace。onKeyDown阻止默认行为,onKeyUp再执行选择或确认;同一按键在180毫秒内重复到达会被忽略。当前选中项只存索引,确认时读取actions中的key,交给_act分发。

4. 自己管理上下文

每次请求都创建模型会话,本轮记忆由页面传入。对话记录存到_turns,每条回答带上当时的目标和问题。有效事实与历史分别组装:

_facts() {
  return this._turns
    .filter(t => t.role === 'user' && t.kind === 'answer' && !t.superseded)
    .map(t => ({
      turnId: t.turnId, stage: t.stage,
      question: t.question, answer: t.text
    }));
},
_context(intent, text) {
  return {
    intent, userInput: cut(text),
    currentStage: this.data.stage,
    currentQuestion: this.data.question,
    currentMode: this.data.mode,
    audience: this.data.audience, focus: this.data.focus,
    followups: this._followups.slice(),
    validUserFacts: this._facts(),
    history: this._turns.filter(t => !t.superseded).slice(-24)
  };
}

这里保留全部有效回答,历史取最近24条非作废对话。cut是工程里的文字清理函数,负责转成字符串、去首尾空白并限制长度。问题和回答成对保存,后面才能知道“这张表”究竟回应了哪一问。

两份材料各有用途:validUserFacts给出可以用于成稿的用户原话,history帮助模型理解问答经过和练法变化。系统提示明确把历史与用户文字当成待分析资料,不能执行其中要求编造经历、改变规则的指令。收到新回答时,_ask先把它写入记录,再组装上下文,因此当前这一句也在本次请求里。

“太难了”记录为adjust,助手示范记录为assistant,都不会进入validUserFacts。用户重说时,把原回答及对应反馈标为superseded,其他回答保留;重新开始则清空本轮。这样既能调整练法,也能查清总结采用了哪些材料。

作废用的是标记,没有直接删掉原记录。对应的助手反馈通过parentTurnId关联到回答,重说时一并排除。调试时仍能追溯原来讲过什么,正常生成时则只读取没有作废的条目。

用社区图书角测试人物小周验证:先说明负责借还登记、带新人,模型便接着询问带新人的经历。

图片

再补“新人经常把归还的书放错位置,我做了一张分类位置对照表”,下一问就变成:“新人用了这张表后,归还书籍的准确率或效率有什么具体变化吗?”

图片

5. 调模型再校验

调用使用内置LanguageModel。系统提示要求每次只找一个信息缺口,并输出JSON,包含question、feedback、action、stage、mode、hint等字段。具体提问由模型生成,程序再检查能否应用。

请求按意图区分:start生成开场,answer处理回答,adjust处理提示、困难和时间变化。同样一句“讲个例子”,在不同经历后面会落到不同细节。请求带上当前目标,系统提示要求模型解释练法调整的原因,页面用modeReason展示这次为何换练法。

陪练调用的核心可以摘成下面这段。完整实现还包含超时、请求编号和日志:

import { LanguageModel } from 'language-model';
if (await LanguageModel.availability() !== 'available') {
  throw new Error('模型当前不可用');
}
const session = await LanguageModel.create({
  initialPrompts: [{ role: 'system', content: COACH_SYSTEM }]
});
try {
  const raw = await session.prompt(JSON.stringify(job.payload));
  const result = coachResult(raw, job.payload);
  this._applyCoach(result, job);
  this._refresh();
} finally {
  session.destroy();
}

coachResult先解析JSON,再检查动作、目标范围、问题和练习模式。followup留在当前目标,advance只能进入相邻目标,finish要求已经到最后目标且至少有三条有效回答。通过以后,_applyCoach把反馈和下一问写入页面。

question为空、mode不在允许范围、降低难度却没有frame,也会被判为无效。界面只接受校验后的结果:普通回答先显示反馈和下一问,start或adjust则直接更新当前问题。JSON在这里承担的是页面与模型之间的约定,具体内容仍然来自本次生成。

这些限制也会约束“太难了”“只剩两分钟”。降难度应保留当前目标,精简则可以进入相邻目标。实际校验中的这一段是:

const stay = x.action === 'followup' && stage === payload.currentStage;
const shorten = x.mode === 'compact' && x.action === 'advance'
  && stage === payload.currentStage + 1;
if (!stay && !shorten) {
  throw new Error('提示或降难度应留在原目标,精简模式可进入相邻目标');
}

我最初把adjust全部限制在原目标,结果连合理的精简安排也被拦住。分开判断后,“太难了”返回围绕对照表的补全句式:“有了这张表,新人______(反应/话语),______(状态变化)。”

图片

再说“只剩两分钟”,模型切到compact,问对新志愿者最大的帮助,要求用一句话总结。这里表达的是缩短练习的要求,没有做成倒计时。

图片

句式里举出的反馈只能当提示。系统要求模型只依据用户提供的事实,没有统计过的准确率、效率或百分比都不能补进成稿。

6. 单独生成总结

用户完成回答或主动要求总结时,_finish建立一个独立任务,再交给同一个执行函数:

const job = {
  kind: 'summary',
  payload: this._context('summary',
    '请综合本轮有效回答,生成总结和可练习的自我介绍。')
};
this._lastJob = job;
return this._execute(job);

_execute根据kind选择SUMMARY_SYSTEM,并用summaryResult校验返回。总结要求overview、strengths、improvements、introduction、nextPractice齐全,优点要有事实依据,改进点要有可执行建议。

如果本轮还没有有效回答,_finish会先拦住。真正发出总结时,输入仍是整轮上下文,系统要求则换成整轮整理任务。优点数组里的point和evidence分别存结论与依据,改进数组里的point和suggestion分别存问题与建议,页面可以按字段组织阅读顺序。

图片

志愿者最后补了“对照表让新人自己找到归还位置,不用反复问我。我希望带大家完成第一次值班”。生成的介绍串起职责、做法和帮助,结尾保留“我的目标是”,没有把希望写成已经取得的成绩。

图片

界面把整体判断、介绍成稿和下次练法分组,再按文字长度拆页。这组恰好是三页,其他轮次会变化。曾经遇到接口已有结果、页面却只有标题,最后修正列表容器写法,再逐页检查内容。

图片

结构化输出也可能失败。程序最多尝试两次;格式或字段不合要求时,把错误和上次原始输出随原上下文交给模型修正一次。仍失败就保留回答和重试入口。陪练等待上限35秒,总结45秒,不让界面无限停在处理中。

第二次请求会增加outputRepair,附上具体错误、截断后的上次输出,并要求仍使用同一份真实上下文。模型不可用或等待超时则直接结束本次尝试。等待通过Promise.race比较模型返回和超时任务,finally清除计时器并destroy会话,成功和失败都走清理。这些分支都留下记录,重试时继续采用原任务,不要求用户重新说完整段经历。

7. 测试和上架

保存代码后,进入“模拟调试”跑完一轮:回答问题、降低难度、缩短练习,最后生成总结。再换一组经历,确认追问和成稿会跟着内容变化,并检查镜腿单击、滑动和暂停是否正常。

模拟器跑通后,先在“真机调试”页填写基本信息并打包AIX,再到“提审”页填写介绍、上传截图和演示视频,提交审核。目前,AIUI Studio中我的“说清楚”智能体的状态已经显示为“已上架”。

图片

这次从平台创建工程,到接入语音、组织上下文,再到模型追问和独立总结,做完了一个能练完整轮的自我介绍陪练。用户说出的零散经历,经过追问补充,最后能整理成一段继续练习的介绍,这就是我想做出来的结果。

图片

欢迎大家使用。