Compare commits

...
4 Commits
Author SHA1 Message Date
jie65535andClaude Opus 4.8 59a98830cd Skip muted-group messages to avoid wasting tokens
When the bot is muted in a group it cannot send replies, so process
nothing: short-circuit in onMessage before any trigger/LLM work using
Group.botMuteRemaining.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-26 14:08:05 +08:00
jie65535andClaude Opus 4.8 bc61e99013 Give visual and reasoning models their own first-chunk timeouts
Vision and reasoning requests reused the chat model's 10s first-chunk
timeout, but both legitimately need longer before the first chunk:
vision must download the image server-side first, and reasoning has a
thinking warmup. Logs showed frequent TimeoutCancellationException at
10s/15s for imageRecognition and reasoning.

Add separate visualFirstChunkTimeout (120s) and reasoningFirstChunkTimeout
(90s) config, and raise each service's socket timeout to at least its
first-chunk budget so the socket layer doesn't sever the connection
before the first-chunk timeout can apply. Chat endpoints are unchanged.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-26 14:06:00 +08:00
jie65535andClaude Opus 4.8 826dc460df Add chat endpoint failover for resilience against unstable LLM APIs
Configure a list of fallback chat endpoints (chatFallbacks); each blank
field inherits the primary, so you can swap just the API key, just the
model, or the whole vendor. On LLM streaming failure the retry loop
advances to the next endpoint, and a failed endpoint enters a cooldown
(fallbackCooldownMinutes) so a dead primary is skipped instead of
wasting a timeout on every message.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-24 22:23:43 +08:00
jie65535andClaude Opus 4.8 13329b5fa3 Track cache-hit tokens and consolidate token stats into one dashboard
Capture DeepSeek's prompt_cache_hit_tokens (dropped before by the
openai-kotlin Usage parser) via a raw-JSON extractor in ModelService,
and persist it plus the group name on each daily record.

