profile: add progressive user profiles

Add cache-expiry profile maintenance and contextual injection, reorganize runtime code by responsibility, remove automatic favorability decay, and prepare version 1.15.0.
This commit is contained in:
2026-08-02 21:45:34 +08:00
parent 2ed39e9fe8
commit 23299b2ae7
59 changed files with 4799 additions and 1340 deletions
@@ -0,0 +1,344 @@
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.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.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 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<Long>()
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(
SendSingleMessageAgent(),
SendCompositeMessage(),
SendVoiceMessage(),
SendLaTeXExpression(),
StopLoopAgent(),
MemoryAppend(),
MemoryReplace(),
LoadSkill(),
SaveSkill(),
DeleteSkill(),
SearchChatHistory(),
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)
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<ToolCall.Function>()
val toolCallTasks = mutableListOf<Deferred<ChatMessage>>()
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)
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 (!streamingOk) {
LargeLanguageModels.reportFailure(endpoint)
if (endpointIndex < endpoints.lastIndex) {
endpointIndex++
JChatGPT.logger.warning(
"接入点[${endpoint.label}]调用失败,切换备用接入点[${endpoints[endpointIndex].label}]重试",
cause,
)
} else {
JChatGPT.logger.warning("接入点[${endpoint.label}]调用失败,无更多备用接入点,重试中", cause)
}
} else {
JChatGPT.logger.warning("调用llm后处理时发生异常,重试中", cause)
}
if (retry <= 1) throw cause
done = false
}
} while (!done && 0 < --retry)
} 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<ChatMessage>,
endpoint: LargeLanguageModels.ChatEndpoint,
onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null,
): Flow<ChatCompletionChunk> {
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[系统提示:因内容过长,部分内容已被省略]"
}
}