diff --git a/README.md b/README.md index 177cc0b..9fe1a42 100644 --- a/README.md +++ b/README.md @@ -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 ` - 设置指定用户的好感度值(-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 消耗,提供多维度统计查询。 diff --git a/build.gradle.kts b/build.gradle.kts index fe9c1bb..1589e89 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,7 @@ plugins { } group = "top.jie65535.mirai" -version = "1.11.0" +version = "1.12.0" mirai { jvmTarget = JavaVersion.VERSION_11 diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index d261b34..3959323 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -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(), diff --git a/src/main/kotlin/PluginCommands.kt b/src/main/kotlin/PluginCommands.kt index ebc9c1c..ae25560 100644 --- a/src/main/kotlin/PluginCommands.kt +++ b/src/main/kotlin/PluginCommands.kt @@ -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) { diff --git a/src/main/kotlin/PluginConfig.kt b/src/main/kotlin/PluginConfig.kt index c78580b..6a8a2e1 100644 --- a/src/main/kotlin/PluginConfig.kt +++ b/src/main/kotlin/PluginConfig.kt @@ -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) diff --git a/src/main/kotlin/SkillStore.kt b/src/main/kotlin/SkillStore.kt new file mode 100644 index 0000000..e8c1d07 --- /dev/null +++ b/src/main/kotlin/SkillStore.kt @@ -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() + + /** 合法技能名:字母数字、下划线、连字符,防止路径穿越。 */ + 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 @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, String> { + val text = raw.replace("\r\n", "\n") + if (!text.startsWith("---")) { + return emptyMap() to text.trim() + } + val lines = text.split("\n") + // 第一行是 ---,找到下一处 --- 作为 frontmatter 结束 + val endIdx = (1 until lines.size).firstOrNull { lines[it].trim() == "---" } + ?: return emptyMap() to text.trim() + val meta = mutableMapOf() + 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 + } +} diff --git a/src/main/kotlin/tools/DeleteSkill.kt b/src/main/kotlin/tools/DeleteSkill.kt new file mode 100644 index 0000000..3155b35 --- /dev/null +++ b/src/main/kotlin/tools/DeleteSkill.kt @@ -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\" 不存在。" + } + } +} diff --git a/src/main/kotlin/tools/LoadSkill.kt b/src/main/kotlin/tools/LoadSkill.kt new file mode 100644 index 0000000..9a18375 --- /dev/null +++ b/src/main/kotlin/tools/LoadSkill.kt @@ -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\" 不存在,可用技能见系统提示词中的技能索引。" + } +} diff --git a/src/main/kotlin/tools/SaveSkill.kt b/src/main/kotlin/tools/SaveSkill.kt new file mode 100644 index 0000000..4f7ed6b --- /dev/null +++ b/src/main/kotlin/tools/SaveSkill.kt @@ -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\" 已保存。" + } +}