mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59a98830cd | ||
|
|
bc61e99013 | ||
|
|
826dc460df | ||
|
|
13329b5fa3 |
@@ -5,6 +5,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
|
||||
## 功能特性
|
||||
|
||||
- **多模型支持**:支持聊天模型、推理模型和视觉模型
|
||||
- **接入点容灾**:聊天模型可配置多个备用接入点,主接入点 key 到期 / 限流 / 服务不稳定时自动切换
|
||||
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
|
||||
- **上下文记忆**:支持持久化记忆存储
|
||||
- **技能系统**:Bot 可在群聊中自我沉淀可复用知识,全局跨群、按需加载、低上下文污染
|
||||
@@ -88,6 +89,15 @@ visualModelToken: ''
|
||||
visualModel: 'qwen-vl-plus'
|
||||
# 聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {"thinking": {"type": "disabled"}}
|
||||
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"}}
|
||||
reasoningModelExtraBody: ''
|
||||
# 视觉模型额外请求体JSON,会合并到请求体中。
|
||||
@@ -276,6 +286,37 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型
|
||||
|
||||
当然,也可以配置为使用其他兼容 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 调用:
|
||||
|
||||
+45
-11
@@ -42,6 +42,7 @@ import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import kotlin.collections.*
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
import kotlin.math.pow
|
||||
import kotlin.math.sign
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
@@ -195,6 +196,11 @@ object JChatGPT : KotlinPlugin(
|
||||
private suspend fun onMessage(event: MessageEvent) {
|
||||
// 检查Token是否设置
|
||||
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 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
|
||||
// 至少循环3次
|
||||
var retry = max(PluginConfig.retryMax, 3)
|
||||
do {
|
||||
// 当前使用的接入点:失败重试时会前移到下一个备用接入点
|
||||
val endpoint = endpoints[min(endpointIndex, endpoints.lastIndex)]
|
||||
// 标记本轮 LLM 流式调用是否成功完成,用于精确区分「LLM失败」与「后续工具失败」
|
||||
var streamingOk = false
|
||||
try {
|
||||
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 reasoningContentBuilder: StringBuilder? = null
|
||||
val responseToolCalls = mutableListOf<ToolCall.Function>()
|
||||
@@ -769,6 +785,9 @@ object JChatGPT : KotlinPlugin(
|
||||
// 捕获token使用量
|
||||
chunk.usage?.let { lastTokenUsage = it }
|
||||
}
|
||||
// LLM 流式调用成功完成,上报接入点健康(清除冷却)
|
||||
streamingOk = true
|
||||
LargeLanguageModels.reportSuccess(endpoint)
|
||||
|
||||
// 移除思考内容
|
||||
val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim()
|
||||
@@ -789,15 +808,17 @@ object JChatGPT : KotlinPlugin(
|
||||
// 记录token使用量(按日聚合,独立JSON文件)
|
||||
lastTokenUsage?.let { usage ->
|
||||
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(
|
||||
timestamp = now,
|
||||
userId = event.sender.id,
|
||||
userNickname = event.senderName,
|
||||
groupId = groupId,
|
||||
groupId = group?.id,
|
||||
groupName = group?.name,
|
||||
promptTokens = usage.promptTokens ?: 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) {
|
||||
// 仅当 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) {
|
||||
throw e
|
||||
} else {
|
||||
done = false
|
||||
logger.warning("调用llm时发生异常,重试中", e)
|
||||
// event.subject.sendMessage("出错了...正在重试...")
|
||||
}
|
||||
}
|
||||
@@ -1028,20 +1061,21 @@ object JChatGPT : KotlinPlugin(
|
||||
|
||||
private fun chatCompletions(
|
||||
chatMessages: List<ChatMessage>,
|
||||
hasTools: Boolean = true
|
||||
endpoint: LargeLanguageModels.ChatEndpoint,
|
||||
hasTools: Boolean = true,
|
||||
onCacheUsage: ((ModelService.CacheUsage) -> Unit)? = null
|
||||
): Flow<ChatCompletionChunk> {
|
||||
val llm = LargeLanguageModels.chat ?: throw NullPointerException("OpenAI Token 未设置,无法开始")
|
||||
val availableTools = if (hasTools) {
|
||||
myTools.filter { it.isEnabled }.map { it.tool }
|
||||
} else null
|
||||
val request = ChatCompletionRequest(
|
||||
model = ModelId(PluginConfig.chatModel),
|
||||
temperature = PluginConfig.chatTemperature,
|
||||
model = ModelId(endpoint.model),
|
||||
temperature = endpoint.temperature,
|
||||
messages = chatMessages,
|
||||
tools = availableTools,
|
||||
)
|
||||
logger.info("API Requesting... Model=${PluginConfig.chatModel}")
|
||||
return llm.chatCompletions(request)
|
||||
logger.info("API Requesting... Model=${endpoint.model} [${endpoint.label}]")
|
||||
return endpoint.service.chatCompletions(request, onCacheUsage)
|
||||
}
|
||||
|
||||
private fun getNameCard(member: Member): String {
|
||||
|
||||
@@ -14,9 +14,28 @@ object LargeLanguageModels {
|
||||
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
|
||||
|
||||
/**
|
||||
* 接入点健康状态:记录各接入点的冷却截止时间戳(毫秒)。
|
||||
* 失败的接入点进入冷却,期间在 [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 {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
@@ -46,35 +99,75 @@ object LargeLanguageModels {
|
||||
val timeout = PluginConfig.timeout.milliseconds
|
||||
val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds
|
||||
|
||||
// 初始化聊天模型
|
||||
// 初始化聊天接入点(主 + 备用),并重置健康状态
|
||||
cooldownUntil.clear()
|
||||
val endpoints = mutableListOf<ChatEndpoint>()
|
||||
if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) {
|
||||
chat = ModelService(
|
||||
baseUrl = PluginConfig.openAiApi,
|
||||
token = PluginConfig.openAiToken,
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
extraBody = parseExtraBody(PluginConfig.chatModelExtraBody)
|
||||
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
|
||||
|
||||
// 初始化推理模型
|
||||
if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) {
|
||||
// 推理模型出首块前常有思考预热,比对话慢,使用单独放宽的首块超时;
|
||||
// socket 超时(两次读间隔,等首块时也归它管)不能小于首块预算,否则首块超时形同虚设
|
||||
val reasoningFirstChunk = PluginConfig.reasoningFirstChunkTimeout.milliseconds
|
||||
reasoning = ModelService(
|
||||
baseUrl = PluginConfig.reasoningModelApi,
|
||||
token = PluginConfig.reasoningModelToken,
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
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 = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
timeout = maxOf(timeout, visualFirstChunk),
|
||||
firstChunkTimeout = visualFirstChunk,
|
||||
extraBody = parseExtraBody(PluginConfig.visualModelExtraBody)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -48,7 +48,28 @@ class ModelService(
|
||||
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)
|
||||
.jsonObject.toMutableMap()
|
||||
requestJson["stream"] = JsonPrimitive(true)
|
||||
@@ -91,7 +112,9 @@ class ModelService(
|
||||
}
|
||||
|
||||
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!!
|
||||
while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
|
||||
@@ -101,7 +124,9 @@ class ModelService(
|
||||
when {
|
||||
line.startsWith("data: [DONE]") -> break
|
||||
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
|
||||
}
|
||||
|
||||
@@ -101,217 +101,85 @@ object PluginCommands : CompositeCommand(
|
||||
val cutoff = calculateCutoffDate(days)
|
||||
val today = LocalDate.now().toString()
|
||||
|
||||
data class Statistics(
|
||||
var totalTokens: Long = 0,
|
||||
var todayTokens: Long = 0,
|
||||
val userTotals: MutableMap<Long, Pair<String, Long>> = mutableMapOf(),
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
if (record.date == today) {
|
||||
acc.todayTokens += record.totalTokens
|
||||
}
|
||||
|
||||
acc
|
||||
val windowed = TokenUsageStore.all.filter { it.date >= cutoff }
|
||||
if (windowed.isEmpty()) {
|
||||
sendMessage("最近 $days 天无 Token 使用记录")
|
||||
return
|
||||
}
|
||||
|
||||
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] - 用户日统计")
|
||||
// 窗口汇总
|
||||
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
|
||||
|
||||
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 } }
|
||||
// 每日趋势
|
||||
val daily = windowed.groupBy { it.date }
|
||||
.mapValues { (_, rs) -> rs.sumOf { it.totalTokens } }
|
||||
.toSortedMap()
|
||||
|
||||
if (dailyStats.isEmpty()) {
|
||||
sendMessage("指定时间范围内无使用记录")
|
||||
return
|
||||
}
|
||||
|
||||
val response = buildString {
|
||||
appendLine("最近 $days 天 Token 使用统计:")
|
||||
appendLine()
|
||||
dailyStats.forEach { (date, total) ->
|
||||
appendLine("$date: ${formatNumber(total)} tokens")
|
||||
// Top 用户
|
||||
val topUsers = windowed.groupBy { it.userId }
|
||||
.map { (_, rs) ->
|
||||
val name = rs.maxByOrNull { it.date }!!.userNickname
|
||||
name to rs.sumOf { it.totalTokens }
|
||||
}
|
||||
}
|
||||
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 }
|
||||
.take(limit)
|
||||
.take(TOP_LIMIT)
|
||||
|
||||
if (groupStats.isEmpty()) {
|
||||
sendMessage("暂无群组使用记录")
|
||||
return
|
||||
}
|
||||
|
||||
val response = buildString {
|
||||
appendLine("群组 Token 使用排名 Top $limit:")
|
||||
appendLine()
|
||||
groupStats.forEach { (groupId, total) ->
|
||||
appendLine("- $groupId: ${formatNumber(total)} tokens")
|
||||
// Top 群组:只显示群名,绝不暴露群号(避免被误判宣群)
|
||||
val topGroups = windowed.filter { it.groupId != null }
|
||||
.groupBy { it.groupId!! }
|
||||
.map { (gid, rs) ->
|
||||
val name = rs.firstNotNullOfOrNull { r -> r.groupName?.takeIf { it.isNotBlank() } }
|
||||
?: resolveGroupName(gid)
|
||||
name to rs.sumOf { it.totalTokens }
|
||||
}
|
||||
}
|
||||
sendMessage(response)
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
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
|
||||
}
|
||||
.sortedByDescending { it.second }
|
||||
.take(TOP_LIMIT)
|
||||
|
||||
val response = buildString {
|
||||
appendLine("最近 $days 天使用记录(最多显示${DEFAULT_QUERY_LIMIT}条,按日聚合):")
|
||||
appendLine("📊 Token 简报 · 最近 $days 天")
|
||||
appendLine()
|
||||
filtered.forEach { record ->
|
||||
val location = if (record.groupId != null) "群${record.groupId}" else "私聊"
|
||||
appendLine("[${record.date}] $location - ${record.userNickname}")
|
||||
appendLine(" 调用 ${record.callCount} 次, Tokens: ${formatNumber(record.totalTokens)} " +
|
||||
"(输入: ${formatNumber(record.promptTokens)}, 输出: ${formatNumber(record.completionTokens)})")
|
||||
appendLine("输入 ${formatCompact(prompt)}(缓存命中 ${"%.1f".format(hitRate)}%,省 ${formatCompact(cached)})")
|
||||
appendLine("输出 ${formatCompact(completion)}")
|
||||
appendLine("总计 ${formatCompact(total)} | 调用 ${formatNumber(calls)} 次 | 活跃 ${users.size} 人")
|
||||
appendLine("今日 ${formatCompact(todayTotal)}")
|
||||
|
||||
if (daily.size > 1) {
|
||||
appendLine()
|
||||
appendLine("📈 每日趋势")
|
||||
daily.forEach { (date, t) ->
|
||||
appendLine(" ${date.substring(5)} ${formatCompact(t)}")
|
||||
}
|
||||
}
|
||||
|
||||
if (topUsers.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("👤 Top 用户")
|
||||
topUsers.forEachIndexed { i, (name, t) ->
|
||||
appendLine(" ${i + 1}. $name ${formatCompact(t)}")
|
||||
}
|
||||
}
|
||||
|
||||
if (topGroups.isNotEmpty()) {
|
||||
appendLine()
|
||||
appendLine("👥 Top 群组")
|
||||
topGroups.forEachIndexed { i, (name, t) ->
|
||||
appendLine(" ${i + 1}. $name ${formatCompact(t)}")
|
||||
}
|
||||
}
|
||||
}
|
||||
sendMessage(response)
|
||||
}
|
||||
|
||||
@SubCommand
|
||||
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()
|
||||
userDailyStats.forEach { (date, total) ->
|
||||
appendLine("$date: ${formatNumber(total)} tokens")
|
||||
}
|
||||
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())
|
||||
}
|
||||
|
||||
/**
|
||||
* 大数压缩为 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
|
||||
@@ -1,9 +1,22 @@
|
||||
package top.jie65535.mirai
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
import net.mamoe.mirai.console.data.AutoSavePluginConfig
|
||||
import net.mamoe.mirai.console.data.ValueDescription
|
||||
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") {
|
||||
@ValueDescription("主人QQ,AI可以通过工具向主人发起请求,会等待一段时间")
|
||||
val ownerId: Long by value()
|
||||
@@ -41,6 +54,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {\"thinking\": {\"type\": \"disabled\"}}")
|
||||
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\"}}")
|
||||
val reasoningModelExtraBody: String by value("")
|
||||
|
||||
@@ -86,6 +105,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("首块响应超时时间,单位毫秒,默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试")
|
||||
val firstChunkTimeout: Long by value(10000L)
|
||||
|
||||
@ValueDescription("视觉模型首块响应超时时间,单位毫秒,默认120秒。视觉模型需先下载图片再出首块,比对话天然慢,故单独放宽")
|
||||
val visualFirstChunkTimeout: Long by value(120000L)
|
||||
|
||||
@ValueDescription("推理模型首块响应超时时间,单位毫秒,默认90秒。推理模型出首块前常有思考预热,比对话慢,故单独放宽")
|
||||
val reasoningFirstChunkTimeout: Long by value(90000L)
|
||||
|
||||
@Deprecated("使用外部文件而不是在配置文件内保存提示词")
|
||||
@ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改")
|
||||
var prompt: String by value("你是一个乐于助人的助手")
|
||||
|
||||
@@ -56,9 +56,13 @@ data class TokenUsageDailyRecord(
|
||||
val userId: Long,
|
||||
val userNickname: String,
|
||||
val groupId: Long?,
|
||||
/** 群名称,记录时捕获。展示时优先用它,避免暴露群号(被误判宣群)。私聊为 null。 */
|
||||
val groupName: String? = null,
|
||||
val promptTokens: Long = 0,
|
||||
val completionTokens: Long = 0,
|
||||
val totalTokens: Long = 0,
|
||||
/** 命中缓存的输入 token 数(DeepSeek: prompt_cache_hit_tokens)。缓存命中率 = cachedTokens / promptTokens */
|
||||
val cachedTokens: Long = 0,
|
||||
val callCount: Int = 0
|
||||
)
|
||||
|
||||
|
||||
@@ -52,13 +52,16 @@ object TokenUsageStore {
|
||||
userId: Long,
|
||||
userNickname: String,
|
||||
groupId: Long?,
|
||||
groupName: String?,
|
||||
promptTokens: Int,
|
||||
completionTokens: Int,
|
||||
totalTokens: Int
|
||||
totalTokens: Int,
|
||||
cachedTokens: Int
|
||||
) {
|
||||
val date = LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault())
|
||||
.format(dateFmt)
|
||||
val nickname = sanitizeNickname(userNickname)
|
||||
val groupNameClean = groupName?.let { sanitizeNickname(it) }
|
||||
val idx = records.indexOfFirst {
|
||||
it.date == date && it.userId == userId && it.groupId == groupId
|
||||
}
|
||||
@@ -66,9 +69,11 @@ object TokenUsageStore {
|
||||
val r = records[idx]
|
||||
records[idx] = r.copy(
|
||||
userNickname = nickname.ifEmpty { r.userNickname },
|
||||
groupName = groupNameClean?.ifEmpty { null } ?: r.groupName,
|
||||
promptTokens = r.promptTokens + promptTokens,
|
||||
completionTokens = r.completionTokens + completionTokens,
|
||||
totalTokens = r.totalTokens + totalTokens,
|
||||
cachedTokens = r.cachedTokens + cachedTokens,
|
||||
callCount = r.callCount + 1
|
||||
)
|
||||
} else {
|
||||
@@ -78,9 +83,11 @@ object TokenUsageStore {
|
||||
userId = userId,
|
||||
userNickname = nickname,
|
||||
groupId = groupId,
|
||||
groupName = groupNameClean?.ifEmpty { null },
|
||||
promptTokens = promptTokens.toLong(),
|
||||
completionTokens = completionTokens.toLong(),
|
||||
totalTokens = totalTokens.toLong(),
|
||||
cachedTokens = cachedTokens.toLong(),
|
||||
callCount = 1
|
||||
)
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user