mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-15 02:56:10 +08:00
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]>
229 lines
7.7 KiB
Kotlin
229 lines
7.7 KiB
Kotlin
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 |