Add global skill system for self-accumulated knowledge

Introduce a cross-group skill system that lets the bot distill reusable
knowledge into markdown docs and load them on demand, keeping day-to-day
context pollution low.

- SkillStore manages data/skills/*.md files with name/description
  frontmatter and an in-memory index cache (rebuilt on init/reload)
- Only the skill index (name + one-line description) is injected via the
  new {skills} system-prompt placeholder; bodies load on demand
- New tools: loadSkill / saveSkill (upsert, iterate = load+overwrite) /
  deleteSkill, gated by PluginConfig.skillsEnabled
- Skill names validated against ^[A-Za-z0-9_-]+$ to prevent traversal
- Wire SkillStore.init into onEnable and refresh on /jgpt reload; add
  /jgpt skills listing command
- Bump version to 1.12.0; update README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-20 23:37:44 +08:00
parent 538fe563a0
commit 4307019ee8
9 changed files with 416 additions and 8 deletions

View File

@@ -7,6 +7,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
- **多模型支持**:支持聊天模型、推理模型和视觉模型
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
- **上下文记忆**:支持持久化记忆存储
- **技能系统**Bot 可在群聊中自我沉淀可复用知识,全局跨群、按需加载、低上下文污染
- **用户画像系统**好感度、印象、标签、Bot 自定义代号
- **Token消耗统计**:按天 × 用户 × 群聚合记录,支持多维度统计查询
- **LaTeX 渲染**:自动将数学表达式渲染为图片
@@ -30,6 +31,7 @@ AI 可以自动调用多种工具来完成复杂任务:
- 推理思考(需要配置推理模型)
- 群管理(禁言等,需启用相应权限)
- 记忆管理(添加和修改对话记忆)
- 技能管理(沉淀、加载、迭代、删除可复用知识技能)
- 聊天历史搜索(按关键词、发送者、时间范围检索群聊消息,需启用历史消息上下文)
## 权限列表
@@ -45,6 +47,7 @@ AI 可以自动调用多种工具来完成复杂任务:
- `/jgpt reload` - 重载配置文件
- `/jgpt clearMemory` - 清空所有对话记忆
- `/jgpt clearContextCache` - 清空所有对话上下文缓存
- `/jgpt skills` - 列出当前所有技能(名称 + 简介)
### 好感度管理
- `/jgpt setFavor <user> <value>` - 设置指定用户的好感度值(-100~100
@@ -135,6 +138,8 @@ callKeyword: '[小筱][林淋月玥]'
showToolCallingMessage: true
# 是否启用记忆编辑功能记忆存在data目录提示词中需要加上{memory}来填充记忆,每个群都有独立记忆
memoryEnabled: true
# 是否启用技能系统技能存在data/skills目录全局跨群提示词中需要加上{skills}来注入技能索引
skillsEnabled: true
# 是否启用好感度系统
enableFavorabilitySystem: true
# 好感度每日基础偏移速度(点/天)
@@ -165,6 +170,7 @@ JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件
- `{time}` - 当前时间格式yyyy年MM月dd E HH:mm:ss
- `{subject}` - 当前聊天环境信息(群聊名称或私聊信息)
- `{memory}` - 当前联系人的记忆内容
- `{skills}` - 全局技能索引(仅名称 + 一句话简介,正文按需加载)
### 示例提示词
@@ -279,12 +285,13 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型
3. **VisualAgent** - 图像识别和理解
4. **ReasoningAgent** - 深度思考和推理
5. **MemoryAppend/Replace** - 对话记忆管理
6. **GroupManageAgent** - 群管理功能(如禁言
7. **SendSingleMessage/CompositeMessage** - 发送消息
8. **SendVoiceMessage** - 发送语音消息
9. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
10. **WeatherService** - 天气查询
11. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin
6. **LoadSkill/SaveSkill/DeleteSkill** - 技能管理(加载、沉淀/迭代、删除全局技能
7. **GroupManageAgent** - 群管理功能(如禁言)
8. **SendSingleMessage/CompositeMessage** - 发送消息
9. **SendVoiceMessage** - 发送语音消息
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
11. **WeatherService** - 天气查询
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin
## 用户画像系统
@@ -315,6 +322,40 @@ JChatGPT 维护对每位用户的画像由好感度、Bot 自定义代号、
- `enableFavorabilitySystem` - 是否启用画像系统默认true
- `favorabilityBaseShiftSpeed` - 好感度每日基础偏移速度(点/天默认2.0
## 技能系统
JChatGPT 允许 Bot 在群聊中**自我沉淀可复用的知识和经验**,存成"技能"(本质是带简介的提示词文档),并在需要时按需加载。例如群友反复问到某个软件/模组的用法、常见报错排查Bot 在回答或被纠正的过程中学到的内容可以沉淀成技能,跨群复用。
### 设计要点
- **全局跨群**:技能不按群隔离,任何群学到的都能在其它群复用。
- **低上下文污染**:日常对话只在系统提示词里常驻"技能名 + 一句话简介"的索引,正文不进上下文。
- **按需加载**当话题命中某个技能时Bot 才用 `loadSkill` 把正文读入上下文。
- **自我迭代**Bot 通过 `saveSkill` 沉淀/更新技能,过时的用 `deleteSkill` 删除,无需人工介入(也支持手动编辑文件)。
### 存储格式
每个技能 = `data/skills/` 下的一个 markdown 文件,带 frontmatter
```markdown
---
name: kubejs-basics
description: KubeJS 基础语法、常见报错与排查方法
---
(正文:沉淀下来的知识、经验或提示词)
```
技能名为 kebab-case只能包含字母、数字、下划线、连字符用于校验防止路径穿越
### 相关工具
- **loadSkill(name)** - 加载某技能正文到上下文
- **saveSkill(name, description, content)** - 新增或整篇覆盖一个技能(迭代 = 先 loadSkill 读全文,改好后同名写回)
- **deleteSkill(name)** - 删除过时或失效的技能
### 配置与命令
- 配置项 `skillsEnabled`(默认 true控制是否启用技能系统
- 系统提示词中需包含 `{skills}` 占位符以注入技能索引
- `/jgpt skills` - 列出当前所有技能;`/jgpt reload` 会重新扫描技能目录
## Token消耗统计
JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消耗,提供多维度统计查询。

View File

@@ -7,7 +7,7 @@ plugins {
}
group = "top.jie65535.mirai"
version = "1.11.0"
version = "1.12.0"
mirai {
jvmTarget = JavaVersion.VERSION_11

View File

@@ -52,7 +52,7 @@ object JChatGPT : KotlinPlugin(
JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT",
version = "1.11.0",
version = "1.12.0",
) {
author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
@@ -82,6 +82,9 @@ object JChatGPT : KotlinPlugin(
// 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug
TokenUsageStore.init(dataFolder)
// 初始化技能存储data/skills/ 下的 markdown 文件,全局跨群)
SkillStore.init(dataFolder)
// 设置Token
LargeLanguageModels.reload()
@@ -269,6 +272,12 @@ object JChatGPT : KotlinPlugin(
} else memoryText
}
replace("{skills}") {
if (PluginConfig.skillsEnabled) {
SkillStore.buildIndexPrompt()
} else "暂无技能"
}
replace("{meme}") {
memePrompt?.let { return@replace it }
@@ -928,6 +937,15 @@ object JChatGPT : KotlinPlugin(
// 记忆修改
MemoryReplace(),
// 技能:加载
LoadSkill(),
// 技能:沉淀/迭代
SaveSkill(),
// 技能:删除
DeleteSkill(),
// 搜索聊天历史
SearchChatHistory(),

View File

@@ -21,9 +21,24 @@ object PluginCommands : CompositeCommand(
PluginConfig.reload()
PluginData.reload()
LargeLanguageModels.reload()
SkillStore.reload()
sendMessage("OK")
}
@SubCommand
suspend fun CommandSender.skills() {
val all = SkillStore.all
if (all.isEmpty()) {
sendMessage("暂无技能")
return
}
val response = buildString {
appendLine("当前技能(共 ${all.size} 个):")
all.forEach { appendLine("- ${it.name}: ${it.description}") }
}
sendMessage(response.trim())
}
@SubCommand
suspend fun CommandSender.enable(contact: Contact) {
when (contact) {

View File

@@ -123,6 +123,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("是否启用记忆编辑功能记忆存在data目录提示词中需要加上{memory}来填充记忆,每个群都有独立记忆")
val memoryEnabled by value(true)
@ValueDescription("是否启用技能系统技能存在data/skills目录全局跨群提示词中需要加上{skills}来注入技能索引")
val skillsEnabled by value(true)
@ValueDescription("是否启用好感度系统")
val enableFavorabilitySystem by value(true)

View File

@@ -0,0 +1,169 @@
package top.jie65535.mirai
import java.io.File
/**
* 技能元信息:用于索引展示,不含正文。
* @param name 技能名kebab-case同时是文件名唯一
* @param description 一句话简介,决定 bot 何时按需加载
*/
data class SkillMeta(
val name: String,
val description: String,
)
/**
* 技能存储。每个技能 = 一个带 frontmatter 的 markdown 文件,放在 data/skills/ 下,全局跨群共享。
*
* 文件格式:
* ```
* ---
* name: kubejs-basics
* description: KubeJS 基础语法、常见报错与排查方法
* ---
*
* (正文:沉淀下来的知识/经验/提示词)
* ```
*
* 索引name + description常驻系统提示词正文按需通过 loadSkill 工具加载,
* 以此实现"低上下文污染 + 可自我沉淀迭代"。
*/
object SkillStore {
private lateinit var dir: File
/** 内存索引缓存key 为技能名。仅缓存元信息,正文每次按需读盘。 */
private val index = linkedMapOf<String, SkillMeta>()
/** 合法技能名:字母数字、下划线、连字符,防止路径穿越。 */
private val nameRegex = Regex("^[A-Za-z0-9_-]+$")
/**
* 在 onEnable 中调用一次,传入插件数据目录。随后可通过 [reload] 刷新。
*/
fun init(dataFolder: File) {
dir = File(dataFolder, "skills")
reload()
}
/** 重新扫描技能目录,重建内存索引。/jgpt reload 时调用。 */
@Synchronized
fun reload() {
if (!::dir.isInitialized) return
index.clear()
if (!dir.exists()) {
dir.mkdirs()
return
}
dir.listFiles { f -> f.isFile && f.extension.equals("md", ignoreCase = true) }
?.sortedBy { it.name }
?.forEach { file ->
try {
val (meta, _) = parse(file.readText())
val name = file.nameWithoutExtension
val desc = meta["description"].orEmpty()
index[name] = SkillMeta(name, desc)
} catch (_: Exception) {
// 单个文件解析失败不影响其它技能加载
}
}
}
/** 当前所有技能元信息。 */
val all: List<SkillMeta> @Synchronized get() = index.values.toList()
/**
* 构建注入到系统提示词 {skills} 占位符的索引文本,仅含 name + description。
*/
@Synchronized
fun buildIndexPrompt(): String {
if (index.isEmpty()) return "暂无技能"
return index.values.joinToString("\n") { "- ${it.name}: ${it.description}" }
}
/**
* 读取技能正文(不含 frontmatter。技能不存在返回 null。
*/
@Synchronized
fun load(name: String): String? {
if (!isValidName(name)) return null
val file = File(dir, "$name.md")
if (!file.exists()) return null
return try {
parse(file.readText()).second
} catch (_: Exception) {
null
}
}
/**
* 新增或整篇覆盖一个技能upsert。迭代即"读全文→改→整篇写回"。
* @return 失败时返回错误信息,成功返回 null
*/
@Synchronized
fun save(name: String, description: String, content: String): String? {
if (!isValidName(name)) {
return "技能名非法,只能包含字母、数字、下划线、连字符:$name"
}
if (!::dir.isInitialized) return "技能目录未初始化"
if (!dir.exists()) dir.mkdirs()
val file = File(dir, "$name.md")
val safeDesc = description.replace('\n', ' ').trim()
val text = buildString {
appendLine("---")
appendLine("name: $name")
appendLine("description: $safeDesc")
appendLine("---")
appendLine()
append(content.trim())
appendLine()
}
return try {
file.writeText(text)
index[name] = SkillMeta(name, safeDesc)
null
} catch (e: Exception) {
"写入技能文件失败:${e.message}"
}
}
/**
* 删除一个技能。
* @return 是否删除成功(技能不存在返回 false
*/
@Synchronized
fun delete(name: String): Boolean {
if (!isValidName(name)) return false
val file = File(dir, "$name.md")
index.remove(name)
return if (file.exists()) file.delete() else false
}
private fun isValidName(name: String): Boolean = nameRegex.matches(name)
/**
* 解析 frontmatter。返回 (元信息键值对, 正文)。
* 无 frontmatter 时元信息为空,正文为全文。
*/
private fun parse(raw: String): Pair<Map<String, String>, String> {
val text = raw.replace("\r\n", "\n")
if (!text.startsWith("---")) {
return emptyMap<String, String>() to text.trim()
}
val lines = text.split("\n")
// 第一行是 ---,找到下一处 --- 作为 frontmatter 结束
val endIdx = (1 until lines.size).firstOrNull { lines[it].trim() == "---" }
?: return emptyMap<String, String>() to text.trim()
val meta = mutableMapOf<String, String>()
for (i in 1 until endIdx) {
val line = lines[i]
val sep = line.indexOf(':')
if (sep > 0) {
val key = line.substring(0, sep).trim()
val value = line.substring(sep + 1).trim()
meta[key] = value
}
}
val body = lines.subList(endIdx + 1, lines.size).joinToString("\n").trim()
return meta to body
}
}

