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>
This commit is contained in:
2026-06-20 22:46:59 +08:00
parent eedbd55f62
commit d2bdd273b2
2 changed files with 158 additions and 58 deletions

View File

@@ -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<Long>()
@@ -141,13 +139,52 @@ 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 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)

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