跳转至

注解开发模式

注解开发模式通过声明式注解定义插件功能,由 KSP (Kotlin Symbol Processing) 在编译时自动生成扩展类、工具集和 PF4J 服务注册文件。适合中等复杂度、逻辑相对直接的插件。

以下以 erii 内置的 lolisuki(二次元图片插件)为真实示例进行说明。

依赖配置

// erii-plugins/my-plugin/build.gradle.kts
plugins {
    id("uesugi.erii-plugin")
}

dependencies {
    // KSP 注解处理器(编译时代码生成)
    add("ksp", project(":erii-spi:erii-spi-annotation"))
    // 注解定义(仅编译时需要,运行时由核心框架提供)
    compileOnly(project(":erii-spi:erii-spi-annotation"))
}

version = "0.0.1"

uesugi.erii-plugin 约定插件已预配置 KSP 插件,开发者只需声明依赖即可。

文件级注解:@file:Definition

每个使用注解模式的插件必须在任意一个 .kt 文件的顶部声明文件级 @Definition 注解:

// Lolisuki.kt 文件顶部
@file:Definition(
    pluginId = "lolisuki",
    version = "0.0.1",
    description = "二次元涩图插件"
)

package uesugi.plugin
参数 说明 必需
pluginId 插件唯一标识符
version 语义化版本号
requires 依赖的其他插件(PF4J requires 表达式)
dependencies 外部依赖声明
description 插件描述
provider 提供者信息
license 许可证类型

KSP 处理器扫描所有文件找到 @file:Definition,自动生成 GeneratedPlugin 类(继承 AgentPlugin)并注册 PF4J plugin 描述。

功能注解详解

@Route — 路由扩展

将函数注册为 RouteExtension,由 LLM RoutingAgent 根据意图分类匹配调用:

@Route(
    path = "REQUEST_R18_IMAGE",
    method = """
        仅当用户【明确提出图片请求】时,才选择此分类。
        必须【同时满足】以下两个条件,缺一不可:

        条件一:图片内容明确指向【成人 / R18】
        条件二:用户有索要图片的意图或行为

        强制排除规则:
        如果内容仅包含文字但没有任何索要图片的意图
        则绝对不要归为此类。
    """,
    toolSets = ["lolisuki"]
)
suspend fun lolisukiRoute(meta: Meta) {
    // 处理逻辑
    val image = getImage(meta)
    // ...
}
参数 类型 说明
method String 意图匹配规则(LLM 提示词)。不同于 HTTP Method,此处是 LLM RoutingAgent 用来判断是否匹配此路由的分类描述文本
path String 路由路径标识。与 method 共同构成 matcher: Pair<String, String>,path 作为 key,method 作为 value
toolSets Array\<String> 关联的工具集名称列表,默认 ["default"]
onLoad Array\<String> 加载时调用的 @OnLoad("name") 函数名列表
onUnload Array\<String> 卸载时调用的 @OnUnload("name") 函数名列表

函数参数可为空或接收 meta: Meta。若有 meta 参数,KSP 自动注入当前消息元数据。

KSP 生成的扩展类为 GeneratedRoute_<函数名>,实现 RouteExtension<GeneratedPlugin>

@Extension
class GeneratedRoute_lolisukiRoute : RouteExtension<GeneratedPlugin> {
    override val matcher: Pair<String, String>
        get() = "REQUEST_R18_IMAGE" to "<LLM规则文本>"

    override fun onLoad(context: PluginContext) {
        context.chain { meta ->
            withPluginContext(context) {
                withMeta(meta) {
                    lolisukiRoute(meta)
                }
            }
        }
        context.tool { { GeneratedToolSet_lolisuki(context) } }
    }
}

@Cmd — 命令扩展

将函数注册为 SlashCmdExtension,响应用户的 /xxx 命令:

@Cmd(
    name = "hello",
    alias = ["hi", "hey"],
    toolSets = ["default"]
)
suspend fun helloCommand(meta: Meta, args: List<String>) {
    // meta.input 包含完整用户输入
    // args 包含命令参数列表
}
参数 类型 说明
name String 命令名(不含斜杠),如 hello 对应 /hello
alias Array\<String> 命令别名列表,如 /hi/hey
toolSets Array\<String> 关联的工具集名称列表
onLoad Array\<String> 加载时调用的函数名列表
onUnload Array\<String> 卸载时调用的函数名列表

