React Native 鸿蒙实战:给应用接入小艺语音指令,跑通 Skill 声明到操作闭环
本文三方库仓库:https://atomgit.com/dijun_520/react-native-ohos-ai-agent
社区引导:RNOH 框架源码与版本发布见 https://atomgit.com/CPF-RN/ohos_react_native/tree/0.84-main
CPF-RN 鸿蒙社区:https://atomgit.com/CPF-rn
用户说一句"帮我记一下明天十点开会",应用就把这条笔记写进去、再用语音回一句"记下了"——这篇文章讲的就是怎么把这条链路在 React Native 鸿蒙工程里跑通:小艺 Skill 怎么声明、系统入口唤起意图怎么接住、端侧语音识别怎么变成指令、指令怎么分发到业务、结果怎么回传。全部能力封装成了一个开源 TurboModule 三方库(react-native-ohos-ai-agent)。
一、痛点:你的应用,能被"叫"过来吗
先说一个真实的场景。开车的时候想往购物清单里加一条东西,腾不出手点屏幕;做饭的时候两只手都是面粉,想查一下菜谱的下一步;老人不会用复杂界面,但会说"帮我记一下降压药每天早上吃一次"。这些场景的共同点是:用户手里有嘴、没有手,而应用的所有入口都是按钮。
系统对此早有答案——小艺。它是一个系统级入口:锁屏可以叫、长按电源键可以叫、耳机捏一下可以叫,用户说一句话它就能路由到对应的应用能力。HarmonyOS 从 API 12 起逐步开放了这套体系的接口:应用可以把"我能做什么"声明成 Skill 和 InsightIntent,小艺在匹配到用户指令时反过来唤起应用执行,再把执行结果取回去播报。对应用来说,这等于多了一条不需要用户找到你的入口——是系统替用户找到你。
问题在于 RN 这边。React Native for OpenHarmony(RNOH)把 RN 生态带到了鸿蒙,但系统能力类库和 UI 类库不一样:桥的另一头必须接真实现。我沿着 RN 鸿蒙生态翻了一圈(包括 CPF-RN 社区维护的三方库清单),音频、文件、权限都有人封装了,AI 开放能力这块是空的——没有现成库能帮 RN 应用完成"声明 Skill → 被小艺唤起 → 执行 → 回传结果"这个闭环。自己接的话,要同时啃四套 Kit 和一堆系统级限制,任何一处写错都表现为玄学报错。
动手之前,最该搞清楚的是边界,因为这套能力的权限模型比一般的 Kit 严格得多。我把 SDK 声明文件逐个翻完(@kit.AbilityKit 里的 skillManager、insightIntentProvider,@kit.AgentFrameworkKit),先把结论摆出来:
| 接口 | 用途 | 三方应用可用性 |
|---|---|---|
skillManager.getSkillInfos(跨应用查询) | 查其他应用的 Skill | ✗(需要 MANAGE_SKILL,system_basic) |
skillManager.getSkillInfosForSelf | 查本应用的 Skill | ✓ |
MANAGE_SELF_SKILLS / MANAGE_SKILL_PRIVILEGE | 管理技能生命周期 | ✗(system_basic,三方拿不到) |
AgentController.isAgentSupport | 查询智能体可用性 | ✓ |
GET_AGENT_CARD / CONNECT_AGENT | 读取任意应用智能体名片、直连智能体 | ✗(system_basic) |
insightIntentProvider.sendExecuteResult | 意图结果回传(API 23+) | ✓ |
这张表决定了整个设计:三方应用在"小艺体系"里的身份是被调用的提供方,不是调用人。我们能做的是把技能和意图声明好、把系统派下来的活接住、把结果还回去,以及在设备支持时感知智能体可用性。哪些事想都不要想(跨应用查别人的技能、直连别人的智能体),提前知道能省掉很多无效调研——这三条 system_basic 权限我一个个查过 PermissionDefinitions,全是系统级领地。
二、效果先行:的五个决定性瞬间
demo 是一个"语音笔记本":顶部是状态徽标和能力卡片,中间是笔记列表、文本输入框和三个快捷指令按钮,底部是事件日志。useAIAgent Hook 挂载后自动探测一次。

第一张:小艺那一行终于是 ✓ 了。
上一版文章里这一行是红的 ✗,收到的第一条反馈就是"那不就是没连通吗,写这么多干啥"。这个质疑是对的,我也认——所以这一轮先把它彻底修对,再谈闭环。现在它写的是 ✓ Skill 已注册 · 链路可达,展开是 add-note@1.0.0 · system · EntryAbility。
这块得先把概念掰开:"与小艺连通"在三方应用里不是一条 ping 命令,而是三层确认,得同时压住:
| 层面 | 确认方式 | 真机结果 |
|---|---|---|
| 应用有没有把技能声明进系统 | skillManager.getSkillInfosForSelf() 返回条目 | 1 条 add-note@1.0.0 |
| 系统能不能把语音入口路由到我们 | bundleManager.canOpenLink('vassistant://voice/main') | true |
| 我们能不能把小艺智能体拉起来 | startAbility(承载页) → 官方组件渲染成功 | 截图 04 / 05 |
前两层是查询,第三层是真的把界面和语音条拉到前台了。第一层来自我们自己的 Skill 声明被技能中心收录(系统亲口承认名字、版本、可见性、挂在哪个 Ability),第二层要求 module.json5 里声明 "querySchemes": ["vassistant"],第三层是 4.7 节那套承载页方案。三层全绿,才敢写"链路可达"。
上一版为什么是红的?因为判定只查了 bundleManager.getBundleInfo('com.huawei.hmos.vassistant')——bm dump 里小艺的 applicationInfo.accessible 是 false,它对三方应用的查询不可见,于是必抛 201 Permission denied;canOpenLink 又因为没声明 querySchemes 抛 17700056。两个都失败,逻辑就落到 false,UI 画一个 ✗。✗ 的含义从来不是"没装",而是"这一条路查不通"——判定逻辑的颗粒度不够,就会把"我查不到"误报成"不存在"。
第二张:一句话到操作的闭环结果。点快捷指令"记一笔买牛奶"(演示工程内置三个按钮,省去在敲中文的麻烦),11 毫秒后卡片出来:

✓ add-note · 1.00,“已记下:买牛奶”,下面是"示例句精确命中 · 11ms",页面底部"笔记(1)“里落了一条真实数据,事件日志三条记录完整对应"探测 → 进入匹配 → 命中执行”。这一张就是闭环 A(应用内语音/文本 → 业务)在的证据。11ms 是纯端侧匹配加执行,不碰网络;上一轮复测是 13ms,两次差异在噪声范围内,说明耗时稳定。
第三张:出向链路——把意图上报给小艺。

✗ 上报失败 1000101101:The application has not been registered with the InsightIntent。红叉看着不好看,但它是这一轮最有价值的一张截图:它证明请求真的发到了系统。同一时刻 hdc hilog 里 InsightIntentHandler 打出了 ShareIntent ... headerNames: AddNote——请求头正确、意图名正确、参数正确,唯一的失败点是"这个应用还没在意图平台报备"。换句话说,通道是通的,缺的是一纸注册。这个错误码我会在 4.11 节展开,它同时也是给读者的一个明确预期:出向链路能不能在你自己机器上跑绿,取决于你的应用有没有报备,代码本身不用改。
第四张:跨端调起智能体的承载页。

RN 侧点"调起智能体",库不再去赌小艺的私有 Ability,而是 startAbility 起宿主自己的 AgentHostAbility,页面 body 只有一个 ArkUI 的 FunctionComponent。截图里是承载页起来后的样子:顶部是我们自己的标题栏,中间那块卡片就是 AgentFrameworkKit 官方组件渲染出来的智能体入口,agentId 是从 RN 的 want 参数传进来的。
第五张:点下去之后,小艺的对话层真的弹出来了。

