把智能体能力接进鸿蒙应用时,最容易走偏的一步,是先做一个聊天框。输入框、发送按钮、气泡列表,看起来很快能跑起来,但联调时真正需要看的并不是一句回复,而是任务现在处在哪个阶段:服务是否在线、模型识别出了什么意图、准备调用哪个工具、是否需要确认、失败时错误落在哪里。
这次做的是一个更偏工程侧的 Agent 工作台。页面不追求复杂交互,只保留几个必要对象:本机 Agent Gateway、ArkUI 消息列表、Network Kit 请求、意图路由结果、错误状态。它的目标不是让用户和模型闲聊,而是让一次端侧任务从输入到路由都有清楚的状态。
先把端侧后面的服务跑起来
工作台页面不能靠静态数据撑起来。这里先在 M1 Mac 本机启动一个 Agent Gateway,监听 127.0.0.1:18081。大模型 Key 只放在 Gateway 环境变量里,鸿蒙端只访问本机服务,不把 Key 写进 ArkTS 代码。

这个 Gateway 提供几个基础接口:/health 用来判断服务是否可达,/llm/route 用来把自然语言任务转换成结构化意图。页面后面显示的服务状态和意图结果,都来自这两个接口。
/health 返回里能看到 status: ok 和服务名,说明端侧要连接的对象已经存在。这里先用 curl 验证,是为了把问题拆开:如果终端都访问不了,DevEco 里的页面失败就不是 ArkUI 的问题。

接着直接请求 /llm/route,输入是"现在这台 Gateway 机器状态怎么样?"。返回结果不再是一段自然语言,而是包含 intent、tool、needConfirm、routeSource 的结构化对象。

这里已经能看出工作台和聊天框的区别。聊天框关心"模型怎么回答",Agent 工作台关心"模型判断出了什么任务,准备把任务交给哪个能力执行"。后面的 ArkUI 页面只是把这条链路放到端侧。
从 DevEco Studio 的空项目开始
项目是 DevEco Studio 里新建的空白鸿蒙应用,目录里能看到 entry/src/main/ets/pages/Index.ets。刚开始没有复杂工程结构,只需要一个页面文件和网络权限。

页面里定义了一个很小的消息模型:role 表示来源,status 表示状态,content 放接口返回后的展示文本,time 记录本地时间。这个模型不复杂,但它比单纯拼接字符串更适合承载 Agent 过程。
核心状态只有四个:
ts
@State inputText: string = '现在这台 Gateway 机器状态怎么样?'
@State messages: MessageItem[] = []
@State sending: boolean = false
@State serviceText: string = '未检查'
messages 负责承载用户输入、助手分析和错误;sending 控制重复点击;serviceText 用来放 /health 的返回结果。页面一开始看起来很轻,但它已经把"任务状态"从普通文本里拆出来了。

网络权限放在 entry/src/main/module.json5 里。没有这一步,页面代码写得再完整,也可能在运行时被网络权限拦住。
json
{
"name": "ohos.permission.INTERNET"
}
这里保持一个原则:端侧只负责请求 Gateway 和展示状态,不在端侧保存模型 Key,也不把工具执行逻辑塞进 ArkTS 页面。
健康检查先进入页面
页面右上角放了一个"健康检查"按钮。点击后请求 /health,服务状态直接显示在页面顶部。这个按钮看起来不起眼,但联调时很有用:它能快速区分"页面代码错了"和"Gateway 没启动"。

健康检查通过后,页面就不再是一个孤立 UI。它已经能从鸿蒙端访问本机 Agent Gateway,Network Kit、端侧权限、服务端端口三件事都跑通了。
输入一句话,页面展示意图而不是闲聊回答
输入框默认放的是一个真实联调问题:
text
现在这台 Gateway 机器状态怎么样?
点击发送后,页面先插入一条用户消息,再插入一条 assistant / pending 消息。接口返回后,pending 消息被替换成结构化路由结果:
text
intent=查看Gateway机器状态
tool=local_status
needConfirm=false
source=llm
reason=用户想查看当前Gateway所在机器的系统、磁盘和负载摘要

这一步是工作台的核心。端侧没有直接展示一段"机器状态看起来正常"的回答,而是先展示模型选择出来的工具。用户能看到这次任务会交给 local_status,也能看到它不需要二次确认。对一个 Agent 入口来说,这些字段比一段顺滑回答更重要:它们决定用户能不能判断这次任务是否可信、是否可执行。
页面里的 postRoute 只做三件事:发请求、解析返回、更新消息状态。
ts
const route = JSON.parse(String(data.result)) as RouteResult
const content = 'intent=' + String(route.intent) + '\n' +
'tool=' + String(route.tool) + '\n' +
'needConfirm=' + String(route.needConfirm) + '\n' +
'source=' + String(route.routeSource) + '\n' +
'reason=' + String(route.reason)
为了让状态可见,消息并不是直接追加最终结果,而是先显示 pending,再替换为 success。这个细节会影响实际使用体验:用户能知道请求已经发出,不会因为网络等待误以为按钮没点上。
错误状态也要进入工作台
Agent 页面不能只做成功路径。这里把 Gateway 地址临时改错,让页面请求失败。失败后消息列表里出现 error / failed,内容保留 Network Kit 返回的错误信息。

这个失败状态比弹一个笼统提示更有用。它让问题停留在当前任务上下文里:前面用户发了什么,后面哪一步失败,失败信息是什么,都在同一个页面里。联调时不用切回终端猜测是不是服务没启动、端口写错或者权限没配。
这里也暴露出一个工程边界:Previewer 或模拟器访问 127.0.0.1 时,要确认它指向的是 Mac 本机服务。如果换成局域网访问,Gateway 需要用 0.0.0.0 启动,端侧地址也要换成 Mac 的局域网 IP。能在本机解决的问题,不需要绕到 Linux 服务器上处理。
工作台的边界要落在任务状态上
这个工作台只接了 /health 和 /llm/route 两个接口,范围很小,但它已经把端侧 Agent 入口里最容易混在一起的几件事拆开了:服务可达性、用户输入、模型路由、执行状态和错误反馈。
聊天 UI 往往只关心消息是否返回,任务工作台还要关心返回的内容是否能指导下一步操作。intent 说明任务被理解成什么,tool 说明准备使用哪个能力,needConfirm 说明是否需要更谨慎的处理,reason 解释模型为什么这样判断。页面把这些字段摆出来以后,用户不需要从一段自然语言里猜系统到底做了什么。
从这次联调结果看,鸿蒙端接入 Agent 能力时,ArkUI 页面至少要承担三个职责:
- 让用户发起任务。
- 让模型判断过程可见。
- 让失败状态留在任务上下文里。
普通聊天框只能完成第一件事。Agent 工作台要多做两步,才适合放进真实应用流程里。
这次实现验证了四个点:本机 Gateway 可访问,Network Kit 能从鸿蒙端发起请求,自然语言任务能被路由成结构化结果,错误地址能在页面里形成明确失败状态。页面功能不大,但它把 Agent 入口从"能聊天"推进到了"能看见任务过程"。这一步做好以后,端侧交互才有资格承载真实任务,而不是只停留在一个问答壳子里。