本文三方库仓库: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 · skillManagergetSkillInfosForSelf查询"我声明了哪些技能"(验证声明是否被系统接受)API 22
CoreSpeechKitspeechRecognizer / textToSpeech端侧"听"和"说",不依赖网络API 12
AgentFrameworkKitAgentController(可用性)、FunctionComponent(智能体组件)感知/展示系统智能体API 20
IntentsKit / AbilityKitinsightIntent、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 拉得起原生页面。这就是第三版。

第三版:承载页(真机跑通)。 思路三句话:

  1. 宿主工程里加一个页面 AgentHost,页面 body 只有那个 FunctionComponent;
  2. 给页面套一个 AgentHostAbility,exported: true,让库侧能 startAbility 起它;
  3. 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.config metadata 配置)
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