外观
内置演示脚本
音频需单独生成
内置演示脚本的音频不能直接使用官网生成的音频:官网直接导出的音频结尾带有「嗡嗡」杂音。需单独写一个工具调用接口来生成这些音频,保证音质干净、可复现。
系统导览模式(demo)面向大屏演示汇报,是一套确定性脚本:不调用大模型生成回答,全部走本地内置问答库 + NLU 语义识别,保证演示稳定、可复现。
一、内置演示脚本
脚本定义在 data/demoQA.js(demoList 同时用于生成"演示指令"下拉按钮,新增条目即自动多一个按钮),匹配方式为 Fuse.js 模糊搜索(threshold: 0.9,keys: ['question'],取 Top1),兜底回复 fallbackReply,开场白 startReply("你好,我是管廊系统数智人小慧…")。
实现位置(会话核心已拆分为组装层 +
core/模块,见前端项目结构):脚本数据在data/demoQA.js;导览播放编排(分段循环、打字机、段内预取、段间 UE 预投喂 / 双工预准备、段结束动作)在core/demoTour.js;functionTool派发到 clientTools 在core/clientTools.js;语音播放在core/speech.js。
| # | 指令 | 回复文本 | 触发的工具 | 音频 |
|---|---|---|---|---|
| 1 | 智能解说 | 11 段讲解词,由 buildIntroAnswer() 异步构造、按当前账号实时填充统计数字;第 1 段 introduce2(keepOpen) 打开介绍弹框,第 4 段结束 closeIntroModal 关闭;第 5 段含动态统计(承建单位/管廊主轴里程/管线企业/管线数/管线长度/介质数),需实时 TTS 合成;第 8–11 段依次 switchModule 切到巡检/设备/隐患/施工模块 | introduce2 / closeIntroModal / switchModule | intro/intro_p1~p11.mp3(第 5 段为实时 TTS,无预录音频) |
| 2 | 查看气体设备 | "好的,地图上即将展示气体设备" | selectDeviceCategory {category1:'气体', category2:null} | device_气体.mp3 |
| 3 | 今日在线设备 | "好的,这就为您打开在线设备信息" | lookDeviceStatusDialog {module:'device', status:'online'} | device_在线设备.mp3 |
| 4 | 执行中的巡检任务 | "好的,这就为您打开执行中的巡检任务信息" | lookInspectionStatusDialog {module:'inspection', status:'执行中'} | inspection_执行中.mp3 |
| 5 | 查看在建的管线 | "好的,这就为您查看在建管线信息" | lookPipelineStatusDialog {module:'pipeLine', status:'在建'} | pipeLine_在建.mp3 |
| 6 | 管线隐患有哪些 | "好的,地图将展示管线隐患相关点位。" | lookHazardStatusOrLevelMap {module:'hazard', type:'map', mapType:'category', categoryName:'管线隐患'} | hazard_管线隐患.mp3 |
| 7 | 漫游(已隐藏) | "好的,这就为您演示漫游功能" | openRoamPanel + closeAssistant | roam.mp3 |
| 8 | 作业单位有哪些 | "好的,这就为您打开作业单位信息" | lookZypDialog {status:'作业单位'} | construct_作业单位.mp3 |
| 9 | 作业票有哪些 | "好的,这就为您打开作业票信息" | lookZypDialog {status:'作业票'} | construct_作业票.mp3 |
| 10 | 高处作业有哪些 | "好的,地图即将展示高处作业票点位信息" | lookZypTypeMap {type:'map', jobName:'高处'} | construct_高处.mp3 |
指令 7「漫游」会同时调用
closeAssistant隐藏对话面板;漫游进行中大屏还会通过isRoaming监听强制保持面板隐藏。
原「管廊系统平台介绍」已升级为「智能解说」:
answer改为() => buildIntroAnswer()异步构造函数,讲解词按当前登录账号实时拉取统计数字(承建单位、管廊主轴里程、管线企业、管线数、管线长度、介质数等)动态拼句,不同账号看到的数据不同;由 11 段预录音频(第 1–4、6–11 段)+ 第 5 段实时 TTS 组成,第 5 段因含动态数字必须实时合成。
二、自定义输入的 NLU 语义识别与动作触发
用户输入(含系统导览下的自定义问题、智能交互、专家问答)都会异步 fire-and-forget 调用 NLU:
POST /digital-human/nlu/recognize { text, mode }- 响应解析:兼容 JSON 对象 /
key=value##key=value串 / 单个key=value/null(无匹配)。 - 回复拼接
buildAnswer(nlu):前端依据结构化字段确定性拼接待朗读文本 + 本地音频路径(无audio则回退实时 TTS)。 - 动作映射
buildToolFromNlu(nlu):把结构化意图映射到clientTools中的方法并await调用。 - ID 推导:
STATUS_TO_ID表由module + status前端推导数字id,避免 LLM 输出格式错误。
已覆盖的业务模块与取值:
| module | type | 识别字段 | 触发工具 | 示例说法 |
|---|---|---|---|---|
hazard | map | mapType=level/levelName | lookHazardStatusOrLevelMap | "展示高危隐患点位" |
hazard | map | mapType=category/categoryName | lookHazardStatusOrLevelMap | "看看管线隐患有哪些" |
hazard | dialog | status=当前隐患/今日新增/今日处理 | lookHazardStatusDialog | "今日新增了多少隐患" |
device | dialog | status=online/offline/alarm/total/deal | lookDeviceStatusDialog | "打开今日报警设备" |
device | map | category1/category2 | selectDeviceCategory | "地图上显示位移设备" |
construct | dialog | status=作业单位/作业票 | lookZypDialog | "作业单位都有哪些" |
construct | map | jobName | lookZypTypeMap | "动火作业票在哪" |
inspection | — | status=巡检任务/待执行/已完成/未完成/执行中/超期 | lookInspectionStatusDialog | "有哪些超期的巡检任务" |
pipeLine | — | status=total/在建/未投用/已投用/已停用 | lookPipelineStatusDialog | "查看已停用管线" |
gallery | — | value=intro | introduce2 | "介绍一下管廊平台" |
gallery | — | 仅 module | switchGallery | "切到管廊模块" |
roam | — | — | openRoamPanel | "开始漫游" |
兜底策略:
- NLU 返回无法识别(
buildAnswer返回null)→ 追加"暂时无法回答您的问题" + 播放audios/无法回答.mp3(失败回退 TTS),跳过动作触发; - NLU 请求异常 → 追加系统消息提示,并在移动端等待回合时兜底回执,避免手机端一直"思考中"。
三、客户端工具与大屏事件总线
clientTools 由 views/home/index.vue 注入,合并了通用工具(mapLocate、cameraFlyTo、closeAssistant)与演示工具 demoFuncs.js 的 funcList(14 个函数):
jump、switchGallery、introduce、introduce2、openInfoPanel、openRoamPanel、selectDeviceCategory、lookDeviceStatusDialog、lookInspectionStatusDialog、lookPipelineStatusDialog、lookHazardStatusOrLevelMap、lookHazardStatusDialog、lookZypDialog、lookZypTypeMap
解耦方式:demoFuncs.js 不直接依赖业务 store,统一通过轻量事件总线 demoBus.js(基于原生 EventTarget)派发事件,由各业务模块自行订阅:
| 事件 | 载荷 | 含义 |
|---|---|---|
switch-menu | 'device' | 'inspection' | 'pipeLine' | 'hazard' | 'construct' | 'gallery' | 切换大屏底部模块 |
open-pipe-list | true / { type:'lifecycle' } / { location_name, detail } | 打开期数列表 / 飞行定位 |
open-intro-gallery | — | 管廊平台介绍特效 |
hideWindows | — | 关闭所有弹窗 |
select-device-category | { category1, category2 } | 勾选设备分类 |
look-device-status-dialog | { status } | 打开设备状态弹窗 |
look-inspection-status-dialog | { status } | 打开巡检任务弹窗 |
look-pipeline-status-dialog / look-pipeline-total-dialog | { status } | 打开管线状态弹窗 |
look-hazard-status-or-level-map | { mapType, categoryName } | 地图勾选隐患分类/等级 |
look-hazard-status-dialog | { status } | 打开隐患统计弹窗 |
look-zyp-dialog | { status } | 打开作业票/作业单位弹窗 |
look-zyp-type-map | { type, jobName } | 地图展示作业票点位 |
open-info-panel | { name, info } | 打开设备信息面板 |
open-roam-panel | {} | 打开漫游面板 |
现状:切模块已不再走该事件总线——
switchGallery/switchPipeLine(以及讲解词里的按段切模块)统一改为调用能力注册表的系统级动作invoke('system.switchModule', ...)(见第四节),demoFuncs.js目前仅保留open-pipe-list/hideWindows两个纯地图事件的派发;上表为早期实现的完整事件表,保留作历史参考。
四、AI 动作注册中心(src/assistant)
随着演示脚本与 AI 语义识别规模扩大,原 demoBus 事件总线 + 各业务文件手写 busOn 的写法难以维护,新增了 src/assistant 作为统一的动作注册中心 / 能力中心,把"业务原子能力"与"AI 语义映射 / 演示编排"彻底分离(上一节的 clientTools/demoBus 是早期实现,src/assistant 是其解耦后的继任者)。
4.1 核心文件与对外 API
| 文件 | 职责 |
|---|---|
index.js | 插件入口:用 import.meta.glob 自动扫描并 eager 注册 actions/*.js 与 orchestrations/*.js,新增一个能力 = 新增一个文件,主流程零改动;对外只暴露统一 API(invoke / registerAction / unregisterAction / hasAction / whenReady / ensureModule / listActions) |
registry.js | 动作注册表(能力中心):Map<actionId, record> 保存动作,提供 registerAction、unregisterAction、hasAction、whenReady(就绪握手,替代 sleep(600) 猜时序)、ensureModule(切模块并等目标动作就绪)、invoke(调用动作并注入 ctx)、listActions(可直接导出 NLU / LLM 工具 schema) |
useAssistantAction.js | 业务组件侧唯一需要写的一行:useAssistantAction(id, handler, meta) 声明"我能被怎么调用",组件卸载时自动注销(无需手写 onUnmounted / 反订阅) |
约定:actionId 全局唯一,命名 module.verbObject(如 hazard.openStatusDialog);区分两类动作——原子能力(组件注册、与用户手动点击等价的最小动作)与 AI 动作(插件注册、对外给助手调用、内部再调原子能力)。
4.2 actions/(AI 语义 → 业务原子能力 的映射插件)
每个文件只放语义映射与编排,不碰业务组件:
| actionId | 文件 | 作用 |
|---|---|---|
axis.viewDetail | actions/axis.js | 按管轴编号在本地列表定位,飞入并打开管轴详情弹框(全局弹框,无需切模块) |
device.selectCategory | actions/device.js | 地图展示某类设备(参数 category1/category2),先 ensureModule('device') |
device.viewStatusDialog | actions/device.js | 设备状态弹框,AI 语义 status(online/offline/alarm/total/deal) → 组件 label |
hazard.viewLevelOrCategoryMap | actions/hazard.js | 地图展示某类 / 某等级隐患点位(mapType=level/category) |
hazard.viewStatusDialog | actions/hazard.js | 隐患统计弹框,status(当前隐患/今日新增/今日处理) → (index,title) |
inspection.viewStatusDialog | actions/inspection.js | 巡检任务弹框,status → (index,title)(0 全部 … 5 超期) |
pipeLine.viewStatusDialog | actions/pipeLine.js | 管线状态弹框,status(total/在建/已投用/未投用/已停用) → (statusName,statusId);total 走总览弹框 |
pipeLine.viewDetail | actions/pipeLine.js | 按编号 / 名称查管线,飞入起点管轴 + 高亮并打开管线详情弹框 |
construct.viewZypTypeMap | actions/construct.js | 地图展示某类作业票点位(jobName) |
construct.viewZypDialog | actions/construct.js | 作业单位 / 作业票汇总弹框,status ∈ {作业单位, 作业票} |
4.3 orchestrations/(演示编排)
把"讲解词 / 指令 → 多步业务动作"的时序编排外置(原写在 views/map/index.vue 中):
| actionId | 文件 | 作用 |
|---|---|---|
gallery.introduce | orchestrations/gallery.js | 切 3D + 打开「管廊介绍」弹框并自动下滚;keepOpen=true(智能解说 第 1 段传入)时弹框保持到第 4 段 closeIntroModal 才关,模块轮切由讲解词各段 switchModule 驱动;每步检查 demo-interrupt 打断信号 |
gallery.closeIntroModal | orchestrations/gallery.js | 仅关闭介绍弹框(不连累演示中其他模块弹框) |
hazard.openPanelDemo | orchestrations/hazard.js | 切隐患模块 → 主动预加载点位 → 飞到首个隐患点 → 模拟点击打开详情弹框(对齐用户点击行为) |
roam.openDemo | orchestrations/roam.js | 切漫游模块 → 等挂件挂载(roamWidgetStore.visible['left1']) → 切 3D → 播放第一条漫游(调用原子能力 roam.start) |
4.4 演示代码侵入业务代码的情况
src/assistant 的设计目标是最小化侵入:业务组件只需 1 行 useAssistantAction(...) 声明原子能力、不认识任何 AI 词汇;AI 语义与编排全部外置。下面先列出散落在业务组件里的原子能力声明(即"1 行侵入"),再说明历史与过渡期残留的更深侵入。
业务组件中声明的原子能力(最小侵入,每处仅 1 行):
| 原子能力 actionId | 声明位置(业务组件) |
|---|---|
system.switchModule | FooterBox(底部菜单切换,并回报"切换完成"信号) |
device.applyCategory | device/MenuBox/index.vue |
device.openGalleryItem | device/leftWidgets/LeftWidget1/Left1Content.vue |
hazard.applyLevelOrCategoryMap / hazard.openStatusDialog | hazard/mainBox/totalView.vue 等 |
inspection.openStatusDialog | inspection/realTimeOverview/index.vue |
pipeLine.openTotalDialog | pipeLine/totalView/index.vue |
pipeLine.openStatusDialog | pipeLine/statusDistribution/index.vue |
construct.toggleZypType | construct/MenuBox/index.vue |
construct.openZypDialog | construct/leftWidgets/LeftWidget1/index.vue |
roam.start | 漫游 Left1Content(等价于列表"开始"按钮) |
历史与过渡期残留的更深侵入(待清理):
- 历史深耦合(已外置):演示编排原先直接写在业务组件
views/map/index.vue内(约数十行demoBus订阅 + handler);各业务文件里import demoBus → busOn('xxx-event') → handler → onUnmounted反订阅,事件名(魔法字符串)与 AI 语义散落在十几个业务文件中。现已把gallery / hazard / roam编排与axis/device/hazard/inspection/pipeLine/construct语义映射迁移到src/assistant,业务组件恢复干净。 demoBus过渡桥接(已清理):原先demoFuncs.js的switchGallery/switchPipeLine通过demoBus.emit('switch-menu', …)切模块,再由registry.js的bus.on('switch-menu', …)桥接到system.switchModule。现已把这两处统一改为invoke('system.switchModule', ...),并删除registry.js中的桥接订阅(registry.js不再依赖demoBus),行为与用户点击底部菜单完全一致;demoBus现仅用于系统级广播(demo-interrupt)与open-pipe-list/hideWindows等纯地图事件。- 系统级广播仍走
demoBus:打断信号demo-interrupt由stopBroadcast()经demoBus派发,gallery.js仍bus.on('demo-interrupt')订阅——打断不是"能力调用",所以不走 action,仍以事件总线方式侵入。 - 切模块时序依赖业务组件:
ensureModule依赖settingsStore.currentMenu判断当前模块,并依赖FooterBox注册system.switchModule回报"切换完成";仅当该动作未注册(FooterBox 未挂载,理论不可达)时,才退回MODULE_SWITCH_FALLBACK_MS = 600固定等待(替代原先的sleep(600)猜时序),已不再经由demoBus派发事件。 - 编排直接操作业务 store:
roam.openDemo轮询roamWidgetStore.visible['left1']判断组件是否挂载;ensureModule/whenReady通过"业务组件挂载即注册原子能力"来握手;axis/pipeLine等直接调用mapStore.switchMode/flyTo/setHighLightAxis、modalStore.openModal/closeModal、useCloseAllChildModals以及galleryDataStore / pipeLineWidgetStore / hazardStore等业务状态。 - 业务侧残留助手概念:漫游指令会调用
closeAssistant隐藏对话面板,大屏通过isRoaming监听强制保持面板隐藏;这些"助手 / 演示"语义仍写在业务侧,属于单向侵入。
小结:当前主链路(AI 动作调用)已通过
src/assistant与actionId解耦,业务组件的侵入收敛为"1 行声明";切模块已统一收敛为system.switchModule能力(demoBus桥接已删除),剩下的脏活(打断信号广播、切模块时序兜底、store 直调)集中在registry.js与orchestrations/*的过渡期代码里,是后续要清理的重点。
五、AI 动作注册示例(以「打开作业单位/作业票弹框」为例)
用一个真实能力 construct.openZypDialog 走完整条链路:演示指令 / NLU 语义识别 → 演示工具 → AI 动作 → 业务组件原子能力,逐层贴出要写的代码与声明位置。
5.1 触发链路(4 层)
① 触发源:演示指令 / NLU 语义识别
data/demoQA.js { func: 'lookZypDialog', args: { status: '作业单位' } }
VisAssistant.vue buildToolFromNlu() 把 NLU 结果映射成同一批 clientTools
│
▼
② 演示工具(clientTools,由 views/home/index.vue 注入)
data/demoFuncs.js lookZypDialog(args) → invokeAction('construct.viewZypDialog', { status })
│
▼
③ AI 动作(语义映射 / 参数转换 / 切模块 / 等就绪)
src/assistant/actions/construct.js
registerAction('construct.viewZypDialog', { …, handler })
│ ctx.ensureModule('construct', 'construct.openZypDialog')
│ ctx.invoke('construct.openZypDialog', { status })
▼
④ 原子能力(业务组件声明,与用户手动点击等价)
src/views/construct/leftWidgets/LeftWidget1/index.vue
useAssistantAction('construct.openZypDialog', handleLookZypDialog, { … })分工:② 只认
actionId、③ 只做语义映射与编排、④ 只做业务动作;业务组件不认识任何 AI 词汇。
5.2 业务组件侧:1 行声明原子能力(唯一的侵入点)
文件:src/views/construct/leftWidgets/LeftWidget1/index.vue
js
/**
* 处理 AI 助手的"查看作业单位/作业票弹框"请求
* 注意:Left1Content 在 v-if="visible" 内,初始可能未挂载,
* 所以能力声明放在始终挂载的 LeftWidget1/index.vue 中
* @param {{ status: string }} payload
* - status: '作业单位' / '作业票'
*/
function handleLookZypDialog({ status }) {
if (status !== '作业单位' && status !== '作业票') return
// 确保施工概览面板可见
constructWidgetStore.show(widgetUniqueKey)
if (status === '作业单位') {
constructWidgetStore.showDialog('unitWidget', { name: '作业单位' })
constructWidgetStore.hideDialog('ticketsWidget')
} else {
constructWidgetStore.showDialog('ticketsWidget', { name: '作业票', isMapSelect: false })
constructWidgetStore.hideDialog('unitWidget')
constructWidgetStore.hideDialog('unitTicketWidget')
}
constructWidgetStore.hideDialog('ticketDetailWidget')
}
// === AI 可调用能力声明 ===
// 只暴露"打开作业单位/作业票汇总弹框"的原子能力,
// AI 语义映射见 src/assistant/actions/construct.js
import { useAssistantAction } from '@/assistant'
useAssistantAction('construct.openZypDialog', handleLookZypDialog, {
module: 'construct',
title: '打开作业单位/作业票汇总弹框',
params: { status: '作业单位 | 作业票' },
})要点:
- handler 内只有业务动作(
constructWidgetStore.show / showDialog / hideDialog),没有AI / 助手 / demo任何字样; useAssistantAction(id, handler, meta)内部调registerAction注册,并在组件卸载时自动注销(内部onUnmounted),因此不需要手写onUnmounted/busOff;- 声明点刻意放在始终挂载的
LeftWidget1/index.vue,而不是v-if="visible"内的Left1Content:whenReady('construct.openZypDialog')的"就绪"判定就是组件挂载即注册,放进v-if会在面板未显示时握手超时; meta是自描述信息(module / title / params),会被listActions()导出成 NLU / LLM 的 function-calling schema。
5.3 AI 侧:注册 AI 动作(语义映射与切模块)
文件:src/assistant/actions/construct.js
js
// src/assistant/actions/construct.js
// 施工模块的 AI 能力插件
// - 组件侧各声明 1 行原子能力:
// construct.toggleZypType construct/MenuBox/index.vue
// construct.openZypDialog construct/leftWidgets/LeftWidget1/index.vue
import { registerAction } from '../registry'
/** AI 语义允许的作业票弹框类型 */
const ZYP_DIALOG_STATUS = ['作业单位', '作业票']
registerAction('construct.viewZypDialog', {
module: 'construct',
title: '查看作业单位/作业票汇总弹框',
params: { status: '作业单位 | 作业票' },
async handler({ status } = {}, ctx) {
if (!ZYP_DIALOG_STATUS.includes(status)) {
throw new Error(`[assistant] 未知的作业票弹框类型: ${status}`)
}
await ctx.ensureModule('construct', 'construct.openZypDialog')
return ctx.invoke('construct.openZypDialog', { status })
},
})要点:
- 白名单校验把 AI 的自由文本收窄为确定取值(
作业单位 / 作业票),脏数据不会流进业务组件; ctx.ensureModule(module, readyActionId):当前不在construct模块就先调系统级动作system.switchModule切过去(与用户点底部菜单完全一致),再用whenReady等目标原子能力就绪——替代早期sleep(600)猜时序;ctx.invoke('construct.openZypDialog', { status })才真正调 5.2 那一行声明;- 新增文件即自动注册:
src/assistant/index.js用import.meta.glob('./actions/*.js', { eager: true })扫描,加一个业务模块的能力只需新增一个文件,主流程零改动。
5.4 触发入口:脚本与 NLU 侧的声明位置
(1)演示脚本 —— data/demoQA.js(functionTool 的 func 就是 clientTools 的键名):
js
{
question: '查询作业单位有哪些',
answer: [
{
text: '好的,这就为您打开作业单位信息',
audio: './audios/construct_作业单位.mp3',
extensions: {
functionTool: [
{ func: 'lookZypDialog', args: { module: 'construct', type: 'dialog', status: '作业单位' } },
],
},
},
],
}(2)演示工具 —— data/demoFuncs.js:只把 clientTools 的调用转成能力注册表调用,自己不再派发 demoBus 事件:
js
import { invoke, ensureModule } from '@/assistant'
/** 统一封装:invoke + 日志与错误兜底 */
async function invokeAction(actionId, args, extra = {}) {
try {
await invoke(actionId, args)
return { success: true, action: actionId, ...extra }
} catch (error) {
console.error(`[bigScreen][demo] ${actionId} 执行失败:`, error)
return { success: false, action: actionId, error: error?.message || String(error) }
}
}
async function lookZypDialog(args) {
const { type, status } = args || {}
if (!status) {
// 与原逻辑一致:缺少 status 时仍切到施工模块
await ensureModule('construct')
return { success: false, error: 'status is required' }
}
// 切模块 / 就绪等待见 src/assistant/actions/construct.js
return invokeAction('construct.viewZypDialog', { status }, { type, status })
}(3)NLU 分支 —— VisAssistant.vue 的 buildToolFromNlu()(把 NLU 结构化结果映射成同一批工具,module=construct##type=dialog##status=作业单位):
js
return { func: 'lookZypDialog', args: { type: 'dialog', status: nlu.status } }注:
demoFuncs.js/index.vue里"由lookZypDialog派发look-zyp-dialog事件触发"是早期demoBus实现的注释残留;当前链路已改为上图的src/assistant动作调用,事件总线仅保留demo-interrupt/open-pipe-list/hideWindows(见第三节「现状」)。
5.5 新增一个能力要改哪些地方
| 目标 | 要改的文件 | 写入内容 |
|---|---|---|
| 能力可被 AI / 演示调用 | 业务组件(如 construct/leftWidgets/LeftWidget1/index.vue) | useAssistantAction('module.verbObject', handler, meta) 1 行 |
| AI 语义 → 该能力(参数转换 / 切模块 / 等就绪) | src/assistant/actions/<module>.js | registerAction('module.viewXxx', { module, title, params, handler });新文件自动扫描注册 |
| 演示指令能触发 | data/demoQA.js + data/demoFuncs.js | 脚本条目 { func: 'xxx', args } + 工具函数内 invokeAction('module.viewXxx', …) |
| 自然语言能触发(NLU) | 后端 NLU 模板(SiliconFlowNluService)+ VisAssistant.vue | 增补 模块名: keywords + prompt,并在 buildToolFromNlu() 加分支 |
| 给 LLM function-calling 用 | — | listActions() 直接导出 { id, module, title, params } schema |
约定:
actionId全局唯一、命名module.verbObject;原子能力由组件注册(与用户手动点击等价),AI 动作由actions/*.js注册(内部再调原子能力)。不要跨层——把 AI 语义映射写进组件、或在组件里判断status的业务含义,等于把两者重新耦合回去。