package top.jie65535.mirai.conversation import com.aallam.openai.api.chat.ChatCompletionChunk import com.aallam.openai.api.chat.ChatCompletionRequest import com.aallam.openai.api.chat.ChatMessage import com.aallam.openai.api.chat.ChatRole import com.aallam.openai.api.chat.ToolCall 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 import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.launch import net.mamoe.mirai.event.events.GroupMessageEvent import net.mamoe.mirai.event.events.MessageEvent import top.jie65535.mirai.JChatGPT import top.jie65535.mirai.config.PluginConfig import top.jie65535.mirai.data.TokenUsageStore import top.jie65535.mirai.llm.LargeLanguageModels import top.jie65535.mirai.llm.ModelService import top.jie65535.mirai.profile.ProfileAutoMaintenance import top.jie65535.mirai.tools.AdjustUserFavorabilityAgent import top.jie65535.mirai.tools.BaseAgent import top.jie65535.mirai.tools.DeleteSkill import top.jie65535.mirai.tools.GroupManageAgent import top.jie65535.mirai.tools.ImageAgent import top.jie65535.mirai.tools.LoadSkill import top.jie65535.mirai.tools.MemoryAppend import top.jie65535.mirai.tools.MemoryReplace import top.jie65535.mirai.tools.QueryUserProfileAgent import top.jie65535.mirai.tools.ReasoningAgent import top.jie65535.mirai.tools.RequestOwner import top.jie65535.mirai.tools.RunCode import top.jie65535.mirai.tools.SaveSkill import top.jie65535.mirai.tools.SearchChatHistory import top.jie65535.mirai.tools.SendCompositeMessage import top.jie65535.mirai.tools.SendLaTeXExpression import top.jie65535.mirai.tools.SendSingleMessageAgent import top.jie65535.mirai.tools.SendVoiceMessage import top.jie65535.mirai.tools.StopLoopAgent import top.jie65535.mirai.tools.VisitWeb import top.jie65535.mirai.tools.VisualAgent import top.jie65535.mirai.tools.WeatherService import top.jie65535.mirai.tools.WebSearch import top.jie65535.mirai.util.RetryBackoff import java.time.OffsetDateTime import java.time.format.DateTimeFormatter import kotlin.math.max import kotlin.math.min import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds internal object ConversationEngine { private val activeRequests = ConcurrentSet() private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss") private val thinkRegex = Regex("[\\s\\S]*?") private val tools: List = listOf( SendSingleMessageAgent(), SendCompositeMessage(), SendVoiceMessage(), SendLaTeXExpression(), StopLoopAgent(), MemoryAppend(), MemoryReplace(), LoadSkill(), SaveSkill(), DeleteSkill(), SearchChatHistory(), QueryUserProfileAgent(), WebSearch(), VisitWeb(), RunCode(), ReasoningAgent(), VisualAgent(), ImageAgent(), WeatherService(), AdjustUserFavorabilityAgent(), RequestOwner(), GroupManageAgent(), ) fun clear() { activeRequests.clear() } suspend fun start(event: MessageEvent) { val subjectId = event.subject.id if (!activeRequests.add(subjectId)) { JChatGPT.logger.warning("The current Contact is busy!") return } try { val cache = ConversationContext.cache(subjectId) val reuseCache = PluginConfig.enableContextCache && cache != null && !cache.isExpired(PluginConfig.contextCacheTimeoutMinutes * 60) val replyIndex = ConversationContext.activateReplyIndex( subjectId, cache?.replyIndex?.takeIf { reuseCache }, ) val imageIndex = ConversationContext.activateImageIndex( subjectId, cache?.imageIndex?.takeIf { reuseCache }, ) val history = if (reuseCache) { JChatGPT.logger.info("使用缓存的对话上下文,包含 ${cache.history.size} 条互动消息") cache.history } else mutableListOf() if (history.isEmpty() || cache == null) { val prompt = ConversationContext.getSystemPrompt(event) if (PluginConfig.logPrompt) JChatGPT.logger.info("Prompt: $prompt") history += ChatMessage(ChatRole.System, prompt) val historyText = ConversationContext.getHistory(event) JChatGPT.logger.info("注入聊天记录:\n$historyText") history += ChatMessage.User(historyText) } else { val newMessages = ConversationContext.getAfterHistory(cache.lastActivityAt, event) JChatGPT.logger.info("补充聊天记录:\n$newMessages") history += ChatMessage.User("## 以下是上次对话结束至今的新消息\n\n$newMessages") } val endpoints = LargeLanguageModels.orderedChatEndpoints() if (endpoints.isEmpty()) error("OpenAI Token 未设置,无法开始") var endpointIndex = 0 var done: Boolean var retry = max(PluginConfig.retryMax, 3) val retryBackoff = RetryBackoff.fromConfig() var consecutiveFailures = 0 do { val endpoint = endpoints[min(endpointIndex, endpoints.lastIndex)] var streamingOk = false try { val startedAt = OffsetDateTime.now().toEpochSecond().toInt() var lastCacheUsage: ModelService.CacheUsage? = null val responseFlow = chatCompletions(history, endpoint) { lastCacheUsage = it } var responseContent: StringBuilder? = null var reasoningContent: StringBuilder? = null val responseToolCalls = mutableListOf() val toolCallTasks = mutableListOf>() var lastTokenUsage: Usage? = null responseFlow.collect { chunk -> val delta = chunk.choices[0].delta ?: return@collect delta.reasoningContent?.let { content -> if (reasoningContent == null) reasoningContent = StringBuilder(content) else reasoningContent.append(content) } delta.content?.let { content -> if (responseContent == null) responseContent = StringBuilder(content) else responseContent.append(content) } delta.toolCalls?.forEach { toolCallChunk -> val index = toolCallChunk.index val function = toolCallChunk.function if (index >= responseToolCalls.size) { responseToolCalls.lastOrNull()?.let { toolCall -> toolCallTasks += JChatGPT.async { toolCall.toResultMessage(event) } } val id = toolCallChunk.id if (id != null && function != null) { responseToolCalls += ToolCall.Function(id, function) } } else if (function != null) { val current = responseToolCalls[index] var updated = current.function function.nameOrNull?.let { name -> updated = updated.copy(nameOrNull = updated.nameOrNull.orEmpty() + name) } function.argumentsOrNull?.let { arguments -> updated = updated.copy( argumentsOrNull = updated.argumentsOrNull.orEmpty() + arguments ) } responseToolCalls[index] = current.copy(function = updated) } } chunk.usage?.let { lastTokenUsage = it } } streamingOk = true LargeLanguageModels.reportSuccess(endpoint) consecutiveFailures = 0 val answer = responseContent?.replace(thinkRegex, "")?.trim() JChatGPT.logger.info("LLM Response: $answer") history += ChatMessage( role = ChatRole.Assistant, content = answer, toolCalls = responseToolCalls.ifEmpty { null }, reasoningContent = if (responseToolCalls.isNotEmpty()) reasoningContent?.toString() else null, ) recordUsage(event, lastTokenUsage, lastCacheUsage) if (responseToolCalls.size > toolCallTasks.size) { val finalToolResult = responseToolCalls.last().toResultMessage(event) if (toolCallTasks.isNotEmpty()) history += toolCallTasks.awaitAll() history += finalToolResult done = responseToolCalls.any { it.function.name == "endConversation" } } else { done = true } if (!done) { history += ChatMessage.User(buildContinuationPrompt(retry, startedAt, event)) } else { if (PluginConfig.enableContextCache) { ConversationContext.saveCache( subjectId, ConversationCache(history, startedAt, replyIndex, imageIndex), ) JChatGPT.logger.debug("已保存对话上下文到缓存") } if (event is GroupMessageEvent) { ProfileAutoMaintenance.recordCompletedConversation(event, startedAt) } } } catch (cause: Exception) { if (cause is CancellationException) throw cause val failureMessage = if (!streamingOk) { LargeLanguageModels.reportFailure(endpoint) if (endpointIndex < endpoints.lastIndex) { endpointIndex++ "接入点[${endpoint.label}]调用失败,将切换备用接入点[${endpoints[endpointIndex].label}]" } else { "接入点[${endpoint.label}]调用失败,无更多备用接入点" } } else { "调用llm后处理时发生异常" } if (retry <= 1) { JChatGPT.logger.warning("$failureMessage,已无剩余尝试", cause) throw cause } consecutiveFailures++ val retryDelayMillis = retryBackoff.delayMillis(consecutiveFailures) JChatGPT.logger.warning( "$failureMessage,将在 ${retryDelayMillis}ms 后重试", cause, ) if (retryDelayMillis > 0) delay(retryDelayMillis) done = false } } while (!done && 0 < --retry) } catch (cause: CancellationException) { throw cause } catch (cause: Throwable) { JChatGPT.logger.warning(cause) event.subject.sendMessage("很抱歉,发生异常,请稍后重试") } finally { ConversationContext.releaseActiveIndexes(subjectId) JChatGPT.launch { delay(500.milliseconds) activeRequests.remove(subjectId) } } } private fun chatCompletions( history: List, endpoint: LargeLanguageModels.ChatEndpoint, onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null, ): Flow { val availableTools = tools.filter { it.isEnabled }.map { it.tool } val request = ChatCompletionRequest( model = ModelId(endpoint.model), temperature = endpoint.temperature, messages = history, tools = availableTools, ) JChatGPT.logger.info("API Requesting... Model=${endpoint.model} [${endpoint.label}]") return endpoint.service.chatCompletions(request, onCacheUsage) } private suspend fun ToolCall.Function.toResultMessage(event: MessageEvent): ChatMessage = ChatMessage( role = ChatRole.Tool, toolCallId = id, name = function.name, content = execute(event), ) private suspend fun ToolCall.Function.execute(event: MessageEvent): String { val agent = tools.find { it.tool.function.name == function.name } ?: return "Function ${function.name} not found" val receipt = if (PluginConfig.showToolCallingMessage && agent.loadingMessage.isNotEmpty()) { event.subject.sendMessage(agent.loadingMessage) } else null val result = try { val arguments = function.argumentsAsJsonOrNull() JChatGPT.logger.info("Calling ${function.name}($arguments)") agent.execute(arguments, event) } catch (cause: Throwable) { JChatGPT.logger.error("Failed to call ${function.name}", cause) "工具调用失败,请尝试自行回答用户,或如实告知。\n异常信息:${cause.message}" } JChatGPT.logger.info("Result=\"$result\"") val truncated = truncateToolOutput(result) if (truncated.length != result.length) { JChatGPT.logger.warning( "工具 ${function.name} 返回内容过长,已从 ${result.length} 字符截断至 ${truncated.length} 字符" ) } if (receipt != null) { JChatGPT.launch { delay(3.seconds) try { receipt.recall() } catch (cause: Throwable) { JChatGPT.logger.error( "消息撤回失败,调试信息:source.internalIds=${receipt.source.internalIds.joinToString()} " + "source.ids=${receipt.source.ids.joinToString()}", cause, ) } } } return truncated } private fun buildContinuationPrompt(retry: Int, startedAt: Int, event: MessageEvent): String = buildString { appendLine("## 系统提示") append("本次运行最多还剩").append(retry - 1).appendLine("轮。") appendLine("如果要多次发言,可以一次性调用多次发言工具。") appendLine("如果没有什么要做的,可以提前结束。") appendLine("当前时间:${dateTimeFormatter.format(OffsetDateTime.now())}") val messages = ConversationContext.getAfterHistory(startedAt, event) if (messages.isNotEmpty()) append("## 以下是上次运行至今的新消息\n\n$messages") } private fun recordUsage( event: MessageEvent, usage: Usage?, cacheUsage: ModelService.CacheUsage?, ) { usage ?: return val group = (event as? GroupMessageEvent)?.group TokenUsageStore.record( timestamp = OffsetDateTime.now().toEpochSecond(), userId = event.sender.id, userNickname = event.senderName, groupId = group?.id, groupName = group?.name, promptTokens = usage.promptTokens ?: 0, completionTokens = usage.completionTokens ?: 0, totalTokens = usage.totalTokens ?: 0, cachedTokens = cacheUsage?.hitTokens ?: 0, ) } private fun truncateToolOutput(content: String): String { val maxLength = PluginConfig.maxToolOutputLength return if (content.length <= maxLength) content else content.take(maxLength) + "\n\n[系统提示:因内容过长,部分内容已被省略]" } }