函数参数接受两个可选位置参数(前缀匹配):

  1. meta: Meta — 消息元数据
  2. args: List<String> — 解析后的命令参数列表

KSP 生成的扩展类为 GeneratedCmd_<命令名>(命令名中的 - 替换为 _),实现 SlashCmdExtension<GeneratedPlugin>

@Passive — 被动扩展

将函数注册为 PassiveExtension,在插件加载时执行:

@Passive(toolSets = ["default"])
suspend fun backgroundTask(meta: Meta) {
    val scheduler = useScheduler()
    scheduler.scheduleRecurrently("task-id", "0 */1 * * *") {
        // 每小时执行的后台任务
    }
}

KSP 生成的扩展类为 GeneratedPassive_<函数名>

@LLMTool — LLM 工具

将函数注册为 Agent 可调用的 Function Calling 工具:

@LLMTool(name = "send_sex_image", set = "lolisuki")
@LLMDesc("发送一张涩图给群友")
suspend fun sendSexImage(): String {
    val meta = useToolMeta().value
    val resource = getImage(meta)
    if (resource != null) {
        val base64 = Base64.getEncoder().encodeToString(resource)
        MetaToolSet.meta.roledBot.refBot.sendGroupMsg(
            meta.groupId.toLong(),
            buildMessage { image("base64://$base64") }
        )
    }
    return "发送成功"
}
参数 类型 说明
name String 工具名称(Agent 通过此名称调用),默认为函数名
set String 所属工具集名称,默认为 "default"。同一 set 的工具被归类到同一个 GeneratedToolSet_<set> 类中

函数参数可使用 @LLMDesc 注解描述:

@LLMTool(name = "search_images")
@LLMDesc("根据查询字符串搜索图片")
suspend fun searchImages(
    @LLMDesc("搜索关键词") query: String,
    @LLMDesc("返回结果数量") limit: Int = 10
): String {
    // 实现搜索逻辑
}

KSP 为每个工具集名称生成一个 GeneratedToolSet_<set> 类:

class GeneratedToolSet_lolisuki(private val context: PluginContext) : MetaToolSet {
    @LLMDescription("发送一张涩图给群友")
    @Tool
    suspend fun sendSexImage(): String? = withPluginContext(context) {
        _erii_sendSexImage()
    }
}
suspend 函数限制

@LLMTool 标注的函数必须是 suspend 函数。非 suspend 函数会被 KSP 自动包装到 withContext(Dispatchers.IO) 块中执行。

@LLMDesc — 工具描述

可标注在函数和参数上:

@LLMDesc("根据关键词发送动漫图片")
@LLMTool(name = "send_anime_image")
suspend fun sendAnimeImage(
    @LLMDesc("动漫角色名或风格关键词") keyword: String,
    @LLMDesc("返回数量(1-10)") count: Int = 3
): String { ... }

生命周期注解

注解 作用 限定
@OnLoad("name") 插件或扩展加载时调用。有 name 参数时可被 @Route/@Cmd/@Passive 的 onLoad 引用 函数必须零参数,可 suspend
@OnUnload("name") 插件或扩展卸载时调用。与 @OnLoad 配对使用 函数必须零参数,可 suspend
@OnStart Plugin.start() 时调用 每个插件最多一个;必须零参数,可 suspend
@OnStop Plugin.stop() 时调用 每个插件最多一个;必须零参数,可 suspend

@OnLoad() 无 name 参数时为全局 onLoad,所有扩展都会调用; @OnLoad("name") 为命名 onLoad,仅被引用它的扩展调用。

@OnLoad("init_config")
suspend fun initConfig() {
    val config = useConfig()
    // 加载配置
}

@OnUnload("cleanup_config")
suspend fun cleanupConfig() {
    // 清理配置
}

// 此 Route 扩展加载时会自动调用 initConfig(),卸载时调用 cleanupConfig()
@Route(
    path = "MY_FEATURE",
    method = "...",
    onLoad = ["init_config"],
    onUnload = ["cleanup_config"]
)
suspend fun myFeature(meta: Meta) { ... }

上下文访问函数

