mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-06-23 00:49:31 +08:00
Compare commits
8 Commits
92acbb2310
...
cfc61c52ba
| Author | SHA1 | Date | |
|---|---|---|---|
| cfc61c52ba | |||
| ebb1fbe10c | |||
| a29cf17361 | |||
| 4307019ee8 | |||
| 538fe563a0 | |||
| 890ccb10d5 | |||
| d2bdd273b2 | |||
| eedbd55f62 |
53
README.md
53
README.md
@@ -7,6 +7,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
|
|||||||
- **多模型支持**:支持聊天模型、推理模型和视觉模型
|
- **多模型支持**:支持聊天模型、推理模型和视觉模型
|
||||||
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
|
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
|
||||||
- **上下文记忆**:支持持久化记忆存储
|
- **上下文记忆**:支持持久化记忆存储
|
||||||
|
- **技能系统**:Bot 可在群聊中自我沉淀可复用知识,全局跨群、按需加载、低上下文污染
|
||||||
- **用户画像系统**:好感度、印象、标签、Bot 自定义代号
|
- **用户画像系统**:好感度、印象、标签、Bot 自定义代号
|
||||||
- **Token消耗统计**:按天 × 用户 × 群聚合记录,支持多维度统计查询
|
- **Token消耗统计**:按天 × 用户 × 群聚合记录,支持多维度统计查询
|
||||||
- **LaTeX 渲染**:自动将数学表达式渲染为图片
|
- **LaTeX 渲染**:自动将数学表达式渲染为图片
|
||||||
@@ -30,6 +31,7 @@ AI 可以自动调用多种工具来完成复杂任务:
|
|||||||
- 推理思考(需要配置推理模型)
|
- 推理思考(需要配置推理模型)
|
||||||
- 群管理(禁言等,需启用相应权限)
|
- 群管理(禁言等,需启用相应权限)
|
||||||
- 记忆管理(添加和修改对话记忆)
|
- 记忆管理(添加和修改对话记忆)
|
||||||
|
- 技能管理(沉淀、加载、迭代、删除可复用知识技能)
|
||||||
- 聊天历史搜索(按关键词、发送者、时间范围检索群聊消息,需启用历史消息上下文)
|
- 聊天历史搜索(按关键词、发送者、时间范围检索群聊消息,需启用历史消息上下文)
|
||||||
|
|
||||||
## 权限列表
|
## 权限列表
|
||||||
@@ -45,6 +47,7 @@ AI 可以自动调用多种工具来完成复杂任务:
|
|||||||
- `/jgpt reload` - 重载配置文件
|
- `/jgpt reload` - 重载配置文件
|
||||||
- `/jgpt clearMemory` - 清空所有对话记忆
|
- `/jgpt clearMemory` - 清空所有对话记忆
|
||||||
- `/jgpt clearContextCache` - 清空所有对话上下文缓存
|
- `/jgpt clearContextCache` - 清空所有对话上下文缓存
|
||||||
|
- `/jgpt skills` - 列出当前所有技能(名称 + 简介)
|
||||||
|
|
||||||
### 好感度管理
|
### 好感度管理
|
||||||
- `/jgpt setFavor <user> <value>` - 设置指定用户的好感度值(-100~100)
|
- `/jgpt setFavor <user> <value>` - 设置指定用户的好感度值(-100~100)
|
||||||
@@ -135,6 +138,8 @@ callKeyword: '[小筱][林淋月玥]'
|
|||||||
showToolCallingMessage: true
|
showToolCallingMessage: true
|
||||||
# 是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆
|
# 是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆
|
||||||
memoryEnabled: true
|
memoryEnabled: true
|
||||||
|
# 是否启用技能系统,技能存在data/skills目录(全局跨群),提示词中需要加上{skills}来注入技能索引
|
||||||
|
skillsEnabled: true
|
||||||
# 是否启用好感度系统
|
# 是否启用好感度系统
|
||||||
enableFavorabilitySystem: true
|
enableFavorabilitySystem: true
|
||||||
# 好感度每日基础偏移速度(点/天)
|
# 好感度每日基础偏移速度(点/天)
|
||||||
@@ -165,6 +170,7 @@ JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件
|
|||||||
- `{time}` - 当前时间(格式:yyyy年MM月dd E HH:mm:ss)
|
- `{time}` - 当前时间(格式:yyyy年MM月dd E HH:mm:ss)
|
||||||
- `{subject}` - 当前聊天环境信息(群聊名称或私聊信息)
|
- `{subject}` - 当前聊天环境信息(群聊名称或私聊信息)
|
||||||
- `{memory}` - 当前联系人的记忆内容
|
- `{memory}` - 当前联系人的记忆内容
|
||||||
|
- `{skills}` - 全局技能索引(仅名称 + 一句话简介,正文按需加载)
|
||||||
|
|
||||||
### 示例提示词
|
### 示例提示词
|
||||||
|
|
||||||
@@ -279,12 +285,13 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型
|
|||||||
3. **VisualAgent** - 图像识别和理解
|
3. **VisualAgent** - 图像识别和理解
|
||||||
4. **ReasoningAgent** - 深度思考和推理
|
4. **ReasoningAgent** - 深度思考和推理
|
||||||
5. **MemoryAppend/Replace** - 对话记忆管理
|
5. **MemoryAppend/Replace** - 对话记忆管理
|
||||||
6. **GroupManageAgent** - 群管理功能(如禁言)
|
6. **LoadSkill/SaveSkill/DeleteSkill** - 技能管理(加载、沉淀/迭代、删除全局技能)
|
||||||
7. **SendSingleMessage/CompositeMessage** - 发送消息
|
7. **GroupManageAgent** - 群管理功能(如禁言)
|
||||||
8. **SendVoiceMessage** - 发送语音消息
|
8. **SendSingleMessage/CompositeMessage** - 发送消息
|
||||||
9. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
|
9. **SendVoiceMessage** - 发送语音消息
|
||||||
10. **WeatherService** - 天气查询
|
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
|
||||||
11. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin)
|
11. **WeatherService** - 天气查询
|
||||||
|
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin)
|
||||||
|
|
||||||
## 用户画像系统
|
## 用户画像系统
|
||||||
|
|
||||||
@@ -315,6 +322,40 @@ JChatGPT 维护对每位用户的画像,由好感度、Bot 自定义代号、
|
|||||||
- `enableFavorabilitySystem` - 是否启用画像系统(默认:true)
|
- `enableFavorabilitySystem` - 是否启用画像系统(默认:true)
|
||||||
- `favorabilityBaseShiftSpeed` - 好感度每日基础偏移速度(点/天,默认:2.0)
|
- `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消耗统计
|
## Token消耗统计
|
||||||
|
|
||||||
JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消耗,提供多维度统计查询。
|
JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消耗,提供多维度统计查询。
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "top.jie65535.mirai"
|
group = "top.jie65535.mirai"
|
||||||
version = "1.11.0"
|
version = "1.12.0"
|
||||||
|
|
||||||
mirai {
|
mirai {
|
||||||
jvmTarget = JavaVersion.VERSION_11
|
jvmTarget = JavaVersion.VERSION_11
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ object JChatGPT : KotlinPlugin(
|
|||||||
JvmPluginDescription(
|
JvmPluginDescription(
|
||||||
id = "top.jie65535.mirai.JChatGPT",
|
id = "top.jie65535.mirai.JChatGPT",
|
||||||
name = "J ChatGPT",
|
name = "J ChatGPT",
|
||||||
version = "1.11.0",
|
version = "1.12.0",
|
||||||
) {
|
) {
|
||||||
author("jie65535")
|
author("jie65535")
|
||||||
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
|
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
|
||||||
@@ -82,6 +82,9 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug)
|
// 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug)
|
||||||
TokenUsageStore.init(dataFolder)
|
TokenUsageStore.init(dataFolder)
|
||||||
|
|
||||||
|
// 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群)
|
||||||
|
SkillStore.init(dataFolder)
|
||||||
|
|
||||||
// 设置Token
|
// 设置Token
|
||||||
LargeLanguageModels.reload()
|
LargeLanguageModels.reload()
|
||||||
|
|
||||||
@@ -116,8 +119,6 @@ object JChatGPT : KotlinPlugin(
|
|||||||
logger.info { "Plugin loaded" }
|
logger.info { "Plugin loaded" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
|
||||||
.withZone(ZoneOffset.systemDefault())
|
|
||||||
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
|
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
|
||||||
|
|
||||||
private val requestMap = ConcurrentSet<Long>()
|
private val requestMap = ConcurrentSet<Long>()
|
||||||
@@ -141,13 +142,56 @@ object JChatGPT : KotlinPlugin(
|
|||||||
*/
|
*/
|
||||||
private data class ConversationCache(
|
private data class ConversationCache(
|
||||||
val history: MutableList<ChatMessage>,
|
val history: MutableList<ChatMessage>,
|
||||||
val lastActivityAt: Int
|
val lastActivityAt: Int,
|
||||||
|
val replyIndex: ReplyIndex
|
||||||
) {
|
) {
|
||||||
fun isExpired(ttlSeconds: Int): Boolean {
|
fun isExpired(ttlSeconds: Int): Boolean {
|
||||||
return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds
|
return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回复索引:每个会话(subject)在一次对话期间维护一份「短编号 -> 消息记录」映射,
|
||||||
|
* 让 LLM 能用历史里每行行首的 [n] 来引用回复某条消息。
|
||||||
|
* 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。
|
||||||
|
*/
|
||||||
|
class ReplyIndex {
|
||||||
|
private val byIndex = LinkedHashMap<Int, MessageRecord>()
|
||||||
|
private val indexByIds = HashMap<String, Int>()
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
|
fun add(record: MessageRecord): Int {
|
||||||
|
// ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号
|
||||||
|
val ids = record.ids
|
||||||
|
if (ids != null) {
|
||||||
|
indexByIds[ids]?.let { return it }
|
||||||
|
}
|
||||||
|
val i = ++counter
|
||||||
|
byIndex[i] = record
|
||||||
|
if (ids != null) {
|
||||||
|
indexByIds[ids] = i
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(index: Int): MessageRecord? = byIndex[index]
|
||||||
|
fun indexOfIds(ids: String): Int? = indexByIds[ids]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各会话的回复索引,startChat 开始时重建,结束时清理 */
|
||||||
|
private val replyIndexMap = ConcurrentMap<Long, ReplyIndex>()
|
||||||
|
|
||||||
|
/** 供发言工具按编号查找被引用的历史消息 */
|
||||||
|
internal fun lookupReplyTarget(subjectId: Long, index: Int): MessageRecord? =
|
||||||
|
replyIndexMap[subjectId]?.get(index)
|
||||||
|
|
||||||
|
private val shortTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
.withZone(ZoneOffset.systemDefault())
|
||||||
|
|
||||||
|
// 同一发言者连续消息默认省略时间以节省上下文;但间隔超过此阈值(秒)时仍补回时间,
|
||||||
|
// 避免模型把刚发的续行消息误判为很久以前发生。
|
||||||
|
private const val CONTINUATION_TIME_GAP_SECONDS = 60L
|
||||||
|
|
||||||
private suspend fun onMessage(event: MessageEvent) {
|
private suspend fun onMessage(event: MessageEvent) {
|
||||||
// 检查Token是否设置
|
// 检查Token是否设置
|
||||||
if (LargeLanguageModels.chat == null) return
|
if (LargeLanguageModels.chat == null) return
|
||||||
@@ -232,6 +276,12 @@ object JChatGPT : KotlinPlugin(
|
|||||||
} else memoryText
|
} else memoryText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replace("{skills}") {
|
||||||
|
if (PluginConfig.skillsEnabled) {
|
||||||
|
SkillStore.buildIndexPrompt()
|
||||||
|
} else "暂无技能"
|
||||||
|
}
|
||||||
|
|
||||||
replace("{meme}") {
|
replace("{meme}") {
|
||||||
memePrompt?.let { return@replace it }
|
memePrompt?.let { return@replace it }
|
||||||
|
|
||||||
@@ -319,6 +369,9 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 构造历史消息
|
// 构造历史消息
|
||||||
val historyText = StringBuilder()
|
val historyText = StringBuilder()
|
||||||
var lastId = 0L
|
var lastId = 0L
|
||||||
|
var lastTime = 0L
|
||||||
|
// 本轮回复索引,逐条登记消息编号供 [n] 引用
|
||||||
|
val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() }
|
||||||
if (event is GroupMessageEvent) {
|
if (event is GroupMessageEvent) {
|
||||||
if (PluginConfig.enableFavorabilitySystem) {
|
if (PluginConfig.enableFavorabilitySystem) {
|
||||||
val knownUsers = history.asSequence()
|
val knownUsers = history.asSequence()
|
||||||
@@ -345,11 +398,14 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine("## 近期群消息(更早已隐藏)")
|
historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
||||||
for (record in history) {
|
for (record in history) {
|
||||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||||
appendGroupMessageRecord(historyText, record, event, lastId != record.fromId)
|
val showSender = lastId != record.fromId
|
||||||
|
val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS
|
||||||
|
appendGroupMessageRecord(historyText, record, event, replyIndex, showSender, showTime)
|
||||||
lastId = record.fromId
|
lastId = record.fromId
|
||||||
|
lastTime = record.time.toLong()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (PluginConfig.enableFavorabilitySystem) {
|
if (PluginConfig.enableFavorabilitySystem) {
|
||||||
@@ -366,11 +422,14 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine("## 近期对话(更早已隐藏)")
|
historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
||||||
for (record in history) {
|
for (record in history) {
|
||||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||||
appendMessageRecord(historyText, record, event, lastId != record.fromId)
|
val showSender = lastId != record.fromId
|
||||||
|
val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS
|
||||||
|
appendMessageRecord(historyText, record, event, replyIndex, showSender, showTime)
|
||||||
lastId = record.fromId
|
lastId = record.fromId
|
||||||
|
lastTime = record.time.toLong()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,43 +446,79 @@ object JChatGPT : KotlinPlugin(
|
|||||||
historyText: StringBuilder,
|
historyText: StringBuilder,
|
||||||
record: MessageRecord,
|
record: MessageRecord,
|
||||||
event: GroupMessageEvent,
|
event: GroupMessageEvent,
|
||||||
|
replyIndex: ReplyIndex,
|
||||||
showSender: Boolean,
|
showSender: Boolean,
|
||||||
|
showTime: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val index = replyIndex.add(record)
|
||||||
|
val recordMessage = record.toMessageChain()
|
||||||
|
|
||||||
|
historyText.append('[').append(index).append("] ")
|
||||||
if (showSender) {
|
if (showSender) {
|
||||||
// 名字前空行
|
// 新发言者:[n] 名称 时间
|
||||||
historyText.appendLine()
|
|
||||||
// 名称显示
|
|
||||||
if (event.bot.id == record.fromId) {
|
if (event.bot.id == record.fromId) {
|
||||||
historyText.append("**你** " + getNameCard(event.subject.botAsMember))
|
historyText.append("**你** ").append(getNameCard(event.subject.botAsMember))
|
||||||
} else {
|
} else {
|
||||||
historyText.append(getNameCard(event.subject, record.fromId))
|
historyText.append(getNameCard(event.subject, record.fromId))
|
||||||
}
|
}
|
||||||
// 发言时间
|
|
||||||
historyText.append(' ')
|
historyText.append(' ')
|
||||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
|
.append(' ')
|
||||||
|
} else {
|
||||||
|
// 同一发言者续行;间隔过久则补回时间,避免被误判为很久以前发生
|
||||||
|
historyText.append(" └ ")
|
||||||
|
if (showTime) {
|
||||||
|
historyText.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
|
.append(' ')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 引用:用编号指针替代内联原文,避免被误认为是本人发言
|
||||||
val recordMessage = record.toMessageChain()
|
|
||||||
recordMessage[QuoteReply.Key]?.let {
|
recordMessage[QuoteReply.Key]?.let {
|
||||||
historyText.append(" 引用 ${getNameCard(event.subject, it.source.fromId)} 说的\n > ")
|
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
||||||
.appendLine(it.source.originalMessage.content.replace("\n", "\n > "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSender) {
|
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
||||||
// 消息内容
|
|
||||||
historyText.append(" 说:")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine(record.toMessageChain().joinToString("") {
|
/**
|
||||||
|
* 序列化「引用回复」标记:被引用消息在窗口内时用 ↩[编号],否则内联简短原文并标注原作者。
|
||||||
|
*/
|
||||||
|
private fun appendQuoteMarker(
|
||||||
|
sb: StringBuilder,
|
||||||
|
quote: QuoteReply,
|
||||||
|
contact: Contact,
|
||||||
|
replyIndex: ReplyIndex
|
||||||
|
) {
|
||||||
|
val srcIds = quote.source.ids.joinToString(",")
|
||||||
|
val idx = replyIndex.indexOfIds(srcIds)
|
||||||
|
if (idx != null) {
|
||||||
|
sb.append("↩[").append(idx).append("] ")
|
||||||
|
} else {
|
||||||
|
val author = if (contact is Group) {
|
||||||
|
contact[quote.source.fromId]?.nameCardOrNick ?: "未知(${quote.source.fromId})"
|
||||||
|
} else {
|
||||||
|
quote.source.fromId.toString()
|
||||||
|
}
|
||||||
|
val snippet = quote.source.originalMessage
|
||||||
|
.joinToString("", transform = ::singleMessageToText)
|
||||||
|
.replace("\n", " ")
|
||||||
|
.let { if (it.length > 20) it.take(20) + "…" else it }
|
||||||
|
sb.append("↩(").append(author).append(":\"").append(snippet).append("\") ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化消息正文(剔除引用/源元数据,@显示为名称,转发折叠)。
|
||||||
|
*/
|
||||||
|
private fun formatRecordContent(chain: MessageChain, contact: Contact): String =
|
||||||
|
chain.asSequence()
|
||||||
|
.filterNot { it is QuoteReply || it is MessageSource }
|
||||||
|
.joinToString("") {
|
||||||
when (it) {
|
when (it) {
|
||||||
is At -> {
|
is At -> if (contact is Group) it.getDisplay(contact) else it.content
|
||||||
it.getDisplay(event.subject)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> singleMessageToText(it)
|
else -> singleMessageToText(it)
|
||||||
}
|
}
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getNameCard(group: Group, qq: Long): String {
|
private fun getNameCard(group: Group, qq: Long): String {
|
||||||
@@ -445,42 +540,43 @@ object JChatGPT : KotlinPlugin(
|
|||||||
historyText: StringBuilder,
|
historyText: StringBuilder,
|
||||||
record: MessageRecord,
|
record: MessageRecord,
|
||||||
event: MessageEvent,
|
event: MessageEvent,
|
||||||
showSender: Boolean
|
replyIndex: ReplyIndex,
|
||||||
|
showSender: Boolean,
|
||||||
|
showTime: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val index = replyIndex.add(record)
|
||||||
|
val recordMessage = record.toMessageChain()
|
||||||
|
|
||||||
|
historyText.append('[').append(index).append("] ")
|
||||||
if (showSender) {
|
if (showSender) {
|
||||||
if (event.bot.id == record.fromId) {
|
if (event.bot.id == record.fromId) {
|
||||||
historyText.append("**你** " + event.bot.nameCardOrNick)
|
historyText.append("**你** ").append(event.bot.nameCardOrNick)
|
||||||
} else {
|
} else {
|
||||||
historyText.append(event.senderName)
|
historyText.append(event.senderName)
|
||||||
}
|
}
|
||||||
historyText
|
historyText.append(' ')
|
||||||
.append(" ")
|
.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
// 发言时间
|
.append(' ')
|
||||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
} else {
|
||||||
|
// 同一发言者续行;间隔过久则补回时间,避免被误判为很久以前发生
|
||||||
|
historyText.append(" └ ")
|
||||||
|
if (showTime) {
|
||||||
|
historyText.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
|
.append(' ')
|
||||||
}
|
}
|
||||||
val recordMessage = record.toMessageChain()
|
}
|
||||||
|
|
||||||
recordMessage[QuoteReply.Key]?.let {
|
recordMessage[QuoteReply.Key]?.let {
|
||||||
historyText.append(" 引用\n > ")
|
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
||||||
.appendLine(
|
|
||||||
it.source.originalMessage
|
|
||||||
.joinToString("", transform = ::singleMessageToText)
|
|
||||||
.replace("\n", "\n > ")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (showSender) {
|
|
||||||
historyText.append(" 说:")
|
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
||||||
}
|
|
||||||
// 消息内容
|
|
||||||
historyText.appendLine(
|
|
||||||
record.toMessageChain().joinToString("", transform = ::singleMessageToText)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun singleMessageToText(it: SingleMessage): String {
|
private fun singleMessageToText(it: SingleMessage): String {
|
||||||
return when (it) {
|
return when (it) {
|
||||||
is ForwardMessage -> {
|
// 完整展开合并转发内容,便于 LLM 阅读分析转发的对话(依赖大上下文+缓存,不做截断)
|
||||||
it.title + "\n " + it.preview
|
is ForwardMessage -> formatForward(it, 1)
|
||||||
}
|
|
||||||
|
|
||||||
// 图片格式化
|
// 图片格式化
|
||||||
is Image -> {
|
is Image -> {
|
||||||
@@ -499,6 +595,32 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归展开合并转发消息,用 Markdown 引用块表示:每加深一层嵌套多一个 `>`(>、>>、>>>…)。
|
||||||
|
* @param depth 当前嵌套层级,从 1 开始
|
||||||
|
*/
|
||||||
|
private fun formatForward(forward: ForwardMessage, depth: Int): String = buildString {
|
||||||
|
val quote = ">".repeat(depth) + " "
|
||||||
|
append("[转发消息·").append(forward.nodeList.size).append("条")
|
||||||
|
if (forward.title.isNotEmpty()) append(':').append(forward.title)
|
||||||
|
append(']')
|
||||||
|
for (node in forward.nodeList) {
|
||||||
|
append('\n').append(quote)
|
||||||
|
.append(node.senderName).append(' ')
|
||||||
|
.append(shortTimeFormatter.format(Instant.ofEpochSecond(node.time.toLong())))
|
||||||
|
.append(": ")
|
||||||
|
node.messageChain.forEach { sub ->
|
||||||
|
if (sub is ForwardMessage) {
|
||||||
|
// 嵌套转发:层级加深,自带更深的 `>` 前缀,无需再次缩进
|
||||||
|
append(formatForward(sub, depth + 1))
|
||||||
|
} else {
|
||||||
|
// 其它内容:多行正文对齐到当前引用层级
|
||||||
|
append(singleMessageToText(sub).replace("\n", "\n$quote"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// endregion - 历史消息相关 -
|
// endregion - 历史消息相关 -
|
||||||
|
|
||||||
private val thinkRegex = Regex("<think>[\\s\\S]*?</think>")
|
private val thinkRegex = Regex("<think>[\\s\\S]*?</think>")
|
||||||
@@ -524,12 +646,16 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 尝试从缓存加载上下文
|
// 尝试从缓存加载上下文
|
||||||
val subjectId = event.subject.id
|
val subjectId = event.subject.id
|
||||||
val cache = contextCache[subjectId]
|
val cache = contextCache[subjectId]
|
||||||
val history = if (PluginConfig.enableContextCache
|
val reuseCache = PluginConfig.enableContextCache
|
||||||
&& cache != null
|
&& cache != null
|
||||||
&& !cache.isExpired(PluginConfig.contextCacheTimeoutMinutes * 60)
|
&& !cache.isExpired(PluginConfig.contextCacheTimeoutMinutes * 60)
|
||||||
) {
|
// 回复索引与对话上下文同寿命:复用缓存时沿用旧索引,保证 LLM 看到的 [n] 编号连续不串号;
|
||||||
|
// 否则新建(供 sendSingleMessage 的 replyTo 按编号引用历史消息)
|
||||||
|
val replyIndex = if (reuseCache) cache!!.replyIndex else ReplyIndex()
|
||||||
|
replyIndexMap[subjectId] = replyIndex
|
||||||
|
val history = if (reuseCache) {
|
||||||
// 缓存有效,复用历史
|
// 缓存有效,复用历史
|
||||||
logger.info("使用缓存的对话上下文,包含 ${cache.history.size} 条互动消息")
|
logger.info("使用缓存的对话上下文,包含 ${cache!!.history.size} 条互动消息")
|
||||||
cache.history
|
cache.history
|
||||||
} else {
|
} else {
|
||||||
// 缓存无效或不存在,创建新上下文
|
// 缓存无效或不存在,创建新上下文
|
||||||
@@ -720,7 +846,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
if (PluginConfig.enableContextCache) {
|
if (PluginConfig.enableContextCache) {
|
||||||
contextCache[subjectId] = ConversationCache(
|
contextCache[subjectId] = ConversationCache(
|
||||||
history = history,
|
history = history,
|
||||||
lastActivityAt = startedAt
|
lastActivityAt = startedAt,
|
||||||
|
replyIndex = replyIndex
|
||||||
)
|
)
|
||||||
logger.debug("已保存对话上下文到缓存")
|
logger.debug("已保存对话上下文到缓存")
|
||||||
}
|
}
|
||||||
@@ -739,6 +866,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
||||||
} finally {
|
} finally {
|
||||||
|
// 清理本轮回复索引
|
||||||
|
replyIndexMap.remove(event.subject.id)
|
||||||
// 一段时间后才允许再次提问,防止高频对话
|
// 一段时间后才允许再次提问,防止高频对话
|
||||||
launch {
|
launch {
|
||||||
delay(500.milliseconds)
|
delay(500.milliseconds)
|
||||||
@@ -830,6 +959,15 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 记忆修改
|
// 记忆修改
|
||||||
MemoryReplace(),
|
MemoryReplace(),
|
||||||
|
|
||||||
|
// 技能:加载
|
||||||
|
LoadSkill(),
|
||||||
|
|
||||||
|
// 技能:沉淀/迭代
|
||||||
|
SaveSkill(),
|
||||||
|
|
||||||
|
// 技能:删除
|
||||||
|
DeleteSkill(),
|
||||||
|
|
||||||
// 搜索聊天历史
|
// 搜索聊天历史
|
||||||
SearchChatHistory(),
|
SearchChatHistory(),
|
||||||
|
|
||||||
@@ -907,21 +1045,29 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getNameCard(member: Member): String {
|
private fun getNameCard(member: Member): String {
|
||||||
val nameCard = StringBuilder()
|
val nameCard = StringBuilder("【")
|
||||||
// 群活跃等级
|
// 群活跃等级:active 依赖 OneBot 拉取群荣誉数据,繁忙/失败时会抛 "Error code: 2",
|
||||||
nameCard.append("【lv").append(member.active.temperature).append(" ")
|
// 必须兜底,否则整次回复都会因取名片失败而中断。
|
||||||
try {
|
try {
|
||||||
// 群头衔
|
nameCard.append("lv").append(member.active.temperature).append(' ')
|
||||||
if (member.specialTitle.isNotEmpty()) {
|
} catch (e: Throwable) {
|
||||||
nameCard.append(member.specialTitle)
|
logger.warning("获取群活跃等级失败", e)
|
||||||
} else {
|
}
|
||||||
|
// 真实群身份:始终按实际权限显示,不会被专属头衔覆盖
|
||||||
nameCard.append(
|
nameCard.append(
|
||||||
when (member.permission) {
|
when (member.permission) {
|
||||||
OWNER -> "群主"
|
OWNER -> "群主"
|
||||||
ADMINISTRATOR -> "管理员"
|
ADMINISTRATOR -> "管理员"
|
||||||
MEMBER -> member.temperatureTitle
|
MEMBER -> "群员"
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
// 头衔:有专属头衔则显示专属头衔(群主可任意赋予,可能与真实身份不符,故标注"头衔"以区分),
|
||||||
|
// 否则回退到聊天窗口可见的活跃等级称号
|
||||||
|
try {
|
||||||
|
if (member.specialTitle.isNotEmpty()) {
|
||||||
|
nameCard.append(" 头衔\"").append(member.specialTitle).append('"')
|
||||||
|
} else if (member.temperatureTitle.isNotEmpty()) {
|
||||||
|
nameCard.append(' ').append(member.temperatureTitle)
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
logger.warning("获取群头衔失败", e)
|
logger.warning("获取群头衔失败", e)
|
||||||
|
|||||||
@@ -28,8 +28,9 @@ class ModelService(
|
|||||||
val httpClient: HttpClient by lazy {
|
val httpClient: HttpClient by lazy {
|
||||||
HttpClient(OkHttp) {
|
HttpClient(OkHttp) {
|
||||||
install(HttpTimeout) {
|
install(HttpTimeout) {
|
||||||
// 总请求/socket 超时保持长值,允许慢速流式输出;连接握手则用短超时。
|
// 流式响应的「首 token」与「token 间隔」超时统一由应用层 withTimeout 管控(见 chatCompletions)。
|
||||||
requestTimeoutMillis = timeout.inWholeMilliseconds
|
// 这里特意不设 requestTimeoutMillis:否则正常但耗时较长的流式输出会被 Ktor 在中途整体掐断。
|
||||||
|
// socket 超时作为字节级兜底,连接超时只覆盖 TCP 握手。
|
||||||
socketTimeoutMillis = timeout.inWholeMilliseconds
|
socketTimeoutMillis = timeout.inWholeMilliseconds
|
||||||
connectTimeoutMillis = firstChunkTimeout.inWholeMilliseconds
|
connectTimeoutMillis = firstChunkTimeout.inWholeMilliseconds
|
||||||
}
|
}
|
||||||
@@ -57,7 +58,16 @@ class ModelService(
|
|||||||
val body = JsonObject(requestJson).toString()
|
val body = JsonObject(requestJson).toString()
|
||||||
|
|
||||||
return flow {
|
return flow {
|
||||||
httpClient.post("chat/completions") {
|
// 关键:服务器繁忙时会拖住「响应头」,使 httpClient.post() 自身阻塞在等待响应的阶段,
|
||||||
|
// 因此必须把 post() 连同首个 data 块的读取一起包进 withTimeout。
|
||||||
|
// 否则首 token 超时永远不会触发(post() 还没返回,根本进不到读取循环),
|
||||||
|
// 只能落到 Ktor 的兜底超时(很久)后再重试,表现为「等很久才报异常」。
|
||||||
|
// channel 在 withTimeout 外层持有:哪怕首块读取在 withTimeout 内超时,
|
||||||
|
// 只要 response.body() 已拿到通道,finally 也能释放它,避免慢速 API 重试时连接泄漏。
|
||||||
|
var channel: ByteReadChannel? = null
|
||||||
|
try {
|
||||||
|
val firstDataLine = withTimeout(firstChunkTimeout) {
|
||||||
|
val response = httpClient.post("chat/completions") {
|
||||||
setBody(body)
|
setBody(body)
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
accept(ContentType.Text.EventStream)
|
accept(ContentType.Text.EventStream)
|
||||||
@@ -65,15 +75,12 @@ class ModelService(
|
|||||||
append(HttpHeaders.CacheControl, "no-cache")
|
append(HttpHeaders.CacheControl, "no-cache")
|
||||||
append(HttpHeaders.Connection, "keep-alive")
|
append(HttpHeaders.Connection, "keep-alive")
|
||||||
}
|
}
|
||||||
}.let { response ->
|
}
|
||||||
val channel: ByteReadChannel = response.body()
|
val ch: ByteReadChannel = response.body()
|
||||||
try {
|
channel = ch
|
||||||
// 首块 data: 必须在 firstChunkTimeout 内到达,否则抛 TimeoutCancellationException
|
|
||||||
// 走 JChatGPT 的重试流程;之后的流式读取不再有应用层超时,由 socketTimeoutMillis 兜底。
|
|
||||||
val firstDataLine: String? = withTimeout(firstChunkTimeout) {
|
|
||||||
var found: String? = null
|
var found: String? = null
|
||||||
while (currentCoroutineContext().isActive && !channel.isClosedForRead) {
|
while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
|
||||||
val line = channel.readUTF8Line() ?: continue
|
val line = ch.readUTF8Line() ?: continue
|
||||||
if (line.startsWith("data: ")) {
|
if (line.startsWith("data: ")) {
|
||||||
found = line
|
found = line
|
||||||
break
|
break
|
||||||
@@ -83,12 +90,14 @@ class ModelService(
|
|||||||
found
|
found
|
||||||
}
|
}
|
||||||
|
|
||||||
if (firstDataLine != null) {
|
if (firstDataLine != null && !firstDataLine.startsWith("data: [DONE]")) {
|
||||||
if (!firstDataLine.startsWith("data: [DONE]")) {
|
|
||||||
emit(json.decodeFromString(firstDataLine.removePrefix("data: ")))
|
emit(json.decodeFromString(firstDataLine.removePrefix("data: ")))
|
||||||
|
|
||||||
while (currentCoroutineContext().isActive && !channel.isClosedForRead) {
|
val ch = channel!!
|
||||||
val line = channel.readUTF8Line() ?: continue
|
while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
|
||||||
|
// 流式期间同样对每次读取设「token 间隔」超时,避免中途卡死后干等兜底超时,
|
||||||
|
// 从而能快速失败并交给上层重试。正常流式 token 间隔远小于 firstChunkTimeout。
|
||||||
|
val line = withTimeout(firstChunkTimeout) { ch.readUTF8Line() } ?: continue
|
||||||
when {
|
when {
|
||||||
line.startsWith("data: [DONE]") -> break
|
line.startsWith("data: [DONE]") -> break
|
||||||
line.startsWith("data: ") -> {
|
line.startsWith("data: ") -> {
|
||||||
@@ -98,10 +107,8 @@ class ModelService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
channel.cancel()
|
channel?.cancel()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,9 +21,24 @@ object PluginCommands : CompositeCommand(
|
|||||||
PluginConfig.reload()
|
PluginConfig.reload()
|
||||||
PluginData.reload()
|
PluginData.reload()
|
||||||
LargeLanguageModels.reload()
|
LargeLanguageModels.reload()
|
||||||
|
SkillStore.reload()
|
||||||
sendMessage("OK")
|
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
|
@SubCommand
|
||||||
suspend fun CommandSender.enable(contact: Contact) {
|
suspend fun CommandSender.enable(contact: Contact) {
|
||||||
when (contact) {
|
when (contact) {
|
||||||
|
|||||||
@@ -123,6 +123,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
|||||||
@ValueDescription("是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆")
|
@ValueDescription("是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆")
|
||||||
val memoryEnabled by value(true)
|
val memoryEnabled by value(true)
|
||||||
|
|
||||||
|
@ValueDescription("是否启用技能系统,技能存在data/skills目录(全局跨群),提示词中需要加上{skills}来注入技能索引")
|
||||||
|
val skillsEnabled by value(true)
|
||||||
|
|
||||||
@ValueDescription("是否启用好感度系统")
|
@ValueDescription("是否启用好感度系统")
|
||||||
val enableFavorabilitySystem by value(true)
|
val enableFavorabilitySystem by value(true)
|
||||||
|
|
||||||
|
|||||||
169
src/main/kotlin/SkillStore.kt
Normal file
169
src/main/kotlin/SkillStore.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/main/kotlin/tools/DeleteSkill.kt
Normal file
50
src/main/kotlin/tools/DeleteSkill.kt
Normal 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\" 不存在。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
src/main/kotlin/tools/LoadSkill.kt
Normal file
50
src/main/kotlin/tools/LoadSkill.kt
Normal 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\" 不存在,可用技能见系统提示词中的技能索引。"
|
||||||
|
}
|
||||||
|
}
|
||||||
62
src/main/kotlin/tools/SaveSkill.kt
Normal file
62
src/main/kotlin/tools/SaveSkill.kt
Normal 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\" 已保存。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import com.aallam.openai.api.chat.Tool
|
|||||||
import com.aallam.openai.api.core.Parameters
|
import com.aallam.openai.api.core.Parameters
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
import net.mamoe.mirai.event.events.MessageEvent
|
import net.mamoe.mirai.event.events.MessageEvent
|
||||||
|
import net.mamoe.mirai.message.data.Message
|
||||||
|
import net.mamoe.mirai.message.data.QuoteReply
|
||||||
import top.jie65535.mirai.JChatGPT
|
import top.jie65535.mirai.JChatGPT
|
||||||
|
|
||||||
class SendSingleMessageAgent : BaseAgent(
|
class SendSingleMessageAgent : BaseAgent(
|
||||||
@@ -17,6 +19,10 @@ class SendSingleMessageAgent : BaseAgent(
|
|||||||
put("type", "string")
|
put("type", "string")
|
||||||
put("description", "消息内容")
|
put("description", "消息内容")
|
||||||
}
|
}
|
||||||
|
putJsonObject("replyTo") {
|
||||||
|
put("type", "integer")
|
||||||
|
put("description", "可选。要引用回复的历史消息编号(即历史记录中每行行首的[n])。不需要回复具体某条消息时省略此参数。")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
putJsonArray("required") {
|
putJsonArray("required") {
|
||||||
add("content")
|
add("content")
|
||||||
@@ -27,7 +33,28 @@ class SendSingleMessageAgent : BaseAgent(
|
|||||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||||
requireNotNull(args)
|
requireNotNull(args)
|
||||||
val content = args.getValue("content").jsonPrimitive.content
|
val content = args.getValue("content").jsonPrimitive.content
|
||||||
event.subject.sendMessage(JChatGPT.toMessage(event.subject, content))
|
val replyTo = args["replyTo"]?.jsonPrimitive?.intOrNull
|
||||||
return "OK"
|
|
||||||
|
val baseMsg = JChatGPT.toMessage(event.subject, content)
|
||||||
|
var note = ""
|
||||||
|
val message: Message = if (replyTo != null) {
|
||||||
|
val record = JChatGPT.lookupReplyTarget(event.subject.id, replyTo)
|
||||||
|
val source = try {
|
||||||
|
record?.toMessageSource()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (source != null) {
|
||||||
|
QuoteReply(source) + baseMsg
|
||||||
|
} else {
|
||||||
|
note = "(编号${replyTo}对应的消息已失效,未能引用,已直接发送)"
|
||||||
|
baseMsg
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
event.subject.sendMessage(message)
|
||||||
|
return "OK$note"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user