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 自定义代号
|
||||
- **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 消耗,提供多维度统计查询。
|
||||
|
||||
@@ -7,7 +7,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "top.jie65535.mirai"
|
||||
version = "1.11.0"
|
||||
version = "1.12.0"
|
||||
|
||||
mirai {
|
||||
jvmTarget = JavaVersion.VERSION_11
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -116,8 +119,6 @@ object JChatGPT : KotlinPlugin(
|
||||
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 requestMap = ConcurrentSet<Long>()
|
||||
@@ -141,13 +142,56 @@ object JChatGPT : KotlinPlugin(
|
||||
*/
|
||||
private data class ConversationCache(
|
||||
val history: MutableList<ChatMessage>,
|
||||
val lastActivityAt: Int
|
||||
val lastActivityAt: Int,
|
||||
val replyIndex: ReplyIndex
|
||||
) {
|
||||
fun isExpired(ttlSeconds: Int): Boolean {
|
||||
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) {
|
||||
// 检查Token是否设置
|
||||
if (LargeLanguageModels.chat == null) return
|
||||
@@ -232,6 +276,12 @@ object JChatGPT : KotlinPlugin(
|
||||
} else memoryText
|
||||
}
|
||||
|
||||
replace("{skills}") {
|
||||
if (PluginConfig.skillsEnabled) {
|
||||
SkillStore.buildIndexPrompt()
|
||||
} else "暂无技能"
|
||||
}
|
||||
|
||||
replace("{meme}") {
|
||||
memePrompt?.let { return@replace it }
|
||||
|
||||
@@ -319,6 +369,9 @@ object JChatGPT : KotlinPlugin(
|
||||
// 构造历史消息
|
||||
val historyText = StringBuilder()
|
||||
var lastId = 0L
|
||||
var lastTime = 0L
|
||||
// 本轮回复索引,逐条登记消息编号供 [n] 引用
|
||||
val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() }
|
||||
if (event is GroupMessageEvent) {
|
||||
if (PluginConfig.enableFavorabilitySystem) {
|
||||
val knownUsers = history.asSequence()
|
||||
@@ -345,11 +398,14 @@ object JChatGPT : KotlinPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
historyText.appendLine("## 近期群消息(更早已隐藏)")
|
||||
historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
||||
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
|
||||
lastTime = record.time.toLong()
|
||||
}
|
||||
} else {
|
||||
if (PluginConfig.enableFavorabilitySystem) {
|
||||
@@ -366,11 +422,14 @@ object JChatGPT : KotlinPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
historyText.appendLine("## 近期对话(更早已隐藏)")
|
||||
historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
||||
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
|
||||
lastTime = record.time.toLong()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,45 +446,81 @@ object JChatGPT : KotlinPlugin(
|
||||
historyText: StringBuilder,
|
||||
record: MessageRecord,
|
||||
event: GroupMessageEvent,
|
||||
replyIndex: ReplyIndex,
|
||||
showSender: Boolean,
|
||||
showTime: Boolean,
|
||||
) {
|
||||
val index = replyIndex.add(record)
|
||||
val recordMessage = record.toMessageChain()
|
||||
|
||||
historyText.append('[').append(index).append("] ")
|
||||
if (showSender) {
|
||||
// 名字前空行
|
||||
historyText.appendLine()
|
||||
// 名称显示
|
||||
// 新发言者:[n] 名称 时间
|
||||
if (event.bot.id == record.fromId) {
|
||||
historyText.append("**你** " + getNameCard(event.subject.botAsMember))
|
||||
historyText.append("**你** ").append(getNameCard(event.subject.botAsMember))
|
||||
} else {
|
||||
historyText.append(getNameCard(event.subject, record.fromId))
|
||||
}
|
||||
// 发言时间
|
||||
historyText.append(' ')
|
||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||
}
|
||||
|
||||
|
||||
val recordMessage = record.toMessageChain()
|
||||
recordMessage[QuoteReply.Key]?.let {
|
||||
historyText.append(" 引用 ${getNameCard(event.subject, it.source.fromId)} 说的\n > ")
|
||||
.appendLine(it.source.originalMessage.content.replace("\n", "\n > "))
|
||||
}
|
||||
|
||||
if (showSender) {
|
||||
// 消息内容
|
||||
historyText.append(" 说:")
|
||||
}
|
||||
|
||||
historyText.appendLine(record.toMessageChain().joinToString("") {
|
||||
when (it) {
|
||||
is At -> {
|
||||
it.getDisplay(event.subject)
|
||||
}
|
||||
|
||||
else -> singleMessageToText(it)
|
||||
.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||
.append(' ')
|
||||
} else {
|
||||
// 同一发言者续行;间隔过久则补回时间,避免被误判为很久以前发生
|
||||
historyText.append(" └ ")
|
||||
if (showTime) {
|
||||
historyText.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||
.append(' ')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 引用:用编号指针替代内联原文,避免被误认为是本人发言
|
||||
recordMessage[QuoteReply.Key]?.let {
|
||||
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
||||
}
|
||||
|
||||
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
||||
}
|
||||
|
||||
/**
|
||||
* 序列化「引用回复」标记:被引用消息在窗口内时用 ↩[编号],否则内联简短原文并标注原作者。
|
||||
*/
|
||||
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) {
|
||||
is At -> if (contact is Group) it.getDisplay(contact) else it.content
|
||||
else -> singleMessageToText(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNameCard(group: Group, qq: Long): String {
|
||||
val member = group[qq]
|
||||
return if (member == null) {
|
||||
@@ -445,42 +540,43 @@ object JChatGPT : KotlinPlugin(
|
||||
historyText: StringBuilder,
|
||||
record: MessageRecord,
|
||||
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 (event.bot.id == record.fromId) {
|
||||
historyText.append("**你** " + event.bot.nameCardOrNick)
|
||||
historyText.append("**你** ").append(event.bot.nameCardOrNick)
|
||||
} else {
|
||||
historyText.append(event.senderName)
|
||||
}
|
||||
historyText
|
||||
.append(" ")
|
||||
// 发言时间
|
||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||
historyText.append(' ')
|
||||
.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 {
|
||||
historyText.append(" 引用\n > ")
|
||||
.appendLine(
|
||||
it.source.originalMessage
|
||||
.joinToString("", transform = ::singleMessageToText)
|
||||
.replace("\n", "\n > ")
|
||||
)
|
||||
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
||||
}
|
||||
if (showSender) {
|
||||
historyText.append(" 说:")
|
||||
}
|
||||
// 消息内容
|
||||
historyText.appendLine(
|
||||
record.toMessageChain().joinToString("", transform = ::singleMessageToText)
|
||||
)
|
||||
|
||||
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
||||
}
|
||||
|
||||
private fun singleMessageToText(it: SingleMessage): String {
|
||||
return when (it) {
|
||||
is ForwardMessage -> {
|
||||
it.title + "\n " + it.preview
|
||||
}
|
||||
// 完整展开合并转发内容,便于 LLM 阅读分析转发的对话(依赖大上下文+缓存,不做截断)
|
||||
is ForwardMessage -> formatForward(it, 1)
|
||||
|
||||
// 图片格式化
|
||||
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 - 历史消息相关 -
|
||||
|
||||
private val thinkRegex = Regex("<think>[\\s\\S]*?</think>")
|
||||
@@ -524,12 +646,16 @@ object JChatGPT : KotlinPlugin(
|
||||
// 尝试从缓存加载上下文
|
||||
val subjectId = event.subject.id
|
||||
val cache = contextCache[subjectId]
|
||||
val history = if (PluginConfig.enableContextCache
|
||||
val reuseCache = PluginConfig.enableContextCache
|
||||
&& cache != null
|
||||
&& !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
|
||||
} else {
|
||||
// 缓存无效或不存在,创建新上下文
|
||||
@@ -720,7 +846,8 @@ object JChatGPT : KotlinPlugin(
|
||||
if (PluginConfig.enableContextCache) {
|
||||
contextCache[subjectId] = ConversationCache(
|
||||
history = history,
|
||||
lastActivityAt = startedAt
|
||||
lastActivityAt = startedAt,
|
||||
replyIndex = replyIndex
|
||||
)
|
||||
logger.debug("已保存对话上下文到缓存")
|
||||
}
|
||||
@@ -739,6 +866,8 @@ object JChatGPT : KotlinPlugin(
|
||||
logger.warning(ex)
|
||||
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
||||
} finally {
|
||||
// 清理本轮回复索引
|
||||
replyIndexMap.remove(event.subject.id)
|
||||
// 一段时间后才允许再次提问,防止高频对话
|
||||
launch {
|
||||
delay(500.milliseconds)
|
||||
@@ -830,6 +959,15 @@ object JChatGPT : KotlinPlugin(
|
||||
// 记忆修改
|
||||
MemoryReplace(),
|
||||
|
||||
// 技能:加载
|
||||
LoadSkill(),
|
||||
|
||||
// 技能:沉淀/迭代
|
||||
SaveSkill(),
|
||||
|
||||
// 技能:删除
|
||||
DeleteSkill(),
|
||||
|
||||
// 搜索聊天历史
|
||||
SearchChatHistory(),
|
||||
|
||||
@@ -907,21 +1045,29 @@ object JChatGPT : KotlinPlugin(
|
||||
}
|
||||
|
||||
private fun getNameCard(member: Member): String {
|
||||
val nameCard = StringBuilder()
|
||||
// 群活跃等级
|
||||
nameCard.append("【lv").append(member.active.temperature).append(" ")
|
||||
val nameCard = StringBuilder("【")
|
||||
// 群活跃等级:active 依赖 OneBot 拉取群荣誉数据,繁忙/失败时会抛 "Error code: 2",
|
||||
// 必须兜底,否则整次回复都会因取名片失败而中断。
|
||||
try {
|
||||
nameCard.append("lv").append(member.active.temperature).append(' ')
|
||||
} catch (e: Throwable) {
|
||||
logger.warning("获取群活跃等级失败", e)
|
||||
}
|
||||
// 真实群身份:始终按实际权限显示,不会被专属头衔覆盖
|
||||
nameCard.append(
|
||||
when (member.permission) {
|
||||
OWNER -> "群主"
|
||||
ADMINISTRATOR -> "管理员"
|
||||
MEMBER -> "群员"
|
||||
}
|
||||
)
|
||||
// 头衔:有专属头衔则显示专属头衔(群主可任意赋予,可能与真实身份不符,故标注"头衔"以区分),
|
||||
// 否则回退到聊天窗口可见的活跃等级称号
|
||||
try {
|
||||
// 群头衔
|
||||
if (member.specialTitle.isNotEmpty()) {
|
||||
nameCard.append(member.specialTitle)
|
||||
} else {
|
||||
nameCard.append(
|
||||
when (member.permission) {
|
||||
OWNER -> "群主"
|
||||
ADMINISTRATOR -> "管理员"
|
||||
MEMBER -> member.temperatureTitle
|
||||
}
|
||||
)
|
||||
nameCard.append(" 头衔\"").append(member.specialTitle).append('"')
|
||||
} else if (member.temperatureTitle.isNotEmpty()) {
|
||||
nameCard.append(' ').append(member.temperatureTitle)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logger.warning("获取群头衔失败", e)
|
||||
|
||||
@@ -28,8 +28,9 @@ class ModelService(
|
||||
val httpClient: HttpClient by lazy {
|
||||
HttpClient(OkHttp) {
|
||||
install(HttpTimeout) {
|
||||
// 总请求/socket 超时保持长值,允许慢速流式输出;连接握手则用短超时。
|
||||
requestTimeoutMillis = timeout.inWholeMilliseconds
|
||||
// 流式响应的「首 token」与「token 间隔」超时统一由应用层 withTimeout 管控(见 chatCompletions)。
|
||||
// 这里特意不设 requestTimeoutMillis:否则正常但耗时较长的流式输出会被 Ktor 在中途整体掐断。
|
||||
// socket 超时作为字节级兜底,连接超时只覆盖 TCP 握手。
|
||||
socketTimeoutMillis = timeout.inWholeMilliseconds
|
||||
connectTimeoutMillis = firstChunkTimeout.inWholeMilliseconds
|
||||
}
|
||||
@@ -57,51 +58,57 @@ class ModelService(
|
||||
val body = JsonObject(requestJson).toString()
|
||||
|
||||
return flow {
|
||||
httpClient.post("chat/completions") {
|
||||
setBody(body)
|
||||
contentType(ContentType.Application.Json)
|
||||
accept(ContentType.Text.EventStream)
|
||||
headers {
|
||||
append(HttpHeaders.CacheControl, "no-cache")
|
||||
append(HttpHeaders.Connection, "keep-alive")
|
||||
}
|
||||
}.let { response ->
|
||||
val channel: ByteReadChannel = response.body()
|
||||
try {
|
||||
// 首块 data: 必须在 firstChunkTimeout 内到达,否则抛 TimeoutCancellationException
|
||||
// 走 JChatGPT 的重试流程;之后的流式读取不再有应用层超时,由 socketTimeoutMillis 兜底。
|
||||
val firstDataLine: String? = withTimeout(firstChunkTimeout) {
|
||||
var found: String? = null
|
||||
while (currentCoroutineContext().isActive && !channel.isClosedForRead) {
|
||||
val line = channel.readUTF8Line() ?: continue
|
||||
if (line.startsWith("data: ")) {
|
||||
found = line
|
||||
break
|
||||
}
|
||||
// 心跳/空行/注释行,不计为首块,继续等
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
if (firstDataLine != null) {
|
||||
if (!firstDataLine.startsWith("data: [DONE]")) {
|
||||
emit(json.decodeFromString(firstDataLine.removePrefix("data: ")))
|
||||
|
||||
while (currentCoroutineContext().isActive && !channel.isClosedForRead) {
|
||||
val line = channel.readUTF8Line() ?: continue
|
||||
when {
|
||||
line.startsWith("data: [DONE]") -> break
|
||||
line.startsWith("data: ") -> {
|
||||
emit(json.decodeFromString(line.removePrefix("data: ")))
|
||||
}
|
||||
else -> continue
|
||||
}
|
||||
}
|
||||
// 关键:服务器繁忙时会拖住「响应头」,使 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)
|
||||
contentType(ContentType.Application.Json)
|
||||
accept(ContentType.Text.EventStream)
|
||||
headers {
|
||||
append(HttpHeaders.CacheControl, "no-cache")
|
||||
append(HttpHeaders.Connection, "keep-alive")
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
channel.cancel()
|
||||
val ch: ByteReadChannel = response.body()
|
||||
channel = ch
|
||||
var found: String? = null
|
||||
while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
|
||||
val line = ch.readUTF8Line() ?: continue
|
||||
if (line.startsWith("data: ")) {
|
||||
found = line
|
||||
break
|
||||
}
|
||||
// 心跳/空行/注释行,不计为首块,继续等
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
if (firstDataLine != null && !firstDataLine.startsWith("data: [DONE]")) {
|
||||
emit(json.decodeFromString(firstDataLine.removePrefix("data: ")))
|
||||
|
||||
val ch = channel!!
|
||||
while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
|
||||
// 流式期间同样对每次读取设「token 间隔」超时,避免中途卡死后干等兜底超时,
|
||||
// 从而能快速失败并交给上层重试。正常流式 token 间隔远小于 firstChunkTimeout。
|
||||
val line = withTimeout(firstChunkTimeout) { ch.readUTF8Line() } ?: continue
|
||||
when {
|
||||
line.startsWith("data: [DONE]") -> break
|
||||
line.startsWith("data: ") -> {
|
||||
emit(json.decodeFromString(line.removePrefix("data: ")))
|
||||
}
|
||||
else -> continue
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
channel?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
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 kotlinx.serialization.json.*
|
||||
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
|
||||
|
||||
class SendSingleMessageAgent : BaseAgent(
|
||||
@@ -17,6 +19,10 @@ class SendSingleMessageAgent : BaseAgent(
|
||||
put("type", "string")
|
||||
put("description", "消息内容")
|
||||
}
|
||||
putJsonObject("replyTo") {
|
||||
put("type", "integer")
|
||||
put("description", "可选。要引用回复的历史消息编号(即历史记录中每行行首的[n])。不需要回复具体某条消息时省略此参数。")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("content")
|
||||
@@ -27,7 +33,28 @@ class SendSingleMessageAgent : BaseAgent(
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val content = args.getValue("content").jsonPrimitive.content
|
||||
event.subject.sendMessage(JChatGPT.toMessage(event.subject, content))
|
||||
return "OK"
|
||||
val replyTo = args["replyTo"]?.jsonPrimitive?.intOrNull
|
||||
|
||||
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