mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-06-23 00:49:31 +08:00
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:
@@ -116,8 +116,6 @@ object JChatGPT : KotlinPlugin(
|
|||||||
logger.info { "Plugin loaded" }
|
logger.info { "Plugin loaded" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
|
||||||
.withZone(ZoneOffset.systemDefault())
|
|
||||||
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
|
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
|
||||||
|
|
||||||
private val requestMap = ConcurrentSet<Long>()
|
private val requestMap = ConcurrentSet<Long>()
|
||||||
@@ -141,13 +139,52 @@ object JChatGPT : KotlinPlugin(
|
|||||||
*/
|
*/
|
||||||
private data class ConversationCache(
|
private data class ConversationCache(
|
||||||
val history: MutableList<ChatMessage>,
|
val history: MutableList<ChatMessage>,
|
||||||
val lastActivityAt: Int
|
val lastActivityAt: Int,
|
||||||
|
val replyIndex: ReplyIndex
|
||||||
) {
|
) {
|
||||||
fun isExpired(ttlSeconds: Int): Boolean {
|
fun isExpired(ttlSeconds: Int): Boolean {
|
||||||
return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds
|
return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回复索引:每个会话(subject)在一次对话期间维护一份「短编号 -> 消息记录」映射,
|
||||||
|
* 让 LLM 能用历史里每行行首的 [n] 来引用回复某条消息。
|
||||||
|
* 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。
|
||||||
|
*/
|
||||||
|
class ReplyIndex {
|
||||||
|
private val byIndex = LinkedHashMap<Int, MessageRecord>()
|
||||||
|
private val indexByIds = HashMap<String, Int>()
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
|
fun add(record: MessageRecord): Int {
|
||||||
|
// ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号
|
||||||
|
val ids = record.ids
|
||||||
|
if (ids != null) {
|
||||||
|
indexByIds[ids]?.let { return it }
|
||||||
|
}
|
||||||
|
val i = ++counter
|
||||||
|
byIndex[i] = record
|
||||||
|
if (ids != null) {
|
||||||
|
indexByIds[ids] = i
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(index: Int): MessageRecord? = byIndex[index]
|
||||||
|
fun indexOfIds(ids: String): Int? = indexByIds[ids]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各会话的回复索引,startChat 开始时重建,结束时清理 */
|
||||||
|
private val replyIndexMap = ConcurrentMap<Long, ReplyIndex>()
|
||||||
|
|
||||||
|
/** 供发言工具按编号查找被引用的历史消息 */
|
||||||
|
internal fun lookupReplyTarget(subjectId: Long, index: Int): MessageRecord? =
|
||||||
|
replyIndexMap[subjectId]?.get(index)
|
||||||
|
|
||||||
|
private val shortTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
.withZone(ZoneOffset.systemDefault())
|
||||||
|
|
||||||
private suspend fun onMessage(event: MessageEvent) {
|
private suspend fun onMessage(event: MessageEvent) {
|
||||||
// 检查Token是否设置
|
// 检查Token是否设置
|
||||||
if (LargeLanguageModels.chat == null) return
|
if (LargeLanguageModels.chat == null) return
|
||||||
@@ -319,6 +356,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 构造历史消息
|
// 构造历史消息
|
||||||
val historyText = StringBuilder()
|
val historyText = StringBuilder()
|
||||||
var lastId = 0L
|
var lastId = 0L
|
||||||
|
// 本轮回复索引,逐条登记消息编号供 [n] 引用
|
||||||
|
val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() }
|
||||||
if (event is GroupMessageEvent) {
|
if (event is GroupMessageEvent) {
|
||||||
if (PluginConfig.enableFavorabilitySystem) {
|
if (PluginConfig.enableFavorabilitySystem) {
|
||||||
val knownUsers = history.asSequence()
|
val knownUsers = history.asSequence()
|
||||||
@@ -345,10 +384,10 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine("## 近期群消息(更早已隐藏)")
|
historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
||||||
for (record in history) {
|
for (record in history) {
|
||||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||||
appendGroupMessageRecord(historyText, record, event, lastId != record.fromId)
|
appendGroupMessageRecord(historyText, record, event, replyIndex, lastId != record.fromId)
|
||||||
lastId = record.fromId
|
lastId = record.fromId
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -366,10 +405,10 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine("## 近期对话(更早已隐藏)")
|
historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
||||||
for (record in history) {
|
for (record in history) {
|
||||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||||
appendMessageRecord(historyText, record, event, lastId != record.fromId)
|
appendMessageRecord(historyText, record, event, replyIndex, lastId != record.fromId)
|
||||||
lastId = record.fromId
|
lastId = record.fromId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -387,45 +426,76 @@ object JChatGPT : KotlinPlugin(
|
|||||||
historyText: StringBuilder,
|
historyText: StringBuilder,
|
||||||
record: MessageRecord,
|
record: MessageRecord,
|
||||||
event: GroupMessageEvent,
|
event: GroupMessageEvent,
|
||||||
|
replyIndex: ReplyIndex,
|
||||||
showSender: Boolean,
|
showSender: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val index = replyIndex.add(record)
|
||||||
|
val recordMessage = record.toMessageChain()
|
||||||
|
|
||||||
|
historyText.append('[').append(index).append("] ")
|
||||||
if (showSender) {
|
if (showSender) {
|
||||||
// 名字前空行
|
// 新发言者:[n] 名称 时间
|
||||||
historyText.appendLine()
|
|
||||||
// 名称显示
|
|
||||||
if (event.bot.id == record.fromId) {
|
if (event.bot.id == record.fromId) {
|
||||||
historyText.append("**你** " + getNameCard(event.subject.botAsMember))
|
historyText.append("**你** ").append(getNameCard(event.subject.botAsMember))
|
||||||
} else {
|
} else {
|
||||||
historyText.append(getNameCard(event.subject, record.fromId))
|
historyText.append(getNameCard(event.subject, record.fromId))
|
||||||
}
|
}
|
||||||
// 发言时间
|
|
||||||
historyText.append(' ')
|
historyText.append(' ')
|
||||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
|
.append(' ')
|
||||||
|
} else {
|
||||||
|
// 同一发言者续行
|
||||||
|
historyText.append(" └ ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 引用:用编号指针替代内联原文,避免被误认为是本人发言
|
||||||
val recordMessage = record.toMessageChain()
|
|
||||||
recordMessage[QuoteReply.Key]?.let {
|
recordMessage[QuoteReply.Key]?.let {
|
||||||
historyText.append(" 引用 ${getNameCard(event.subject, it.source.fromId)} 说的\n > ")
|
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
||||||
.appendLine(it.source.originalMessage.content.replace("\n", "\n > "))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showSender) {
|
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
||||||
// 消息内容
|
|
||||||
historyText.append(" 说:")
|
|
||||||
}
|
|
||||||
|
|
||||||
historyText.appendLine(record.toMessageChain().joinToString("") {
|
|
||||||
when (it) {
|
|
||||||
is At -> {
|
|
||||||
it.getDisplay(event.subject)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> singleMessageToText(it)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化「引用回复」标记:被引用消息在窗口内时用 ↩[编号],否则内联简短原文并标注原作者。
|
||||||
|
*/
|
||||||
|
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 {
|
private fun getNameCard(group: Group, qq: Long): String {
|
||||||
val member = group[qq]
|
val member = group[qq]
|
||||||
return if (member == null) {
|
return if (member == null) {
|
||||||
@@ -445,41 +515,37 @@ object JChatGPT : KotlinPlugin(
|
|||||||
historyText: StringBuilder,
|
historyText: StringBuilder,
|
||||||
record: MessageRecord,
|
record: MessageRecord,
|
||||||
event: MessageEvent,
|
event: MessageEvent,
|
||||||
|
replyIndex: ReplyIndex,
|
||||||
showSender: Boolean
|
showSender: Boolean
|
||||||
) {
|
) {
|
||||||
|
val index = replyIndex.add(record)
|
||||||
|
val recordMessage = record.toMessageChain()
|
||||||
|
|
||||||
|
historyText.append('[').append(index).append("] ")
|
||||||
if (showSender) {
|
if (showSender) {
|
||||||
if (event.bot.id == record.fromId) {
|
if (event.bot.id == record.fromId) {
|
||||||
historyText.append("**你** " + event.bot.nameCardOrNick)
|
historyText.append("**你** ").append(event.bot.nameCardOrNick)
|
||||||
} else {
|
} else {
|
||||||
historyText.append(event.senderName)
|
historyText.append(event.senderName)
|
||||||
}
|
}
|
||||||
historyText
|
historyText.append(' ')
|
||||||
.append(" ")
|
.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
// 发言时间
|
.append(' ')
|
||||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
} else {
|
||||||
|
historyText.append(" └ ")
|
||||||
}
|
}
|
||||||
val recordMessage = record.toMessageChain()
|
|
||||||
recordMessage[QuoteReply.Key]?.let {
|
recordMessage[QuoteReply.Key]?.let {
|
||||||
historyText.append(" 引用\n > ")
|
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
||||||
.appendLine(
|
|
||||||
it.source.originalMessage
|
|
||||||
.joinToString("", transform = ::singleMessageToText)
|
|
||||||
.replace("\n", "\n > ")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (showSender) {
|
|
||||||
historyText.append(" 说:")
|
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
||||||
}
|
|
||||||
// 消息内容
|
|
||||||
historyText.appendLine(
|
|
||||||
record.toMessageChain().joinToString("", transform = ::singleMessageToText)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun singleMessageToText(it: SingleMessage): String {
|
private fun singleMessageToText(it: SingleMessage): String {
|
||||||
return when (it) {
|
return when (it) {
|
||||||
is ForwardMessage -> {
|
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 subjectId = event.subject.id
|
||||||
val cache = contextCache[subjectId]
|
val cache = contextCache[subjectId]
|
||||||
val history = if (PluginConfig.enableContextCache
|
val reuseCache = PluginConfig.enableContextCache
|
||||||
&& cache != null
|
&& cache != null
|
||||||
&& !cache.isExpired(PluginConfig.contextCacheTimeoutMinutes * 60)
|
&& !cache.isExpired(PluginConfig.contextCacheTimeoutMinutes * 60)
|
||||||
) {
|
// 回复索引与对话上下文同寿命:复用缓存时沿用旧索引,保证 LLM 看到的 [n] 编号连续不串号;
|
||||||
|
// 否则新建(供 sendSingleMessage 的 replyTo 按编号引用历史消息)
|
||||||
|
val replyIndex = if (reuseCache) cache!!.replyIndex else ReplyIndex()
|
||||||
|
replyIndexMap[subjectId] = replyIndex
|
||||||
|
val history = if (reuseCache) {
|
||||||
// 缓存有效,复用历史
|
// 缓存有效,复用历史
|
||||||
logger.info("使用缓存的对话上下文,包含 ${cache.history.size} 条互动消息")
|
logger.info("使用缓存的对话上下文,包含 ${cache!!.history.size} 条互动消息")
|
||||||
cache.history
|
cache.history
|
||||||
} else {
|
} else {
|
||||||
// 缓存无效或不存在,创建新上下文
|
// 缓存无效或不存在,创建新上下文
|
||||||
@@ -720,7 +790,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
if (PluginConfig.enableContextCache) {
|
if (PluginConfig.enableContextCache) {
|
||||||
contextCache[subjectId] = ConversationCache(
|
contextCache[subjectId] = ConversationCache(
|
||||||
history = history,
|
history = history,
|
||||||
lastActivityAt = startedAt
|
lastActivityAt = startedAt,
|
||||||
|
replyIndex = replyIndex
|
||||||
)
|
)
|
||||||
logger.debug("已保存对话上下文到缓存")
|
logger.debug("已保存对话上下文到缓存")
|
||||||
}
|
}
|
||||||
@@ -739,6 +810,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
||||||
} finally {
|
} finally {
|
||||||
|
// 清理本轮回复索引
|
||||||
|
replyIndexMap.remove(event.subject.id)
|
||||||
// 一段时间后才允许再次提问,防止高频对话
|
// 一段时间后才允许再次提问,防止高频对话
|
||||||
launch {
|
launch {
|
||||||
delay(500.milliseconds)
|
delay(500.milliseconds)
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import com.aallam.openai.api.chat.Tool
|
|||||||
import com.aallam.openai.api.core.Parameters
|
import com.aallam.openai.api.core.Parameters
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
import net.mamoe.mirai.event.events.MessageEvent
|
import net.mamoe.mirai.event.events.MessageEvent
|
||||||
|
import net.mamoe.mirai.message.data.Message
|
||||||
|
import net.mamoe.mirai.message.data.QuoteReply
|
||||||
import top.jie65535.mirai.JChatGPT
|
import top.jie65535.mirai.JChatGPT
|
||||||
|
|
||||||
class SendSingleMessageAgent : BaseAgent(
|
class SendSingleMessageAgent : BaseAgent(
|
||||||
@@ -17,6 +19,10 @@ class SendSingleMessageAgent : BaseAgent(
|
|||||||
put("type", "string")
|
put("type", "string")
|
||||||
put("description", "消息内容")
|
put("description", "消息内容")
|
||||||
}
|
}
|
||||||
|
putJsonObject("replyTo") {
|
||||||
|
put("type", "integer")
|
||||||
|
put("description", "可选。要引用回复的历史消息编号(即历史记录中每行行首的[n])。不需要回复具体某条消息时省略此参数。")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
putJsonArray("required") {
|
putJsonArray("required") {
|
||||||
add("content")
|
add("content")
|
||||||
@@ -27,7 +33,28 @@ class SendSingleMessageAgent : BaseAgent(
|
|||||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||||
requireNotNull(args)
|
requireNotNull(args)
|
||||||
val content = args.getValue("content").jsonPrimitive.content
|
val content = args.getValue("content").jsonPrimitive.content
|
||||||
event.subject.sendMessage(JChatGPT.toMessage(event.subject, content))
|
val replyTo = args["replyTo"]?.jsonPrimitive?.intOrNull
|
||||||
return "OK"
|
|
||||||
|
val baseMsg = JChatGPT.toMessage(event.subject, content)
|
||||||
|
var note = ""
|
||||||
|
val message: Message = if (replyTo != null) {
|
||||||
|
val record = JChatGPT.lookupReplyTarget(event.subject.id, replyTo)
|
||||||
|
val source = try {
|
||||||
|
record?.toMessageSource()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (source != null) {
|
||||||
|
QuoteReply(source) + baseMsg
|
||||||
|
} else {
|
||||||
|
note = "(编号${replyTo}对应的消息已失效,未能引用,已直接发送)"
|
||||||
|
baseMsg
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
event.subject.sendMessage(message)
|
||||||
|
return "OK$note"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user