Skip to content

trigger 服务 ​

1. 定位 ​

trigger 标记当前生效的触发插件。触发插件回答"这条入站消息要不要让 agent 开口":它在 inbound:trigger 相位判定,放行的消息写好 triggerType,不开口的归档后吞掉。每个触发插件都是完整的判定,各自 provide.use(trigger, 自己的实例),trigger 选中的那一家即生效者,二选一:同一条消息只由生效者判定,其余触发插件直接放行、什么都不做。"现在能不能说"(禁言、冷却、限速)不归它管,由下一相位的 flow-control 把关。

  • 服务注册名:'trigger'(描述符 trigger)
  • 契约包:@aalis/api-trigger(描述符、服务接口,以及触发插件共用的函数)
  • 触发插件:@aalis/plugin-trigger-policy(规则:点名、计数与评分,另有闲置主动开口);仓库内的私有插件 @aalis/plugin-trigger-laya(模型:经本机侧车由 Laya 模型判定,不发布到 npm,说明见该包 README)
  • 相邻相位:inbound:command → inbound:trigger(生效的触发插件)→ inbound:flow(flow-control)→ inbound:dispatch。

2. 契约 ​

服务接口与描述符(packages/api-trigger/src/index.ts):

ts
export interface TriggerService {
  readonly label: string | TextRef;
}

export const trigger = defineService<TriggerService>('trigger');

服务只用来选出生效者:触发插件拿 trigger 选中那一条的 id(trigger.list().find(p => p.selected)?.id)与自己的实例 id 比,不调用方法。label 是触发插件的名字,进诊断(如 Laya 的诊断项报「生效的触发插件是某某」);WebUI 设置「系统」的服务偏好显示的是 provide.use 时传的 label,两处取同一个值。

触发插件共用的函数(同一文件)。写日志的函数由调用方传入日志前缀 tag(如 '[laya]'),告警行与调用方自己的日志同一前缀:

函数作用
isActiveTrigger(phase, trigger, selfId)这次入站是否由 selfId(触发插件登记 trigger 时的条目 id,缺省即它的实例 id caps.lifecycle.use.id)判定。trigger 选中那一条的 id 每次入站只取一次,以这次的相位数据为键记在进程内全部 api-trigger 副本共用的表里(见 §5)
hitsMuteKeyword(message, keywords)正文是否包含任一禁言关键词;戳一戳通知恒不命中(正文是合成文案,内嵌戳者昵称)
createBotNames(persona, logger, tag)建一个名字表,返回 (triggerNames, message) => string[](persona 只用 present 与 use):每次调用按 message 的会话现取别名与在用的人设(persona.use,按触发插件自己的服务偏好选出)的名字、昵称的并集,去重、去空,别名在前。人设按会话取,与 agent 同一取法:这条消息的 { sessionId, platform } 交给它的 getPersonaName / getNickNames,人设服务按这个会话的「人设」取卡。读名字抛错时名字表只剩别名,记一条 warn,同一原因只记一次(读成功一次后再出错会再记)。触发插件激活时建一个
isAddressed(message, names, opts)是否被点名:戳一戳只看 triggerOnPoke,不做 @ 与名字检测;其余消息看 triggerOnAt 的 <at self> 与名字检测(names 里任一个出现在正文里即命中,names 通常取自 createBotNames)
markTriggered(message, addressed)放行收尾:点名写 triggerType = 'immediate',否则 'interval';不动授权身份,被频率挑中的话照旧按说它的人算
archiveSwallowed(message, archive, logger, tag)吞掉前影子归档;先看 archive.present,message-archive 缺席时跳过,失败记 warn

3. 谁提供 / 谁消费 ​

提供方:每个触发插件提供一个实例,并以 optional(trigger) 声明本服务(自己提供的服务写 required 会把激活闸架在自己的产出上)。

插件标签优先级判定
@aalis/plugin-trigger-policy「规则(计数/评分)」0点名直接开口,否则按计数与活跃指数;同步判定。另有闲置主动开口
@aalis/plugin-trigger-laya(私有)「Laya 模型」-10模型分数 ≥ 阈值即开口,点名不强制开口;判定不了时只回点名

两个都启用、没有偏好时 trigger-policy 生效(见 §5)。