View File

@@ -0,0 +1,50 @@
package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import net.mamoe.mirai.event.events.MessageEvent
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig
import top.jie65535.mirai.SkillStore
/**
* 删除一个过时或失效的技能。
*/
class DeleteSkill : BaseAgent(
tool = Tool.function(
name = "deleteSkill",
description = "删除一个已过时或失效的技能。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("name") {
put("type", "string")
put("description", "要删除的技能名")
}
}
putJsonArray("required") {
add("name")
}
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.skillsEnabled
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val name = args.getValue("name").jsonPrimitive.content
JChatGPT.logger.info("Delete skill: \"$name\"")
return if (SkillStore.delete(name)) {
"OK技能 \"$name\" 已删除。"
} else {
"技能 \"$name\" 不存在。"
}
}
}

View File

@@ -0,0 +1,50 @@
package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import net.mamoe.mirai.event.events.MessageEvent
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig
import top.jie65535.mirai.SkillStore
/**
* 按需加载某个技能的正文进上下文。技能索引name+简介)常驻系统提示词,
* 当话题命中某技能时调用本工具读取其完整内容。
*/
class LoadSkill : BaseAgent(
tool = Tool.function(
name = "loadSkill",
description = "当话题命中某个技能时,加载该技能的完整内容到上下文。可用技能见系统提示词中的技能索引。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("name") {
put("type", "string")
put("description", "技能名(技能索引中的 name")
}
}
putJsonArray("required") {
add("name")
}
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.skillsEnabled
override val loadingMessage: String = "翻阅资料中..."
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val name = args.getValue("name").jsonPrimitive.content
JChatGPT.logger.info("Load skill: \"$name\"")
val content = SkillStore.load(name)
return content ?: "技能 \"$name\" 不存在,可用技能见系统提示词中的技能索引。"
}
}

