Files
JChatGPT/src/main/kotlin/PluginCommands.kt
T
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

229 lines
7.7 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package top.jie65535.mirai
import net.mamoe.mirai.console.command.CommandSender
import net.mamoe.mirai.console.command.CompositeCommand
import net.mamoe.mirai.console.permission.PermissionService.Companion.cancel
import net.mamoe.mirai.console.permission.PermissionService.Companion.permit
import net.mamoe.mirai.console.permission.PermitteeId.Companion.permitteeId
import net.mamoe.mirai.contact.Contact
import net.mamoe.mirai.contact.Group
import net.mamoe.mirai.contact.Member
import net.mamoe.mirai.contact.User
import top.jie65535.mirai.JChatGPT.reload
import java.time.LocalDate
object PluginCommands : CompositeCommand(
JChatGPT, "jgpt", description = "J OpenAI ChatGPT"
) {
@SubCommand
suspend fun CommandSender.reload() {
PluginConfig.reload()
PluginData.reload()
LargeLanguageModels.reload()
SkillStore.reload()
sendMessage("OK")
}
@SubCommand
suspend fun CommandSender.skills() {
val all = SkillStore.all
if (all.isEmpty()) {
sendMessage("暂无技能")
return
}
val response = buildString {
appendLine("当前技能(共 ${all.size} 个):")
all.forEach { appendLine("- ${it.name}: ${it.description}") }
}
sendMessage(response.trim())
}
@SubCommand
suspend fun CommandSender.enable(contact: Contact) {
when (contact) {
is Member -> contact.permitteeId.permit(JChatGPT.chatPermission)
is User -> contact.permitteeId.permit(JChatGPT.chatPermission)
is Group -> contact.permitteeId.permit(JChatGPT.chatPermission)
}
sendMessage("OK")
}
@SubCommand
suspend fun CommandSender.disable(contact: Contact) {
when (contact) {
is Member -> contact.permitteeId.cancel(JChatGPT.chatPermission, false)
is User -> contact.permitteeId.cancel(JChatGPT.chatPermission, false)
is Group -> contact.permitteeId.cancel(JChatGPT.chatPermission, false)
}
sendMessage("OK")
}
@SubCommand
suspend fun CommandSender.clearMemory() {
PluginData.contactMemory.clear()
sendMessage("OK")
}
@SubCommand
suspend fun CommandSender.setFavor(user: User, value: Int) {
// 限制好感度值在-100到100之间
val clampedValue = value.coerceIn(-100, 100)
// 获取当前的好感度信息
val currentInfo = PluginData.userFavorability[user.id] ?: FavorabilityInfo(user.id)
// 创建新的好感度信息,保持原因和印象不变
val newInfo = currentInfo.copy(value = clampedValue)
PluginData.userFavorability[user.id] = newInfo
sendMessage("OK")
}
@SubCommand
suspend fun CommandSender.clearFavor() {
PluginData.userFavorability.clear()
sendMessage("OK")
}
@SubCommand
suspend fun CommandSender.clearContextCache() {
JChatGPT.clearContextCache()
sendMessage("已清空所有对话上下文缓存")
}
@SubCommand
suspend fun CommandSender.tokens(days: Int = 7) {
validateDays(days)
if (TokenUsageStore.all.isEmpty()) {
sendMessage("暂无 Token 使用记录")
return
}
val cutoff = calculateCutoffDate(days)
val today = LocalDate.now().toString()
val windowed = TokenUsageStore.all.filter { it.date >= cutoff }
if (windowed.isEmpty()) {
sendMessage("最近 $days 天无 Token 使用记录")
return
}
// 窗口汇总
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
// 每日趋势
val daily = windowed.groupBy { it.date }
.mapValues { (_, rs) -> rs.sumOf { it.totalTokens } }
.toSortedMap()
// Top 用户
val topUsers = windowed.groupBy { it.userId }
.map { (_, rs) ->
val name = rs.maxByOrNull { it.date }!!.userNickname
name to rs.sumOf { it.totalTokens }
}
.sortedByDescending { it.second }
.take(TOP_LIMIT)
// 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 }
}
.sortedByDescending { it.second }
.take(TOP_LIMIT)
val response = buildString {
appendLine("📊 Token 简报 · 最近 $days 天")
appendLine()
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.trim())
}
// ==================== 辅助函数 ====================
/**
* 计算截止日期字符串(指定天数前的日期,含今天共 days 天)
*/
private fun calculateCutoffDate(days: Int): String {
return LocalDate.now().minusDays((days - 1).toLong()).toString()
}
/**
* 格式化数字(添加千位分隔符)
*/
private fun formatNumber(number: Number): String {
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 }
?: "未知群聊"
}
/**
* 验证天数参数
*/
private fun validateDays(days: Int) {
require(days > 0) { "days must be positive: $days" }
}
}
// 常量定义
private const val TOP_LIMIT = 5