Add token consumption tracking system Update version to v1.10.0

This commit is contained in:
2026-03-18 12:09:08 +08:00
parent c4afdb811b
commit b0c985e220
6 changed files with 426 additions and 5 deletions

123
FavorabilitySystem.md Normal file
View File

@@ -0,0 +1,123 @@
# 好感度系统功能规范
## 功能概述
为机器人添加一个可开关的好感度系统通过AI工具自动调整用户的好感度值。好感度数据将保存在插件数据中以用户QQ号为键包含好感度值和调整原因。
## 核心功能
### 1. 好感度数据存储
-`PluginData`中新增一个映射来存储好感度数据
-用户QQ号(Long)
- 值:包含好感度值和调整原因的数据结构
- 默认值0中立
### 2. 好感度变化规则
- **问正经问题**+好感度(例如:询问学习/工作相关问题、寻求帮助等)
- **问无聊问题**-好感度(例如:骚扰机器人要求评价他人、攻击性言论、让机器人做无意义的事情、引战问题等)
- **骂人**:直接降至-100
- **时间偏移**好感度会随时间向0偏移偏移速度与当前好感度绝对值相关
- 好感度越高或越低,偏移速度越慢
- 设计算法确保极端值变化缓慢(具体公式见实现细节)
### 3. 回复概率机制
- 当好感度为负数时,有一定概率不回复用户消息
- 概率计算:好感度绝对值的百分比
- 例如:好感度为-50则有50%概率不回复即50%概率回复)
### 4. 好感度调整工具
- 新增一个AI工具允许AI根据对话内容自主调整用户的好感度
- 工具名称:`adjustUserFavorability`
- 工具参数:
- `userId`: 用户QQ号
- `change`: 好感度变化值(可正可负)
- `reason`: 调整原因(用于溯源)
- `impression`: 对用户的印象/画像(可选)
### 5. 系统开关
- 在配置文件中添加开关选项,控制是否启用好感度系统
- 默认启用
### 6. 管理员命令
- 添加插件命令手动修改某个人的好感度
- 添加命令重置所有好感度
## 实现细节
### 1. 数据结构
`PluginData`中添加:
```kotlin
/**
* 用户好感度数据
* Key: 用户QQ号
* Value: 好感度信息
*/
val userFavorability by value(mutableMapOf<Long, FavorabilityInfo>())
/**
* 好感度信息数据类
* @param value 好感度值 (-100 ~ 100)
* @param reason 调整原因列表,用于溯源
* @param impression 对用户的印象/画像
*/
data class FavorabilityInfo(
val value: Int = 0,
val reasons: List<String> = emptyList(),
val impression: String = ""
)
```
### 2. 好感度工具
创建新的工具类`AdjustUserFavorabilityAgent`,继承`BaseAgent`
工具描述:`根据用户行为调整其好感度值,范围-100~100`
### 3. 消息处理逻辑
`JChatGPT.kt``onMessage`函数中:
- 添加好感度系统开关检查
- 在决定是否回复前,计算回复概率
- 如果随机数小于不回复概率,则直接返回,不进行后续处理
### 4. 时间偏移机制
设计时间偏移算法使好感度逐渐向0回归
- 偏移公式:`偏移量 = sign(好感度) * (1 - (|好感度| / 100)^2) * 基础偏移速度`
- 基础偏移速度可设置为每天1-5点
- 这样确保当好感度接近极端值时,变化速度会显著减慢
### 5. 配置选项
`PluginConfig.kt`中添加:
```kotlin
/**
* 是否启用好感度系统
*/
val enableFavorabilitySystem by value(true)
/**
* 好感度每日基础偏移速度(点/天)
*/
val favorabilityBaseShiftSpeed by value(2.0)
```
### 6. 插件命令
添加以下命令:
- `/jgpt favorability <qq> <value>`: 设置指定QQ号的好感度值
- `/jgpt resetFavorability`: 重置所有用户的好感度为0
### 7. 提示词设计
不再使用系统提示词中的占位符,而是将好感度信息直接添加到聊天历史的顶部。
### 8. 好感度信息展示
- 不再使用系统提示词中的占位符
- 在获取历史消息时,将好感度信息作为摘要添加到聊天历史的顶部
- 格式示例:
```
[好感度摘要]
用户840465812(筱杰) 好感度: 75
印象: 热心的开发者,经常提供有用的建议
调整原因:
- 2025-09-10 14:30: 提供了关于代码优化的建议 +10
- 2025-09-09 10:15: 帮助测试新功能 +5
```
## 待确认事项
1. 时间偏移的基础速度设定(每天多少点)
2. 好感度调整工具的具体参数和使用方式

122
README.md
View File

