mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
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:
@@ -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[系统提示:因内容过长,部分内容已被省略]"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user