From d2bdd273b259248ff2758b0c2c104387c788e175 Mon Sep 17 00:00:00 2001 From: jie65535 Date: Sat, 20 Jun 2026 22:46:59 +0800 Subject: [PATCH] Add reply-to-message capability with ID-addressable history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/main/kotlin/JChatGPT.kt | 183 ++++++++++++------ .../kotlin/tools/SendSingleMessageAgent.kt | 33 +++- 2 files changed, 158 insertions(+), 58 deletions(-) diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index a330ad6..bfabf90 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -116,8 +116,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() @@ -141,13 +139,52 @@ object JChatGPT : KotlinPlugin( */ private data class ConversationCache( val history: MutableList, - 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() + private val indexByIds = HashMap() + 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() + + /** 供发言工具按编号查找被引用的历史消息 */ + internal fun lookupReplyTarget(subjectId: Long, index: Int): MessageRecord? = + replyIndexMap[subjectId]?.get(index) + + private val shortTimeFormatter = DateTimeFormatter.ofPattern("HH:mm") + .withZone(ZoneOffset.systemDefault()) + private suspend fun onMessage(event: MessageEvent) { // 检查Token是否设置 if (LargeLanguageModels.chat == null) return @@ -319,6 +356,8 @@ object JChatGPT : KotlinPlugin( // 构造历史消息 val historyText = StringBuilder() var lastId = 0L + // 本轮回复索引,逐条登记消息编号供 [n] 引用 + val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() } if (event is GroupMessageEvent) { if (PluginConfig.enableFavorabilitySystem) { val knownUsers = history.asSequence() @@ -345,10 +384,10 @@ object JChatGPT : KotlinPlugin( } } - historyText.appendLine("## 近期群消息(更早已隐藏)") + historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号,可用于引用回复)") for (record in history) { // 同一人发言不要反复出现这人的名字,减少上下文 - appendGroupMessageRecord(historyText, record, event, lastId != record.fromId) + appendGroupMessageRecord(historyText, record, event, replyIndex, lastId != record.fromId) lastId = record.fromId } } else { @@ -366,10 +405,10 @@ object JChatGPT : KotlinPlugin( } } - historyText.appendLine("## 近期对话(更早已隐藏)") + historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号,可用于引用回复)") for (record in history) { // 同一人发言不要反复出现这人的名字,减少上下文 - appendMessageRecord(historyText, record, event, lastId != record.fromId) + appendMessageRecord(historyText, record, event, replyIndex, lastId != record.fromId) lastId = record.fromId } } @@ -387,45 +426,76 @@ object JChatGPT : KotlinPlugin( historyText: StringBuilder, record: MessageRecord, event: GroupMessageEvent, + replyIndex: ReplyIndex, showSender: 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()))) + .append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong()))) + .append(' ') + } else { + // 同一发言者续行 + historyText.append(" └ ") } - - 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 > ")) + appendQuoteMarker(historyText, it, event.subject, replyIndex) } - if (showSender) { - // 消息内容 - historyText.append(" 说:") - } - - historyText.appendLine(record.toMessageChain().joinToString("") { - when (it) { - is At -> { - it.getDisplay(event.subject) - } - - else -> singleMessageToText(it) - } - }) + 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,41 +515,37 @@ object JChatGPT : KotlinPlugin( historyText: StringBuilder, record: MessageRecord, event: MessageEvent, + replyIndex: ReplyIndex, showSender: 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(" └ ") } - 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 + "[转发消息·${it.nodeList.size}条:${it.title}]" } // 图片格式化 @@ -524,12 +590,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 +790,8 @@ object JChatGPT : KotlinPlugin( if (PluginConfig.enableContextCache) { contextCache[subjectId] = ConversationCache( history = history, - lastActivityAt = startedAt + lastActivityAt = startedAt, + replyIndex = replyIndex ) logger.debug("已保存对话上下文到缓存") } @@ -739,6 +810,8 @@ object JChatGPT : KotlinPlugin( logger.warning(ex) event.subject.sendMessage("很抱歉,发生异常,请稍后重试") } finally { + // 清理本轮回复索引 + replyIndexMap.remove(event.subject.id) // 一段时间后才允许再次提问,防止高频对话 launch { delay(500.milliseconds) diff --git a/src/main/kotlin/tools/SendSingleMessageAgent.kt b/src/main/kotlin/tools/SendSingleMessageAgent.kt index b82eaa2..b2122d0 100644 --- a/src/main/kotlin/tools/SendSingleMessageAgent.kt +++ b/src/main/kotlin/tools/SendSingleMessageAgent.kt @@ -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" } -} \ No newline at end of file +}