@@ -8,6 +8,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等 - **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
- **上下文记忆**:支持持久化记忆存储 - **上下文记忆**:支持持久化记忆存储
- **好感度系统**:基于用户行为的好感度管理机制 - **好感度系统**:基于用户行为的好感度管理机制
- **Token消耗统计**记录每次对话的Token消耗支持多维度统计查询
- **LaTeX 渲染**:自动将数学表达式渲染为图片 - **LaTeX 渲染**:自动将数学表达式渲染为图片
- **灵活的触发方式**@机器人、关键字触发、回复消息等 - **灵活的触发方式**@机器人、关键字触发、回复消息等
- **权限控制**:细粒度的权限管理系统 - **权限控制**:细粒度的权限管理系统
@@ -37,12 +38,22 @@ AI 可以自动调用多种工具来完成复杂任务:
## 命令列表 ## 命令列表
- `/jgpt setToken <token>` - 设置 OpenAI API Token ### 基础命令
- `/jgpt enable <contact>` - 启用目标对话权限 - `/jgpt enable <contact>` - 启用目标对话权限
- `/jgpt disable <contact>` - 禁用目标对话权限 - `/jgpt disable <contact>` - 禁用目标对话权限
- `/jgpt reload` - 重载配置文件 - `/jgpt reload` - 重载配置文件
- `/jgpt favorability <userId> <value>` - 设置指定用户的好感度值(-100~100 - `/jgpt clearMemory` - 清空所有对话记忆
- `/jgpt resetFavorability` - 重置所有用户的好感度 - `/jgpt clearContextCache` - 清空所有对话上下文缓存
### 好感度管理
- `/jgpt setFavor <user> <value>` - 设置指定用户的好感度值(-100~100
- `/jgpt clearFavor` - 重置所有用户的好感度
### Token统计
- `/jgpt tokensDaily [days]` - 查看指定天数的每日Token消耗统计默认7天
- `/jgpt tokensUsers [limit]` - 查看Token消耗最多的用户排名默认Top 10
- `/jgpt tokensGroups [limit]` - 查看Token消耗最多的群组排名默认Top 10
- `/jgpt tokensQuery [userId] [days]` - 查询详细的使用记录(可按用户和时间过滤)
## 配置文件 ## 配置文件
@@ -284,6 +295,111 @@ JChatGPT 插件包含一个可选的好感度系统,用于根据用户行为
- `enableFavorabilitySystem` - 是否启用好感度系统默认true - `enableFavorabilitySystem` - 是否启用好感度系统默认true
- `favorabilityBaseShiftSpeed` - 好感度每日基础偏移速度(点/天默认2.0 - `favorabilityBaseShiftSpeed` - 好感度每日基础偏移速度(点/天默认2.0
## Token消耗统计
JChatGPT 插件内置了Token消耗统计功能可以记录每次对话的Token使用情况并提供多维度统计查询。
### 功能特性
- **自动记录**每次对话自动记录Token消耗
- **详细数据**:记录时间戳、用户、群组、模型、输入/输出Token数
- **多维统计**:支持按日期、用户、群组进行统计
- **灵活查询**:支持详细记录查询和过滤
### 记录内容
每次对话记录包含以下信息:
- 时间戳Unix timestamp
- 用户QQ号和昵称
- 群组ID群聊或null私聊
- 使用的模型名称
- 输入Token数promptTokens
- 输出Token数completionTokens
- 总Token数totalTokens
### 统计命令
#### 每日统计
```
/jgpt tokensDaily [days]
```
- 显示指定天数内的每日Token消耗统计
- 默认显示最近7天
- 输出示例:
```
最近 7 天 Token 使用统计:
2026-03-18: 15342 tokens
2026-03-17: 12890 tokens
2026-03-16: 9567 tokens
```
#### 用户排名
```
/jgpt tokensUsers [limit]
```
- 显示Token消耗最多的用户排名
- 默认显示Top 10
- 输出示例:
```
Token 使用排名 Top 10
张三(QQ:123456): 25430 tokens
李四(QQ:234567): 18920 tokens
王五(QQ:345678): 12450 tokens
```
#### 群组排名
```
/jgpt tokensGroups [limit]
```
- 显示Token消耗最多的群组排名
- 默认显示Top 10
- 仅统计群聊对话,不包括私聊
- 输出示例:
```
群组 Token 使用排名 Top 10
群 987654321: 45670 tokens
群 876543210: 32100 tokens
群 765432109: 28930 tokens
```
#### 详细查询
```
/jgpt tokensQuery [userId] [days]
```
- 查询详细的使用记录
- 可按用户ID过滤可选
- 可指定时间范围默认7天
- 最多显示20条记录
- 输出示例:
```
最近 7 天使用记录最多显示20条
[03-18 14:35] 群987654321 - 张三
模型: qwen-max, Tokens: 2345 (提示: 1234, 完成: 1111)
[03-18 14:30] 私聊 - 李四
模型: qwen-max, Tokens: 1876 (提示: 980, 完成: 896)
```
### 数据存储
- Token记录保存在插件数据目录的 `data/data.json` 文件中
- 使用 `AutoSavePluginData` 自动持久化
- 记录永久保存,不会自动删除
- 数据格式为JSON可手动查看和备份
### 使用场景
- **成本监控**了解API调用成本控制预算
- **使用分析**:分析哪些用户或群组使用最频繁
- **性能优化**:识别高消耗对话,优化提示词
- **趋势分析**:观察使用趋势,规划资源
### 注意事项
- 仅统计聊天模型的Token消耗
- 推理模型和视觉模型的消耗不在统计范围内
- 每次对话轮次都会单独记录
- 统计数据基于实际API返回的Token数
## 部署要求 ## 部署要求
- Java 11 或更高版本 - Java 11 或更高版本

View File

@@ -7,7 +7,7 @@ plugins {
} }
group = "top.jie65535.mirai" group = "top.jie65535.mirai"
version = "1.9.0" version = "1.10.0"
mirai { mirai {
jvmTarget = JavaVersion.VERSION_11 jvmTarget = JavaVersion.VERSION_11

View File

@@ -5,6 +5,7 @@ import com.aallam.openai.api.chat.ChatCompletionRequest
import com.aallam.openai.api.chat.ChatMessage import com.aallam.openai.api.chat.ChatMessage
import com.aallam.openai.api.chat.ChatRole import com.aallam.openai.api.chat.ChatRole
import com.aallam.openai.api.chat.ToolCall import com.aallam.openai.api.chat.ToolCall
import com.aallam.openai.api.core.Usage
import com.aallam.openai.api.model.ModelId import com.aallam.openai.api.model.ModelId
import io.ktor.util.collections.* import io.ktor.util.collections.*
import kotlinx.coroutines.Deferred import kotlinx.coroutines.Deferred
@@ -51,7 +52,7 @@ object JChatGPT : KotlinPlugin(
JvmPluginDescription( JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT", id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT", name = "J ChatGPT",
version = "1.9.0", version = "1.10.0",
) { ) {
author("jie65535") author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true) // dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
@@ -548,6 +549,7 @@ object JChatGPT : KotlinPlugin(
var responseMessageBuilder: StringBuilder? = null var responseMessageBuilder: StringBuilder? = null
val responseToolCalls = mutableListOf<ToolCall.Function>() val responseToolCalls = mutableListOf<ToolCall.Function>()
val toolCallTasks = mutableListOf<Deferred<ChatMessage>>() val toolCallTasks = mutableListOf<Deferred<ChatMessage>>()
var lastTokenUsage: Usage? = null
// 处理聊天流式响应 // 处理聊天流式响应
responseFlow.collect { chunk -> responseFlow.collect { chunk ->
val delta = chunk.choices[0].delta ?: return@collect val delta = chunk.choices[0].delta ?: return@collect
@@ -609,6 +611,9 @@ object JChatGPT : KotlinPlugin(
} }
} }
} }
// 捕获token使用量
chunk.usage?.let { lastTokenUsage = it }
} }
// 移除思考内容 // 移除思考内容
@@ -622,6 +627,23 @@ object JChatGPT : KotlinPlugin(
) )
) )
// 记录token使用量
lastTokenUsage?.let { usage ->
val now = OffsetDateTime.now().toEpochSecond()
val groupId = if (event is GroupMessageEvent) event.subject.id else null
val record = TokenUsageRecord(
timestamp = now,
userId = event.sender.id,
userNickname = event.senderName,
groupId = groupId,
model = PluginConfig.chatModel,
promptTokens = usage.promptTokens ?: 0,
completionTokens = usage.completionTokens ?: 0,
totalTokens = usage.totalTokens ?: 0
)
PluginData.tokenUsageRecords.add(record)
}
// 处理最后一个工具调用 // 处理最后一个工具调用
if (responseToolCalls.size > toolCallTasks.size) { if (responseToolCalls.size > toolCallTasks.size) {
val toolCallMessage = responseToolCalls.last().let { toolCall -> val toolCallMessage = responseToolCalls.last().let { toolCall ->

View File

@@ -10,6 +10,10 @@ import net.mamoe.mirai.contact.Group
import net.mamoe.mirai.contact.Member import net.mamoe.mirai.contact.Member
import net.mamoe.mirai.contact.User import net.mamoe.mirai.contact.User
import top.jie65535.mirai.JChatGPT.reload import top.jie65535.mirai.JChatGPT.reload
import java.time.Instant
import java.time.ZoneId
import java.time.LocalDate
import java.time.format.DateTimeFormatter
object PluginCommands : CompositeCommand( object PluginCommands : CompositeCommand(
JChatGPT, "jgpt", description = "J OpenAI ChatGPT" JChatGPT, "jgpt", description = "J OpenAI ChatGPT"
@@ -72,4 +76,132 @@ object PluginCommands : CompositeCommand(
JChatGPT.clearContextCache() JChatGPT.clearContextCache()
sendMessage("已清空所有对话上下文缓存") sendMessage("已清空所有对话上下文缓存")
} }
@SubCommand
suspend fun CommandSender.tokens() {
sendMessage("请使用子命令daily, users, groups, query")
}
@SubCommand
suspend fun CommandSender.tokensDaily(days: Int = 7) {
val now = Instant.now().epochSecond
val secondsPerDay = 86400
val cutoff = now - (days * secondsPerDay)
val dailyStats = PluginData.tokenUsageRecords
.filter { it.timestamp >= cutoff }
.groupBy {
LocalDate.ofInstant(
Instant.ofEpochSecond(it.timestamp),
ZoneId.systemDefault()
)
}
.mapValues { (_, records) ->
records.sumOf { it.totalTokens }
}
.toSortedMap()
if (dailyStats.isEmpty()) {
sendMessage("指定时间范围内无使用记录")
return
}
val response = buildString {
appendLine("最近 $days 天 Token 使用统计:")
appendLine()
dailyStats.forEach { (date, total) ->
appendLine("$date: $total tokens")
}
}
sendMessage(response)
}
@SubCommand
suspend fun CommandSender.tokensUsers(limit: Int = 10) {
val userStats = PluginData.tokenUsageRecords
.groupBy { it.userId }
.mapValues { (_, records) ->
Pair(
records.first().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}): ${it.second.second} tokens")
}
}
sendMessage(response)
}
@SubCommand
suspend fun CommandSender.tokensGroups(limit: Int = 10) {
val groupStats = PluginData.tokenUsageRecords
.filter { it.groupId != null }
.groupBy { it.groupId!! }
.mapValues { (_, records) ->
records.sumOf { it.totalTokens }
}
.toList()
.sortedByDescending { it.second }
.take(limit)
if (groupStats.isEmpty()) {
sendMessage("暂无群组使用记录")
return
}
val response = buildString {
appendLine("群组 Token 使用排名 Top $limit")
appendLine()
groupStats.forEach { (groupId, total) ->
appendLine("- $groupId: $total tokens")
}
}
sendMessage(response)
}
@SubCommand
suspend fun CommandSender.tokensQuery(userId: Long?, days: Int = 7) {
val now = Instant.now().epochSecond
val cutoff = now - (days * 86400)
val filtered = PluginData.tokenUsageRecords
.filter { it.timestamp >= cutoff }
.filter { userId == null || it.userId == userId }
.sortedByDescending { it.timestamp }
.take(20)
if (filtered.isEmpty()) {
sendMessage("指定时间范围内无使用记录")
return
}
val response = buildString {
appendLine("最近 $days 天使用记录最多显示20条")
appendLine()
filtered.forEach { record ->
val time = Instant.ofEpochSecond(record.timestamp)
.atZone(ZoneId.systemDefault())
.format(DateTimeFormatter.ofPattern("MM-dd HH:mm"))
val location = if (record.groupId != null) "${record.groupId}" else "私聊"
appendLine("[$time] $location - ${record.userNickname}")
appendLine(" 模型: ${record.model}, Tokens: ${record.totalTokens} " +
"(输入: ${record.promptTokens}, 输出: ${record.completionTokens})")
appendLine()
}
}
sendMessage(response)
}
} }

View File

@@ -34,6 +34,29 @@ data class FavorabilityInfo(
} }
} }
/**
* Token使用记录数据类
* @param timestamp Unix时间戳
* @param userId 用户QQ号
* @param userNickname 用户昵称
* @param groupId 群号私聊时为null
* @param model 模型名称
* @param promptTokens 输入token数
* @param completionTokens 输出token数
* @param totalTokens 总token数
*/
@Serializable
data class TokenUsageRecord(
val timestamp: Long,
val userId: Long,
val userNickname: String,
val groupId: Long?,
val model: String,
val promptTokens: Int,
val completionTokens: Int,
val totalTokens: Int
)
object PluginData : AutoSavePluginData("data") { object PluginData : AutoSavePluginData("data") {
/** /**
* 联系人记忆 * 联系人记忆
@@ -47,6 +70,11 @@ object PluginData : AutoSavePluginData("data") {
*/ */
val userFavorability by value(mutableMapOf<Long, FavorabilityInfo>()) val userFavorability by value(mutableMapOf<Long, FavorabilityInfo>())
/**
* Token使用记录
*/
val tokenUsageRecords by value(mutableListOf<TokenUsageRecord>())
/** /**
* 添加对话记忆 * 添加对话记忆
*/ */