消费方:只有触发插件自己,经 isActiveTrigger 判断是否生效;trigger-policy 的闲置主动开口到点时也查 trigger 选中那一条的 id 是不是自己的实例 id(caps.trigger.list().find(p => p.selected)?.id === lifecycle.use.id)。

4. 写一个触发插件 ​

触发插件在 inbound:trigger 挂中间件,第一步判断自己是不是生效者,其余步骤按需取用共用函数:

ts
import { flowControl } from '@aalis/api-flow-control';
import { INBOUND_PHASE } from '@aalis/api-gateway';
import { hooks } from '@aalis/api-hooks';
import { messageArchive } from '@aalis/api-message-archive';
import { persona } from '@aalis/api-persona';
import { fromElsewhere, sessionManager } from '@aalis/api-session-manager';
import {
  archiveSwallowed,
  createBotNames,
  isActiveTrigger,
  isAddressed,
  markTriggered,
  type TriggerService,
  trigger,
} from '@aalis/api-trigger';
import { definePlugin, lifecycle, logger, optional, provide } from '@aalis/core';

export default definePlugin({
  name: '@aalis/plugin-my-trigger',
  provides: [trigger],
  uses: {
    logger,
    lifecycle,
    hooks,
    provide,
    trigger: optional(trigger),
    flowControl: optional(flowControl),
    persona: optional(persona),
    sessionManager: optional(sessionManager),
    messageArchive: optional(messageArchive),
  },
  apply(caps) {
    const self: TriggerService = { label: '我的触发判定' };
    const selfId = caps.lifecycle.use.id; // 登记 trigger 时的条目 id(未传 entryId 即实例 id)
    caps.provide.use(trigger, self, { priority: 5, label: self.label });
    const botNames = createBotNames(caps.persona, caps.logger.use, '[my-trigger]');

    caps.hooks.use.middleware(INBOUND_PHASE.TRIGGER, async (data, next) => {
      if (!isActiveTrigger(data, caps.trigger, selfId)) return next(); // 不是生效者:什么都不做
      const { message, chat } = data;
      // 内部注入(网关不给 chat)与从别的平台插话不经判定;生效范围(这里只管多方房间)之外的直接放行
      if (!chat || fromElsewhere(message, caps.sessionManager) || !chat.multiParty) return next();
      if (caps.flowControl.present && caps.flowControl.use.isMuted(message.sessionId)) return next(); // 禁言期交给 flow 相位吞
      const opts = { triggerOnAt: true, triggerOnPoke: true };
      const addressed = isAddressed(message, botNames([], message), opts);
      const speak = await myJudge(message, addressed); // 自带超时,不抛错
      if (!speak) {
        await archiveSwallowed(message, caps.messageArchive, caps.logger.use, '[my-trigger]');
        return; // 吞掉
      }
      markTriggered(message, addressed);
      await next();
    });
  },
});

约束:

  • 不是生效者时直接 next(),不计数、不识别、不归档、不请求外部服务。
  • 带 source 的内部注入(闲置触发、定时任务、workflow)直接放行,不改 triggerType(workflow agent 节点的 proactive 原样保留)。
  • 生效范围判断先于禁言关键词:否则群聊的禁言关键词会作用到私聊等生效范围外的会话。第一方触发插件的生效范围是 api-gateway 导出的「生效范围」字段,可按会话设。
  • 名字表取自在用的人设,按会话取:会话改用别的角色卡时,算点名的是那张卡的名字、昵称,主卡的不算,别的会话不受影响。同时装了多个人设插件时,只有触发插件按服务偏好选出的那一家的名字算点名;给 agent 单设了人设偏好时,触发插件要设同一个。人设读名字出错不影响判定:只剩别名。
  • 放行顺序:api-trigger 不要求触发插件按到达先后放行,也不提供共用的顺序处理。判定要等外部(附件识别、模型服务)时,同一会话先到的消息可能晚于后到的放行,后果见 §6。放行之后由 agent 的通道层按入队先后叠加或排队,网关的到达序号只用于日志关联,不按它重排。
  • 判定日志不含消息正文与昵称。