底部升起对话层,最下面是"点击或按住说话"的语音条,上面是给小艺的预设问题。到这里,"跨端调起智能体"这条链路是完整可证的:RN 的一次点击 → 原生承载页 → 官方智能体组件 → 小艺对话层 + 语音输入。用户不用离开我的应用,就能对小艺说话。
五张图串起来正好是一条完整的语音指令闭环,而且是两个方向都走通了:
用户说话 ──→ 端侧 ASR ──→ 本地匹配 ──→ 业务执行 ──→ TTS 回话 (闭环 A,截图 02)
应用意图 ──→ 系统意图路由 ──→ 执行器 ──→ JS 业务 ──→ 结果回传 (闭环 B)
RN 点击 ──→ 承载页 ──→ 智能体组件 ──→ 小艺对话层 ──→ 语音输入 (闭环 C,截图 04/05)
三、原理:小艺 Skill 体系的全景与边界
3.1 四块能力的分工
小艺体系的公开接口拆开是四块,各自解决闭环里的一段:
| Kit | 关键类/函数 | 在闭环里的角色 | 起始版本 |
|---|---|---|---|
| AbilityKit · skillManager | getSkillInfosForSelf | 查询"我声明了哪些技能"(验证声明是否被系统接受) | API 22 |
| CoreSpeechKit | speechRecognizer / textToSpeech | 端侧"听"和"说",不依赖网络 | API 12 |
| AgentFrameworkKit | AgentController(可用性)、FunctionComponent(智能体组件) | 感知/展示系统智能体 | API 20 |
| IntentsKit / AbilityKit | insightIntent、insightIntentProvider | 意图共享与执行结果回传 | API 12 / 23 |
其中"声明技能"本身不消耗任何 Kit 调用——它是纯静态配置:在宿主 entry 模块的 module.json5 里写 skillProfiles,配上 skills/<name>/SKILL.md 描述文件,打包时由工具链编进 HAP,小艺按这份声明做路由。
3.2 两条硬边界
第一条:声明类只能写在宿主 entry 里。我们的库是 HAR 形态分发,但 skillProfiles 在 module.json5 的 schema 校验里只允许出现在 entry / feature / shared / skill 类型的模块;AgentExtensionAbility 的 SDK 原文更直接,注释写着 “This class cannot be used in Harmony Archive(HAR)”。也就是说"声明技能"和"接住系统意图"这两个动作的宿主是应用工程,不是三方库。库能做的是把这两步的代码量压到最低——宿主只需要一份 JSON 配置加一行转发(第四节 AIAgentBridge 就是干这个的)。
第二条:小艺的编程式调起口子很窄,且版本相关。AgentFrameworkKit 的 AgentController 只开放了 isAgentSupport 查询,组件侧的 FunctionController 只有弹层开/关事件,没有 open(agentId) 这样的编程式拉起;官方唯一的调起入口是 ArkUI 的 FunctionComponent 组件,而 RN 渲染不了 ArkUI 组件。
我最初的设计是走深链:bm dump -n com.huawei.hmos.vassistant 里 VoiceAbility 确实声明了 scheme=vassistant / host=voice / pathStartWith=main 且带 entity.system.browsable,看起来完全合法。没装小艺测不了,真机一测才发现:startAbility 返回成功、openLink 也返回成功,但语音页窗口始终不挂载,前台反而回到桌面。声明里能匹配到,不等于三方有权限把它拉到前台——前者是链接匹配,后者是窗口栈管理,是两份不同的许可。
这条死路最后是绕过去的,不是打通的:AgentFrameworkKit 唯一的拉起入口是 ArkUI 的 FunctionComponent,RN 渲染不了它,但 RN 拉得起一个原生页面。于是库改成"起宿主自己的承载页 → 页面里渲染官方组件"(4.7 节)。深链保留为兜底,语义从"调起成功"改成"调用被接受"。这条经验可以泛化:RN 要用任何官方 ArkUI 能力——支付弹层、系统分享、地图选点——承载页都是同一套解法。
3.3 RNOH 的桥接链路与"手写 C++ 方法表"
RN 侧到鸿蒙侧走 TurboModule:JS 写 spec(类型即契约)→ C++ 侧要有这个模块的方法表(每个方法一条 Metadata)→ ArkTS 侧实现真正逻辑 → 事件经 emitDeviceEvent 回流。0.84 的正常流程是 codegen 扫 spec 自动生成 C++ 胶水,但 codegen 有个对我们不友好的限制:不支持任意字典参数,而我们这套 API 里 paramsJson、事件负载这类东西天然是动态结构。所以库直接手写了 C++ 注册头——用 RNOH 官方导出的宏逐个声明方法,效果等价于 codegen 产物,宿主接入时不用跑任何生成步骤(完整代码见 4.3)。
事件回流遵循"一类通道"设计:语音、意图、弹层三类事件全部从 aiAgentEvent 一个事件名出去,负载里用 kind 字段区分。JS 侧注册一个 listener 就够,比三个事件名各自订阅少三分之二的解绑负担。
3.4 三个闭环链路
整条体系跑起来是三个闭环,前两个共享同一套业务代码,第三个把用户的话交给系统智能体:
闭环 A(应用内语音,真机全链可验):
按住麦克风 → CoreSpeechKit 收音识别 → onResult(isFinal) 出最终文本
→ JS 本地匹配(registry 三路加权) → 技能 handler 执行业务
→ textToSpeech 播报结果 → 回到 idle
闭环 B(系统入口,需报备后由小艺路由):
用户对小艺说指令 → 小艺按 skillProfiles/意图声明路由
→ 宿主 entry 的 InsightIntentExecutor 被创建
→ AIAgentBridge.invokeIntent() 转发进 HAR → AIAgentModule 推 intent 事件到 JS
→ JS registerIntentHandler 注册的处理器执行业务
→ completeIntent(instanceId, result) 回传 → 小艺取结果播报
闭环 C(应用侧主动调起智能体,真机全链可验):
RN 按钮 → openAgent(agentId, query, 'AgentHostAbility')
→ startAbility 宿主承载页 → 渲染 FunctionComponent({agentId, options})
→ 用户点卡片 → 小艺智能体对话层 + 「点击或按住说话」语音条
闭环 A 与 B 的"执行业务"是同一段 JS:A 走 registerSkill 的 handler,B 走 registerIntentHandler 的处理器,最终都落到 setNotes 这类普通 React 状态更新上。闭环 C 不执行业务——它把用户的话直接交给系统智能体,业务能力由小艺那一侧提供,我们只负责"把入口摆在自己的界面里"。
闭环 A/B 走这条路的意义在于:业务实现随 JS 热更新,系统侧只需要知道"这个应用有这个能力",而"这个能力怎么实现"完全留在前端。
四、核心实现:从声明到调起,拆成十一块讲透
4.1 库结构总览
react-native-ohos-ai-agent/
├── src/
│ ├── NativeAIAgent.ts # TurboModule spec(类型即契约,13 个方法)
│ ├── index.ts # 公共 API:dispatch / registerSkill / registerIntentHandler / on
│ ├── skills/registry.ts # 本地意图匹配:注册表 + 三路加权 + 槽位抽取
│ └── hooks/useAIAgent.ts # idle→listening→matching→executing→speaking 状态机
├── harmony/ai_agent/ # ArkTS HAR 模块
│ └── src/main/
│ ├── ets/AIAgentModule.ets # CoreSpeechKit / skillManager / IntentsKit 实现
│ ├── ets/AIAgentBridge.ets # 宿主 → JS 反向桥
│ ├── ets/AgentFeature.ets # AgentFrameworkKit 懒加载壳
│ └── cpp/AIAgentSpec.h # 手写 C++ 方法表(免 codegen)
└── example/ # 语音笔记本 demo + 壳工程搭建文档
4.2 JS 侧 spec:13 个方法的契约
// src/NativeAIAgent.ts —— Spec 接口(完整;全部事件/数据类型定义见仓库同名文件)
export interface Spec extends TurboModule {
/** 能力探测:一次调用拿全(AgentKit / ASR / TTS / 小艺 / Skill 列表) */
detect(): Promise<AgentCapability>;
/** 查询本应用已声明的 Skill(skillManager.getSkillInfosForSelf) */
listSkills(): Promise<SkillDescriptor[]>;
/** 读取 rawfile/agent_config.json,没有则返回 null */
getAgentCard(): Promise<AgentCard | null>;
/** 开始语音识别,结果通过 AIAgentEvent(kind='voice') 推送 */
startListening(options: ListenOptions): Promise<void>;
/** 停止录音并等待最终结果(会触发 complete) */
stopListening(): Promise<void>;
/** 直接取消,不产出最终结果 */
cancelListening(): Promise<void>;
/** 某个 agentId 在当前设备是否可用(AgentController.isAgentSupport) */
isAgentSupported(agentId: string): Promise<boolean>;
/**
* 调起智能体。
* 单参数 JSON:ArkTS 侧签名是 openAgent(optionsJson),为的是避开手写 C++ 方法表
* 参数个数不一致导致的静默丢参(见 4.7 节细节二、坑 16)。
*/
openAgent(optionsJson: string): Promise<OpenAgentResult>;
/** 端侧 TTS 播报,闭环的"回话"环节 */
speak(text: string): Promise<void>;
/** 把一次执行过的意图共享给系统(IntentsKit.shareIntent,用于小艺建议) */
shareIntent(intentJson: string): Promise<void>;
/** 把意图执行结果回传给系统入口(insightIntentProvider),instanceId 来自 intent 事件 */
completeIntent(instanceId: number, resultJson: string): Promise<void>;
/* NativeEventEmitter 约定接口 */
addListener(eventName: string): Promise<void>;
removeListeners(count: number): Promise<void>;
}
export default TurboModuleRegistry.getEnforcing<Spec>('AIAgent');
两个设计取舍说一下。其一,动态参数一律走 JSON 字符串(paramsJson、intentJson):codegen 不支持任意字典,而意图参数本来就是动态结构,字符串透传 + JSON.parse 是最诚实的选择。其二,detect() 做成一次性快照:一次跨端往返把六个能力位全部拿回来,避免 JS 侧为拼状态连续调四次原生——每次跨桥都有成本,探测这种"低频全量"场景就该一枪打完。
4.3 手写 C++ 方法表:免 codegen 的注册头
TurboModule 的机制要求每个方法在 C++ 侧有一条 Metadata(名字 + 参数个数 + 转发器),JS 访问 module.xxx 时才拿得到可调用的函数。下面的注册头是完整文件,可以直接抄:
// harmony/ai_agent/src/main/cpp/AIAgentSpec.h(完整文件)
#pragma once
#include <string>
#include "RNOH/ArkTSTurboModule.h"
namespace rnoh {
class AIAgentSpecJSI final : public ArkTSTurboModule {
public:
AIAgentSpecJSI(const ArkTSTurboModule::Context ctx, const std::string name)
: ArkTSTurboModule(ctx, name) {
methodMap_ = {
// 能力与元数据
ARK_ASYNC_METHOD_METADATA(detect, 0),
ARK_ASYNC_METHOD_METADATA(listSkills, 0),
ARK_ASYNC_METHOD_METADATA(getAgentCard, 0),
// 语音
ARK_ASYNC_METHOD_METADATA(startListening, 1),
ARK_ASYNC_METHOD_METADATA(stopListening, 0),
ARK_ASYNC_METHOD_METADATA(cancelListening, 0),
// 智能体
ARK_ASYNC_METHOD_METADATA(isAgentSupported, 1),
ARK_ASYNC_METHOD_METADATA(openAgent, 2),
ARK_ASYNC_METHOD_METADATA(speak, 1),
// 意图
ARK_ASYNC_METHOD_METADATA(shareIntent, 1),
ARK_ASYNC_METHOD_METADATA(completeIntent, 2),
// NativeEventEmitter 约定
ARK_ASYNC_METHOD_METADATA(addListener, 1),
ARK_ASYNC_METHOD_METADATA(removeListeners, 1),
};
}
};
} // namespace rnoh
三条规则要记牢:ArkTS 侧是 async 方法就用 ARK_ASYNC_METHOD_METADATA(JS 拿到的是 Promise);第二个参数是 JS 调用时的参数个数,不含 resolve/reject,数字对不上运行期就是参数丢失;新增接口必须同步改这里,否则 JS 报 xxx is not a function 而 ArkTS 侧方法明明写着——这是 RNOH 三方库里最隐蔽的"改了没生效"。
光有方法表还不够,0.84 的 TurboModuleFactory 只为"C++ 侧声明过"的模块创建 JSI 绑定,还要一个 Package 把方法表挂上去:
// AIAgentPackageCpp.h 中的关键类(完整文件见仓库)
class AIAgentTurboModuleFactoryDelegate : public TurboModuleFactoryDelegate {
public:
SharedTurboModule createTurboModule(
Context ctx,
const std::string& name) const override {
// 名字必须与 ArkTS 侧 AIAgentPackage.ets 里 map.set 的名字、以及
// JS 侧 getEnforcing<Spec>('AIAgent') 的名字三处一致
if (name == AIAGENT_TURBO_MODULE_NAME) {
return std::make_shared<AIAgentSpecJSI>(ctx, name);
}
return nullptr;
}
};
class AIAgentCppPackage final : public Package {
public:
explicit AIAgentCppPackage(Package::Context ctx) : Package(ctx) {}
std::unique_ptr<TurboModuleFactoryDelegate>
createTurboModuleFactoryDelegate() override {
return std::make_unique<AIAgentTurboModuleFactoryDelegate>();
}
};
宿主接入一共两步:CMakeLists 给 rnoh_app 加库的 cpp include 目录;PackageProvider.cpp 的 getPackages 里加一个 AIAgentCppPackage。名字三处一致(C++ 常量、ArkTS map、JS 字符串)是这套注册唯一的对齐要求。
4.4 AgentFrameworkKit 懒加载:别让缺失的 hsp 杀死进程
@kit.AgentFrameworkKit 在设备上由 HMS 的 agentKitHsp 提供,模拟器镜像里没有这个 hsp。最初把 import 写在模块顶层,应用一启动加载 ETS 记录就 abort:
cppcrash: [LoadJSPandaFile] load hsp failed, hsp name:
com.huawei.hmsapp.hiai.hsp/agentKitHsp
连能力探测都跑不到——一个"设备上没有的东西"把整个库变成了不可用。解法是把强依赖拆到独立文件、动态 import 按需加载,加载失败被 catch 住、探测结果如实报 false:
// harmony/ai_agent/src/main/ets/AgentFeature.ets(完整文件)
import { AgentController } from '@kit.AgentFrameworkKit';
import { common } from '@kit.AbilityKit';
/**
* 查询指定智能体在本机是否可用。
* 会联网校验 agentId、隐私协议是否同意、华为账号是否登录。
*/
export async function isAgentSupport(ctx: common.UIAbilityContext, agentId: string): Promise<boolean> {
const controller = new AgentController();
return await controller.isAgentSupport(ctx, agentId);
}
// AIAgentModule.ets 中的懒加载逻辑(完整方法)
/**
* 懒加载 AgentFrameworkKit。
* 动态 import 失败只会抛到这里,被 catch 掉,不会让进程 abort。
*/
private async ensureAgentFeature(): Promise<AgentFeatureApi | null> {
if (this.agentFeatureTried) {
return this.agentFeature; // 只试一次,失败结果缓存,避免每次调用都撞一次
}
this.agentFeatureTried = true;
try {
const mod = await import('./AgentFeature');
this.agentFeature = mod as AgentFeatureApi;
console.info(`${TAG} AgentFrameworkKit 已加载`);
} catch (e) {
const err = e as BusinessError;
console.warn(`${TAG} AgentFrameworkKit 不可用(缺 agentKitHsp): ${err.code} ${err.message}`);
this.agentFeature = null;
}
return this.agentFeature;
}
这是"库必须比单应用更皮实"的典型案例:单应用可以假设目标设备全量具备某个 hsp,三方库不行——它要跑在各种定制程度不同的系统镜像上,强依赖必须降级成弱依赖,缺失时如实探测、优雅降级,而不是让进程陪葬。
4.5 端侧语音识别与权限:先拿权限,再建引擎
识别的完整流程是:检查 syscap → 申请麦克风 → 创建引擎(带热词)→ setListener → startListening。顺序错了会踩一个完全不提示原因的坑:startListening 返回 1002200002,报错文案里没有任何"缺权限"字样。所以权限申请必须前置:
// AIAgentModule.ets(完整方法)
async startListening(options: ListenOptions): Promise<void> {
if (!this.syscapOk(SYSCAP_ASR)) {
throw new Error('当前设备不支持端侧语音识别(SystemCapability.AI.SpeechRecognizer)');
}
// 麦克风是 user_grant:不申请的话 startListening 只会回一个
// 1002200002 Start listening failed,完全不提示"缺权限",所以在这里替他申请
await this.ensureMicPermission();
const engine = await this.ensureAsrEngine(options);
const sessionId = options.sessionId ?? `aiagent_${Date.now()}`;
this.sessionId = sessionId;
engine.setListener({
onStart: (sid: string, msg: string): void => {
this.emitVoice({ kind: 'start', sessionId: sid });
},
onEvent: (sid: string, eventCode: number, msg: string): void => {
// 1 = 检测到说话开始,3 = 说话结束(VAD 事件,前端可以拿来做波形动画)
const kind = eventCode === 1 ? 'vadBegin' : 'vadEnd';
this.emitVoice({ kind: kind, sessionId: sid });
},
onResult: (sid: string, result: speechRecognizer.SpeechRecognitionResult): void => {
const r: VoiceResultEvent = {
sessionId: sid,
text: result.result, // 识别出的文本,中间结果与最终结果都会回调
isFinal: result.isFinal, // true 才可作为指令分发依据
isLast: result.isLast,
};
this.emitVoice({ kind: 'result', sessionId: sid, result: r });
},
onComplete: (sid: string, msg: string): void => {
this.listening = false;
this.emitVoice({ kind: 'complete', sessionId: sid });
},
onError: (sid: string, code: number, msg: string): void => {
this.listening = false;
this.emitVoice({ kind: 'error', sessionId: sid, errorCode: code, errorMessage: msg });
},
});
engine.startListening({
sessionId: sessionId,
audioInfo: {
audioType: 'pcm', // 端侧引擎只吃 16k 单声道 16bit PCM
sampleRate: 16000,
soundChannel: 1,
sampleBit: 16,
},
extraParams: { 'online': options.online ?? 1 }, // 1 = 离线引擎,端侧模型
});
this.listening = true;
}
/**
* 申请麦克风权限。已授权时系统直接返回不弹框;
* 被拒时抛一个说得清原因的错误(authResults[i] === 0 表示已授权)。
*/
private async ensureMicPermission(): Promise<void> {
const perms: Permissions[] = ['ohos.permission.MICROPHONE'];
const atManager = abilityAccessCtrl.createAtManager();
try {
const result: PermissionRequestResult =
await atManager.requestPermissionsFromUser(this.context, perms);
const denied = result.authResults.some((code: number) => code !== 0);
if (denied) {
// 用户拒绝时给出可操作的指引,而不是一个裸错误码
throw new Error('麦克风权限被拒绝,请到 设置 → 应用 → 权限 里打开后重试');
}
} catch (e) {
if (e instanceof Error) {
throw e;
}
const err = e as BusinessError;
throw new Error(`申请麦克风权限失败 ${err.code}: ${err.message}`);
}
}
一个对识别质量影响很大的细节:热词。把技能名和专有名词通过 sysGeneralLexicon 传给引擎,识别率提升非常明显——registerSkill 注册的技能名可以顺手喂进去,“记一笔"这种口语词就不会被识别成"急一笔”。停止录音用 finish(sessionId) 而不是 cancel:finish 会把最后一段音频走完并回调 onResult(isFinal),cancel 是丢弃。
4.6 本地意图匹配:三路加权,为什么不用云端大模型
拿到文本之后要回答"这句话想干什么"。业内常见做法是送云端大模型做 NLU,但语音指令场景里这条路有三个代价:延迟(用户说完话到执行,多一整个网络往返)、可用性(无网或弱网时功能全灭)、成本(每句都过模型)。而技能指令是开发者自己定义的有限集合,完全可以在端侧用轻量规则做到可用:
// src/skills/registry.ts(匹配核心,完整实现;完整文件见仓库)
/** 去掉标点、空白,统一小写;中文不做分词,用字符二元组替代 */
function normalize(text: string): string {
return text
.toLowerCase()
.replace(/[\s,,。.!!??、;;::"'“”‘’()()【】\[\]]/g, '');
}
/** 字符二元组集合,用于近似匹配(中文短句上比编辑距离稳) */
function bigrams(text: string): Set<string> {
const s = normalize(text);
const out = new Set<string>();
for (let i = 0; i < s.length - 1; i += 1) {
out.add(s.slice(i, i + 2));
}
if (s.length === 1) {
out.add(s);
}
return out;
}
function jaccard(a: Set<string>, b: Set<string>): number {
if (a.size === 0 || b.size === 0) {
return 0;
}
let inter = 0;
a.forEach(x => {
if (b.has(x)) {
inter += 1;
}
});
return inter / (a.size + b.size - inter);
}
function extractSlots(text: string, slots?: SlotSpec[]): Record<string, string> {
const out: Record<string, string> = {};
if (!slots) {
return out;
}
for (const slot of slots) {
let value: string | undefined;
if (slot.values) {
// 枚举槽位:候选值命中优先("今天/明天/后天"这类)
value = slot.values.find(v => text.includes(v));
}
if (!value && slot.pattern) {
// 正则槽位:从话术里抠参数("记一笔 XXX" 抠出 XXX)
const m = slot.pattern.exec(text);
if (m && m[1]) {
value = m[1];
}
}
if (!value && slot.defaultValue !== undefined) {
value = slot.defaultValue; // 没抽到时兜底
}
if (value !== undefined) {
out[slot.name] = value;
}
}
return out;
}
/**
* 本地意图匹配。三个信号加权:
* - 示例句完全相等:直接 1.0(用户按提示说的,最可信)
* - 关键词命中:每个 +0.18,封顶 0.9
* - 字符二元组相似度:作为兜底,最多贡献 0.6
* 必填槽位缺失时整条技能判为不可用——宁可问一句"没听清",也别瞎执行。
*/
export function matchCommand(utterance: string, threshold = 0.3): CommandMatch | null {
const text = normalize(utterance);
if (!text) {
return null;
}
let best: CommandMatch | null = null;
registry.forEach(skill => {
// 槽位先抽:必填槽位抽不到,这条技能直接出局
const slots = extractSlots(utterance, skill.slots);
const missing = (skill.slots ?? [])
.filter(s => s.required && !(s.name in slots))
.map(s => s.name);
if (missing.length > 0) {
return;
}
let score = 0;
const reasons: string[] = [];
const examples = skill.examples ?? [];
if (examples.some(e => normalize(e) === text)) {
score = 1;
reasons.push('示例句精确命中');
} else {
// 信号一:与示例句的字符二元组相似度(容忍语序和虚词变化)
const uttGrams = bigrams(utterance);
let bestSim = 0;
for (const e of examples) {
const sim = jaccard(uttGrams, bigrams(e));
if (sim > bestSim) {
bestSim = sim;
}
}
if (bestSim > 0) {
score += bestSim * 0.6;
reasons.push(`示例相似 ${bestSim.toFixed(2)}`);
}
// 信号二:关键词命中("记一笔""天气"这类词根)
const hits = (skill.keywords ?? []).filter(k => text.includes(normalize(k)));
if (hits.length > 0) {
score += Math.min(hits.length * 0.18, 0.9);
reasons.push(`关键词 ${hits.join('/')}`);
}
}
if (score > 0 && (!best || score > best.score)) {
best = {
skill,
score: Math.min(score, 1),
reason: reasons.join(';') || '无',
slots,
};
}
});
if (!best) {
return null;
}
const result: CommandMatch = best;
return result.score >= threshold ? result : null;
}
三个信号各有分工:示例句精确命中是用户照着 UI 提示念的场景,直接满分执行;关键词是语义锚点,“记一笔”"天气"命中了就说明大方向对;字符二元组相似度兜住口语变化——"记一下买牛奶"和示例"记一笔买牛奶"不精确相等,但 bigram 重叠度高,照样命中。每个技能抽了什么槽、为什么命中(reason 字段)都返回给业务侧,demo 的日志区直接展示,匹配不对时一眼能看出是哪个信号没起作用。阈值 0.3 是实测出来的平衡点:再低误触发多,再高"明天交房租"这种偏离示例的句子就进不来。
必填槽位缺失直接判不匹配是这条设计里最重要的决定:语音场景里"没听懂"是常态,返回一句"没听清要做什么"比执行了一个错误操作好得多——宁可认怂,不要猜。
4.7 调起智能体:从"多路深链"到"承载页 + 官方组件"
这一节是全篇改动最曲折的一处,值得完整讲一遍,因为它是"跨端调起智能体"唯一走得通的那条路。
第一版:深链。 失败。startAbility 和 openLink 都返回成功,但 aa dump 里 com.huawei.hmos.vassistant:entry:VoiceAbility 的 mission 状态是 BACKGROUND、window attached #0——窗口根本没挂,前台反而退回桌面。结论:三方应用有"调用被接受"这一层许可,没有"把它的窗口带到前台"这一层许可。这两件事在系统里是两套东西,前者是链接匹配,后者是窗口栈管理。
第二版:找编程式 API。 把 AgentFrameworkKit 的 d.ts 从头翻到尾:AgentController 只给了 isAgentSupport();组件侧配套的 FunctionController 也只有 agentDialogOpened / agentDialogClosed 两个事件,整个 Kit 里没有 open(agentId) 这种命令式接口。唯一的拉起入口是 ArkUI 组件:
@Component
struct FunctionComponent {
agentId: string = '';
options?: FunctionOptions; // queryText / title 等
controller?: AgentController;
onError?: (err: BusinessError) => void;
}
问题很明确:RN 渲染不了 ArkUI 组件,也就没法直接用这个入口。但反过来想——RN 拉不起 ArkUI 组件,RN 拉得起原生页面。这就是第三版。
第三版:承载页(真机跑通)。 思路三句话:
- 宿主工程里加一个页面
AgentHost,页面 body 只有那个FunctionComponent; - 给页面套一个
AgentHostAbility,exported: true,让库侧能startAbility起它; - RN 侧把
agentId/queryText塞进 want 参数,Ability 收下写AppStorage,页面用@StorageLink取。
库侧 openAgent 的第一优先级就是这条路:
// harmony/ai_agent/src/main/ets/AIAgentModule.ets
async openAgent(optionsJson: string): Promise<OpenAgentResult> {
const opts = JSON.parse(optionsJson) as OpenAgentOptions;
const agentId: string = opts.agentId ?? '';
const queryText: string = opts.queryText ?? '';
const hostAbility: string = opts.hostAbility ?? '';
// 第一优先级:宿主承载页。这是唯一能让"智能体对话层"真正出现在前台的路径。
if (hostAbility !== '') {
let selfBundle: string = '';
try {
// 自己的包名只能这样拿,见下面的"两个细节"
selfBundle = bundleManager.getBundleInfoForSelfSync(
bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT).name;
} catch (e) {
const err = e as BusinessError;
console.warn(`${TAG} getBundleInfoForSelfSync failed: ${err.code} ${err.message}`);
}
if (selfBundle !== '') {
const hostWant: Want = {
bundleName: selfBundle,
abilityName: hostAbility,
parameters: { 'agentId': agentId, 'queryText': queryText },
};
try {
await this.context.startAbility(hostWant);
console.info(`${TAG} openAgent ok via=host ability=${hostAbility} agentId=${agentId}`);
return { opened: true, via: 'host', message: `已打开智能体承载页 ${hostAbility}` };
} catch (e) {
const err = e as BusinessError;
console.warn(`${TAG} openAgent attempt[host] failed: ${err.code} ${err.message}`);
}
}
}
// 兜底:openLink → viewData 深链 → qiankun action → 纯 ability(语义为"调用被接受")
// ...(与第一版相同,此处略)
}
宿主侧三个文件。第一个是 Ability——注意 onCreate 和 onNewWant 都要处理,否则第二次点按钮参数不更新:
// entry/src/main/ets/entryability/AgentHostAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit';
import { window } from '@kit.ArkUI';
export default class AgentHostAbility extends UIAbility {
onCreate(want: Want, _launchParam: AbilityConstant.LaunchParam): void {
this.applyWant(want);
}
// launchType 是 singleton,复用时走这里;漏掉它就会出现"第二次点按钮还是上次的 agentId"
onNewWant(want: Want, _launchParam: AbilityConstant.LaunchParam): void {
this.applyWant(want);
}
private applyWant(want: Want): void {
const agentId = (want.parameters?.agentId as string) ?? '';
const query = (want.parameters?.queryText as string) ?? '';
if (agentId !== '') {
AppStorage.setOrCreate('agentHostAgentId', agentId);
}
AppStorage.setOrCreate('agentHostQuery', query);
}
onWindowStageCreate(windowStage: window.WindowStage): void {
windowStage.loadContent('pages/AgentHost');
}
}
第二个是承载页本体:
// entry/src/main/ets/pages/AgentHost.ets
import { BusinessError } from '@kit.BasicServicesKit';
import { FunctionComponent, FunctionController, FunctionOptions } from '@kit.AgentFrameworkKit';
@Entry
@Component
struct AgentHost {
@StorageLink('agentHostAgentId') agentId: string = '';
@StorageLink('agentHostQuery') queryText: string = '';
private controller: FunctionController = new FunctionController();
aboutToAppear(): void {
this.controller.on('agentDialogOpened', () => console.info('[AgentHost] dialog opened'));
this.controller.on('agentDialogClosed', () => console.info('[AgentHost] dialog closed'));
}
aboutToDisappear(): void {
// 不解绑会泄漏:页面退出后 controller 仍持有回调
this.controller.off('agentDialogOpened');
this.controller.off('agentDialogClosed');
}
build() {
Column() {
if (canIUse('SystemCapability.AI.Agent.AgentKit') && this.agentId !== '') {
FunctionComponent({
agentId: this.agentId,
options: { queryText: this.queryText, title: '小艺' } as FunctionOptions,
controller: this.controller,
onError: (err: BusinessError) => {
console.error(`[AgentHost] agent error ${err.code} ${err.message}`);
},
})
} else {
Text('当前设备不支持智能体组件').fontSize(14)
}
}
.width('100%')
.height('100%')
}
}
第三个是 module.json5 里的 Ability 注册——startWindowIcon / startWindowBackground 一个都不能少,缺了 hvigor 的 schema 校验直接拒(00303038),不是运行时才报:
{
"name": "AgentHostAbility",
"srcEntry": "./ets/entryability/AgentHostAbility.ets",
"launchType": "singleton",
"exported": true,
"startWindowIcon": "$media:startIcon",
"startWindowBackground": "$color:start_window_background",
}
canIUse 那一行不是洁癖:AgentFrameworkKit 在缺 hsp 的设备上连渲染都会崩,包一层判断能把"进程崩掉"降级成"一行提示"。
两个必须讲清楚的细节
细节一:自己的包名怎么拿。 依次试了三处,前两处都是空串:
this.context.abilityInfo.bundleName → "" // TurboModule 的 context 不是 UIAbilityContext
this.context.applicationInfo.name → "" // 同上
bundleManager.getBundleInfoForSelfSync( → "com.example.aiagentdemo" // ✓
BundleFlag.GET_BUNDLE_INFO_DEFAULT).name
一个 TurboModule 拿到的 context 不保证是完整的 UIAbilityContext,abilityInfo / applicationInfo 这两个字段经常是空对象。别赌,直接问系统。这个空包名导致的第一现象是 16000019 No matching ability is found——看着像"承载页没注册",实际是"want 里的 bundleName 是空的"。
细节二:手写方法表把参数吃了。 加上 hostAbility 第三个参数后,库侧收到的永远是 undefined,但 JS 侧明明传了。根因在 C++ 注册头:
// 改之前
ARK_ASYNC_METHOD_METADATA(openAgent, 3)
// 改之后
ARK_ASYNC_METHOD_METADATA(openAgent, 1)
RNOH 的方法表里声明的参数个数与 JS 实际调用传入的个数不一致时,不报错、不告警,多余的参数被静默丢弃。这类问题最难查,因为日志干干净净。最后的解法是彻底消除"个数"这个概念——方法表只声明 1 个参数,JS 侧把整个 options 序列化成一个字符串,ArkTS 侧再 JSON.parse:
// src/index.ts —— 对外仍然保持三个具名参数,接入方感知不到
openAgent(agentId: string, queryText = '', hostAbility = ''): Promise<OpenAgentResult> {
return NativeAIAgent.openAgent(JSON.stringify({ agentId, queryText, hostAbility }));
}
深链那条路没删。 它是 hostAbility 为空时的兜底,语义仍然是"调用被接受",返回值里的 via 会如实写 host / openLink / want / none,UI 按 via 显示不同文案——成功就是成功:
// example/src/App.tsx
const r = await AIAgent.openAgent(agentId, query, 'AgentHostAbility');
setOpenAgentResult(r.via === 'host'
? `✓ ${r.via} · ${r.message}`
: r.opened ? `⚠ ${r.via} · 调用被接受,界面可能不出现` : `✗ ${r.message}`);
4.7.1 可达性判定:三档兜底
配套的探测逻辑也在这一轮被重写。原来只查 bundle 信息,真机查不到就报"未安装",是错的:
private async hasXiaoyi(): Promise<boolean> {
// 第一档:能查到 bundle(模拟器/部分镜像可能没有小艺,真机则被 accessible=false 挡住)
try {
await bundleManager.getBundleInfo(XIAOYI_BUNDLE, bundleManager.BundleFlag.GET_BUNDLE_INFO_DEFAULT);
return true;
} catch (e) {
const err = e as BusinessError;
console.warn(`${TAG} getBundleInfo(${XIAOYI_BUNDLE}) failed: ${err.code} ${err.message}`);
}
// 第二档:语音入口链接能否解析。需要 module.json5 声明 querySchemes,否则抛 17700056
try {
if (bundleManager.canOpenLink(XIAOYI_URI)) {
console.info(`${TAG} hasXiaoyi: canOpenLink(${XIAOYI_URI}) = true`);
return true;
}
} catch (e2) {
const err2 = e2 as BusinessError;
console.warn(`${TAG} canOpenLink failed: ${err2.code} ${err2.message}`);
}
// 第三档:自家技能被技能中心收录 = 系统 AI 侧已经认领了这个应用,语音侧必然可达
try {
const infos = await skillManager.getSkillInfosForSelf(skillManager.SkillInfoFlag.GET_SKILL_INFO_DEFAULT);
if (infos.length > 0) {
console.info(`${TAG} hasXiaoyi: ${infos.length} skill(s) registered → true`);
return true;
}
} catch (e3) {
const err3 = e3 as BusinessError;
console.warn(`${TAG} getSkillInfosForSelf failed: ${err3.code} ${err3.message}`);
}
return false;
}
第三档是这轮最有用的一条:"我的技能被系统收录"本身就意味着语音入口认我,不需要再去证明小艺装没装。配套的一行 module.json5 是第二档能通过的前提:
"querySchemes": ["vassistant"]
少了它,canOpenLink 抛 17700056 The specified URI is not supported——这个错误码说的是"你的应用没被授权查询这个 scheme",不是"链接不存在"。两者的区别,就是上一版那个红叉的全部来源。
4.8 反向桥:系统意图怎么"渡"进 JS
闭环 B 的链路里有一个结构性问题:系统唤起意图时,创建的是宿主 entry 里的 InsightIntentExecutor,而执行逻辑在 JS。中间隔着 HAR 模块边界,需要一个反向桥把调用渡过去。库的答案是 AIAgentBridge——一个静态注册表,HAR 里的 TurboModule 创建时自动登记,宿主执行器一行转发:
// harmony/ai_agent/src/main/ets/AIAgentBridge.ets(完整文件)
/**
* 宿主 ↔ 库 的反向桥。
*
* 为什么需要它:系统入口(小艺 / 小艺建议)唤起意图时,接住的是一个
* InsightIntentExecutor —— 而 InsightIntentExecutor / AgentExtensionAbility
* 这类 Ability 只能在 entry 模块里声明(HAR 里声明不了,SDK 原文写着
* "This class cannot be used in Harmony Archive(HAR)")。
*
* 所以链路必须这么走:
* 小艺 → entry 的 IntentExecutor → AIAgentBridge.invokeIntent()
* → HAR 里的 AIAgentModule → JS 的 registerIntentHandler 处理器
* → completeIntent() 回传结果 → 小艺播报
*
* 宿主只需要在执行器里写一行转发,剩下的匹配与执行业务都在 JS 层。
*/
/** 库内部用的接收方接口,避免 bridge 与 module 循环依赖 */
export interface IntentSink {
/** 系统唤起一次意图 */
onIntentInvoked(instanceId: number, intentName: string, paramsJson: string): void;
/** 智能体弹层开/关(由宿主 ArkUI 侧调用) */
onAgentDialog(kind: string, agentId: string): void;
}
export class AIAgentBridge {
private static sink: IntentSink | null = null;
/** 由 AIAgentModule 构造时自动绑定,业务侧不用调 */
static bind(sink: IntentSink): void {
AIAgentBridge.sink = sink;
}
static unbind(sink: IntentSink): void {
if (AIAgentBridge.sink === sink) {
AIAgentBridge.sink = null;
}
}
/** 是否已有一个活的 RN 侧接收者(模块是否已创建) */
static isReady(): boolean {
return AIAgentBridge.sink !== null;
}
/**
* 宿主 IntentExecutor / 自定义 UI 调用:把系统唤起转发给 JS。
*
* @param instanceId 来自 IntentExecutor 的 context.instanceId,回传结果时必须原样带回
* @param intentName 意图名,与 insight_intent.json 里的 intentName 一致
* @param paramsJson 系统抽取的参数(JSON 字符串);没有就传 '{}'
*/
static invokeIntent(instanceId: number, intentName: string, paramsJson: string): boolean {
const sink = AIAgentBridge.sink;
if (sink === null) {
// RN 模块还没创建(冷启动时系统先唤起意图是可能发生的),忽略并让系统超时降级
console.warn('[AIAgent] bridge 未绑定,RN 模块尚未创建,忽略本次意图调用');
return false;
}
sink.onIntentInvoked(instanceId, intentName, paramsJson === '' ? '{}' : paramsJson);
return true;
}
/** 宿主 ArkUI 侧在用 FunctionComponent 时,可把弹层开关同步给 JS 做埋点 */
static notifyAgentDialog(kind: string, agentId: string): void {
AIAgentBridge.sink?.onAgentDialog(kind, agentId);
}
}
配套的宿主侧声明是一份 JSON 配置加一个空壳类。技能声明进 module.json5:
// example/harmony/entry/src/main/module.json5(skillProfiles 部分,完整)
"skillProfiles": [
{
"name": "add-note",
"version": "1.0.0",
"abilityName": "EntryAbility",
"visibility": "system",
"srcEntries": [
"../../skills/add-note/scripts/AddNoteSkill.ets"
]
}
]
srcEntries 的路径写法是工具链约定死的三级相对路径 ../../skills/<name>/scripts/<File>.ets,写成 skills/add-note/... 会报 00304072(踩坑第 3 条)。技能目录里必须有 SKILL.md,front matter 的 name/description/version 会被小艺拿来展示和路由:
---
name: add-note
description: 把用户说的一句话记成笔记。当用户说「记一笔」「记一下」「帮我记」时使用。
version: 1.0.0
---
# 记一笔(add-note)
把用户口述的一句话存成一条笔记,供小艺 / 智能体调用。
## 输入
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `content` | string | 是 | 要记下来的内容,例如「买牛奶」 |
## 输出
| 参数 | 类型 | 说明 |
|---|---|---|
| `message` | string | 回给用户的一句话,例如「记下了:买牛奶」 |
## 实现说明
执行逻辑不在这里,而在 React Native 侧 `registerSkill({ name: 'add-note', ... })` 的处理器中,
由 `AIAgentBridge` 从小艺的意图入口转发过来。本目录只声明「应用具备这个技能」。
srcEntries 指到的 ArkTS 类是个空壳——它的存在只是为了让"应用有这个技能"这件事在编译期有实体:
// example/harmony/entry/skills/add-note/scripts/AddNoteSkill.ets(完整文件)
/**
* 「记一笔」技能的空壳实现。
*
* 这个文件被 module.json5 的 skillProfiles[].srcEntries 引用,声明"应用里有这么个技能"。
* 真正的执行逻辑不在这里 —— 它在 RN 侧的 registerSkill({ name: 'add-note', ... }) 里,
* 这样技能的业务实现可以随 JS 热更新,而系统只需要知道"这个应用有这个能力"。
*/
export class AddNoteSkill {
/** 技能标识,与 skillProfiles.name 保持一致,便于日志对照 */
static readonly SKILL_NAME: string = 'add-note';
/** 技能的一句话说明,用于调试面板展示 */
static describe(): string {
return '把用户说的一句话记成笔记,支持清空';
}
}
最后是业务侧怎么消费。demo 里三个技能加一个系统意图处理器,全部用 React 的生命周期管理注销,不存在跨页面泄漏:
// example/src/App.tsx(技能注册部分,完整)
/** 演示用的笔记:语音说"记一笔 买牛奶"就写进来 */
const NOTE_SLOT = /(?:记一笔|记一下|记录一下|帮我记)\s*(.+)$/;
export default function App() {
// useAIAgent() 提供 phase / transcript / logs / start / stop / run 等状态与动作(见 4.9)
const { logs, run, start, stop } = useAIAgent();
const [notes, setNotes] = useState<string[]>([]);
/**
* 注册 JS 侧技能:这是"语音指令 → 操作"的业务核心。
* handler 里拿到的 slots 是本地抽取的,不依赖云端大模型。
*/
useEffect(() => {
const offNote = AIAgent.registerSkill({
name: 'add-note',
description: '记一条笔记',
examples: ['记一笔买牛奶', '帮我记一下明天开会', '记一下交房租'],
keywords: ['记一笔', '记一下', '记录'],
slots: [{ name: 'content', required: true, pattern: NOTE_SLOT }],
// 记笔记不用回话,安静点
speakResult: false,
handler: ({ slots }) => {
const content = slots.content ?? '';
setNotes(prev => [content, ...prev].slice(0, 20));
return { message: `已记下:${content}`, data: { count: notes.length + 1 } };
},
});
const offWeather = AIAgent.registerSkill({
name: 'check-weather',
description: '查天气(演示数据)',
examples: ['今天天气怎么样', '明天会下雨吗', '查一下天气'],
keywords: ['天气', '下雨', '气温'],
slots: [
{ name: 'day', values: ['今天', '明天', '后天'], defaultValue: '今天' },
],
handler: ({ slots }): SkillResult => {
const day = slots.day ?? '今天';
// 真实项目里这里换成业务 API;演示只为跑通闭环
return {
message: `${day}多云转晴,18 到 26 度,出门带件外套`,
data: { day, temp: '18~26' },
};
},
});
const offClear = AIAgent.registerSkill({
name: 'clear-notes',
description: '清空笔记',
examples: ['清空笔记', '把笔记都删了'],
keywords: ['清空', '删除'],
handler: () => {
setNotes([]);
return { message: '笔记已清空' };
},
});
// 系统入口(小艺)唤起意图时的处理器:返回值会自动回传给小艺
const offIntent = AIAgent.registerIntentHandler('AddNote', async (_inv, params) => {
const content = String(params.content ?? params.note ?? '');
if (!content) {
return { ok: false, message: '没听清要记什么' };
}
setNotes(prev => [content, ...prev].slice(0, 20));
return { ok: true, message: `已记下:${content}`, data: { content } };
});
return () => {
offNote();
offWeather();
offClear();
offIntent();
};
// notes 只用于日志里的计数展示,重注册代价极低
}, [notes.length]);
}
注意 registerSkill 与 registerIntentHandler 的分工:前者是应用内语音(闭环 A)的匹配与执行,后者是系统意图(闭环 B)的执行。两者可以操作同一份业务状态(这里都是 setNotes),用户体验上就是"自己按住麦克风说"和"对小艺说"落到了同一个笔记本里。
4.9 dispatch 与意图自动路由
把匹配和执行串起来的是 dispatch,返回值携带分数和原因,方便业务侧做 UI 与埋点:
// src/index.ts(完整方法)
/**
* 一句话走完闭环:匹配 → 执行 → 回话。
* 没命中时返回 ok:false,不抛错——语音场景里"没听懂"是常态,不该变成异常。
*/
async dispatch(utterance: string): Promise<DispatchResult> {
const t0 = Date.now();
const matched = matchCommand(utterance);
if (!matched) {
return {
ok: false,
utterance,
skill: null,
score: 0,
reason: '没有任何技能得分超过阈值',
slots: {},
message: '没听清要做什么,能再说一遍吗?',
elapsedMs: Date.now() - t0,
};
}
try {
const result = await matched.skill.handler({
utterance,
slots: matched.slots,
skill: matched.skill,
});
if (matched.skill.speakResult !== false) {
void AIAgent.speak(result.message); // TTS 异步播报,不阻塞返回
}
return {
ok: result.ok !== false,
utterance,
skill: matched.skill.name,
score: matched.score,
reason: matched.reason,
slots: matched.slots,
message: result.message,
elapsedMs: Date.now() - t0,
data: result.data,
};
} catch (e) {
// handler 抛错也转成正常返回:语音闭环里业务失败要"说出来",不是抛异常
const message = e instanceof Error ? e.message : String(e);
return {
ok: false,
utterance,
skill: matched.skill.name,
score: matched.score,
reason: matched.reason,
slots: matched.slots,
message: `执行失败:${message}`,
elapsedMs: Date.now() - t0,
};
}
}
闭环 B 的自动路由在模块加载时绑一次事件监听,把系统派来的意图直接送到业务处理器,并保证任何情况下都回传结果——哪怕没注册处理器,也要回一句"未注册意图处理器",否则小艺侧只能干等到超时:
// src/index.ts(完整函数)
let intentRoutingBound = false;
/** 路由系统意图到 JS 处理器;只绑一次,多次 import 不会重复绑 */
function ensureIntentRouting() {
if (intentRoutingBound || !AIAgent.isSupported()) {
return;
}
intentRoutingBound = true;
emitter.addListener('aiAgentEvent', (payload: AIAgentEventPayload) => {
if (payload?.kind !== 'intent' || !payload.intent) {
return;
}
const invocation = payload.intent;
const handler = intentHandlers.get(invocation.intentName) ?? fallbackIntentHandler;
if (!handler) {
// 没有处理器也要回话,否则小艺侧只能干等到超时
void AIAgent.completeIntent(invocation.instanceId, {
ok: false,
message: `未注册意图处理器:${invocation.intentName}`,
});
return;
}
void (async () => {
try {
const params = parseJson<Record<string, unknown>>(invocation.paramsJson, {});
const result = await handler(invocation, params);
await AIAgent.completeIntent(invocation.instanceId, result);
} catch (e) {
// 业务抛错同样回传失败结果,instanceId 必须原样带回
await AIAgent.completeIntent(invocation.instanceId, {
ok: false,
message: e instanceof Error ? e.message : String(e),
});
}
})();
});
}
这里最值得强调的是 instanceId:它是系统给这次意图调用的唯一凭据,回传时必须原样带回,传错或传旧的会报 16000003。它从 IntentExecutor 一路透传(执行器 → Bridge → JS 事件负载 → completeIntent 参数)。
useAIAgent Hook 把这些串成状态机:idle → listening → matching → executing → speaking → idle,中间结果实时刷 transcript,onResult(isFinal) 触发自动 dispatch(可关),错误事件落到 error 态 1.2 秒后回 idle。状态机全部用 phaseRef + setState 双轨维护——事件回调是异步的,靠 ref 才能拿到准确的当前相位做守卫(比如 listening 中忽略重复 start)。业务侧拿到的是一组可直接渲染的字段,不需要写任何 Platform 判断:非鸿蒙平台 Hook 保持安全空态。
4.10 意图找不到?九成是 metadata 没挂
这一节是闭环 B 的前置条件,也是我卡了最久的一个"静默失败"。系统意图的配置一共三样东西,缺一样都表现为同一个症状:应用能装能跑,但系统完全不知道你会接活。
第一步,写意图描述文件 entry/src/main/resources/base/profile/insight_intent.json:
{
"insightIntents": [
{
"intentName": "AddNote",
"domain": "Note",
"intentVersion": "1.0.1",
"srcEntry": "./ets/insightintent/AddNoteExecutor.ets",
"entities": [
{ "entityName": "noteContent", "entityId": "noteContent" }
],
"executeMode": ["background"]
}
]
}
executeMode 写 background 是关键设计:语音入口派下来的活默认在后台完成,不要弹 UI,用户要的是"记下了"这句话,不是一个页面。
第二步,写执行器 AddNoteExecutor.ets,继承 InsightIntentExecutor,实现 onExecuteInUIAbilityBackgroundMode / onExecuteInUIAbilityForegroundMode,内部一行转发给 AIAgentBridge(4.8 节)。
第三步——也是唯一容易漏的一步:在 module.json5 里给 EntryAbility 挂一条 metadata,把 schema 和文件连起来:
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"metadata": [
{
"name": "ohos.insightIntent.config",
"resource": "$profile:insight_intent"
}
],
// ... 其余字段
}
文件在 resources/base/profile/ 下躺着,JSON 语法正确,schema 也能过编译。结果 bm dump 里这个应用的 insightIntents 是空的,aa start 带上 --ps ohos.insightIntent.executeParam.name AddNote 也毫无反应,日志里连一行错误都没有。原因就是意图配置不是"放进目录就生效",而是必须由 Ability 的 metadata 显式指过来——ohos.insightIntent.config 这个 key 名在 SDK 文档里只有一句话,写在 metadata 的兼容性说明里,很容易扫过去。
补上之后,验证只要一条命令:
hdc shell bm dump -n com.example.aiagentdemo | grep -A3 insightIntent
能 dump 出那条 metadata,才算系统真的认了。这条命令建议写进你的自测清单——配置类的东西一定要有一条"从系统侧反查"的验证手段,否则你永远在"我明明配了"和"它就是不动"之间来回。
顺带记一条边界:意图只能由系统 AI 入口触发。我试过用 aa start --ps 手动构造执行参数,命令返回成功,执行器一次都没被创建。三方应用没有任何"我主动发起一个意图"的 API——这不是权限问题,是设计如此。所以闭环 B 的验证只能靠反向的 shareIntent(4.11)或等报备通过后由小艺真机触发。
4.11 出向链路:shareIntent 上报与 1000101101
正向(系统 → 应用)需要报备才能测,反向(应用 → 系统)不需要。IntentsKit.shareIntent 的作用是把"用户在我的应用里做了一件事"告诉系统,让小艺在合适的时机给出建议卡片——这是三方在意图体系里唯一能主动发起的方向。
// AIAgentModule.ets(节选)
async shareIntent(intentName: string, paramsJson: string): Promise<ShareIntentResult> {
const params = JSON.parse(paramsJson) as Record<string, string>;
const shareParam: intentsKit.ShareParam = {
'intentName': intentName,
'actionParam': params['actionParam'] ?? '',
'identifier': params['identifier'] ?? '',
};
try {
await intentsKit.shareIntent(shareParam);
return { shared: true, code: 0, message: '已上报小艺' };
} catch (e) {
const err = e as BusinessError; // 注意:这里的类型断言不能省
return { shared: false, code: err.code, message: err.message };
}
}
我第一次打印这个错误时得到 [object Object]——因为直接 String(e) 了。BusinessError 必须按字段取,code 和 message 各是各的信息:
✗ 上报失败 1000101101:The application has not been registered with the InsightIntent
1000101101 的定义很直接:应用未在意图平台注册。但同一时刻 hilog 里是这样:
InsightIntentHandler: ShareIntent ... bundleName: com.example.aiagentdemo
headerNames: AddNote
请求头、意图名、bundleName 全部正确,数据已经到达 com.huawei.hmos.aidataservice,只是被"未报备"这一关卡住。所以这条链路的结论要分开写:代码侧已打通(请求发出、格式正确、系统收到),资质侧未打通(应用需在意图平台完成意图/智能体报备)。报备之后不需要改任何一行业务代码。
给读者的预期管理比给个漂亮截图重要:如果你的应用没报备,这张卡片注定是红叉,但它红得有价值——它证明出向通道可用、格式正确,报备只是流程问题。反过来,如果连 1000101101 都拿不到、而是 401 或 17700001,那才是代码或权限真有问题。
五、踩坑:
坑 1|AgentFrameworkKit 静态 import 直接杀进程
cppcrash: [LoadJSPandaFile] load hsp failed, hsp name:
com.huawei.hmsapp.hiai.hsp/agentKitHsp
模块顶层 import { AgentController } from '@kit.AgentFrameworkKit',在缺 hsp 的设备(模拟器、部分定制镜像)上启动即 abort。解法:拆独立文件 + 动态 import + catch 降级(4.4 节完整方案)。三方库里任何"设备上可能不存在"的 Kit 都要过一遍这个检查。
坑 2|addListener/removeListeners 没实现,进程被 Promise 杀死
RNOH: Failed to retrieve property from object
JS 侧 new NativeEventEmitter(module) 之后,React 会调用模块的 addListener/removeListeners——C++ 方法表里声明成 async 了,ArkTS 侧不实现的话调用解析成 undefined,RNOH 在 Promise::then 上抛错直接杀进程。这两个方法本身不需要做事(事件走 emitDeviceEvent),但必须存在且返回 Promise。
坑 3|skillProfiles 三连错
00304071:skillProfiles 目录不存在
00304072:srcEntries 路径非法
10011001:技能缺少 SKILL.md
三个错各对应一条铁律:技能目录必须建在 entry/skills/<name>/;srcEntries 必须写三级相对路径 ../../skills/<name>/scripts/<File>.ets;每个技能目录里必须有带 front matter 的 SKILL.md。缺一个都过不了打包。
坑 4|声明类写不进 HAR
This class cannot be used in Harmony Archive(HAR)
AgentExtensionAbility 的 SDK 注释原文。skillProfiles 同理只在 entry/feature/shared/skill 模块合法。三方库别试图把声明包揽进来——正确姿势是提供反向桥,让宿主一行转发(4.8 节)。
坑 5|不申请权限直接开识别,报错里没有任何"权限"字样
1002200002 Start listening failed
MICROPHONE 是 user_grant 权限,不申请就 startListening 只会得到一个含义模糊的错误码。解法:申请前置(4.5 节完整代码),授权失败抛出带指引的错误信息。真机截图里那个"允许"随手记"访问你的麦克风?"弹窗,就是这个调用触发的。
坑 6|跨应用查技能的接口三方永远调不了
skillManager.getSkillInfos 需要 MANAGE_SKILL,查 PermissionDefinitions 是 system_basic——三方应用拿不到,别浪费时间试。同理 MANAGE_SELF_SKILLS、GET_AGENT_CARD、CONNECT_AGENT。能用的只有 getSkillInfosForSelf。
坑 7|compatibleSdkVersion 写法
api version parameter illegal
build-profile.json5 里写 "26.0.0(26)" 会报这个错,写成 "26.0.0" 才合法——和 DevEco 工程模板里带 (26) 后缀的写法不同,命令行构建时后缀是非法的。
坑 8|bundle 加载顺序让应用"卡死"30 秒
壳工程默认把 MetroJSBundleProvider 放在第一位,没起 Metro dev server 时每次冷启动都要等它超时。解法:rawfile 优先(ResourceJSBundleProvider('bundle.harmony.js') 第一位),Metro 作为开发期兜底放最后。
坑 9|appKey 与 registerComponent 名字不一致,红屏
Invariant Violation: "AwesomeProject" has not been registered
壳工程 Index.ets 里的 appKey 必须等于 JS 侧 AppRegistry.registerComponent 的名字。拷模板改应用名时最容易漏。
坑 10|ArkTS 的两条对象字面量限制
其一,不允许用对象字面量实现"带方法"的接口(arkts-no-untyped-obj-literals),IntentSink 只能走 class ... implements IntentSink;其二,给"只有索引签名"的接口(如 InsightIntent)也不能写字面量,库里的做法是先拼 JSON 字符串再 JSON.parse 断言——效果一致且能过编译。
六、总结:
本文基于 RNOH 0.84、鸿蒙 API26,开源
react-native-ohos-ai-agent三方 TurboModule 库,打通 RN 应用接入小艺语音指令的完整闭环。梳理了鸿蒙小艺 Skill、InsightIntent、AgentFrameworkKit、CoreSpeechKit 能力边界,明确三方应用权限限制,仅能做技能声明、接收系统意图、回传执行结果,无法调用系统级管理接口。
库采用手写 C++ 方法表规避 Codegen 动态参数限制,通过 HAR + 宿主反向桥的方案,解决 Skill、Intent 配置只能放在 entry 模块的约束;用动态 import 懒加载 AgentFrameworkKit,避免缺失 HSP 导致进程崩溃。整体实现三条链路:应用内端侧 ASR + 本地加权匹配、TTS 播报的语音闭环;小艺路由唤起 InsightIntent,转发至 JS 执行业务、回传结果的系统意图闭环;RN 通过原生承载页渲染 ArkUI FunctionComponent,拉起小艺智能体对话层的调起闭环。
配套 useAIAgent 状态机管理语音流程,统一事件通道简化订阅解绑。同时完整沉淀真机踩坑清单,覆盖 Skill 路径、意图 metadata 挂载、权限、深链拉起限制、参数丢失、错误码 1000101101 等高频卡点,给出可复用的验证命令与排查思路,业务逻辑放在 JS 层支持热更新,适合 RN 鸿蒙应用快速接入语音指令、小艺交互能力。
引用:
- 华为开发者官网 Ability Kit 文档(skillManager / insightIntentProvider / openLink / want 参数与 AppStorage)、Core Speech Kit 文档(speechRecognizer / textToSpeech)
- Agent Framework Kit 文档(AgentController 的
isAgentSupport与弹层事件、FunctionComponent组件的agentId/options/controller/onError参数) - Intents Kit 文档(
shareIntent及错误码表,含1000101101的应用注册要求;executeMode与ohos.insightIntent.configmetadata 配置)
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)