conversation: preserve triggers and await follow-ups

This commit is contained in:
2026-08-05 20:24:19 +08:00
parent c931d39d20
commit f416d889b3
13 changed files with 712 additions and 51 deletions
+9 -2
View File
@@ -24,6 +24,11 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
- 通过引用群友消息 + @bot 让 Bot 识别引用消息的内容
- 回复 bot 的消息即可引用对应的上下文对话(包括这个回复的历史对话)
- 使用关键字触发(默认为 "[小筱][林淋月玥]",可在配置中修改)
- Bot 处理期间出现的新触发会合并到当前会话,并在本轮工具结算后继续处理,不再因忙碌而直接丢弃
- Bot 可以通过 `endConversation.waitForFollowUp` 短暂等待同一会话中指定用户的下一条消息;等待超时只会关闭观察状态,不会调用模型或发送消息
初次触发时,Bot 按 `historyWindowMin``historyMessageLimit` 读取有限的近期历史。模型运行期间以及缓存会话再次
激活时,会按时间水位补充自上次运行以来的全部增量消息,不受 `historyMessageLimit` 限制。
### 工具调用
AI 可以自动调用多种工具来完成复杂任务:
@@ -201,6 +206,7 @@ promptFile: 'SystemPrompt.md'
# 创建Prompt时取最近多少分钟内的消息
historyWindowMin: 10
# 创建Prompt时取最多几条消息
# 仅限制初次触发时读取的近期历史;模型运行期间的增量消息会全部补充
historyMessageLimit: 20
# 是否打印Prompt便于调试
logPrompt: false
@@ -351,7 +357,7 @@ JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件
- sendCompositeMessage - 发送组合消息(适用于长内容或代码)
交互规则:
1. 只有当用户@你或在消息中包含你的名字时才会响应
1. 通常只有当用户@你或在消息中包含你的名字时才会响应;你通过 endConversation 明确等待的用户回复除外
2. 回复应简洁明了,避免长篇大论
3. 对于复杂内容,使用组合消息功能发送
4. 不主动参与与你无关的对话
@@ -361,7 +367,8 @@ JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件
- 只在必要时使用工具
- 深度思考工具仅用于复杂问题
- 代码执行工具用于验证技术问题
- **每次对话结束时必须调用 endConversation 工具来结束对话**
- **每次对话结束时必须调用唯一的 endConversation 工具来结束当前运行**
- 通常无参数结束;只有刚明确要求指定用户提供会影响后续处理的反馈时,才使用 waitForFollowUp 短暂等待
- **要发送消息给用户必须使用 sendSingleMessage 或 sendCompositeMessage 工具**
<memory>
+18 -7
View File
@@ -131,6 +131,12 @@ object JChatGPT : KotlinPlugin(
private suspend fun onMessage(event: MessageEvent) {
if (LargeLanguageModels.chat == null) return
if (ConversationEngine.isExpectedUser(event)) {
if (shouldIgnoreBecauseMuted(event)) return
if (ConversationEngine.resumeObserved(event)) return
}
if (!event.toCommandSender().hasPermission(chatPermission)) {
if (event is GroupMessageEvent) {
if (!PluginConfig.groupOpHasChatPermission || !event.sender.isOperator()) {
@@ -145,18 +151,23 @@ object JChatGPT : KotlinPlugin(
event.message[QuoteReply]?.source?.fromId == event.bot.id
if (!triggered) return
if (event is GroupMessageEvent && event.group.botMuteRemaining > 0) {
logger.info(
"bot 在群 ${event.group.name}(${event.group.id}) 被禁言," +
"剩余 ${event.group.botMuteRemaining} 秒,忽略消息"
)
return
}
if (shouldIgnoreBecauseMuted(event)) return
if (PluginConfig.enableFavorabilitySystem && shouldIgnoreForFavorability(event)) return
ConversationEngine.start(event)
}
private fun shouldIgnoreBecauseMuted(event: MessageEvent): Boolean {
if (event !is GroupMessageEvent) return false
val remainingSeconds = event.group.botMuteRemaining
if (remainingSeconds <= 0) return false
logger.info(
"bot 在群 ${event.group.name}(${event.group.id}) 被禁言," +
"剩余 $remainingSeconds 秒,忽略消息"
)
return true
}
private suspend fun shouldIgnoreForFavorability(event: MessageEvent): Boolean {
val info = PluginData.userFavorability[event.sender.id] ?: return false
if (info.value >= 0) return false
+1 -1
View File
@@ -244,7 +244,7 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("创建Prompt时取最近多少分钟内的消息")
val historyWindowMin: Int by value(10)
@ValueDescription("创建Prompt时最多几条消息")
@ValueDescription("初次创建Prompt时最多读取几条近期消息;模型运行期间的增量消息不受此限制")
val historyMessageLimit: Int by value(20)
@ValueDescription("启用对话上下文内存缓存,允许在短时间内保持上下文连续")
@@ -72,6 +72,8 @@ internal class ReplyIndex {
}
internal object ConversationContext {
private val chronologicalRecordOrder = compareBy<ChatMessageRecord> { it.time }
.thenBy { if (it.id == 0L) Long.MAX_VALUE else it.id }
private val contextCache = mutableMapOf<Long, ConversationCache>()
private val replyIndexes = mutableMapOf<Long, ReplyIndex>()
private val imageIndexes = mutableMapOf<Long, ImageIndex>()
@@ -160,18 +162,18 @@ internal object ConversationContext {
.minusMinutes(PluginConfig.historyWindowMin.toLong())
.toEpochSecond()
.toInt()
return getAfterHistory(beforeTimestamp, event)
return getAfterHistory(beforeTimestamp, event, PluginConfig.historyMessageLimit)
}
fun getAfterHistory(time: Int, event: MessageEvent): String {
fun getAfterHistory(time: Int, event: MessageEvent, limit: Int? = null): String {
if (!JChatGPT.includeHistory) return ""
val history = try {
ChatHistoryStore.query(
contact = event.subject,
start = time,
end = OffsetDateTime.now().toEpochSecond().toInt(),
limit = PluginConfig.historyMessageLimit,
).sortedBy { it.time }.toMutableList()
limit = limit,
).sortedWith(chronologicalRecordOrder).toMutableList()
} catch (cause: Throwable) {
JChatGPT.logger.warning("查询 SQLite 消息历史失败", cause)
mutableListOf()
@@ -180,6 +182,7 @@ internal object ConversationContext {
val messageIds = event.message.ids.joinToString(",")
if (history.none { it.ids == messageIds }) {
history += ChatMessageRecord.fromSuccess(event.message.source, event.message)
history.sortWith(chronologicalRecordOrder)
}
val result = StringBuilder()
@@ -8,7 +8,6 @@ import com.aallam.openai.api.chat.ToolCall
import com.aallam.openai.api.chat.ToolChoice
import com.aallam.openai.api.core.Usage
import com.aallam.openai.api.model.ModelId
import io.ktor.util.collections.ConcurrentSet
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@@ -19,6 +18,7 @@ import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import net.mamoe.mirai.event.events.GroupMessageEvent
import net.mamoe.mirai.event.events.MessageEvent
import net.mamoe.mirai.message.data.source
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.config.PluginConfig
import top.jie65535.mirai.data.TokenUsageStore
@@ -51,11 +51,10 @@ import top.jie65535.mirai.tools.WebSearch
import top.jie65535.mirai.util.RetryBackoff
import java.time.OffsetDateTime
import java.time.format.DateTimeFormatter
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
internal object ConversationEngine {
private val activeRequests = ConcurrentSet<Long>()
private val runtimeState = ConversationRuntimeState<MessageEvent>()
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
private val thinkRegex = Regex("<think>[\\s\\S]*?</think>")
private val tools: List<BaseAgent> = listOf(
@@ -84,16 +83,53 @@ internal object ConversationEngine {
)
fun clear() {
activeRequests.clear()
runtimeState.clear()
}
fun isExpectedUser(event: MessageEvent): Boolean = runtimeState.isExpectedUser(
key = event.toConversationKey(),
userId = event.sender.id,
nowEpochSecond = currentEpochSecond(),
)
suspend fun resumeObserved(event: MessageEvent): Boolean {
val started = runtimeState.beginObserved(
key = event.toConversationKey(),
userId = event.sender.id,
nowEpochSecond = currentEpochSecond(),
) ?: return false
runConversation(event, started.running, started.resumedWait)
return true
}
suspend fun start(event: MessageEvent) {
val subjectId = event.subject.id
if (!activeRequests.add(subjectId)) {
JChatGPT.logger.warning("The current Contact is busy!")
return
when (val result = runtimeState.beginExplicit(event.toConversationKey(), event)) {
is ConversationRuntimeState.BeginResult.Queued -> {
if (result.newlyQueued) {
JChatGPT.logger.info(
"当前会话忙碌,已暂存用户 ${event.senderName}(${event.sender.id}) 的二次触发"
)
} else {
JChatGPT.logger.info(
"当前会话已有待处理触发,用户 ${event.senderName}(${event.sender.id}) 的消息将通过增量历史合并"
)
}
}
is ConversationRuntimeState.BeginResult.Started -> {
runConversation(event, result.running, result.resumedWait)
}
}
}
private suspend fun runConversation(
initialEvent: MessageEvent,
running: ConversationRuntimeState.Running<MessageEvent>,
resumedWait: FollowUpWaitDirective?,
) {
val subjectId = initialEvent.subject.id
var currentEvent = initialEvent
var indexesReleased = false
try {
val cache = ConversationContext.cache(subjectId)
val reuseCache = PluginConfig.enableContextCache && cache != null &&
@@ -112,16 +148,25 @@ internal object ConversationEngine {
} else mutableListOf()
if (history.isEmpty() || cache == null) {
val prompt = ConversationContext.getSystemPrompt(event)
val prompt = ConversationContext.getSystemPrompt(currentEvent)
if (PluginConfig.logPrompt) JChatGPT.logger.info("Prompt: $prompt")
history += ChatMessage(ChatRole.System, prompt)
val historyText = ConversationContext.getHistory(event)
val historyText = ConversationContext.getHistory(currentEvent)
JChatGPT.logger.info("注入聊天记录:\n$historyText")
history += ChatMessage.User(historyText)
} else {
val newMessages = ConversationContext.getAfterHistory(cache.lastActivityAt, event)
val newMessages = ConversationContext.getAfterHistory(cache.lastActivityAt, currentEvent)
JChatGPT.logger.info("补充聊天记录:\n$newMessages")
history += ChatMessage.User("## 以下是上次对话结束至今的新消息\n\n$newMessages")
history += ChatMessage.User(
if (resumedWait == null) {
"## 以下是上次对话结束至今的新消息\n\n$newMessages"
} else {
buildObservationResumePrompt(resumedWait, newMessages)
}
)
}
if (resumedWait != null && !reuseCache) {
history += ChatMessage.User(buildObservationResumePrompt(resumedWait, null))
}
val endpoints = LargeLanguageModels.orderedChatEndpoints()
@@ -134,6 +179,7 @@ internal object ConversationEngine {
var consecutiveFailures = 0
do {
val endpoint = endpoints[endpointIndex]
val roundEvent = currentEvent
var streamingOk = false
try {
val startedAt = OffsetDateTime.now().toEpochSecond().toInt()
@@ -161,7 +207,7 @@ internal object ConversationEngine {
if (index >= responseToolCalls.size) {
responseToolCalls.lastOrNull()?.let { toolCall ->
toolCallTasks += JChatGPT.async {
toolCall.toResultMessage(event)
toolCall.toResultMessage(roundEvent)
}
}
val id = toolCallChunk.id
@@ -196,22 +242,48 @@ internal object ConversationEngine {
toolCalls = responseToolCalls.ifEmpty { null },
reasoningContent = if (responseToolCalls.isNotEmpty()) reasoningContent?.toString() else null,
)
recordUsage(event, lastTokenUsage, lastCacheUsage)
recordUsage(roundEvent, lastTokenUsage, lastCacheUsage)
completedRounds++
if (responseToolCalls.size > toolCallTasks.size) {
val finalToolResult = responseToolCalls.last().toResultMessage(event)
val finalToolResult = responseToolCalls.last().toResultMessage(roundEvent)
if (toolCallTasks.isNotEmpty()) history += toolCallTasks.awaitAll()
history += finalToolResult
done = responseToolCalls.any { it.function.name == "endConversation" }
} else {
done = true
}
if (!done) {
val endCalls = responseToolCalls.filter {
it.function.name == END_CONVERSATION_TOOL_NAME
}
if (endCalls.size > 1) {
JChatGPT.logger.warning("模型在同一轮调用了多次 endConversation,将采用第一次调用的参数")
}
val endCall = endCalls.firstOrNull()
val endArguments = endCall?.let { call ->
runCatching { call.function.argumentsAsJsonOrNull() }
.onFailure {
JChatGPT.logger.warning("无法解析 endConversation 参数,将按普通结束处理", it)
}
.getOrNull()
}
val waitDirective = parseFollowUpWait(endArguments)
if (endArguments?.containsKey(FOLLOW_UP_WAIT_ARGUMENT) == true && waitDirective == null) {
JChatGPT.logger.warning("endConversation.waitForFollowUp 参数无效,将按普通结束处理")
}
val requestedEnd = responseToolCalls.isEmpty() || endCall != null
val canContinue = completedRounds < maxRounds
if (!requestedEnd && canContinue) {
val pendingEvent = runtimeState.takePending(running)
if (pendingEvent != null) currentEvent = pendingEvent
history += ChatMessage.User(
buildContinuationPrompt(maxRounds - completedRounds, startedAt, event)
buildContinuationPrompt(
remainingRounds = maxRounds - completedRounds,
startedAt = startedAt,
event = currentEvent,
pendingTrigger = pendingEvent != null,
)
)
done = false
} else {
if (PluginConfig.enableContextCache) {
ConversationContext.saveCache(
@@ -220,8 +292,48 @@ internal object ConversationEngine {
)
JChatGPT.logger.debug("已保存对话上下文到缓存")
}
if (event is GroupMessageEvent) {
ProfileAutoMaintenance.recordCompletedConversation(event, startedAt)
when (val finish = runtimeState.finish(
running = running,
waitDirective = waitDirective.takeIf { requestedEnd },
nowEpochSecond = currentEpochSecond(),
allowPendingContinuation = canContinue,
onFinished = {
ConversationContext.releaseActiveIndexes(subjectId)
indexesReleased = true
},
)) {
is ConversationRuntimeState.FinishResult.Continue -> {
currentEvent = finish.event
history += ChatMessage.User(
buildContinuationPrompt(
remainingRounds = maxRounds - completedRounds,
startedAt = startedAt,
event = currentEvent,
pendingTrigger = true,
)
)
done = false
}
is ConversationRuntimeState.FinishResult.Observing -> {
scheduleObservationTimeout(finish.observation)
(currentEvent as? GroupMessageEvent)?.let {
ProfileAutoMaintenance.recordCompletedConversation(it, startedAt)
}
JChatGPT.logger.info(
"会话已结束,等待用户 ${finish.observation.directive.fromUserIds.joinToString()} " +
"${finish.observation.directive.timeoutSeconds} 秒内发言"
)
done = true
}
ConversationRuntimeState.FinishResult.Ended -> {
(currentEvent as? GroupMessageEvent)?.let {
ProfileAutoMaintenance.recordCompletedConversation(it, startedAt)
}
done = true
}
}
}
} catch (cause: Exception) {
@@ -264,15 +376,54 @@ internal object ConversationEngine {
throw cause
} catch (cause: Throwable) {
JChatGPT.logger.warning(cause)
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
currentEvent.subject.sendMessage("很抱歉,发生异常,请稍后重试")
} finally {
if (!indexesReleased) {
runtimeState.abort(running) {
ConversationContext.releaseActiveIndexes(subjectId)
JChatGPT.launch {
delay(500.milliseconds)
activeRequests.remove(subjectId)
indexesReleased = true
}
}
}
}
private fun scheduleObservationTimeout(observation: ConversationRuntimeState.Observation) {
val job = JChatGPT.launch {
val remainingSeconds = observation.expiresAtEpochSecond - currentEpochSecond()
if (remainingSeconds > 0) delay(remainingSeconds.seconds)
if (runtimeState.expire(observation)) {
JChatGPT.logger.debug(
"等待用户 ${observation.directive.fromUserIds.joinToString()} 的观察窗口已超时"
)
}
}
runtimeState.attachTimeoutJob(observation, job)
}
private fun MessageEvent.toConversationKey(): ConversationKey = ConversationKey(
botId = bot.id,
kind = message.source.kind,
subjectId = subject.id,
)
private fun currentEpochSecond(): Long = OffsetDateTime.now().toEpochSecond()
private fun buildObservationResumePrompt(
directive: FollowUpWaitDirective,
newMessages: String?,
): String = buildString {
appendLine("## 观察状态恢复")
appendLine("你此前结束发言后,选择等待指定用户在当前会话中的下一条消息。")
append("等待用户:").appendLine(directive.fromUserIds.joinToString())
append("等待条件:").appendLine(directive.condition)
appendLine("被观察状态唤醒不代表必须回复。请判断新消息是否满足等待条件、是否承接当前话题。")
appendLine("如果无关,不要发送任何内容,直接调用 endConversation。")
if (newMessages != null) {
appendLine()
appendLine("## 等待后出现的新消息")
append(newMessages)
}
}
private fun chatCompletions(
history: List<ChatMessage>,
@@ -336,13 +487,21 @@ internal object ConversationEngine {
return truncated
}
private fun buildContinuationPrompt(remainingRounds: Int, startedAt: Int, event: MessageEvent): String = buildString {
private fun buildContinuationPrompt(
remainingRounds: Int,
startedAt: Int,
event: MessageEvent,
pendingTrigger: Boolean,
): String = buildString {
appendLine("## 系统提示")
append("本次运行最多还剩").append(remainingRounds).appendLine("轮。")
appendLine("如果要多次发言,可以一次性调用多次发言工具。")
appendLine("如果没有什么要做的,可以提前结束。")
if (pendingTrigger) appendLine("运行期间收到了新的显式触发,请优先处理水位后的新消息。")
appendLine("当前时间:${dateTimeFormatter.format(OffsetDateTime.now())}")
val messages = ConversationContext.getAfterHistory(startedAt, event)
val messages = ConversationContext.getAfterHistory(startedAt, event).ifEmpty {
if (pendingTrigger && !JChatGPT.includeHistory) ConversationContext.getHistory(event) else ""
}
if (messages.isNotEmpty()) append("## 以下是上次运行至今的新消息\n\n$messages")
}
@@ -0,0 +1,9 @@
package top.jie65535.mirai.conversation
import net.mamoe.mirai.message.data.MessageSourceKind
internal data class ConversationKey(
val botId: Long,
val kind: MessageSourceKind,
val subjectId: Long,
)
@@ -0,0 +1,167 @@
package top.jie65535.mirai.conversation
import kotlinx.coroutines.Job
internal class ConversationRuntimeState<E> {
internal class Running<E> internal constructor(
val key: ConversationKey,
) {
internal var pendingEvent: E? = null
}
internal class Observation internal constructor(
val key: ConversationKey,
val directive: FollowUpWaitDirective,
val expiresAtEpochSecond: Long,
) {
internal var timeoutJob: Job? = null
}
internal sealed interface BeginResult<out E> {
data class Started<E>(
val running: Running<E>,
val resumedWait: FollowUpWaitDirective? = null,
) : BeginResult<E>
data class Queued(val newlyQueued: Boolean) : BeginResult<Nothing>
}
internal sealed interface FinishResult<out E> {
data class Continue<E>(val event: E) : FinishResult<E>
data class Observing(val observation: Observation) : FinishResult<Nothing>
data object Ended : FinishResult<Nothing>
}
private sealed interface Slot<E>
private data class RunningSlot<E>(val running: Running<E>) : Slot<E>
private data class ObservationSlot<E>(val observation: Observation) : Slot<E>
private val lock = Any()
private val slots = mutableMapOf<ConversationKey, Slot<E>>()
fun beginExplicit(key: ConversationKey, event: E): BeginResult<E> = synchronized(lock) {
when (val slot = slots[key]) {
is RunningSlot -> {
val newlyQueued = slot.running.pendingEvent == null
if (newlyQueued) slot.running.pendingEvent = event
BeginResult.Queued(newlyQueued)
}
is ObservationSlot -> {
slot.observation.timeoutJob?.cancel()
startRunning(key)
}
null -> startRunning(key)
}
}
fun isExpectedUser(key: ConversationKey, userId: Long, nowEpochSecond: Long): Boolean = synchronized(lock) {
val observation = (slots[key] as? ObservationSlot)?.observation ?: return@synchronized false
if (nowEpochSecond >= observation.expiresAtEpochSecond) {
slots.remove(key)
observation.timeoutJob?.cancel()
return@synchronized false
}
userId in observation.directive.fromUserIds
}
fun beginObserved(
key: ConversationKey,
userId: Long,
nowEpochSecond: Long,
): BeginResult.Started<E>? = synchronized(lock) {
val observation = (slots[key] as? ObservationSlot)?.observation ?: return@synchronized null
if (nowEpochSecond >= observation.expiresAtEpochSecond) {
slots.remove(key)
observation.timeoutJob?.cancel()
return@synchronized null
}
if (userId !in observation.directive.fromUserIds) return@synchronized null
observation.timeoutJob?.cancel()
val running = Running<E>(key)
slots[key] = RunningSlot(running)
BeginResult.Started(running, observation.directive)
}
fun takePending(running: Running<E>): E? = synchronized(lock) {
val active = (slots[running.key] as? RunningSlot)?.running
if (active !== running) return@synchronized null
running.pendingEvent.also { running.pendingEvent = null }
}
fun finish(
running: Running<E>,
waitDirective: FollowUpWaitDirective?,
nowEpochSecond: Long,
allowPendingContinuation: Boolean,
onFinished: () -> Unit,
): FinishResult<E> = synchronized(lock) {
val active = (slots[running.key] as? RunningSlot)?.running
if (active !== running) {
onFinished()
return@synchronized FinishResult.Ended
}
val pending = running.pendingEvent
running.pendingEvent = null
if (pending != null && allowPendingContinuation) {
return@synchronized FinishResult.Continue(pending)
}
onFinished()
if (pending == null && waitDirective != null) {
val observation = Observation(
key = running.key,
directive = waitDirective,
expiresAtEpochSecond = nowEpochSecond + waitDirective.timeoutSeconds,
)
slots[running.key] = ObservationSlot(observation)
FinishResult.Observing(observation)
} else {
slots.remove(running.key)
FinishResult.Ended
}
}
fun abort(running: Running<E>, onFinished: () -> Unit) = synchronized(lock) {
val active = (slots[running.key] as? RunningSlot)?.running
if (active === running) slots.remove(running.key)
onFinished()
}
fun attachTimeoutJob(observation: Observation, job: Job) {
val attached = synchronized(lock) {
val active = (slots[observation.key] as? ObservationSlot)?.observation
if (active === observation) {
observation.timeoutJob = job
true
} else {
false
}
}
if (!attached) job.cancel()
}
fun expire(observation: Observation): Boolean = synchronized(lock) {
val active = (slots[observation.key] as? ObservationSlot)?.observation
if (active !== observation) return@synchronized false
slots.remove(observation.key)
true
}
fun clear() {
val jobs = synchronized(lock) {
slots.values.mapNotNull { (it as? ObservationSlot)?.observation?.timeoutJob }
.also { slots.clear() }
}
jobs.forEach { it.cancel() }
}
private fun startRunning(key: ConversationKey): BeginResult.Started<E> {
val running = Running<E>(key)
slots[key] = RunningSlot(running)
return BeginResult.Started(running)
}
}
@@ -0,0 +1,46 @@
package top.jie65535.mirai.conversation
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull
internal const val END_CONVERSATION_TOOL_NAME = "endConversation"
internal const val FOLLOW_UP_WAIT_ARGUMENT = "waitForFollowUp"
internal data class FollowUpWaitDirective(
val timeoutSeconds: Int,
val fromUserIds: Set<Long>,
val condition: String,
)
internal fun parseFollowUpWait(arguments: JsonObject?): FollowUpWaitDirective? {
val wait = arguments?.get(FOLLOW_UP_WAIT_ARGUMENT) as? JsonObject ?: return null
val timeoutSeconds = (wait["timeoutSeconds"] as? JsonPrimitive)?.intOrNull ?: DEFAULT_WAIT_SECONDS
if (timeoutSeconds !in MIN_WAIT_SECONDS..MAX_WAIT_SECONDS) return null
val userIdsJson = wait["fromUserIds"] as? JsonArray ?: return null
if (userIdsJson.size !in 1..MAX_WAIT_USERS) return null
val userIds = LinkedHashSet<Long>(userIdsJson.size)
for (element in userIdsJson) {
val userId = (element as? JsonPrimitive)?.longOrNull ?: return null
if (userId <= 0 || !userIds.add(userId)) return null
}
val condition = (wait["condition"] as? JsonPrimitive)?.contentOrNull?.trim().orEmpty()
if (condition.isEmpty() || condition.length > MAX_WAIT_CONDITION_LENGTH) return null
return FollowUpWaitDirective(
timeoutSeconds = timeoutSeconds,
fromUserIds = userIds,
condition = condition,
)
}
private const val DEFAULT_WAIT_SECONDS = 30
private const val MIN_WAIT_SECONDS = 5
private const val MAX_WAIT_SECONDS = 120
private const val MAX_WAIT_USERS = 10
private const val MAX_WAIT_CONDITION_LENGTH = 200
+5 -2
View File
@@ -163,7 +163,7 @@ object ChatHistoryStore {
contact: Contact,
start: Int,
end: Int,
limit: Int,
limit: Int? = null,
fromId: Long? = null,
): List<ChatMessageRecord> {
check(initialized) { "聊天记录数据库尚未初始化" }
@@ -227,7 +227,8 @@ object ChatHistoryStore {
)
append(' ')
append(conditions.joinToString(" AND "))
append(" ORDER BY time DESC, id DESC LIMIT ?")
append(" ORDER BY time DESC, id DESC")
if (limit != null) append(" LIMIT ?")
}
return openReadConnection().use { connection ->
@@ -239,7 +240,9 @@ object ChatHistoryStore {
else -> error("不支持的查询参数类型 ${value::class}")
}
}
if (limit != null) {
statement.setInt(parameters.size + 1, limit.coerceAtLeast(1))
}
statement.executeQuery().use { results ->
buildList {
while (results.next()) {
+1 -1
View File
@@ -98,7 +98,7 @@ class SearchChatHistory : BaseAgent(
end = endEpoch,
limit = maxRecords,
fromId = senderQq,
).sortedBy { it.time }
).sortedWith(compareBy<ChatMessageRecord> { it.time }.thenBy { it.id })
} catch (e: Throwable) {
JChatGPT.logger.warning("查询消息历史失败", e)
return "查询消息历史失败: ${e.message}"
+55 -2
View File
@@ -2,11 +2,64 @@ package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.add
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
class StopLoopAgent : BaseAgent(
tool = Tool.function(
name = "endConversation",
description = "结束本轮对话",
parameters = Parameters.Empty
description = """
声明当前模型运行已经完成。主循环会先结算本轮其他工具和运行期间的新触发,
再决定结束、继续处理或短暂等待指定用户的下一条消息。每轮完成时必须且只能调用一次。
通常不传waitForFollowUp并直接结束;只有刚刚明确要求他人提供会影响后续处理的反馈时才等待,
不要仅为了看看是否有人回应、保持活跃或参与普通闲聊而等待。
""".trimIndent(),
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("waitForFollowUp") {
put("type", "object")
put(
"description",
"可选。结束当前运行后,非阻塞地等待指定用户在当前会话中的下一条消息;省略表示立即离开。"
)
putJsonObject("properties") {
putJsonObject("timeoutSeconds") {
put("type", "integer")
put("minimum", 5)
put("maximum", 120)
put("description", "可选,默认30秒。选择满足当前具体等待所需的最短时间。")
}
putJsonObject("fromUserIds") {
put("type", "array")
put("minItems", 1)
put("maxItems", 10)
put("uniqueItems", true)
put("description", "明确等待回复的QQ用户列表。不要猜测或编造用户ID。")
putJsonObject("items") {
put("type", "integer")
}
}
putJsonObject("condition") {
put("type", "string")
put("minLength", 1)
put("maxLength", 200)
put(
"description",
"用一句话描述可由后续消息验证的具体等待条件,不能只写看看有没有人回应。"
)
}
}
putJsonArray("required") {
add("fromUserIds")
add("condition")
}
put("additionalProperties", false)
}
}
put("additionalProperties", false)
}
)
)
@@ -0,0 +1,113 @@
package top.jie65535.mirai.conversation
import net.mamoe.mirai.message.data.MessageSourceKind
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConversationRuntimeStateTest {
@Test
fun keepsFirstPendingTriggerUntilTheRunningLoopConsumesIt() {
val state = ConversationRuntimeState<String>()
val running = assertIs<ConversationRuntimeState.BeginResult.Started<String>>(
state.beginExplicit(KEY, "first")
).running
assertEquals(
ConversationRuntimeState.BeginResult.Queued(newlyQueued = true),
state.beginExplicit(KEY, "second"),
)
assertEquals(
ConversationRuntimeState.BeginResult.Queued(newlyQueued = false),
state.beginExplicit(KEY, "third"),
)
assertEquals("second", state.takePending(running))
assertNull(state.takePending(running))
}
@Test
fun pendingTriggerWinsOverWaitAtTerminalSettlement() {
val state = ConversationRuntimeState<String>()
val running = assertIs<ConversationRuntimeState.BeginResult.Started<String>>(
state.beginExplicit(KEY, "first")
).running
state.beginExplicit(KEY, "second")
val finish = state.finish(
running = running,
waitDirective = WAIT,
nowEpochSecond = 100,
allowPendingContinuation = true,
onFinished = { error("continuing must keep the runtime resources active") },
)
assertEquals("second", assertIs<ConversationRuntimeState.FinishResult.Continue<String>>(finish).event)
assertFalse(state.isExpectedUser(KEY, TARGET_USER, nowEpochSecond = 101))
}
@Test
fun expectedUserAtomicallyConsumesObservationAndRestoresWaitContext() {
val state = ConversationRuntimeState<String>()
val running = assertIs<ConversationRuntimeState.BeginResult.Started<String>>(
state.beginExplicit(KEY, "first")
).running
var released = false
val observation = assertIs<ConversationRuntimeState.FinishResult.Observing>(
state.finish(
running = running,
waitDirective = WAIT,
nowEpochSecond = 100,
allowPendingContinuation = true,
onFinished = { released = true },
)
).observation
assertTrue(released)
assertEquals(130L, observation.expiresAtEpochSecond)
assertFalse(state.isExpectedUser(KEY, 999, nowEpochSecond = 101))
assertTrue(state.isExpectedUser(KEY, TARGET_USER, nowEpochSecond = 101))
val resumed = state.beginObserved(KEY, TARGET_USER, nowEpochSecond = 101)
assertEquals(WAIT, resumed?.resumedWait)
assertFalse(state.isExpectedUser(KEY, TARGET_USER, nowEpochSecond = 101))
assertNull(state.beginObserved(KEY, TARGET_USER, nowEpochSecond = 101))
}
@Test
fun observationExpiresSilentlyAndIsScopedToTheFullConversationKey() {
val state = ConversationRuntimeState<String>()
val running = assertIs<ConversationRuntimeState.BeginResult.Started<String>>(
state.beginExplicit(KEY, "first")
).running
state.finish(
running = running,
waitDirective = WAIT,
nowEpochSecond = 100,
allowPendingContinuation = true,
onFinished = {},
)
val otherGroup = KEY.copy(subjectId = KEY.subjectId + 1)
val otherBot = KEY.copy(botId = KEY.botId + 1)
assertFalse(state.isExpectedUser(otherGroup, TARGET_USER, nowEpochSecond = 101))
assertFalse(state.isExpectedUser(otherBot, TARGET_USER, nowEpochSecond = 101))
assertFalse(state.isExpectedUser(KEY, TARGET_USER, nowEpochSecond = 130))
}
private companion object {
const val TARGET_USER = 123L
val KEY = ConversationKey(
botId = 1,
kind = MessageSourceKind.GROUP,
subjectId = 2,
)
val WAIT = FollowUpWaitDirective(
timeoutSeconds = 30,
fromUserIds = setOf(TARGET_USER),
condition = "等待对方补充版本信息",
)
}
}
@@ -0,0 +1,90 @@
package top.jie65535.mirai.conversation
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class EndConversationDirectiveTest {
@Test
fun emptyEndConversationLeavesImmediately() {
assertNull(parseFollowUpWait(buildJsonObject {}))
}
@Test
fun parsesTargetedOneShotWait() {
val directive = parseFollowUpWait(arguments(timeoutSeconds = 45))
assertEquals(
FollowUpWaitDirective(
timeoutSeconds = 45,
fromUserIds = linkedSetOf(123L, 456L),
condition = "等待对方补充版本信息",
),
directive,
)
}
@Test
fun appliesDefaultTimeout() {
assertEquals(30, parseFollowUpWait(arguments(timeoutSeconds = null))?.timeoutSeconds)
}
@Test
fun rejectsWaitWithoutTargetsOrConcreteCondition() {
assertNull(
parseFollowUpWait(
buildJsonObject {
putJsonObject(FOLLOW_UP_WAIT_ARGUMENT) {
putJsonArray("fromUserIds") {}
put("condition", "等待回复")
}
}
)
)
assertNull(
parseFollowUpWait(
buildJsonObject {
putJsonObject(FOLLOW_UP_WAIT_ARGUMENT) {
putJsonArray("fromUserIds") { add(123) }
put("condition", " ")
}
}
)
)
}
@Test
fun rejectsOutOfRangeTimeoutAndDuplicateTargets() {
assertNull(parseFollowUpWait(arguments(timeoutSeconds = 121)))
assertNull(
parseFollowUpWait(
buildJsonObject {
putJsonObject(FOLLOW_UP_WAIT_ARGUMENT) {
put("timeoutSeconds", 30)
putJsonArray("fromUserIds") {
add(123)
add(123)
}
put("condition", "等待对方补充版本信息")
}
}
)
)
}
private fun arguments(timeoutSeconds: Int?) = buildJsonObject {
putJsonObject(FOLLOW_UP_WAIT_ARGUMENT) {
if (timeoutSeconds != null) put("timeoutSeconds", timeoutSeconds)
putJsonArray("fromUserIds") {
add(123)
add(456)
}
put("condition", "等待对方补充版本信息")
}
}
}