给小艺装上数学能力:数易 App 接入智能体 FunctionComponent 与 A2A 双通路实战
摘要:数易是一款数学可视化 App(函数拟合、学习空间、计算工具、练习挑战)。本文记录把它接入小艺智能体的完整过程:用
FunctionComponent在应用内嵌一个「问小艺」按钮(三档按钮形态 + 可用性探测 + 四类错误码降级),再用AgentExtensionAbility声明type: "agent"扩展能力,通过 A2A 协议让小艺反向调用 App 的 7 个数学技能(问题分析、方程求解、导数/矩阵/行列式验证、知识总结)。附真实代码、错误码处理与 6 个踩坑。
目录
- 背景与前置
- 通路一:App → 小艺(FunctionComponent 嵌入)
- 可用性探测与四类错误降级
- 通路二:小艺 → App(AgentExtensionAbility + A2A)
- 踩坑与排错
- 结论与适用边界
- 参考资料
背景与前置
数易是什么:数易(EasyMath)是一款数学可视化应用,五个 Tab:函数拟合、学习空间、数学之美、计算工具、练习挑战。核心场景是「看得见的数学」——2D/3D 函数图像、参数滑杆实时重绘、极限逼近与积分黎曼和可视化。
为什么接小艺:数学问题天然适合「提问 → 解析 → 求解」。用户在数易里看到一个函数图像,最自然的下一个动作是「问一句这题怎么解」。与其自己造一个问答入口,不如把问题交给系统级的小艺智能体,让它在用户的自然语言里拿到上下文。
版本环境
| 项 | 本文环境 |
|---|---|
| 设备 | 手机 / 平板(Mate 80 实测) |
| 系统 | HarmonyOS 5.1+ |
| 开发工具 | DevEco Studio 26 |
| 关键 Kit | @kit.AgentFrameworkKit(FunctionComponent / FunctionController)、@kit.AbilityKit(AgentExtensionAbility) |
两条通路的分工(这是本文的核心认知):
| 方向 | 组件 | 作用 |
|---|---|---|
| App → 小艺 | FunctionComponent | 在 App 界面内嵌按钮,用户点击直接把当前问题抛给小艺 |
| 小艺 → App | AgentExtensionAbility(A2A 协议) | 小艺(或其它智能体)反向调用 App 的技能,如「帮我解这个方程」 |
只做第一条,用户得离开 App 去小艺;只做第二条,用户在 App 里没有入口。两条都做,才是完整闭环。
实际落地后的两个界面(数易「学习空间」章节内):
入口——底部的「问小艺」按钮,出现在每节知识点的思考题下方:
效果——点击后拉起小艺里的「数易-学习伙伴」智能体,直接回答当前问题:
这两张图正好对应下文的两条通路:按钮由 FunctionComponent 提供,而智能体能根据当前章节内容作答,靠的是 App 通过扩展能力反哺给小艺的业务知识。
通路一:App → 小艺(FunctionComponent 嵌入)
1. 封装一个可复用的胶囊按钮
FunctionComponent 是平台提供的声明式组件,通过 @Builder 包装成 WrappedBuilder 才能在业务组件里复用:
import { FunctionComponent, FunctionController, ButtonType } from '@kit.AgentFrameworkKit'
export enum AgentButtonPreset { CIRCLE = 0, CAPSULE = 1, ICON_ABOVE_TITLE = 2 }
@Builder
export function AgentCapsuleBuilderFn(options: AgentCapsuleOptions) {
FunctionComponent({
agentId: options.agentId,
onError: (err: BusinessError): void => { options.onError?.(err) },
options: {
title: options.title,
queryText: options.queryText,
buttonType: options.preset === AgentButtonPreset.CIRCLE ? ButtonType.CIRCLE :
(options.preset === AgentButtonPreset.ICON_ABOVE_TITLE ? ButtonType.ICON_ABOVE_TITLE : ButtonType.CAPSULE)
},
controller: options.controller ?? undefined
})
}
三档按钮形态对应三种界面语境:CAPSULE(胶囊,正文流内最常用)、CIRCLE(圆形,浮在图表角落)、ICON_ABOVE_TITLE(图标在上文字在下,工具栏位)。
数易的做法是业务侧自定义入口 + 平台组件按需渲染:知识点思考题下方那颗「✨ 问小艺」按钮(见上文图 1)由业务侧用 SymbolGlyph + Text 绘制,保证风格与 App 一致;点击后由 FunctionComponent(能力可用时)打开智能体对话框,或走降级逻辑(能力不可用时复制问题)。这样做的好处是「入口长什么样」与「智能体能不能用」解耦——按钮外观稳定,能力差异只影响点击后的行为。
2. 会话控制器:把「打开/关闭」变成可监听的业务事件
FunctionController 是这套接入的状态中枢:既能探测能力,也能监听对话框生命周期。
export class AgentKitSession {
private controller: FunctionController = new FunctionController()
isSupport(context: common.UIAbilityContext, agentId: string): Promise<boolean> {
return this.controller.isAgentSupport(context, agentId)
}
onOpened(cb: () => void): void { this.controller.on('agentDialogOpened', cb) }
onClosed(cb: () => void): void { this.controller.on('agentDialogClosed', cb) }
dispose(): void {
this.controller.off('agentDialogOpened')
this.controller.off('agentDialogClosed')
}
}
有两个细节值得强调:
- 必须成对 off:
on('agentDialogOpened')这类监听在组件销毁时若不移除,页面切走再回来会重复触发(数易实测表现为「点一次按钮弹两次对话框」); - agentId 是云端智能体身份:它在小艺开发平台侧创建,App 侧只是一个字符串常量。我们的做法是集中定义单点常量,避免散落在多个页面:
const CLOUD_AGENT_ID: string = 'agent4****************************' // 云端智能体 ID(脱敏)
可用性探测与四类错误降级
这是整篇最容易被忽略、但对线上质量影响最大的一段。智能体能力并非所有机型/账号都可用,直接渲染 FunctionComponent 可能白屏或点击无反应。
探测在前,渲染在后
private async initAgent(ctx: common.UIAbilityContext): Promise<void> {
const supported: boolean = await CommonCapability.isAgentAvailable(ctx, CLOUD_AGENT_ID)
this.agentChecked = true
if (!supported) return // 不可用:保持降级按钮
try {
const ns: ESObject = await import('./AgentKitEntry') // 按需加载
const session: AgentKitSession = ns.createSession()
this.session = session
this.capsuleBuilder = ns.agentCapsuleBuilder
this.capsuleOptions = this.buildCapsuleOptions(session)
this.agentReady = true
} catch (err) {
hilog.error(0x0001, 'AskXiaoYi', 'load agent kit entry failed')
}
}
三个关键设计:
isAgentSupport先行:返回false时根本不加载组件模块;- 动态
import():智能体能力只在手机/平板端用,动态加载避免其它设备形态的白加载与包体积浪费; agentChecked与agentReady双状态:探测中、探测完成、能力可用三个状态分开,UI 才不会在探测期间闪烁。
四类错误码,四种用户话术
onError 回调里的错误码不能笼统提示「失败了」,要按用户该做什么来分:
private notifyAgentError(code: number): void {
if (code === 1022400011) { // 隐私未授权
appSnackBarWarning(ui, $r('app.string.learning_xiaoyi_err_privacy'))
} else if (code === 1022400012) { // 账号异常
appSnackBarWarning(ui, $r('app.string.learning_xiaoyi_err_account'))
} else if (code === 1022400013) { // 网络异常
appSnackBarError(ui, $r('app.string.learning_xiaoyi_err_network'))
} else {
appSnackBarError(ui, $r('app.string.learning_xiaoyi_unavailable'))
}
}
| 错误码 | 含义 | 用户可采取的动作 |
|---|---|---|
1022400011 | 隐私授权未完成 | 引导去设置页授权 |
1022400012 | 账号状态异常 | 引导登录/切换账号 |
1022400013 | 网络异常 | 提示检查网络,可重试 |
| 其它 | 能力不可用 | 降级方案(见下) |
降级不是死路:能力不可用时,按钮退化为普通按钮——点击把当前问题复制到剪贴板并提示「已复制,可粘贴到小艺」:
private notifyUnavailable(): void {
const head = res.getStringSync($r('app.string.learning_xiaoyi_unavailable'))
if (this.questionText.length === 0) { appSnackBarWarning(ui, head); return }
CommonCapability.copyToClipboard(this.questionText)
.then(() => appSnackBarWarning(ui, head + tail))
.catch(() => appSnackBarWarning(ui, head + copyFailedTail))
}
这一步让「不支持智能体的设备」也有可用路径,而不是给用户一个点了没反应的灰按钮。
通路二:小艺 → App(AgentExtensionAbility + A2A)
第一条通路是「用户在小艺里问」,第二条是「小艺反过来调用 App 的能力」。后者靠扩展能力实现。
1. 声明一个 type 为 agent 的扩展能力
module.json5 里要声明扩展能力,type 必须是 agent,并挂官方 metadata:
"extensionAbilities": [
{
"name": "AgentExtAbility",
"srcEntry": "./ets/agentextability/AgentExtensionAbility.ets",
"type": "agent",
"exported": true,
"description": "agent",
"icon": "$media:appicon_easymath_starticon",
"metadata": [
{ "name": "ohos.extension.agent", "resource": "$profile:agent_config" }
]
}
]
2. 实现 AgentExtensionAbility 的四个生命周期
export default class AgentExtAbility extends AgentExtensionAbility {
private protocolHandler: A2AProtocolHandler = new A2AProtocolHandler()
private skillDispatcher: SkillDispatcher = createDefaultDispatcher()
private sessionState: A2ASessionState = new A2ASessionState()
onCreate(want: Want): void { this.knowledgeBase.load(this.context.resourceManager) }
onConnect(want: Want, proxy: common.AgentHostProxy): void { this.comProxy = proxy }
onData(proxy: common.AgentHostProxy, data: string): void {
/* 收到 A2A 请求 → 解析 → 分派到具体技能 → 回写结果 */
}
onDisconnect(want: Want, proxy: common.AgentHostProxy): void {
this.comProxy = null
this.sessionState = new A2ASessionState() // 会话状态清零
}
}
onData 收到的是 A2A 协议的 JSON(请求里 params.message.parts[].text 承载自然语言),解析后交给技能分派器。
一条选型说明:官方 A2A 开发指南提供了
createA2AServer()(@kit.AgentFrameworkKit)封装,可直接处理 method 分发、TaskState状态与 Artifact 回传。数易最终选择自己实现协议处理器(A2AProtocolHandler+SkillDispatcher),原因是:数学技能有 7 个细分 handler,需要按关键词做自定义路由,并把每个 handler 的结果组装成带步骤说明的自定义报文——官方的固定 method 语义反而要为它绕一层。如果只是标准问答场景,直接用createA2AServer更快;需要自定义路由与报文结构时才值得手写。
3. 技能分派:7 个数学技能各管一件事
数易把「数学能力」拆成互相独立的技能,用关键词路由:
const dispatcher = new SkillDispatcher()
dispatcher.register({ name: 'problem-analysis', handler: handleProblemAnalysis, keywords: [...] })
dispatcher.register({ name: 'equation-formulation', handler: handleEquationFormulation, ... })
dispatcher.register({ name: 'equation-solving', handler: handleEquationSolving, ... })
dispatcher.register({ name: 'derivative-verification', handler: handleDerivativeVerification, ... })
dispatcher.register({ name: 'matrix-verification', handler: handleMatrixVerification, ... })
dispatcher.register({ name: 'determinant-verification', handler: handleDeterminantVerification, ... })
dispatcher.register({ name: 'knowledge-summary', handler: handleKnowledgeSummary, ... })
| 技能 | 做什么 | 典型用户问法 |
|---|---|---|
| 问题分析 | 判断题目类型、拆解条件 | 「这道题考什么」 |
| 方程列式 | 把自然语言条件转成方程 | 「三倍加二是十七」→ 3x+2=17 |
| 方程求解 | 求解并给出步骤 | 「解一下这个方程」 |
| 导数验证 | 校验用户的求导结果 | 「我算的导数对不对」 |
| 矩阵验证 | 校验矩阵运算 | 「这个矩阵乘法对吗」 |
| 行列式验证 | 校验行列式结果 | 「行列式算得对吗」 |
| 知识总结 | 归纳用到的知识点 | 「总结一下这类题的做法」 |
为什么拆这么细:每个 handler 是一个纯函数式的「输入文本 → 结果」,便于单测、便于按用户问题精准命中,也避免一个巨大 prompt 里塞所有能力导致回答发散。
4. 教程知识库:把 App 内容变成智能体的回答素材
除了技能,数易还接了一个 TutorialKnowledgeBase——从 resourceManager 加载 App 内的教程文本,供「知识总结」类问题直接引用。好处是回答口径与 App 内一致,不会出现「智能体说的和 App 教的不一样」。
踩坑与排错
坑 1:能力不支持时直接渲染组件 → 白屏/无反应
- 现象:部分机型进入页面,按钮区域空白或点了没反应。
- 根因:未先调
isAgentSupport就渲染FunctionComponent。 - 解法:先探测、后渲染;探测期间用降级按钮占位(见上文双状态设计)。
坑 2:错误码一律提示「网络错误」,用户被误导
- 现象:用户明明有网,却被提示检查网络。
- 根因:把
1022400011(隐私)/1022400012(账号)也归到网络类。 - 解法:按码分支给不同话术与不同引导动作(授权页 / 登录页 / 重试)。
坑 3:监听未解绑,点一次弹两次
- 现象:页面返回再进入后,点击按钮弹出两个对话框。
- 根因:
controller.on('agentDialogOpened')注册后未off,组件重建时叠加。 - 解法:在
aboutToDisappear里成对off,或把 controller 生命周期绑定到页面级单例。
坑 4:extensionAbilities 忘了 exported: true 或 type 写错
- 现象:小艺侧调用不到 App 能力,
onConnect不触发。 - 根因:扩展能力声明不完整;
type必须是agent,且metadata要挂ohos.extension.agent。 - 解法:按官方模板声明,并在真机上用 hilog 验证
onCreate → onConnect → onData三步是否依次出现。
坑 5:A2A 会话状态跨请求残留
- 现象:第二次提问带上了上一次的上下文,答案莫名跑偏。
- 根因:会话状态对象未在断开时重置。
- 解法:
onDisconnect里重建A2ASessionState,把「一次连接 = 一次会话」作为契约。
坑 6:把 agentId 散落在各页面
- 现象:换智能体时漏改一处,某些页面仍连旧智能体。
- 根因:
agentId硬编码在多个组件里。 - 解法:集中到单点常量(或配置),全工程只此一处引用。
结论与适用边界
这套接入适合谁:
- App 内已有「结构化提问场景」(数学、法律、健康自查这类有明确输入→输出形态的领域);
- 想让用户「不用离开 App」就能用上系统级智能体;
- 同时希望智能体反过来调用 App 的专业能力(A2A),形成双向闭环。
不适合的场景:
- 纯展示型页面,没有需要问答的上下文——硬塞按钮反而干扰;
- 没有成体系的能力可拆成技能——A2A 那条通路会流于形式;
- 设备覆盖要求极高且无法接受降级体验——需先评估目标机型的能力覆盖。
经验总结:小艺接入的工程量不在「调通」,而在降级路径与错误话术。调通只需半天,把「不支持、未授权、账号异常、网络异常」四种情况都给出用户能行动的出口,才是能上线状态的那部分。
参考资料
官方文档(2026-10-29 逐条核验,均 HTTP 200)
- FunctionComponent 组件参考 —
agentId/onError/options.title|queryText|isShowShadow/controller全量属性与回调 - A2A 开发指南 —
createA2AServer、RequestContext、TaskState与 Artifact 回传 - AgentExtensionAbility 开发指南 — 扩展能力生命周期与
proxy.sendData回包 - AgentExtensionAbility API 参考 — 接口签名
工程内引用
entry/src/main/ets/components/learning/AgentKitEntry.ets— FunctionComponent 封装entry/src/main/ets/components/learning/AskXiaoYiButton.ets— 按钮与降级entry/src/main/ets/agentextability/— AgentExtensionAbility 与 7 个技能 handler
🔗 发布链接
- 中小企业IT网(首发):www.cbismb.com/harmonyos/a…
- 平台状态:审核中(2026-10-29 21:47 发布)
- 站内分类:教育学习 | 站内标签:HarmonyOS、小艺、agent、Skill