Compare commits

...

8 Commits

Author SHA1 Message Date
cfc61c52ba Re-attach timestamp on continuation lines after a time gap
Consecutive messages from the same sender are collapsed under one
name+time header to save context, with follow-ups rendered as bare
"└" continuation lines. When a sender resumed minutes later, the
follow-up still inherited the original timestamp, so the model judged a
just-sent message as having happened long ago and skipped replying.

Track the previous record time and re-print the timestamp on a
continuation line once the gap exceeds CONTINUATION_TIME_GAP_SECONDS
(60s). Short bursts stay timeless to keep context lean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:24:21 +08:00
ebb1fbe10c Guard group active-level fetch in name card serialization
member.active.temperature relies on OneBot pulling group honor data,
which throws "Error code: 2" when the upstream is busy/unavailable. That
access sat outside the existing title try/catch, so a failure propagated
through getSystemPrompt -> onMessage and aborted the entire reply.

Wrap the active.temperature access in its own try/catch and degrade
gracefully (omit the lv segment) instead of breaking the whole turn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:15:34 +08:00
a29cf17361 Release ByteReadChannel when first-chunk timeout fires
The streaming refactor moved post()+body() inside withTimeout, so a
first-chunk timeout threw before `channel` was bound and the finally
guard never ran, leaking the connection on every slow-API retry. Hold
the channel in an outer nullable var and wrap the whole flow in
try/finally so an acquired channel is always cancelled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:56:11 +08:00
4307019ee8 Add global skill system for self-accumulated knowledge
Introduce a cross-group skill system that lets the bot distill reusable
knowledge into markdown docs and load them on demand, keeping day-to-day
context pollution low.

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:37:44 +08:00
538fe563a0 Expand forwarded messages into context with nested support
Forwarded messages were collapsed to a placeholder, so the LLM couldn't
read a forwarded conversation a user asked it to look at. Now forwards
are fully expanded (no truncation, relying on the large context window
and cache hits) as Markdown blockquotes, with each nesting level adding
another ">". Nested forwards recurse by depth for clean, unambiguous
indentation; node bodies (including images) render inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:03:26 +08:00
890ccb10d5 Separate real group role from title in serialized name card
A custom special title (e.g. a troll "群主" given to a regular member)
used to override the permission-based role, misleading the LLM about
who actually holds authority. Now the real role (群主/管理员/群员) from
member.permission is always shown, with the title slot showing the
special title (labeled 头衔"…") when present, otherwise falling back to
the activity-level temperatureTitle that everyone sees in chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:57:52 +08:00
d2bdd273b2 Add reply-to-message capability with ID-addressable history
Lets the bot quote-reply to a specific message via an optional replyTo
on sendSingleMessage, and reworks history serialization so the LLM can
address messages and stop confusing quoted content with the quoter.

- Serialize each history line with a short [n] id; consecutive messages
  from the same sender continue under "[n]  └". A per-subject ReplyIndex
  maps [n] -> MessageRecord, kept alive with the context cache so ids
  stay continuous across cached turns.
- Replace inlined quote text with a reference: "↩[k]" when the quoted
  message is in-window, otherwise "↩(author:"snippet…")". This removes
  the ambiguity where A quoting B looked like A's own speech.
- Collapse forwarded messages to "[转发消息·N条:title]".
- sendSingleMessage accepts replyTo (the [n]); it resolves the record via
  MessageRecord.toMessageSource() and prepends a QuoteReply, falling back
  to a plain send with a note if the source is gone. ids may be null, so
  numbering still happens but such records can't be reply targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:46:59 +08:00
eedbd55f62 Fix streaming first-token timeout never firing on slow API
The firstChunkTimeout only wrapped the response-body read, but when the
upstream (e.g. DeepSeek under load) stalls before sending response
headers, httpClient.post() itself blocks and the withTimeout block is
never reached. Every slow request fell through to Ktor's
requestTimeoutMillis (120s) and was retried up to retryMax times,
causing multi-minute waits before any reply.

- Move post() inside withTimeout(firstChunkTimeout) so the entire
  request-to-first-data-chunk window is bounded and fails fast.
- Apply withTimeout(firstChunkTimeout) to each streaming read so a
  mid-stream stall is also caught quickly instead of waiting on the
  socket/request backstop.
- Drop requestTimeoutMillis so legitimately long streams are no longer
  killed at 120s; TTFT and inter-token gaps are now governed at the
  application layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:01:44 +08:00
11 changed files with 700 additions and 130 deletions

View File

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

View File

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

View File

@@ -52,7 +52,7 @@ object JChatGPT : KotlinPlugin(
JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT",
version = "1.11.0",
version = "1.12.0",
) {
author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
@@ -82,6 +82,9 @@ object JChatGPT : KotlinPlugin(
// 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug
TokenUsageStore.init(dataFolder)
// 初始化技能存储data/skills/ 下的 markdown 文件,全局跨群)
SkillStore.init(dataFolder)
// 设置Token
LargeLanguageModels.reload()
@@ -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)

View File

@@ -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()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"
}
}
}