元服务SKILL是将元服务中的业务功能拆解为标准化的原子接口与原子组件,封装为SKILL供智能体调度。
在元服务项目中ascf/ascf_src目录,采用ASCF框架开发skills分包,目录结构如下:
ascf/ascf_src/skills/ ├── flight-booking-skill/ # 示例:预定航班 SKILL │ ├── SKILL.md # 业务说明文档,行数小于500行(必须) │ ├── mcp.json # 能力声明:暴露给模型的可调接口清单(必须) │ ├── index.js # 原子接口实现入口(必须) │ ├── apis # 原子接口实现 │ └── components # 原子组件配置(将结构化数据渲染为对话流卡片) └── drink-skill/ # 示例:饮品点单 SKILL └── ...
mcp.json描述"单个接口怎么用",SKILL.md描述整个业务怎么跑:业务流程、接口依赖、跨接口约束。
ascf/ascf_src/app.json配置skills分包和skills配置。一个元服务可声明多个SKILLs,并且一个独立分包也可以放置多个 SKILLs。
| 字段 | 必填 | 说明 |
|---|---|---|
| name | 是 | 名称,最大长度64字符。 |
| description | 是 | SKILL简要说明。 |
| path | 是 | SKILL目录路径,建议使用项目内相对路径,且与name匹配。 |
app.json配置示例:
{
"subPackages": [
{
"root": "skills",
"pages": []
}
],
"agent": {
"skills": [
{
"name": "flight-booking-skill",
"description": "机票预订场景:搜索航班、查看详情、确认订单、支付订单完成预订",
"path": "skills/flight-booking-skill"
}
]
}
}
SKILL.md需要带上YAML frontmatter,用于声明name和description:
--- name: flight-booking-skill description: 机票预订场景:搜索航班、查看详情、确认订单、支付订单完成预订 ---
## Function: GetFlightPrice
- **toolName**: GetFlightPrice
- **bundleName**: com.atomicservice.xxx
- **description**: 获取低价航班日历(航班价格趋势卡片)。用户想看某条航线一段时间内每天最低价(如「几天飞最便宜」「未来一个月低价日历」)时调用。仅返回价格日历,不含具体航班明细。
- **参数**: {"type":"object","properties":{"end":{"type":"String","description":"日历结束日期 YYYY-MM-DD,须不早于 start;未传时默认 start 起共7天"},"fromCity":{"type":"String","description":"出发城市名称"},"toCity":{"type":"String","description":"到达城市名称"},"date":{"type":"String","description":"出发日期 YYYY-MM-DD"},"start":{"type":"String","description":"日历开始日期 YYYY-MM-DD;相对时间须先转具体日期;未传时默认明天"}},"required":["fromCity","toCity"]}
- **调用方式**:invoke(functionName:"GetFlightPrice", arguments:{bundleName:"com.atomicservice.xxx", appId:"xxx", skillName:"flight-skill", apiName:"getFlightPrice", renderAtomicCard:true, componentName:"flight-skill_flight-price-card", query:"<用户的语料>", fromCity:"<出发城市>", toCity:"<到达城市>", start:"<YYYY-MM-DD 可选>", end:"<YYYY-MM-DD 可选>"}) invoke(functionName:"SearchFlights", arguments:{bundleName:"com.atomicservice.xxx", appId:"xxx", skillName:"flight-skill", apiName:"searchFlights", renderAtomicCard:true, componentName:"flight-skill_flight-card", args:{query:"<用户的语料>", departure:"<出发城市,未说明留空>", destination:"<目的城市,无flightNumber时必填>", flightNumber:"<航班号,可选>", departDateTime:"<YYYY-MM-DD>"},query:"<用户的语料>"}) 其中renderAtomicCard参数控制是否原子组件,默认为true。
```renderAtomicCard
{"appId": "xxx", "skillName": "flight-skill", "componentName": "flight-skill_flight-card", "abilityDatas": {"structuredContent": <全量原始数据>, "_meta": <插件返回的 _meta 元数据,原样透传>}, "componentPath": "<卡片组件路径>"}
```
为了提升推理性能,需尽量减少structuredContent的内容,可以用关键信息和索引结合。
mcp.json 包含所有原子接口的 Schema 声明。
| 属性 | 是否必填 | 说明 |
|---|---|---|
| name | 是 | 标识符,跟 index.js 中导出的原子接口函数名一致,最大长度64字符。 |
| description | 是 | 原子接口的功能描述。 |
| inputSchema | 是 | 原子接口的入参,需为对象格式。 |
| outputSchema | 建议填 | 原子接口返回的 structuredContent 对应的 schema。 |
| _meta | 否 | 可指定渲染的原子组件,componentPath 相对于 SKILL 目录,如 { "ui": { "componentPath": "components/xx/xx" } }。 |
{
"apis": [
{
"name": "SearchFlights",
"description": "搜索航班",
"inputSchema": {
"type": "object",
"properties": {
"departure": {
"type": "string",
"description": "【位置】出发城市或机场"
},
"destination": {
"type": "string",
"description": "【位置】目的城市或机场"
},
"departDateTime": {
"type": "string",
"description": "出发日期,格式 YYYY-MM-DD"
}
}
},
"outputSchema": {
"type": "object",
"properties": {
"flights": {
"type": "array",
"description": "航班列表;可基于已返回结果筛选/排序后更新",
"items": {
"type": "object",
"properties": {
"flightNo": { "type": "string", "description": "航班号" },
"price": { "type": "number", "description": "票价" }
}
}
},
"total": {
"type": "number",
"description": "航班总数"
},
"hasMore": {
"type": "boolean",
"description": "是否显示「查看更多」入口;仅 total>3 时为 true;Agent 回复只说明条数,勿引导用户点击「查看更多」"
}
}
},
"_meta": {
"ui": {
"componentPath": "components/flight-card/index"
}
}
}
]
} 在mcp.json的components字段下可以声明原子组件的额外配置:
| 参数 | 类型 | 必填 | 描述 |
|---|---|---|---|
| path | string | 是 | 组件路径(相对于 SKILL 目录)。 |
| relatedPage | string | 是 | 关联的元服务页面路径。 |
| relatedPageLabel | string | 否 | 关联的元服务页面按钮文字,不配置或者为空不显示。 |
| expirable | boolean | 否 | 该组件渲染的卡片是否可被过期,默认 false。 |
| expiredText | string | 否 | 过期后显示的文案,默认"服务已过期"。 |
原子接口的返回值包含以下字段:
| 参数 | 类型 | 必填 | 描述 |
|---|---|---|---|
| isError | boolean | 是 | 是否为错误结果。 |
| content | ITextContent[] | 是 | 返回给 LLM 的文本内容。 |
| structuredContent | Record<string, any> | 否 | 返回给 LLM 的结构化数据,需最小必要原则。 |
| _meta | Record<string, any> | 否 | 对 LLM 不可见,可携带元数据。 |
其中isError、structuredContent、content三个字段的数据会作为 LLM 的上下文供其理解,而 _meta内容对 LLM 不可见,用于携带私有数据传递给原子组件。
content 目前仅支持1个ITextContent:
interface ITextContent {
type: "text";
text: string;
} 当isError: true时,不进行卡片渲染,structuredContent将被忽略,content常用于异常流程的引导。
当isError: false且该原子接口绑定了原子组件时,智能体将倾向于渲染卡片。structuredContent数据会传给原子组件用来渲染(便于模型理解卡片内容),content用于后续流程引导。
实现示例
在原子接口的内部实现中,可以通过has.request从服务器请求数据,也可以调用项目自身封装的服务获取数据。
async function getStores({ location }) {
// 原子接口内部实现,此处省略
// has.request({ url: 'https://www.example.com/xx' })
return {
isError: false,
content: [{ type: "text", text: "已为您查询附近门店" }],
structuredContent: {
location: "深圳",
stores: [
{ name: "坂田 1 店", address: "xxx", code: "001" },
{ name: "坂田 2 店", address: "xxx", code: "002" },
]
},
_meta: {}
}
} 在SKILL的index.js中通过has.modelContext.registerAPI注册原子接口。当智能体发起调用时,会在注册列表中找到对应的函数入口并执行:
const searchFlights = require('./apis/searchFlights.js');
const skill = has.modelContext.createSkill('skills/flight-booking-skill');
skill.registerAPI('SearchFlights', searchFlights); 为方便在项目侧统一封装接口注册、登录校验、埋点上报和错误处理逻辑,避免相同逻辑散落在每个原子接口中,我们提供了中间件能力。
// 1. 创建 skill,需与 app.json 中保持一致
const searchFlights = require('./apis/searchFlights.js');
const skill = has.modelContext.createSkill('skills/flight-booking-skill');
// 2. 可用 skill 注册 API,通过这种方式的注册才会走到中间件
skill.registerAPI('SearchFlights', searchFlights);
// 3. 注册中间件
skill.use(async (ctx, next) => {
// 登录校验
const token = has.getStorageSync({ key: 'token'});
if (!token) {
// 执行登录操作
// 也可以直接抛出错误,让 Agent 决定其他操作
// throw new Error('登录过期,接下来自动执行 login 工具')
}
await next() // 执行实际 handler
});
interface IModelContextMiddlewareContext {
name: string // 当前执行的原子接口名
skillPath: string // 当前中间件执行时的 skillPath
arguments: Record<string, any> // 原子接口的入参
} 原子组件用于承接原子接口返回的数据和内容展示。使用与元服务的自定义组件相同编码风格。通过has.modelContext.getContext / getViewContext获取渲染所需的尺寸、数据等信息。原子组件与ASCF框架的组件采用类似范式开发,元服务SKILL模式下运行采用了全新的原生渲染机制,能力与ASCF框架普通模式下有差异,需避免兼容性问题。当前支持的基础组件如下:
| 组件 | 支持范围 |
|---|---|
| view | 支持。 |
| text | 支持,不支持 user-select。 |
| button | 不支持 open-type。 |
| image | 支持,仅支持网络图片和png、jpg 格式,且只支持缩放模式。 |
| scroll-view | 支持横向和纵向滚动,但是建议不要滚动。 |
| canvas | 支持。 |
原子组件的运行环境与原子接口隔离,处于不同的上下文中,可调用的接口能力也有所不同。
功能和约束如下:
原子组件中的 CSS 仅支持少量选择器:
原子组件渲染出来的卡片左上方是标题栏,右上方提供了进入元服务关联页的按钮。点击按钮默认进入配置关联页面。在mcp.json中声明关联页面。
{
"apis": [],
"components": [
{
"path": "components/flight-list-card/index",
"relatedPage": "/pages/index/index",
"relatedPageLabel": "打开元服务"
}
]
} 关联页接受query参数:
Page({
onLoad(query) {
console.info(query);
}
})
媒体查询仅支持深色模式:
@media (prefers-color-scheme: dark) {
/* DarkMode 下的样式 */
} 支持onThemeChange 和 offThemeChange监听深色模式切换:
// 注册监听
has.onThemeChange((res) => {
console.info('主题变化:', res.theme) // 'light' 或 'dark'
})
// 注销监听
has.offThemeChange() 折叠屏场景下,原子组件宽度会动态变化,需监听原子组件宽度变化做适配。尤其是canvas场景必须要适配:
Component({
pageLifetimes: {
resize(res) {
res.size.windowWidth // 新的显示区域宽度
res.size.windowHeight // 新的显示区域高度
}
}
}) 一般位于skills目录中components下开发组件,监听NotificationType.Result获取数据:
Component({
lifetimes: {
created() {
const { NotificationType } = has.modelContext;
const ctx = has.modelContext.getContext(this);
const vCtx = has.modelContext.getViewContext(this);
ctx.on(NotificationType.Result, (data) => {
const id = data.result.id;
// 在原子组件渲染时动态设置 query 参数
vCtx.setRelatedPage({ query: `id=${id}` });
});
}
},
}) 提供两种粒度的过期接口:
方式一:has.modelContext.expireAllCards()
可在原子接口或组件里调用。所有声明了expirable: true的组件将被标记为过期并渲染蒙层。
// 过期所有可过期卡片
has.modelContext.expireAllCards();
// 按 componentPath 过滤
has.modelContext.expireAllCards({
componentPaths: ['components/flight-list-card/index']
});
// 只过期最近一张匹配的卡片
has.modelContext.expireAllCards({
componentPaths: ['components/flight-list-card/index'],
match: 'latest'
}); 方式二:vCtx.expirePreviousCards()
只能在原子组件里调用,将当前组件之前渲染过并声明了expirable: true的组件标记为过期。
const viewCtx = has.modelContext.getViewContext(this);
viewCtx.expirePreviousCards()
// 按 componentPath 过滤
viewCtx.expirePreviousCards({
componentPaths: ['components/flight-list-card/index']
})
// 只过期最近一张匹配的卡片
viewCtx.expirePreviousCards({
componentPaths: ['components/flight-list-card/index'],
match: 'latest'
}) 当需要展示更多详细信息或需要用户补充信息时,可通过原子组件上按钮引导用户打开半屏页面完成。半屏页面与元服务环境基本一致,不允许任何跳转行为。当完成指定任务,需要进行下一步操作时,可通过半屏里的按钮触发一段上行文本消息,此时会同步关闭半屏页面回到智能体界面,智能体将继续处理新的用户请求。
在原子组件内点击并打开半屏页面:
<view onTap="handleOpenMore">查看更多</view>
Component({
methods: {
handleOpenMore() {
const vCtx = has.modelContext.getViewContext(this);
vCtx.openDetailPage({
url: '/pages/detail/index?id=xxx'
})
}
}
}) 在半屏页面中可通过接口代用户以第一人称视角发送一条上行文本消息,之后会自动关闭半屏页面。
Page({
onTap() {
const ctx = has.modelContext.getContext();
ctx.sendFollowUpMessage({
content: [
{
type: 'text',
text: '选择商品'
},
{
// 指定下一步应调用哪个原子接口
type: 'api/call',
data: {
name: 'selectGoods',
arguments: {}
}
}
]
});
}
}) 打开半屏页面操作后,有时需要更新卡片,支持通过重新调用对应的原子接口的方式,来重新渲染原子组件。
const ctx = has.modelContext.getContext();
ctx.reapplyApiCall({
arguments: {
city: '深圳'
}
}); 若半屏页面是个web-view加载的H5页面,也可通过相同的方式发送上行。
// html页面中发送消息:
// post sendFollowUpMessage message
has.ascfweb.postMessage({
options: { mode: 'realtime' },
data: {
type: 'sendFollowUpMessage',
args: {
content: [
{ type: 'text', text: '选择智利火山酒庄火魂红葡萄酒' },
{ type: 'api/call', data: { name: 'selectProduct', arguments: '{"drinkId":10774}' } }
]
}
},
});
// post reapplyApiCall message
has.ascfweb.postMessage({
options: { mode: 'realtime' },
data: {
type: 'reapplyApiCall',
args: {
arguments: {
name: 'searchDrinks',
arguments: '{"keyword": "葡萄酒"}'
}
}
},
});
// 监听h5中的事件
// <web-view src="resource://rawfile/html/index.html" bindmessage="handleMsg"></web-view>
Page({
handleMsg(e) {
const ctx = has.modelContext?.getContext?.();
const data = e.detail?.data?.[0];
if (!ctx || !data) {
return;
}
// 发送上行消息
if (data.type === 'sendFollowUpMessage' && data.args) {
ctx.sendFollowUpMessage(data.args);
} else if (data.type === 'reapplyApiCall' && data.args) {
ctx.reapplyApiCall(data.args);
}
},
}); 对于ArkUI元服务,需要依赖ASCF框架提供的接口能力。
// entry\src\main\ets\entryability\EntryAbility.ets
import { AtomicServiceAI } from '@atomicservice/ascfapi';
export default class EntryAbility extends UIAbility {
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
AtomicServiceAI.getInstance().ensureInit(this.context, want);
}
onDestroy(): void {
AtomicServiceAI.getInstance().cleanup();
}
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
AtomicServiceAI.getInstance().ensureInit(this.context, want);
}
}
// 使用说明
import { AtomicServiceAI } from '@atomicservice/ascfapi';
AtomicServiceAI.getInstance().sendFollowUpMessage({
content: [
{ type: 'text', text: '选择智利火山酒庄火魂红葡萄酒' },
{ type: 'api/call', data: { name: 'selectProduct', arguments: '{"drinkId":10774}' } }
]
}); 对于ArkUI元服务中的H5页面,通过AtomicServiceEnhancedWeb双向通信机制,可以实现半屏通信功能。详细示例如下:
html页面中发送消息:
// post sendFollowUpMessage message
has.asWeb.postMessage({
options: { mode: 'realtime' },
data: {
type: 'sendFollowUpMessage',
args: {
content: [
{ type: 'text', text: '选择智利火山酒庄火魂红葡萄酒' },
{ type: 'api/call', data: { name: 'selectProduct', arguments: '{"drinkId":10774}' } }
]
}
},
});
// post reapplyApiCall message
has.asWeb.postMessage({
options: { mode: 'realtime' },
data: {
type: 'reapplyApiCall',
args: {
arguments: {
name: 'searchDrinks',
arguments: '{"keyword": "葡萄酒"}'
}
}
},
}); webview中监听消息并且处理:
AtomicServiceEnhancedWeb({
src: $rawfile("html/index.html"),
controller: this.controller,
onMessage: (event: OnMessageEvent) => {
const payload = event.data?.[0] as Record<string, object>; // 注意转换类型
if (payload?.type === 'sendFollowUpMessage') {
AtomicServiceAI.getInstance().sendFollowUpMessage(payload.args);
} else if (payload?.type === 'reapplyApiCall') {
AtomicServiceAI.getInstance().reapplyApiCall(payload.args);
}
}
}) Context
ViewContext
原有元服务 API 在元服务SKILL下并不是全部可用,不同运行环境支持的接口有所不同,支持情况如下表。
| 分类 | API | 说明 | 原子接口 | 原子组件 |
|---|---|---|---|---|
| 基础 | has.env | 获取客户端环境变量。 | 支持 | 支持 |
| 静默登录 | has.login | 华为账号登录。 | 支持 | 不支持 |
| 获取手机号 | has.getPhoneNumber | 获取授权的手机号。 | 支持 | 不支持 |
| 发起请求 | has.request | 发起 HTTPS 网络请求。 | 支持 | 不支持 |
| websocket | 全部网络 API | WebSocket 网络请求。 | 支持 | 不支持 |
| 上传 | has.uploadFile | 上传文件。 | 支持 | 不支持 |
| 下载 | has.downloadFile | 下载文件。 | 支持 | 不支持 |
| 系统信息 | has.getDeviceInfo | 获取设备基础信息。 | 支持 | 支持 |
| 系统信息 | has.getAppBaseInfo | 获取宿主基础信息。 | 支持 | 支持 |
| 系统信息 | has.getWindowInfo | 获取窗口信息。 | 支持 | 支持 |
| 系统信息 | has.getSystemInfo | 获取系统信息。 | 支持 | 支持 |
| 数据缓存 | has.setStorage | 异步存储数据。 | 支持 | 支持 |
| 数据缓存 | has.setStorageSync | 同步存储数据。 | 支持 | 支持 |
| 数据缓存 | has.getStorage | 异步读取数据。 | 支持 | 支持 |
| 数据缓存 | has.getStorageSync | 同步读取数据。 | 支持 | 支持 |
| 数据缓存 | has.getStorageInfo | 异步获取缓存相关信息。 | 支持 | 支持 |
| 数据缓存 | has.getStorageInfoSync | 同步获取缓存相关信息。 | 支持 | 支持 |
| 数据缓存 | has.removeStorage | 异步删除缓存数据。 | 支持 | 支持 |
| 数据缓存 | has.removeStorageSync | 同步删除缓存数据。 | 支持 | 支持 |
| 数据缓存 | has.clearStorage | 异步清除本地缓存。 | 支持 | 支持 |
| 数据缓存 | has.clearStorageSync | 同步清除本地缓存。 | 支持 | 支持 |
| 位置 | has.openLocation | 使用内置地图打开位置。 | 不支持 | 支持 |
| 位置 | has.chooseLocation | 打开地图选择位置。 | 不支持 | 支持 |
| 交互反馈 | has.showToast | 显示消息提示框。 | 不支持 | 支持 |
| 交互反馈 | has.hideToast | 隐藏消息提示框。 | 不支持 | 支持 |
| 拨打电话 | has.makePhoneCall | 跳转拨打电话。 | 不支持 | 支持 |
| 元服务信息 | has.getServiceInfoSync | 获取元服务信息。 | 支持 | 支持 |
| 图片视频 | has.getImageInfo | 获取图片信息。 | 支持 | 不支持 |
| 图片视频 | has.chooseImage | 选择图片。 | 不支持 | 支持 |
| 图片视频 | has.previewImage | 预览图片。 | 不支持 | 支持 |
| 图片视频 | has.saveImageToPhotosAlbum | 保存图片到相册。 | 不支持 | 支持 |
| 文件 | has.openDocument | 打开文件。 | 不支持 | 不支持 |
| 扫码 | has.scanCode | 扫码 | 不支持。 | 支持 |
| 振动 | 全部振动 API | vibrate 等 | 不支持。 | 支持 |
| 收货地址 | has.chooseAddress | 选择收货地址。 | 不支持 | 支持 |
| 隐私授权 | has.getPrivacySetting | 获取隐私授权设置。 | 支持 | 支持 |
| 支付 | has.requestPayment | 鸿蒙收银台华为支付。 | 支持 | 支持 |
| 支付 | has.cashierPicker | 拉起通用收银台。 | 支持 | 支持 |
选择联系人或者收货地址等功能,可以直接基于元服务的页面通过半屏嵌入运行,实现功能闭环。
半屏页面运行环境基本等价于元服务环境,但是也有一些区别,半屏页面不建议路由跳出,建议直接完成任务后关闭返回。半屏可以通过has.modelContext与对话流直接交互,例如获取上下文:has.modelContext.getContext(),上行消息 ctx.sendFollowUpMessage({ content }) 、更新卡片ctx.reapplyApiCall({ arguments })。
当前半屏页面页面暂时没有做接口能力约束,后续可能会变更。
元服务SKILL中账号体系与元服务是互通,支持使用has.login和has.getPhoneNumber实现 静默登录功能。暂不支持使用storage互通数据。
has.login和 has.getPhoneNumber等接口会自动触发隐私弹框授权,当用户授权通过后才会继续执行,否则会接口调用异常,走异常处理流程。为了实现更好的交互体验,建议非必要情况下提供游客模式接口,避免登录后触发权限授权弹框。可以参考如下示例处理:
// 可以根据是否需要隐私授权状态判断,是否已经授权过,当前调用 has.login 才会触发授权
if (has.getPrivacySetting().needAuthorization) {
// 调用游客模式接口,避免触发隐私弹框授权
} else {
// 已授权后 has.login 静默登录
// 非必要情况下,不要额外获取手机号,让半屏进入到元服务后处理
}