Collapse the six /jgpt tokens* subcommands into a single /jgpt tokens
dashboard showing cache-hit rate, input/output split, daily trend and
top users/groups. Groups are shown by name only, never by group id.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-23 23:01:16 +08:00
8 changed files with 340 additions and 224 deletions
+41
View File
@@ -5,6 +5,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
## 功能特性 ## 功能特性
- **多模型支持**:支持聊天模型、推理模型和视觉模型 - **多模型支持**:支持聊天模型、推理模型和视觉模型
- **接入点容灾**:聊天模型可配置多个备用接入点,主接入点 key 到期 / 限流 / 服务不稳定时自动切换
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等 - **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
- **上下文记忆**:支持持久化记忆存储 - **上下文记忆**:支持持久化记忆存储
- **技能系统**:Bot 可在群聊中自我沉淀可复用知识,全局跨群、按需加载、低上下文污染 - **技能系统**:Bot 可在群聊中自我沉淀可复用知识,全局跨群、按需加载、低上下文污染
@@ -88,6 +89,15 @@ visualModelToken: ''
visualModel: 'qwen-vl-plus' visualModel: 'qwen-vl-plus'
# 聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {"thinking": {"type": "disabled"}} # 聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {"thinking": {"type": "disabled"}}
chatModelExtraBody: '' chatModelExtraBody: ''
# 聊天模型备用接入点列表(容灾)。主接入点连续失败时按顺序切换;每项留空的字段会继承主接入点
# 例如只换API KEY就只填token,只换模型就只填model,整体换服务商就都填
chatFallbacks: []
# - api: 'https://api.deepseek.com/v1/'
# token: 'sk-xxxx'
# model: 'deepseek-chat'
# extraBody: ''
# 备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。0为禁用
fallbackCooldownMinutes: 5
# 推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {"thinking": {"type": "enabled"}} # 推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {"thinking": {"type": "enabled"}}
reasoningModelExtraBody: '' reasoningModelExtraBody: ''
# 视觉模型额外请求体JSON,会合并到请求体中。 # 视觉模型额外请求体JSON,会合并到请求体中。
@@ -276,6 +286,37 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型
当然,也可以配置为使用其他兼容 OpenAI API 的模型,如 GPT 系列模型。 当然,也可以配置为使用其他兼容 OpenAI API 的模型,如 GPT 系列模型。
## 接入点容灾
聊天模型支持配置多个**备用接入点**,当主接入点连续调用失败(key 到期、用量超限、服务不稳定、超时等)时自动切换,提升可用性。
### 配置方式
`chatFallbacks` 中按优先级顺序列出备用接入点。每项的任意字段**留空则继承主接入点**对应配置,因此三种容灾场景都覆盖:
```yaml
chatFallbacks:
# 只换 API KEY(同服务商备用 key)
- token: 'sk-备用key'
# 只换模型(同接入点降级到更稳定/更便宜的模型)
- model: 'qwen-plus'
# 整体换一个服务商
- api: 'https://api.deepseek.com/v1/'
token: 'sk-deepseek-xxxx'
model: 'deepseek-chat'
extraBody: ''
fallbackCooldownMinutes: 5
```
### 工作机制
- 主接入点为列表首位,备用接入点按配置顺序排在其后。
- **单次对话内**:某接入点流式调用失败时,立即切换到下一个接入点重试,而非反复重试同一个故障点。
- **跨对话冷却**:失败的接入点进入冷却期(`fallbackCooldownMinutes` 分钟),冷却期内会被排到重试队尾。这样主接入点 key 到期后,后续消息会直接走健康的备用接入点,不必每条都先卡一次超时。调用成功或冷却到期后自动恢复。
- 仅在**LLM 调用本身失败**时才切换接入点,后续工具执行异常不会误判正常接入点为故障。
- 推理模型、视觉模型不受影响,仍各自独立配置。
- `/jgpt reload` 会重建接入点列表并清空冷却状态。
## 工具系统 ## 工具系统
插件内置了丰富的工具供 AI 调用: 插件内置了丰富的工具供 AI 调用:
+45 -11
View File
@@ -42,6 +42,7 @@ import java.time.ZoneOffset
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import kotlin.collections.* import kotlin.collections.*
import kotlin.math.max import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow import kotlin.math.pow
import kotlin.math.sign import kotlin.math.sign
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -195,6 +196,11 @@ object JChatGPT : KotlinPlugin(
private suspend fun onMessage(event: MessageEvent) { private suspend fun onMessage(event: MessageEvent) {
// 检查Token是否设置 // 检查Token是否设置
if (LargeLanguageModels.chat == null) return if (LargeLanguageModels.chat == null) return
// 如果bot在群里被禁言,则无法发言,直接结束,避免浪费token
if (event is GroupMessageEvent && event.group.botMuteRemaining > 0) {
logger.info("bot 在群 ${event.group.name}(${event.group.id}) 被禁言,剩余 ${event.group.botMuteRemaining} 秒,忽略消息")
return
}
// 发送者是否有权限 // 发送者是否有权限
if (!event.toCommandSender().hasPermission(chatPermission)) { if (!event.toCommandSender().hasPermission(chatPermission)) {
if (event is GroupMessageEvent) { if (event is GroupMessageEvent) {
@@ -683,13 +689,23 @@ object JChatGPT : KotlinPlugin(
) )
} }
// 聊天接入点容灾:按健康度排序,主接入点故障冷却时备用接入点会自动排到前面
val endpoints = LargeLanguageModels.orderedChatEndpoints()
if (endpoints.isEmpty()) throw NullPointerException("OpenAI Token 未设置,无法开始")
var endpointIndex = 0
var done: Boolean var done: Boolean
// 至少循环3次 // 至少循环3次
var retry = max(PluginConfig.retryMax, 3) var retry = max(PluginConfig.retryMax, 3)
do { do {
// 当前使用的接入点:失败重试时会前移到下一个备用接入点
val endpoint = endpoints[min(endpointIndex, endpoints.lastIndex)]
// 标记本轮 LLM 流式调用是否成功完成,用于精确区分「LLM失败」与「后续工具失败」
var streamingOk = false
try { try {
val startedAt = OffsetDateTime.now().toEpochSecond().toInt() val startedAt = OffsetDateTime.now().toEpochSecond().toInt()
val responseFlow = chatCompletions(history) var lastCacheUsage: ModelService.CacheUsage? = null
val responseFlow = chatCompletions(history, endpoint) { lastCacheUsage = it }
var responseMessageBuilder: StringBuilder? = null var responseMessageBuilder: StringBuilder? = null
var reasoningContentBuilder: StringBuilder? = null var reasoningContentBuilder: StringBuilder? = null
val responseToolCalls = mutableListOf<ToolCall.Function>() val responseToolCalls = mutableListOf<ToolCall.Function>()
@@ -769,6 +785,9 @@ object JChatGPT : KotlinPlugin(
// 捕获token使用量 // 捕获token使用量
chunk.usage?.let { lastTokenUsage = it } chunk.usage?.let { lastTokenUsage = it }
} }
// LLM 流式调用成功完成,上报接入点健康(清除冷却)
streamingOk = true
LargeLanguageModels.reportSuccess(endpoint)
// 移除思考内容 // 移除思考内容
val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim() val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim()
@@ -789,15 +808,17 @@ object JChatGPT : KotlinPlugin(
// 记录token使用量(按日聚合,独立JSON文件) // 记录token使用量(按日聚合,独立JSON文件)
lastTokenUsage?.let { usage -> lastTokenUsage?.let { usage ->
val now = OffsetDateTime.now().toEpochSecond() val now = OffsetDateTime.now().toEpochSecond()
val groupId = if (event is GroupMessageEvent) event.subject.id else null val group = if (event is GroupMessageEvent) event.group else null
TokenUsageStore.record( TokenUsageStore.record(
timestamp = now, timestamp = now,
userId = event.sender.id, userId = event.sender.id,
userNickname = event.senderName, userNickname = event.senderName,
groupId = groupId, groupId = group?.id,
groupName = group?.name,
promptTokens = usage.promptTokens ?: 0, promptTokens = usage.promptTokens ?: 0,
completionTokens = usage.completionTokens ?: 0, completionTokens = usage.completionTokens ?: 0,
totalTokens = usage.totalTokens ?: 0 totalTokens = usage.totalTokens ?: 0,
cachedTokens = lastCacheUsage?.hitTokens ?: 0
) )
} }
@@ -853,11 +874,23 @@ object JChatGPT : KotlinPlugin(
} }
} }
} catch (e: Exception) { } catch (e: Exception) {
// 仅当 LLM 流式调用本身失败时才上报接入点故障并切换;
// 若流式已成功、异常来自后续工具执行,则保持当前接入点不变
if (!streamingOk) {
LargeLanguageModels.reportFailure(endpoint)
if (endpointIndex < endpoints.lastIndex) {
endpointIndex++
logger.warning("接入点[${endpoint.label}]调用失败,切换备用接入点[${endpoints[endpointIndex].label}]重试", e)
} else {
logger.warning("接入点[${endpoint.label}]调用失败,无更多备用接入点,重试中", e)
}
} else {
logger.warning("调用llm后处理时发生异常,重试中", e)
}
if (retry <= 1) { if (retry <= 1) {
throw e throw e
} else { } else {
done = false done = false
logger.warning("调用llm时发生异常,重试中", e)
// event.subject.sendMessage("出错了...正在重试...") // event.subject.sendMessage("出错了...正在重试...")
} }
} }
@@ -1028,20 +1061,21 @@ object JChatGPT : KotlinPlugin(
private fun chatCompletions( private fun chatCompletions(
chatMessages: List<ChatMessage>, chatMessages: List<ChatMessage>,
hasTools: Boolean = true endpoint: LargeLanguageModels.ChatEndpoint,
hasTools: Boolean = true,
onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null
): Flow<ChatCompletionChunk> { ): Flow<ChatCompletionChunk> {
val llm = LargeLanguageModels.chat ?: throw NullPointerException("OpenAI Token 未设置,无法开始")
val availableTools = if (hasTools) { val availableTools = if (hasTools) {
myTools.filter { it.isEnabled }.map { it.tool } myTools.filter { it.isEnabled }.map { it.tool }
} else null } else null
val request = ChatCompletionRequest( val request = ChatCompletionRequest(
model = ModelId(PluginConfig.chatModel), model = ModelId(endpoint.model),
temperature = PluginConfig.chatTemperature, temperature = endpoint.temperature,
messages = chatMessages, messages = chatMessages,
tools = availableTools, tools = availableTools,
) )
logger.info("API Requesting... Model=${PluginConfig.chatModel}") logger.info("API Requesting... Model=${endpoint.model} [${endpoint.label}]")
return llm.chatCompletions(request) return endpoint.service.chatCompletions(request, onCacheUsage)
} }
private fun getNameCard(member: Member): String { private fun getNameCard(member: Member): String {
+101 -8
View File
@@ -14,9 +14,28 @@ object LargeLanguageModels {
private set private set
/** /**
* 聊天助手 * 一个聊天接入点:封装了请求服务、模型名与温度。
* 主接入点为列表第 0 项,其余为备用接入点,用于容灾切换。
*/ */
var chat: ModelService? = null data class ChatEndpoint(
val service: ModelService,
val model: String,
val temperature: Double?,
/** 唯一标识,用于健康状态跟踪与日志 */
val label: String,
)
/**
* 聊天接入点列表:index 0 为主接入点,其余按配置顺序为备用接入点。
*/
var chatEndpoints: List<ChatEndpoint> = emptyList()
private set
/**
* 主聊天接入点服务(向后兼容旧用法)。
*/
val chat: ModelService?
get() = chatEndpoints.firstOrNull()?.service
/** /**
* 推理模型 * 推理模型
@@ -28,6 +47,40 @@ object LargeLanguageModels {
*/ */
var visual: ModelService? = null var visual: ModelService? = null
/**
* 接入点健康状态:记录各接入点的冷却截止时间戳(毫秒)。
* 失败的接入点进入冷却,期间在 [orderedChatEndpoints] 中被排到队尾,
* 避免每条消息都先卡在故障接入点上白白等一次超时。
*/
private val cooldownUntil = HashMap<String, Long>()
/** 上报某接入点调用失败,使其进入冷却。 */
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<ChatEndpoint> {
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 { private val json = Json {
isLenient = true isLenient = true
ignoreUnknownKeys = true ignoreUnknownKeys = true
@@ -46,35 +99,75 @@ object LargeLanguageModels {
val timeout = PluginConfig.timeout.milliseconds val timeout = PluginConfig.timeout.milliseconds
val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds
// 初始化聊天模型 // 初始化聊天接入点(主 + 备用),并重置健康状态
cooldownUntil.clear()
val endpoints = mutableListOf<ChatEndpoint>()
if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) { if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) {
chat = ModelService( endpoints.add(
ChatEndpoint(
service = ModelService(
baseUrl = PluginConfig.openAiApi, baseUrl = PluginConfig.openAiApi,
token = PluginConfig.openAiToken, token = PluginConfig.openAiToken,
timeout = timeout, timeout = timeout,
firstChunkTimeout = firstChunkTimeout, firstChunkTimeout = firstChunkTimeout,
extraBody = parseExtraBody(PluginConfig.chatModelExtraBody) 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
// 初始化推理模型 // 初始化推理模型
if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) { if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) {
// 推理模型出首块前常有思考预热,比对话慢,使用单独放宽的首块超时;
// socket 超时(两次读间隔,等首块时也归它管)不能小于首块预算,否则首块超时形同虚设
val reasoningFirstChunk = PluginConfig.reasoningFirstChunkTimeout.milliseconds
reasoning = ModelService( reasoning = ModelService(
baseUrl = PluginConfig.reasoningModelApi, baseUrl = PluginConfig.reasoningModelApi,
token = PluginConfig.reasoningModelToken, token = PluginConfig.reasoningModelToken,
timeout = timeout, timeout = maxOf(timeout, reasoningFirstChunk),
firstChunkTimeout = firstChunkTimeout, firstChunkTimeout = reasoningFirstChunk,
extraBody = parseExtraBody(PluginConfig.reasoningModelExtraBody) extraBody = parseExtraBody(PluginConfig.reasoningModelExtraBody)
) )
} }
// 初始化视觉模型 // 初始化视觉模型
if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) { if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) {
// 视觉模型需服务端先下载图片再出首块,比对话天然慢,使用单独放宽的首块超时;
// socket 超时(两次读间隔,等首块时也归它管)不能小于首块预算,否则首块超时形同虚设
val visualFirstChunk = PluginConfig.visualFirstChunkTimeout.milliseconds
visual = ModelService( visual = ModelService(
baseUrl = PluginConfig.visualModelApi, baseUrl = PluginConfig.visualModelApi,
token = PluginConfig.visualModelToken, token = PluginConfig.visualModelToken,
timeout = timeout, timeout = maxOf(timeout, visualFirstChunk),
firstChunkTimeout = firstChunkTimeout, firstChunkTimeout = visualFirstChunk,
extraBody = parseExtraBody(PluginConfig.visualModelExtraBody) extraBody = parseExtraBody(PluginConfig.visualModelExtraBody)
) )
} }
+28 -3
View File
@@ -48,7 +48,28 @@ class ModelService(
explicitNulls = false explicitNulls = false
} }
fun chatCompletions(request: ChatCompletionRequest): Flow<ChatCompletionChunk> { /**
* 一次响应的缓存命中用量。DeepSeek 在 usage 顶层返回的非标准字段,
* openai-kotlin 的 Usage 类不含这些字段,必须从原始 JSON 抠出来。
*/
data class CacheUsage(val hitTokens: Int, val missTokens: Int)
/** 从原始 data 行(已去掉 "data: " 前缀)解析缓存命中用量;无相关字段返回 null。 */
private fun extractCacheUsage(rawJson: String): CacheUsage? {
return try {
val usage = json.parseToJsonElement(rawJson).jsonObject["usage"]?.jsonObject ?: return null
val hit = usage["prompt_cache_hit_tokens"]?.jsonPrimitive?.intOrNull
val miss = usage["prompt_cache_miss_tokens"]?.jsonPrimitive?.intOrNull
if (hit == null && miss == null) null else CacheUsage(hit ?: 0, miss ?: 0)
} catch (_: Exception) {
null
}
}
fun chatCompletions(
request: ChatCompletionRequest,
onCacheUsage: ((CacheUsage) -> Unit)? = null
): Flow<ChatCompletionChunk> {
val requestJson = json.encodeToJsonElement(ChatCompletionRequest.serializer(), request) val requestJson = json.encodeToJsonElement(ChatCompletionRequest.serializer(), request)
.jsonObject.toMutableMap() .jsonObject.toMutableMap()
requestJson["stream"] = JsonPrimitive(true) requestJson["stream"] = JsonPrimitive(true)
@@ -91,7 +112,9 @@ class ModelService(
} }
if (firstDataLine != null && !firstDataLine.startsWith("data: [DONE]")) { if (firstDataLine != null && !firstDataLine.startsWith("data: [DONE]")) {
emit(json.decodeFromString(firstDataLine.removePrefix("data: "))) val firstRaw = firstDataLine.removePrefix("data: ")
emit(json.decodeFromString(firstRaw))
onCacheUsage?.let { cb -> extractCacheUsage(firstRaw)?.let(cb) }
val ch = channel!! val ch = channel!!
while (currentCoroutineContext().isActive && !ch.isClosedForRead) { while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
@@ -101,7 +124,9 @@ class ModelService(
when { when {
line.startsWith("data: [DONE]") -> break line.startsWith("data: [DONE]") -> break
line.startsWith("data: ") -> { line.startsWith("data: ") -> {
emit(json.decodeFromString(line.removePrefix("data: "))) val raw = line.removePrefix("data: ")
emit(json.decodeFromString(raw))
onCacheUsage?.let { cb -> extractCacheUsage(raw)?.let(cb) }
} }
else -> continue else -> continue
} }
+77 -190
View File
@@ -101,217 +101,85 @@ object PluginCommands : CompositeCommand(
val cutoff = calculateCutoffDate(days) val cutoff = calculateCutoffDate(days)
val today = LocalDate.now().toString() val today = LocalDate.now().toString()
data class Statistics( val windowed = TokenUsageStore.all.filter { it.date >= cutoff }
var totalTokens: Long = 0, if (windowed.isEmpty()) {
var todayTokens: Long = 0, sendMessage("最近 $days 天无 Token 使用记录")
val userTotals: MutableMap<Long, Pair<String, Long>> = mutableMapOf(), return
val groupTotals: MutableMap<Long, Long> = mutableMapOf(),
val users: MutableSet<Long> = mutableSetOf()
)
val stats = TokenUsageStore.all.fold(Statistics()) { acc, record ->
if (record.date >= cutoff) {
acc.totalTokens += record.totalTokens
acc.users.add(record.userId)
val existing = acc.userTotals[record.userId]
if (existing == null) {
acc.userTotals[record.userId] = record.userNickname to record.totalTokens
} else {
acc.userTotals[record.userId] = existing.first to (existing.second + record.totalTokens)
} }
record.groupId?.let { groupId -> // 窗口汇总
acc.groupTotals[groupId] = acc.groupTotals.getOrDefault(groupId, 0L) + record.totalTokens var prompt = 0L; var completion = 0L; var total = 0L; var cached = 0L
} var calls = 0; var todayTotal = 0L
val users = HashSet<Long>()
for (r in windowed) {
prompt += r.promptTokens
completion += r.completionTokens
total += r.totalTokens
cached += r.cachedTokens
calls += r.callCount
users.add(r.userId)
if (r.date == today) todayTotal += r.totalTokens
} }
val hitRate = if (prompt > 0) cached * 100.0 / prompt else 0.0
if (record.date == today) { // 每日趋势
acc.todayTokens += record.totalTokens val daily = windowed.groupBy { it.date }
} .mapValues { (_, rs) -> rs.sumOf { it.totalTokens } }
acc
}
val topUser = stats.userTotals.entries.maxByOrNull { it.value.second }
val topGroup = stats.groupTotals.entries.maxByOrNull { it.value }
val response = buildString {
appendLine("📊 Token 使用简报(最近 $days 天)")
appendLine()
appendLine("总计: ${formatNumber(stats.totalTokens)} tokens")
appendLine("今日: ${formatNumber(stats.todayTokens)} tokens")
appendLine("活跃用户: ${stats.users.size}")
topUser?.let {
appendLine()
appendLine("👤 最活跃用户:")
appendLine(" ${it.value.first} - ${formatNumber(it.value.second)} tokens")
}
topGroup?.let {
appendLine()
appendLine("👥 最活跃群组:")
appendLine(" ${it.key} - ${formatNumber(it.value)} tokens")
}
appendLine()
appendLine("📋 详细查询:")
appendLine(" /jgpt tokensDaily [days] - 每日统计")
appendLine(" /jgpt tokensUsers [limit] - 用户排名")
appendLine(" /jgpt tokensGroups [limit] - 群组排名")
appendLine(" /jgpt tokensQuery [userId] [days] - 每日逐人记录")
appendLine(" /jgpt tokensUserDaily <userId> [days] - 用户日统计")
}
sendMessage(response)
}
@SubCommand
suspend fun CommandSender.tokensDaily(days: Int = 7) {
validateDays(days)
val cutoff = calculateCutoffDate(days)
val dailyStats = TokenUsageStore.all
.filter { it.date >= cutoff }
.groupBy { it.date }
.mapValues { (_, records) -> records.sumOf { it.totalTokens } }
.toSortedMap() .toSortedMap()
if (dailyStats.isEmpty()) { // Top 用户
sendMessage("指定时间范围内无使用记录") val topUsers = windowed.groupBy { it.userId }
return .map { (_, rs) ->
val name = rs.maxByOrNull { it.date }!!.userNickname
name to rs.sumOf { it.totalTokens }
} }
val response = buildString {
appendLine("最近 $days 天 Token 使用统计:")
appendLine()
dailyStats.forEach { (date, total) ->
appendLine("$date: ${formatNumber(total)} tokens")
}
}
sendMessage(response)
}
@SubCommand
suspend fun CommandSender.tokensUsers(limit: Int = 10) {
require(limit > 0) { "limit must be positive: $limit" }
val userStats = TokenUsageStore.all
.groupBy { it.userId }
.mapValues { (_, records) ->
val latest = records.maxByOrNull { it.date }!!
Pair(latest.userNickname, records.sumOf { it.totalTokens })
}
.toList()
.sortedByDescending { it.second.second }
.take(limit)
if (userStats.isEmpty()) {
sendMessage("暂无使用记录")
return
}
val response = buildString {
appendLine("Token 使用排名 Top $limit")
appendLine()
userStats.forEach {
appendLine("- ${it.second.first}(${it.first}): ${formatNumber(it.second.second)} tokens")
}
}
sendMessage(response)
}
@SubCommand
suspend fun CommandSender.tokensGroups(limit: Int = 10) {
require(limit > 0) { "limit must be positive: $limit" }
val groupStats = TokenUsageStore.all
.filter { it.groupId != null }
.groupBy { it.groupId!! }
.mapValues { (_, records) -> records.sumOf { it.totalTokens } }
.toList()
.sortedByDescending { it.second } .sortedByDescending { it.second }
.take(limit) .take(TOP_LIMIT)
if (groupStats.isEmpty()) { // Top 群组:只显示群名,绝不暴露群号(避免被误判宣群)
sendMessage("暂无群组使用记录") val topGroups = windowed.filter { it.groupId != null }
return .groupBy { it.groupId!! }
.map { (gid, rs) ->
val name = rs.firstNotNullOfOrNull { r -> r.groupName?.takeIf { it.isNotBlank() } }
?: resolveGroupName(gid)
name to rs.sumOf { it.totalTokens }
} }
.sortedByDescending { it.second }
.take(TOP_LIMIT)
val response = buildString { val response = buildString {
appendLine("群组 Token 使用排名 Top $limit") appendLine("📊 Token 简报 · 最近 $days")
appendLine() appendLine()
groupStats.forEach { (groupId, total) -> appendLine("输入 ${formatCompact(prompt)}(缓存命中 ${"%.1f".format(hitRate)}%,省 ${formatCompact(cached)}")
appendLine("- $groupId: ${formatNumber(total)} tokens") appendLine("输出 ${formatCompact(completion)}")
} appendLine("总计 ${formatCompact(total)} 调用 ${formatNumber(calls)} 活跃 ${users.size}")
} appendLine("今日 ${formatCompact(todayTotal)}")
sendMessage(response)
}
@SubCommand if (daily.size > 1) {
suspend fun CommandSender.tokensQuery(userId: Long?, days: Int = 7) {
validateDays(days)
val cutoff = calculateCutoffDate(days)
val filtered = TokenUsageStore.all
.filter { it.date >= cutoff }
.filter { userId == null || it.userId == userId }
.sortedWith(compareByDescending<TokenUsageDailyRecord> { it.date }.thenByDescending { it.totalTokens })
.take(DEFAULT_QUERY_LIMIT)
if (filtered.isEmpty()) {
sendMessage("指定时间范围内无使用记录")
return
}
val response = buildString {
appendLine("最近 $days 天使用记录(最多显示${DEFAULT_QUERY_LIMIT}条,按日聚合):")
appendLine() appendLine()
filtered.forEach { record -> appendLine("📈 每日趋势")
val location = if (record.groupId != null) "${record.groupId}" else "私聊" daily.forEach { (date, t) ->
appendLine("[${record.date}] $location - ${record.userNickname}") appendLine(" ${date.substring(5)} ${formatCompact(t)}")
appendLine(" 调用 ${record.callCount} 次, Tokens: ${formatNumber(record.totalTokens)} " + }
"(输入: ${formatNumber(record.promptTokens)}, 输出: ${formatNumber(record.completionTokens)})") }
if (topUsers.isNotEmpty()) {
appendLine() appendLine()
appendLine("👤 Top 用户")
topUsers.forEachIndexed { i, (name, t) ->
appendLine(" ${i + 1}. $name ${formatCompact(t)}")
} }
} }
sendMessage(response)
}
@SubCommand if (topGroups.isNotEmpty()) {
suspend fun CommandSender.tokensUserDaily(userId: Long, days: Int = 7) {
validateDays(days)
val cutoff = calculateCutoffDate(days)
val userRecords = TokenUsageStore.all
.filter { it.date >= cutoff && it.userId == userId }
if (userRecords.isEmpty()) {
sendMessage("用户 $userId 在指定时间范围内无使用记录")
return
}
val userNickname = userRecords.maxByOrNull { it.date }!!.userNickname
val userDailyStats = userRecords
.groupBy { it.date }
.mapValues { (_, records) -> records.sumOf { it.totalTokens } }
.toSortedMap()
val response = buildString {
appendLine("用户 $userNickname 最近 $days 天 Token 使用统计:")
appendLine() appendLine()
userDailyStats.forEach { (date, total) -> appendLine("👥 Top 群组")
appendLine("$date: ${formatNumber(total)} tokens") topGroups.forEachIndexed { i, (name, t) ->
appendLine(" ${i + 1}. $name ${formatCompact(t)}")
} }
appendLine()
appendLine("总计: ${formatNumber(userDailyStats.values.sum())} tokens")
} }
sendMessage(response) }
sendMessage(response.trim())
} }
// ==================== 辅助函数 ==================== // ==================== 辅助函数 ====================
@@ -330,6 +198,25 @@ object PluginCommands : CompositeCommand(
return String.format("%,d", number.toLong()) return String.format("%,d", number.toLong())
} }
/**
* 大数压缩为 K/M,简报用,避免一屏全是逗号长串。
*/
private fun formatCompact(n: Long): String = when {
n >= 1_000_000 -> "%.2fM".format(n / 1_000_000.0)
n >= 1_000 -> "%.1fK".format(n / 1_000.0)
else -> n.toString()
}
/**
* 解析群名:记录里没存到群名时(旧数据)才回退到在线 Bot 查询,
* 仍查不到则用占位文案,绝不直接展示群号。
*/
private fun resolveGroupName(groupId: Long): String {
return net.mamoe.mirai.Bot.instances
.firstNotNullOfOrNull { it.getGroup(groupId)?.name }
?: "未知群聊"
}
/** /**
* 验证天数参数 * 验证天数参数
*/ */
@@ -339,4 +226,4 @@ object PluginCommands : CompositeCommand(
} }
// 常量定义 // 常量定义
private const val DEFAULT_QUERY_LIMIT = 20 private const val TOP_LIMIT = 5
+25
View File
@@ -1,9 +1,22 @@
package top.jie65535.mirai package top.jie65535.mirai
import kotlinx.serialization.Serializable
import net.mamoe.mirai.console.data.AutoSavePluginConfig import net.mamoe.mirai.console.data.AutoSavePluginConfig
import net.mamoe.mirai.console.data.ValueDescription import net.mamoe.mirai.console.data.ValueDescription
import net.mamoe.mirai.console.data.value import net.mamoe.mirai.console.data.value
/**
* 聊天模型备用接入点。用于主接入点(openAiApi/openAiToken/chatModel)连续失败时容灾切换。
* 任一字段留空则继承主接入点对应配置,因此可只换 API KEY、只换模型、或整体换一个服务商。
*/
@Serializable
data class ChatFallbackEndpoint(
val api: String = "",
val token: String = "",
val model: String = "",
val extraBody: String = "",
)
object PluginConfig : AutoSavePluginConfig("Config") { object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("主人QQ,AI可以通过工具向主人发起请求,会等待一段时间") @ValueDescription("主人QQ,AI可以通过工具向主人发起请求,会等待一段时间")
val ownerId: Long by value() val ownerId: Long by value()
@@ -41,6 +54,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {\"thinking\": {\"type\": \"disabled\"}}") @ValueDescription("聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {\"thinking\": {\"type\": \"disabled\"}}")
val chatModelExtraBody: String by value("") val chatModelExtraBody: String by value("")
@ValueDescription("聊天模型备用接入点列表(容灾)。主接入点连续失败时按顺序切换;每项留空的字段会继承主接入点,例如只换API KEY就只填token,只换模型就只填model")
val chatFallbacks: List<ChatFallbackEndpoint> by value()
@ValueDescription("备用接入点冷却时间(分钟)。某接入点失败后在此时间内会被排到重试队尾,避免每条消息都先卡在故障接入点上。设为0禁用,默认5分钟")
val fallbackCooldownMinutes: Long by value(5L)
@ValueDescription("推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {\"thinking\": {\"type\": \"enabled\"}}") @ValueDescription("推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {\"thinking\": {\"type\": \"enabled\"}}")
val reasoningModelExtraBody: String by value("") val reasoningModelExtraBody: String by value("")
@@ -86,6 +105,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("首块响应超时时间,单位毫秒,默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试") @ValueDescription("首块响应超时时间,单位毫秒,默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试")
val firstChunkTimeout: Long by value(10000L) val firstChunkTimeout: Long by value(10000L)
@ValueDescription("视觉模型首块响应超时时间,单位毫秒,默认120秒。视觉模型需先下载图片再出首块,比对话天然慢,故单独放宽")
val visualFirstChunkTimeout: Long by value(120000L)
@ValueDescription("推理模型首块响应超时时间,单位毫秒,默认90秒。推理模型出首块前常有思考预热,比对话慢,故单独放宽")
val reasoningFirstChunkTimeout: Long by value(90000L)
@Deprecated("使用外部文件而不是在配置文件内保存提示词") @Deprecated("使用外部文件而不是在配置文件内保存提示词")
@ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改") @ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改")
var prompt: String by value("你是一个乐于助人的助手") var prompt: String by value("你是一个乐于助人的助手")
+4
View File
@@ -56,9 +56,13 @@ data class TokenUsageDailyRecord(
val userId: Long, val userId: Long,
val userNickname: String, val userNickname: String,
val groupId: Long?, val groupId: Long?,
/** 群名称,记录时捕获。展示时优先用它,避免暴露群号(被误判宣群)。私聊为 null。 */
val groupName: String? = null,
val promptTokens: Long = 0, val promptTokens: Long = 0,
val completionTokens: Long = 0, val completionTokens: Long = 0,
val totalTokens: Long = 0, val totalTokens: Long = 0,
/** 命中缓存的输入 token 数(DeepSeek: prompt_cache_hit_tokens)。缓存命中率 = cachedTokens / promptTokens */
val cachedTokens: Long = 0,
val callCount: Int = 0 val callCount: Int = 0
) )
+8 -1
View File
@@ -52,13 +52,16 @@ object TokenUsageStore {
userId: Long, userId: Long,
userNickname: String, userNickname: String,
groupId: Long?, groupId: Long?,
groupName: String?,
promptTokens: Int, promptTokens: Int,
completionTokens: Int, completionTokens: Int,
totalTokens: Int totalTokens: Int,
cachedTokens: Int
) { ) {
val date = LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault()) val date = LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault())
.format(dateFmt) .format(dateFmt)
val nickname = sanitizeNickname(userNickname) val nickname = sanitizeNickname(userNickname)
val groupNameClean = groupName?.let { sanitizeNickname(it) }
val idx = records.indexOfFirst { val idx = records.indexOfFirst {
it.date == date && it.userId == userId && it.groupId == groupId it.date == date && it.userId == userId && it.groupId == groupId
} }
@@ -66,9 +69,11 @@ object TokenUsageStore {
val r = records[idx] val r = records[idx]
records[idx] = r.copy( records[idx] = r.copy(
userNickname = nickname.ifEmpty { r.userNickname }, userNickname = nickname.ifEmpty { r.userNickname },
groupName = groupNameClean?.ifEmpty { null } ?: r.groupName,
promptTokens = r.promptTokens + promptTokens, promptTokens = r.promptTokens + promptTokens,
completionTokens = r.completionTokens + completionTokens, completionTokens = r.completionTokens + completionTokens,
totalTokens = r.totalTokens + totalTokens, totalTokens = r.totalTokens + totalTokens,
cachedTokens = r.cachedTokens + cachedTokens,
callCount = r.callCount + 1 callCount = r.callCount + 1
) )
} else { } else {
@@ -78,9 +83,11 @@ object TokenUsageStore {
userId = userId, userId = userId,
userNickname = nickname, userNickname = nickname,
groupId = groupId, groupId = groupId,
groupName = groupNameClean?.ifEmpty { null },
promptTokens = promptTokens.toLong(), promptTokens = promptTokens.toLong(),
completionTokens = completionTokens.toLong(), completionTokens = completionTokens.toLong(),
totalTokens = totalTokens.toLong(), totalTokens = totalTokens.toLong(),
cachedTokens = cachedTokens.toLong(),
callCount = 1 callCount = 1
) )
) )