package top.jie65535.mirai.llm import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonObject import top.jie65535.mirai.JChatGPT import top.jie65535.mirai.config.PluginConfig import kotlin.time.Duration.Companion.milliseconds object LargeLanguageModels { /** * 系统提示词 */ var systemPrompt: String = "你是一个乐于助人的助手" private set /** * 一个聊天接入点:封装了请求服务、模型名与温度。 * 主接入点为列表第 0 项,其余为备用接入点,用于容灾切换。 */ data class ChatEndpoint( val service: ModelService, val model: String, val temperature: Double?, /** 唯一标识,用于健康状态跟踪与日志 */ val label: String, ) data class ProfileEndpoint( val service: ModelService, val model: String, val temperature: Double?, ) /** * 聊天接入点列表:index 0 为主接入点,其余按配置顺序为备用接入点。 */ var chatEndpoints: List = emptyList() private set /** * 主聊天接入点服务(向后兼容旧用法)。 */ val chat: ModelService? get() = chatEndpoints.firstOrNull()?.service /** * 推理模型 */ var reasoning: ModelService? = null /** * 视觉模型 */ var visual: ModelService? = null /** 历史用户画像分析模型。 */ var profile: ProfileEndpoint? = null private set /** * 接入点健康状态:记录各接入点的冷却截止时间戳(毫秒)。 * 失败的接入点进入冷却,期间在 [orderedChatEndpoints] 中被排到队尾, * 避免每条消息都先卡在故障接入点上白白等一次超时。 */ private val cooldownUntil = HashMap() /** 上报某接入点调用失败,使其进入冷却。 */ fun reportFailure(endpoint: ChatEndpoint) { val minutes = PluginConfig.fallbackCooldownMinutes // 只有存在备用接入点时冷却才有意义;否则没有可切换的目标,标记冷却反而无益 if (minutes > 0 && chatEndpoints.size > 1) { cooldownUntil[endpoint.label] = System.currentTimeMillis() + minutes * 60_000L } } /** 上报某接入点调用成功,清除其冷却。 */ fun reportSuccess(endpoint: ChatEndpoint) { cooldownUntil.remove(endpoint.label) } /** * 返回按健康度排序的接入点:未冷却的保持配置原顺序在前,冷却中的排到后面 * (冷却中再按剩余冷却时间升序,优先重试快恢复的)。排序稳定,主接入点健康时始终最先。 */ fun orderedChatEndpoints(): List { if (chatEndpoints.size <= 1) return chatEndpoints val now = System.currentTimeMillis() return chatEndpoints.sortedBy { ep -> val until = cooldownUntil[ep.label] ?: 0L if (until > now) until else 0L } } private val json = Json { isLenient = true ignoreUnknownKeys = true } private fun parseExtraBody(raw: String): JsonObject? { if (raw.isBlank()) return null return try { json.parseToJsonElement(raw).jsonObject } catch (_: Exception) { null } } fun reload() { val timeout = PluginConfig.timeout.milliseconds val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds // 初始化聊天接入点(主 + 备用),并重置健康状态 cooldownUntil.clear() val endpoints = mutableListOf() if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) { endpoints.add( ChatEndpoint( service = ModelService( baseUrl = PluginConfig.openAiApi, token = PluginConfig.openAiToken, timeout = timeout, firstChunkTimeout = firstChunkTimeout, extraBody = parseExtraBody(PluginConfig.chatModelExtraBody) ), model = PluginConfig.chatModel, temperature = PluginConfig.chatTemperature, label = "primary", ) ) // 备用接入点:留空字段继承主接入点配置 PluginConfig.chatFallbacks.forEachIndexed { i, fb -> val api = fb.api.ifBlank { PluginConfig.openAiApi } val token = fb.token.ifBlank { PluginConfig.openAiToken } val model = fb.model.ifBlank { PluginConfig.chatModel } val extraBody = fb.extraBody.ifBlank { PluginConfig.chatModelExtraBody } if (api.isNotBlank() && token.isNotBlank()) { endpoints.add( ChatEndpoint( service = ModelService( baseUrl = api, token = token, timeout = timeout, firstChunkTimeout = firstChunkTimeout, extraBody = parseExtraBody(extraBody) ), model = model, temperature = PluginConfig.chatTemperature, label = "fallback$i:$model", ) ) } } } chatEndpoints = endpoints profile = null if (PluginConfig.profileEnabled) { val api = PluginConfig.profileModelApi.ifBlank { PluginConfig.openAiApi } val token = PluginConfig.profileModelToken.ifBlank { PluginConfig.openAiToken } val model = PluginConfig.profileModel.ifBlank { PluginConfig.chatModel } val extraBody = PluginConfig.profileModelExtraBody.ifBlank { PluginConfig.chatModelExtraBody } if (api.isNotBlank() && token.isNotBlank() && model.isNotBlank()) { val profileFirstChunk = PluginConfig.profileFirstChunkTimeout.milliseconds profile = ProfileEndpoint( service = ModelService( baseUrl = api, token = token, timeout = maxOf(timeout, profileFirstChunk), firstChunkTimeout = profileFirstChunk, extraBody = parseExtraBody(extraBody), maxConcurrentRequests = PluginConfig.profileMaxConcurrentRequests, ), model = model, temperature = PluginConfig.profileModelTemperature ?: PluginConfig.chatTemperature, ) } } // 初始化推理模型 if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) { // 推理模型出首块前常有思考预热,比对话慢,使用单独放宽的首块超时; // socket 超时(两次读间隔,等首块时也归它管)不能小于首块预算,否则首块超时形同虚设 val reasoningFirstChunk = PluginConfig.reasoningFirstChunkTimeout.milliseconds reasoning = ModelService( baseUrl = PluginConfig.reasoningModelApi, token = PluginConfig.reasoningModelToken, timeout = maxOf(timeout, reasoningFirstChunk), firstChunkTimeout = reasoningFirstChunk, extraBody = parseExtraBody(PluginConfig.reasoningModelExtraBody) ) } // 初始化视觉模型 if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) { // 视觉模型需服务端先下载图片再出首块,比对话天然慢,使用单独放宽的首块超时; // socket 超时(两次读间隔,等首块时也归它管)不能小于首块预算,否则首块超时形同虚设 val visualFirstChunk = PluginConfig.visualFirstChunkTimeout.milliseconds visual = ModelService( baseUrl = PluginConfig.visualModelApi, token = PluginConfig.visualModelToken, timeout = maxOf(timeout, visualFirstChunk), firstChunkTimeout = visualFirstChunk, extraBody = parseExtraBody(PluginConfig.visualModelExtraBody) ) } // 载入提示词 if (PluginConfig.promptFile.isNotEmpty()) { val file = JChatGPT.resolveConfigFile(PluginConfig.promptFile) systemPrompt = if (file.exists()) { file.readText() } else { // 迁移提示词 file.writeText(PluginConfig.prompt) PluginConfig.prompt } // 空提示词兜底 if (systemPrompt.isEmpty()) { systemPrompt = "你是一个乐于助人的助手" } } } }