在注解标注的函数中,使用以下 suspend 函数访问 PluginContext 的各项能力:

函数 返回类型 对应 PluginContext 属性
useMeta() Meta 当前消息元数据(仅在 chain 上下文中可用)
useToolMeta() Lazy<Meta> @LLMTool 函数中懒获取 Meta
useMem() Mem 内存缓存
useKv() Kv 键值存储
useBlob() Blob 文件存储
useVector() Vector 向量嵌入和检索
useConfig() PluginConfig 插件配置
useDatabase() Database 数据库查询
useScheduler() Scheduler 任务调度
useLLM() PromptExecutor LLM 调用
useHttp() HttpClient HTTP 客户端
useServer() Server HTTP 路由注册

这些函数的实现基于 Kotlin 协程上下文(currentCoroutineContext())查找 PluginContextElementMetaElement。KSP 生成的扩展代码自动注入相应上下文。

KSP 自动生成文件清单

KSP 处理器(KspAnnotationProcessor)为注解插件生成以下文件:

生成文件 说明 生成条件
GeneratedPlugin.kt 插件主类,继承 AgentPlugin,包含 @PluginDefinition 和生命周期方法 始终生成
GeneratedRoute_<函数名>.kt Route 扩展类 每个 @Route 函数
GeneratedCmd_<命令名>.kt Cmd 扩展类(SlashCmdExtension 每个 @Cmd 函数
GeneratedPassive_<函数名>.kt Passive 扩展类 每个 @Passive 函数
GeneratedPassive_default.kt 默认被动扩展(仅注册工具集,无 chain handler) 仅有 @LLMTool 函数而无其他扩展时
GeneratedToolSet_<set名>.kt 工具集类,继承 MetaToolSet 每个 set 参数值对应的工具组合
META-INF/services/org.pf4j.Extension PF4J 扩展注册文件 有任意扩展时生成

参数插槽验证

KSP 处理器对函数参数进行严格的插槽匹配验证:

Route / Passive 函数插槽(按顺序可选):

顺序 类型 说明
1 meta: Meta 消息元数据

Cmd 函数插槽(按顺序可选):

顺序 类型 说明
1 meta: Meta 消息元数据
2 args: List<String> 命令参数列表

参数声明规则: - 可声明少于插槽数的参数(取插槽的前缀) - 不可声明多于插槽数的参数(编译错误) - 参数类型必须与插槽类型严格匹配,顺序不可打乱

真实示例:lolisuki 插件完整流程

@file:Definition(pluginId = "lolisuki", version = "0.0.1", description = "二次元涩图插件")
package uesugi.plugin

// 1. LLM 判定用户意图 → 匹配 @Route
@Route(path = "REQUEST_R18_IMAGE", method = "<LLM分类规则>", toolSets = ["lolisuki"])
suspend fun lolisukiRoute(meta: Meta) {
    val image = getImage(meta)  // 从 lolisuki.cn API 搜索下载图片

    // 2. 调用 meta.sendAgent() 让 Agent 携带自定义工具发消息
    meta.sendAgent(
        input = "加入群聊天,你已经获取到了一张涩图,调用工具发送",
        ToolSetBuilder { tool { ImageTool(image, groupId, it, state) } }
            + Feature(PSFeature.GRAB or PSFeature.FALLBACK),
    ) {
        runCompletion { /* fallback: 直接发送图片 */ }
        callCompletion { /* fallback: 直接发送图片 */ }
        callFallback { /* fallback: 直接发送图片 */ }
        scope
    }
}

// 3. 声明为 default 工具集的工具(可在 @Route toolSets 之外独立存在)
@LLMTool(set = "lolisuki")
@LLMDesc("发送一张涩图")
suspend fun sendSexImage(): String { ... }

// 4. 使用上下文函数访问系统能力
suspend fun getImage(meta: Meta): ByteArray? {
    val database = useDatabase()     // 查询聊天历史
    val llm = useLLM()               // 调用 LLM 提取关键词
    val http = useHttp()             // 调用 lolisuki.cn HTTP API

    val history = database.getLatestHistory(meta.botId, meta.groupId, 10, 1.days)
    // LLM 从历史中提取图片关键词 → 调用 API 搜索 → 下载图片
    // ...
}