View File

@@ -0,0 +1,62 @@
package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import net.mamoe.mirai.event.events.MessageEvent
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig
import top.jie65535.mirai.SkillStore
/**
* 新增或整篇覆盖一个技能(全局,跨群共享)。
* 用于把群里学到/被纠正的知识沉淀下来;迭代时先用 loadSkill 读全文,改好后整篇写回。
*/
class SaveSkill : BaseAgent(
tool = Tool.function(
name = "saveSkill",
description = "沉淀或更新一个技能(知识文档),全局跨群共享。新增直接写;迭代时先 loadSkill 读全文,修改后整篇写回。技能名相同则覆盖。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("name") {
put("type", "string")
put("description", "技能名kebab-case只能含字母/数字/下划线/连字符,如 kubejs-basics。相同则覆盖")
}
putJsonObject("description") {
put("type", "string")
put("description", "一句话简介,会常驻技能索引,决定你以后何时加载它")
}
putJsonObject("content") {
put("type", "string")
put("description", "技能正文markdown沉淀的知识、经验或提示词。整篇内容会覆盖旧版本")
}
}
putJsonArray("required") {
add("name")
add("description")
add("content")
}
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.skillsEnabled
override val loadingMessage: String = "记下来了..."
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val name = args.getValue("name").jsonPrimitive.content
val description = args.getValue("description").jsonPrimitive.content
val content = args.getValue("content").jsonPrimitive.content
JChatGPT.logger.info("Save skill: \"$name\" - \"$description\"")
val error = SkillStore.save(name, description, content)
return error ?: "OK技能 \"$name\" 已保存。"
}
}