mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-06-23 00:49:31 +08:00
Move token usage to JSON store
This commit is contained in:
113
src/main/kotlin/TokenUsageStore.kt
Normal file
113
src/main/kotlin/TokenUsageStore.kt
Normal file
@@ -0,0 +1,113 @@
|
||||
package top.jie65535.mirai
|
||||
|
||||
import kotlinx.serialization.builtins.ListSerializer
|
||||
import kotlinx.serialization.json.Json
|
||||
import java.io.File
|
||||
import java.time.Instant
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
/**
|
||||
* Token使用日聚合存储。独立于 mamoe 的 plugin data 系统,直接管 JSON 文件,
|
||||
* 避免 yamlkt 在大数据量下编/解码不互通的 bug。
|
||||
*/
|
||||
object TokenUsageStore {
|
||||
private val json = Json {
|
||||
prettyPrint = true
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
private val dateFmt = DateTimeFormatter.ISO_LOCAL_DATE
|
||||
private val listSerializer = ListSerializer(TokenUsageDailyRecord.serializer())
|
||||
|
||||
private lateinit var file: File
|
||||
private val records = mutableListOf<TokenUsageDailyRecord>()
|
||||
|
||||
/**
|
||||
* 在 onEnable 中调用一次,传入插件数据目录。
|
||||
*/
|
||||
fun init(dataFolder: File) {
|
||||
file = File(dataFolder, "token_usage.json")
|
||||
records.clear()
|
||||
if (file.exists() && file.length() > 0) {
|
||||
try {
|
||||
records.addAll(json.decodeFromString(listSerializer, file.readText()))
|
||||
} catch (_: Exception) {
|
||||
// 加载失败不阻塞插件启动,备份原文件后从空开始
|
||||
val backup = File(file.parentFile, "token_usage.json.broken-${System.currentTimeMillis()}")
|
||||
file.copyTo(backup, overwrite = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val all: List<TokenUsageDailyRecord> get() = records
|
||||
|
||||
/**
|
||||
* 将一次调用的 token 用量累加到当日聚合行;若不存在则创建。写盘失败不抛。
|
||||
*/
|
||||
@Synchronized
|
||||
fun record(
|
||||
timestamp: Long,
|
||||
userId: Long,
|
||||
userNickname: String,
|
||||
groupId: Long?,
|
||||
promptTokens: Int,
|
||||
completionTokens: Int,
|
||||
totalTokens: Int
|
||||
) {
|
||||
val date = LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault())
|
||||
.format(dateFmt)
|
||||
val nickname = sanitizeNickname(userNickname)
|
||||
val idx = records.indexOfFirst {
|
||||
it.date == date && it.userId == userId && it.groupId == groupId
|
||||
}
|
||||
if (idx >= 0) {
|
||||
val r = records[idx]
|
||||
records[idx] = r.copy(
|
||||
userNickname = nickname.ifEmpty { r.userNickname },
|
||||
promptTokens = r.promptTokens + promptTokens,
|
||||
completionTokens = r.completionTokens + completionTokens,
|
||||
totalTokens = r.totalTokens + totalTokens,
|
||||
callCount = r.callCount + 1
|
||||
)
|
||||
} else {
|
||||
records.add(
|
||||
TokenUsageDailyRecord(
|
||||
date = date,
|
||||
userId = userId,
|
||||
userNickname = nickname,
|
||||
groupId = groupId,
|
||||
promptTokens = promptTokens.toLong(),
|
||||
completionTokens = completionTokens.toLong(),
|
||||
totalTokens = totalTokens.toLong(),
|
||||
callCount = 1
|
||||
)
|
||||
)
|
||||
}
|
||||
save()
|
||||
}
|
||||
|
||||
/** 把控制字符压成空格,避免昵称里的换行/零宽字符把 JSON/展示弄乱。 */
|
||||
private fun sanitizeNickname(s: String): String {
|
||||
if (s.isEmpty()) return s
|
||||
val cleaned = buildString(s.length) {
|
||||
for (c in s) {
|
||||
if (c == ' ' || (!c.isISOControl() && c.category != CharCategory.FORMAT)) append(c)
|
||||
else append(' ')
|
||||
}
|
||||
}
|
||||
return cleaned.trim().replace(Regex(" {2,}"), " ")
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
try {
|
||||
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||
tmp.writeText(json.encodeToString(listSerializer, records))
|
||||
tmp.copyTo(file, overwrite = true)
|
||||
tmp.delete()
|
||||
} catch (_: Exception) {
|
||||
// 写盘失败由日志/上层关心,这里不抛断对话流程
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user