5. 行为不变量 ​

  • 二选一。选中哪一家按服务的挑选规则:本插件偏好 > 全局偏好 > 优先级 > 注册顺序。每条消息只由生效者判定;不生效的触发插件对这条消息不做任何事。两者都启用、没有偏好时由 trigger-policy 生效(Laya 的优先级 -10 低于它的 0,两者都是插件里的常量,选哪个只用偏好;从源码运行时 Laya 会被自动发现并启用,但默认不生效);想用 Laya,在配置文件的 servicePreferences 写 trigger: "@aalis/plugin-trigger-laya",或在 WebUI 设置「系统」的服务偏好里切换 trigger。
  • 生效者每次入站只取一次。isActiveTrigger 在相位里先跑到的触发插件处取下 trigger 选中那一条的 id,以这次入站的相位数据为键记下(api-trigger 模块内的 WeakMap,经全局符号表取同一个对象:契约包装了两份、两个触发插件各用一份时仍共用这张表),后跑到的沿用它。判定途中切换偏好、停用或重载触发插件时,同一条消息不会被两个触发插件各判一次:在途消息由取下的那个判完;它若在轮到自己之前就被停用(中间件已从链上撤下),这条消息不经判定直接放行。
  • 切换即时生效。在 WebUI 设置「系统」的服务偏好里把 trigger 切到另一个触发插件,下一条消息起由它判定;停用生效的触发插件,下一条消息由剩下的接手。手改 aalis.config.yaml 的 servicePreferences 随配置热重载生效。不要给单个触发插件设 trigger 的本插件偏好:生效者由相位里先跑到的那个触发插件按它自己的选择取下,各插件的选择不同时,谁生效取决于哪个先跑到。
  • owner 自己的会话不经本相位。房间类别为 owner-only 的会话由 plugin-gateway 跳过 inbound:trigger(结构性放行),触发插件收不到这些消息,不必按平台或生效范围另做判断。
  • 一个都不在时不做判定。触发插件都停用或都没装时,inbound:trigger 相位不做判定,消息直接进入 flow 相位,不写 triggerType,与没装触发插件一致:agent 对每条消息都处理,只受 flow-control 的禁言、冷却与限速约束(冷却与限速只挡非 immediate 的消息,此时所有消息都不是 immediate)。
  • 状态与配置不共享。禁言关键词与时长、点名的别名与开关、生效范围由各触发插件在自己的配置里分别设置(都可按会话覆盖),只有生效者的起作用:切换后按新生效者的配置执行,只在一边配了禁言关键词的,切到另一边后这些关键词不再生效;要在切换后保持一致,两边须同样配置。各触发插件的状态只属于自己:trigger-policy 的计数、活跃指数与闲置活动时间只统计它生效时经过的消息,不生效期间不计数;闲置主动开口到点时它不生效就跳过(不注入,也不记为 bot 开口),agent 回复也不记。
  • 附件只识别一次。要看附件描述的触发插件自己调 media 的 processMessage 启动识别(Laya 判定前至多等 mediaWaitMs);放行与吞掉都不等识别跑完。media 的 processMessage 按消息对象只处理一次,agent 预处理器与归档对同一个消息对象调用时命中这次识别(在途则等它),不再识别第二遍。trigger-policy 不看附件,不启动识别。

6. 注意事项与边界情形 ​

  • trigger-policy 从不生效切回生效时,计数接着它上次生效时的状态算,不包含不生效期间的消息;闲置调度用的"最近活动"也只来自它生效时经过的消息与 bot 回复,切回后第一轮闲置可能按较早的活动时间到点。
  • 模型类触发插件在判定之内等附件识别(Laya 至多 mediaWaitMs),带附件的消息可能晚于同一会话后到、不用等识别的消息放行。agent 不让新消息打断在跑的一轮:先发图再说一句时,文字那一轮照常回;图判为开口时插进那一轮的下一次模型调用,或排到下一轮,剩下的只是上下文更完整;判为不开口时到判定结束才影子归档,只出现在之后回合的历史里。被吞掉的消息与排着、还没进一轮的输入不在历史里,在跑的一轮越长,读历史的触发判定少的上下文越多。网关给每条入站打的到达序号只用于日志关联,放行与排队都不按它重排。trigger-policy 同步判定,放行顺序即到达顺序。识别可能与 agent 的预处理链并发;media 与 file-reader 的写回都按写回那一刻的消息只补不换,不会互相覆盖。

7. 交叉链接 ​

文档对应 dev@c4e046e41,core 0.21.0,构建于 2026-11-02
基于 MIT 许可发布