mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-06-23 00:49:31 +08:00
Compare commits
12 Commits
2d3045e110
...
92acbb2310
| Author | SHA1 | Date | |
|---|---|---|---|
| 92acbb2310 | |||
| 39b49bb302 | |||
| 98bb1066c4 | |||
| 4dde523dfc | |||
| f17adee4ba | |||
| 72892336bc | |||
| e629d37fa8 | |||
| 200a404927 | |||
| 3ed54dae0e | |||
| 417b5f631f | |||
| bc2ab553b9 | |||
| e5d5445a1f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
||||
# User-specific stuff
|
||||
.idea/
|
||||
.run/
|
||||
build-with-jdk17.bat
|
||||
|
||||
*.iml
|
||||
*.ipr
|
||||
|
||||
114
README.md
114
README.md
@@ -7,8 +7,8 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
|
||||
- **多模型支持**:支持聊天模型、推理模型和视觉模型
|
||||
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
|
||||
- **上下文记忆**:支持持久化记忆存储
|
||||
- **好感度系统**:基于用户行为的好感度管理机制
|
||||
- **Token消耗统计**:记录每次对话的Token消耗,支持多维度统计查询
|
||||
- **用户画像系统**:好感度、印象、标签、Bot 自定义代号
|
||||
- **Token消耗统计**:按天 × 用户 × 群聚合记录,支持多维度统计查询
|
||||
- **LaTeX 渲染**:自动将数学表达式渲染为图片
|
||||
- **灵活的触发方式**:@机器人、关键字触发、回复消息等
|
||||
- **权限控制**:细粒度的权限管理系统
|
||||
@@ -30,6 +30,7 @@ AI 可以自动调用多种工具来完成复杂任务:
|
||||
- 推理思考(需要配置推理模型)
|
||||
- 群管理(禁言等,需启用相应权限)
|
||||
- 记忆管理(添加和修改对话记忆)
|
||||
- 聊天历史搜索(按关键词、发送者、时间范围检索群聊消息,需启用历史消息上下文)
|
||||
|
||||
## 权限列表
|
||||
|
||||
@@ -54,7 +55,7 @@ AI 可以自动调用多种工具来完成复杂任务:
|
||||
- `/jgpt tokensDaily [days]` - 查看指定天数的每日Token消耗统计(默认7天)
|
||||
- `/jgpt tokensUsers [limit]` - 查看Token消耗最多的用户排名(默认Top 10)
|
||||
- `/jgpt tokensGroups [limit]` - 查看Token消耗最多的群组排名(默认Top 10)
|
||||
- `/jgpt tokensQuery [userId] [days]` - 查询详细的使用记录(可按用户和时间过滤)
|
||||
- `/jgpt tokensQuery [userId] [days]` - 查询日聚合记录(每行一天一人,可按用户和时间过滤)
|
||||
- `/jgpt tokensUserDaily <userId> [days]` - 查询指定用户每天的消费统计(默认7天)
|
||||
|
||||
## 配置文件
|
||||
@@ -82,12 +83,20 @@ visualModelApi: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
|
||||
visualModelToken: ''
|
||||
# 视觉模型
|
||||
visualModel: 'qwen-vl-plus'
|
||||
# 聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {"thinking": {"type": "disabled"}}
|
||||
chatModelExtraBody: ''
|
||||
# 推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {"thinking": {"type": "enabled"}}
|
||||
reasoningModelExtraBody: ''
|
||||
# 视觉模型额外请求体JSON,会合并到请求体中。
|
||||
visualModelExtraBody: ''
|
||||
# 百炼平台API KEY
|
||||
dashScopeApiKey: ''
|
||||
# 百炼平台图片编辑模型
|
||||
imageEditModel: 'qwen-image-edit'
|
||||
# 百炼平台TTS模型
|
||||
ttsModel: 'qwen-tts'
|
||||
# 百炼平台图像模型(文生图 + 图像编辑)
|
||||
imageModel: 'qwen-image-2.0'
|
||||
# 是否在生成图片右下角添加 Qwen-Image 水印
|
||||
imageWatermark: false
|
||||
# 百炼平台TTS模型,qwen3-tts-instruct-flash 支持 instructions 指令控制语气
|
||||
ttsModel: 'qwen3-tts-instruct-flash'
|
||||
# Jina API Key
|
||||
jinaApiKey: ''
|
||||
# SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回
|
||||
@@ -102,8 +111,10 @@ friendHasChatPermission: true
|
||||
canMute: false
|
||||
# 群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。
|
||||
temperaturePermission: 50
|
||||
# 等待响应超时时间,单位毫秒,默认60秒
|
||||
# 等待响应超时时间(整个请求的总超时与socket读超时),单位毫秒,默认60秒
|
||||
timeout: 60000
|
||||
# 首块响应超时时间,单位毫秒,默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试
|
||||
firstChunkTimeout: 10000
|
||||
# 系统提示词,该字段已弃用,使用提示词文件而不是在这里修改
|
||||
prompt: '你是一个乐于助人的助手'
|
||||
# 系统提示词文件路径,相对于插件配置目录
|
||||
@@ -128,6 +139,10 @@ memoryEnabled: true
|
||||
enableFavorabilitySystem: true
|
||||
# 好感度每日基础偏移速度(点/天)
|
||||
favorabilityBaseShiftSpeed: 2.0
|
||||
# 聊天记录搜索最大天数
|
||||
searchHistoryMaxDays: 30
|
||||
# 聊天记录搜索最大查询条数,防止内存溢出
|
||||
searchHistoryMaxRecords: 5000
|
||||
```
|
||||
|
||||
## 系统提示词
|
||||
@@ -267,55 +282,60 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型
|
||||
6. **GroupManageAgent** - 群管理功能(如禁言)
|
||||
7. **SendSingleMessage/CompositeMessage** - 发送消息
|
||||
8. **SendVoiceMessage** - 发送语音消息
|
||||
9. **ImageEdit** - 图像编辑
|
||||
9. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
|
||||
10. **WeatherService** - 天气查询
|
||||
11. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin)
|
||||
|
||||
## 好感度系统
|
||||
## 用户画像系统
|
||||
|
||||
JChatGPT 插件包含一个可选的好感度系统,用于根据用户行为调整机器人对用户的好感度。该系统有以下特性:
|
||||
JChatGPT 维护对每位用户的画像,由好感度、Bot 自定义代号、最多 5 个标签和印象文本组成。模型可以通过 `adjustUserFavorability` 工具在交流后增量更新这些字段(同一次调用可以同时调好感度、加 tag、改印象,不必分开)。
|
||||
|
||||
### 核心机制
|
||||
- 好感度值范围:-100(完全不理会)到 100(非常好的朋友)
|
||||
- 负好感度用户有一定概率不会收到回复,概率为好感度绝对值的百分比
|
||||
- 好感度会随时间向0偏移,偏移速度与当前好感度绝对值相关
|
||||
### 字段
|
||||
- **value(好感度)**:范围 -100(完全不理会)到 100(非常好的朋友)
|
||||
- **name(代号)**:Bot 给此人起的内部代号,区别于 QQ 昵称,长度 ≤20
|
||||
- **tags(标签)**:最多 5 个简短标签,记录身份/职业/偏好/技术栈等
|
||||
- **impression(印象)**:自由文本,长度 ≤200
|
||||
- **reasons(调整原因)**:只有 change ≠ 0 的调整才会追加,保留最近 10 条
|
||||
|
||||
### 好感度调整规则
|
||||
- 问正经问题:+好感度
|
||||
- 问无聊问题:-好感度
|
||||
- 骂人:直接降至-100
|
||||
### 好感度机制
|
||||
- 负好感度用户有一定概率不会收到回复,概率 = |好感度| / 100
|
||||
- 好感度会随时间向 0 偏移:偏移量 = sign(好感度) × (1 - (|好感度| / 100)²) × 基础偏移速度
|
||||
- 极端值变化缓慢,-100 需要好几天才能回升,100 也不会快速衰减
|
||||
|
||||
### 时间偏移机制
|
||||
好感度会随时间自然向0回归:
|
||||
- 偏移公式:偏移量 = sign(好感度) * (1 - (|好感度| / 100)^2) * 基础偏移速度
|
||||
- 极端值变化缓慢,-100可能需要好几天才消气,100可能好多天都不会降低
|
||||
### 注入到上下文
|
||||
- 群聊:列出会话历史中"认识的群友"(name/tags/impression 任一非空)
|
||||
- 私聊:仅当对方有 name/tags/impression 时注入对方画像
|
||||
- 仅有好感度数值、其它字段全空的用户不会被列出,避免提示词噪声
|
||||
|
||||
### 管理命令
|
||||
- `/jgpt favorability <userId> <value>` - 设置指定用户的好感度值(-100~100)
|
||||
- `/jgpt resetFavorability` - 重置所有用户的好感度
|
||||
- `/jgpt setFavor <user> <value>` - 设置指定用户的好感度值(-100~100),不改其他字段
|
||||
- `/jgpt clearFavor` - 清空所有用户画像
|
||||
|
||||
### 配置选项
|
||||
- `enableFavorabilitySystem` - 是否启用好感度系统(默认:true)
|
||||
- `enableFavorabilitySystem` - 是否启用画像系统(默认:true)
|
||||
- `favorabilityBaseShiftSpeed` - 好感度每日基础偏移速度(点/天,默认:2.0)
|
||||
|
||||
## Token消耗统计
|
||||
|
||||
JChatGPT 插件内置了Token消耗统计功能,可以记录每次对话的Token使用情况,并提供多维度统计查询。
|
||||
JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消耗,提供多维度统计查询。
|
||||
|
||||
> 历史版本曾按每次请求逐条记录,但增长不受控(数千条后会触发 mamoe-yamlkt 的编/解码 bug 导致整个 data.yml 无法加载)。现已改为按天聚合并搬到独立的 `token_usage.json`,data.yml 只保留小规模的 memory / favorability 数据。
|
||||
|
||||
### 功能特性
|
||||
- **自动记录**:每次对话自动记录Token消耗
|
||||
- **详细数据**:记录时间戳、用户、群组、模型、输入/输出Token数
|
||||
- **自动记录**:每次对话累加到当日聚合行
|
||||
- **聚合维度**:日期 × 用户 × 群(同一人同一天在同一群只占一行)
|
||||
- **多维统计**:支持按日期、用户、群组进行统计
|
||||
- **灵活查询**:支持详细记录查询和过滤
|
||||
|
||||
### 记录内容
|
||||
每次对话记录包含以下信息:
|
||||
- 时间戳(Unix timestamp)
|
||||
- 用户QQ号和昵称
|
||||
每条聚合记录包含:
|
||||
- 日期(yyyy-MM-dd,本地时区)
|
||||
- 用户QQ号和最近一次记录到的昵称
|
||||
- 群组ID(群聊)或null(私聊)
|
||||
- 使用的模型名称
|
||||
- 输入Token数(promptTokens)
|
||||
- 输出Token数(completionTokens)
|
||||
- 总Token数(totalTokens)
|
||||
- 当日累计输入Token数(promptTokens)
|
||||
- 当日累计输出Token数(completionTokens)
|
||||
- 当日累计总Token数(totalTokens)
|
||||
- 当日调用次数(callCount)
|
||||
|
||||
### 统计命令
|
||||
|
||||
@@ -398,19 +418,19 @@ JChatGPT 插件内置了Token消耗统计功能,可以记录每次对话的Tok
|
||||
```
|
||||
/jgpt tokensQuery [userId] [days]
|
||||
```
|
||||
- 查询详细的使用记录
|
||||
- 查询日聚合记录(每行 = 某天某人某群的当日合计)
|
||||
- 可按用户ID过滤(可选)
|
||||
- 可指定时间范围(默认7天)
|
||||
- 最多显示20条记录
|
||||
- 最多显示20条记录,按日期倒序
|
||||
- 输出示例:
|
||||
```
|
||||
最近 7 天使用记录(最多显示20条):
|
||||
最近 7 天使用记录(最多显示20条,按日聚合):
|
||||
|
||||
[03-18 14:35] 群987654321 - 张三
|
||||
模型: qwen-max, Tokens: 2,345 (提示: 1,234, 输出: 1,111)
|
||||
[2026-03-18] 群987654321 - 张三
|
||||
调用 12 次, Tokens: 23,450 (输入: 12,340, 输出: 11,110)
|
||||
|
||||
[03-18 14:30] 私聊 - 李四
|
||||
模型: qwen-max, Tokens: 1,876 (提示: 980, 输出: 896)
|
||||
[2026-03-18] 私聊 - 李四
|
||||
调用 5 次, Tokens: 8,760 (输入: 4,800, 输出: 3,960)
|
||||
```
|
||||
|
||||
#### 用户日统计
|
||||
@@ -433,8 +453,10 @@ JChatGPT 插件内置了Token消耗统计功能,可以记录每次对话的Tok
|
||||
```
|
||||
|
||||
### 数据存储
|
||||
- Token记录保存在插件数据目录的 `data/data.json` 文件中
|
||||
- 使用 `AutoSavePluginData` 自动持久化
|
||||
- Token 聚合记录保存在插件数据目录的 `token_usage.json` 文件中
|
||||
- 由 `TokenUsageStore` 直接管,绕开 mamoe 的 plugin data 系统(避免 yamlkt 在大数据量下的编/解码 bug)
|
||||
- 每次记录后写盘(先写 `.tmp` 再覆盖,避免半文件)
|
||||
- 加载失败会自动备份原文件为 `token_usage.json.broken-<timestamp>` 并从空开始
|
||||
- 记录永久保存,不会自动删除
|
||||
- 数据格式为JSON,可手动查看和备份
|
||||
|
||||
@@ -447,7 +469,7 @@ JChatGPT 插件内置了Token消耗统计功能,可以记录每次对话的Tok
|
||||
### 注意事项
|
||||
- 仅统计聊天模型的Token消耗
|
||||
- 推理模型和视觉模型的消耗不在统计范围内
|
||||
- 每次对话轮次都会单独记录
|
||||
- 同一用户同一天在同一群的多次调用合并为一行(callCount 自增)
|
||||
- 统计数据基于实际API返回的Token数
|
||||
|
||||
## 部署要求
|
||||
|
||||
@@ -7,7 +7,7 @@ plugins {
|
||||
}
|
||||
|
||||
group = "top.jie65535.mirai"
|
||||
version = "1.10.0"
|
||||
version = "1.11.0"
|
||||
|
||||
mirai {
|
||||
jvmTarget = JavaVersion.VERSION_11
|
||||
@@ -25,7 +25,7 @@ repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
val openaiClientVersion = "4.0.1"
|
||||
val openaiClientVersion = "4.1.0"
|
||||
val ktorVersion = "3.0.3"
|
||||
val jLatexMathVersion = "1.0.7"
|
||||
val commonTextVersion = "1.13.0"
|
||||
|
||||
72
scripts/recover_data_yml.py
Normal file
72
scripts/recover_data_yml.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
恢复 data.yml:把 tokenUsageDailyRecords 抽出成 token_usage.json,
|
||||
顺手清理 tokenUsageRecords,把 data.yml 重写成合法的、yamlkt 能读回的 JSON。
|
||||
|
||||
用法(在 data.yml 所在目录运行):
|
||||
python3 recover_data_yml.py /path/to/top.jie65535.mirai.JChatGPT/
|
||||
|
||||
会做:
|
||||
1. 备份原 data.yml -> data.yml.bak-<timestamp>
|
||||
2. 读 data.yml(按 JSON 解析,目前文件就是 JSON-flow YAML)
|
||||
3. 把 tokenUsageDailyRecords 写到 token_usage.json
|
||||
4. 删除 tokenUsageRecords 和 tokenUsageDailyRecords 字段
|
||||
5. 重写 data.yml(保留 contactMemory / userFavorability 等)
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
|
||||
def main(target_dir: str) -> int:
|
||||
data_path = os.path.join(target_dir, "data.yml")
|
||||
if not os.path.exists(data_path):
|
||||
print(f"NOT FOUND: {data_path}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
with open(data_path, "r", encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
|
||||
try:
|
||||
data = json.loads(text)
|
||||
except json.JSONDecodeError as e:
|
||||
print(f"data.yml 不是合法 JSON:{e}", file=sys.stderr)
|
||||
print("如果文件其实是 block-style YAML,请先用 yq/python yaml 转换", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
if not isinstance(data, dict):
|
||||
print(f"顶层不是 map,是 {type(data).__name__}", file=sys.stderr)
|
||||
return 3
|
||||
|
||||
ts = int(time.time())
|
||||
backup_path = os.path.join(target_dir, f"data.yml.bak-{ts}")
|
||||
with open(backup_path, "w", encoding="utf-8") as f:
|
||||
f.write(text)
|
||||
print(f"已备份 -> {backup_path}")
|
||||
|
||||
daily_records = data.pop("tokenUsageDailyRecords", [])
|
||||
raw_records = data.pop("tokenUsageRecords", [])
|
||||
print(f"提取 tokenUsageDailyRecords: {len(daily_records)} 条")
|
||||
print(f"丢弃 tokenUsageRecords (legacy): {len(raw_records)} 条")
|
||||
|
||||
token_path = os.path.join(target_dir, "token_usage.json")
|
||||
if os.path.exists(token_path):
|
||||
token_backup = os.path.join(target_dir, f"token_usage.json.bak-{ts}")
|
||||
os.rename(token_path, token_backup)
|
||||
print(f"已备份现有 token_usage.json -> {token_backup}")
|
||||
|
||||
with open(token_path, "w", encoding="utf-8") as f:
|
||||
json.dump(daily_records, f, ensure_ascii=False, indent=2)
|
||||
print(f"写入 -> {token_path} ({len(daily_records)} 条)")
|
||||
|
||||
with open(data_path, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||
print(f"重写 -> {data_path}(剩余字段: {list(data.keys())})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print(__doc__, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
sys.exit(main(sys.argv[1]))
|
||||
@@ -52,7 +52,7 @@ object JChatGPT : KotlinPlugin(
|
||||
JvmPluginDescription(
|
||||
id = "top.jie65535.mirai.JChatGPT",
|
||||
name = "J ChatGPT",
|
||||
version = "1.10.0",
|
||||
version = "1.11.0",
|
||||
) {
|
||||
author("jie65535")
|
||||
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
|
||||
@@ -61,7 +61,7 @@ object JChatGPT : KotlinPlugin(
|
||||
/**
|
||||
* 是否包含历史对话
|
||||
*/
|
||||
private var includeHistory: Boolean = false
|
||||
internal var includeHistory: Boolean = false
|
||||
|
||||
/**
|
||||
* 聊天权限
|
||||
@@ -79,6 +79,9 @@ object JChatGPT : KotlinPlugin(
|
||||
PluginConfig.reload()
|
||||
PluginData.reload()
|
||||
|
||||
// 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug)
|
||||
TokenUsageStore.init(dataFolder)
|
||||
|
||||
// 设置Token
|
||||
LargeLanguageModels.reload()
|
||||
|
||||
@@ -318,17 +321,27 @@ object JChatGPT : KotlinPlugin(
|
||||
var lastId = 0L
|
||||
if (event is GroupMessageEvent) {
|
||||
if (PluginConfig.enableFavorabilitySystem) {
|
||||
val favorabilityInfos = history.map { it.fromId }
|
||||
val knownUsers = history.asSequence()
|
||||
.map { it.fromId }
|
||||
.filter { it != event.bot.id }
|
||||
.distinct()
|
||||
.mapNotNull { PluginData.userFavorability[it] }
|
||||
if (favorabilityInfos.isNotEmpty()) {
|
||||
historyText.appendLine("## 相关成员的好感信息")
|
||||
for (info in favorabilityInfos) {
|
||||
historyText.append(getNameCard(event.group, info.userId)).append('\t')
|
||||
.appendLine(info).appendLine()
|
||||
.filter { it.name.isNotEmpty() || it.tags.isNotEmpty() || it.impression.isNotEmpty() }
|
||||
.sortedBy { it.userId }
|
||||
.toList()
|
||||
if (knownUsers.isNotEmpty()) {
|
||||
historyText.appendLine("【你认识的群友】")
|
||||
for (info in knownUsers) {
|
||||
val displayName = if (info.name.isNotEmpty()) info.name
|
||||
else getNameCard(event.subject, info.userId)
|
||||
historyText.append("- ").append(displayName)
|
||||
.append("(${info.userId})")
|
||||
.append(" 好感度${if (info.value >= 0) "+" else ""}${info.value}")
|
||||
if (info.tags.isNotEmpty()) historyText.append(" [${info.tags.joinToString(", ")}]")
|
||||
if (info.impression.isNotEmpty()) historyText.append(" ${info.impression}")
|
||||
historyText.appendLine()
|
||||
}
|
||||
historyText.appendLine("---").appendLine()
|
||||
historyText.appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -341,10 +354,15 @@ object JChatGPT : KotlinPlugin(
|
||||
} else {
|
||||
if (PluginConfig.enableFavorabilitySystem) {
|
||||
val favorabilityInfo = PluginData.userFavorability[event.sender.id]
|
||||
if (favorabilityInfo != null) {
|
||||
historyText.append("你对\"").append(event.senderName).append("\"的好感信息如下: ")
|
||||
.appendLine(favorabilityInfo).appendLine()
|
||||
historyText.appendLine("---").appendLine()
|
||||
if (favorabilityInfo != null && (favorabilityInfo.name.isNotEmpty() || favorabilityInfo.tags.isNotEmpty() || favorabilityInfo.impression.isNotEmpty())) {
|
||||
val displayName = if (favorabilityInfo.name.isNotEmpty()) favorabilityInfo.name else event.senderName
|
||||
historyText.appendLine("【你认识的对方】")
|
||||
historyText.append("- ").append(displayName)
|
||||
.append("(${event.sender.id})")
|
||||
.append(" 好感度${if (favorabilityInfo.value >= 0) "+" else ""}${favorabilityInfo.value}")
|
||||
if (favorabilityInfo.tags.isNotEmpty()) historyText.append(" [${favorabilityInfo.tags.joinToString(", ")}]")
|
||||
if (favorabilityInfo.impression.isNotEmpty()) historyText.append(" ${favorabilityInfo.impression}")
|
||||
historyText.appendLine().appendLine()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,6 +565,7 @@ object JChatGPT : KotlinPlugin(
|
||||
val startedAt = OffsetDateTime.now().toEpochSecond().toInt()
|
||||
val responseFlow = chatCompletions(history)
|
||||
var responseMessageBuilder: StringBuilder? = null
|
||||
var reasoningContentBuilder: StringBuilder? = null
|
||||
val responseToolCalls = mutableListOf<ToolCall.Function>()
|
||||
val toolCallTasks = mutableListOf<Deferred<ChatMessage>>()
|
||||
var lastTokenUsage: Usage? = null
|
||||
@@ -554,6 +573,15 @@ object JChatGPT : KotlinPlugin(
|
||||
responseFlow.collect { chunk ->
|
||||
val delta = chunk.choices[0].delta ?: return@collect
|
||||
|
||||
// 处理推理内容更新
|
||||
if (delta.reasoningContent != null) {
|
||||
if (reasoningContentBuilder == null) {
|
||||
reasoningContentBuilder = StringBuilder(delta.reasoningContent)
|
||||
} else {
|
||||
reasoningContentBuilder.append(delta.reasoningContent)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理内容更新
|
||||
if (delta.content != null) {
|
||||
if (responseMessageBuilder == null) {
|
||||
@@ -620,28 +648,31 @@ object JChatGPT : KotlinPlugin(
|
||||
val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim()
|
||||
logger.info("LLM Response: $responseContent")
|
||||
// 记录AI回答
|
||||
// reasoning_content仅在工具调用时需要回传(DeepSeek规范),否则丢弃
|
||||
// toolCalls空列表转null,避免序列化为"tool_calls":[]导致DeepSeek V4报400
|
||||
// explicitNulls=false确保null字段不会序列化到JSON中,兼容所有API
|
||||
history.add(
|
||||
ChatMessage.Assistant(
|
||||
ChatMessage(
|
||||
role = ChatRole.Assistant,
|
||||
content = responseContent,
|
||||
toolCalls = responseToolCalls
|
||||
toolCalls = responseToolCalls.ifEmpty { null },
|
||||
reasoningContent = if (responseToolCalls.isNotEmpty()) reasoningContentBuilder?.toString() else null
|
||||
)
|
||||
)
|
||||
|
||||
// 记录token使用量
|
||||
// 记录token使用量(按日聚合,独立JSON文件)
|
||||
lastTokenUsage?.let { usage ->
|
||||
val now = OffsetDateTime.now().toEpochSecond()
|
||||
val groupId = if (event is GroupMessageEvent) event.subject.id else null
|
||||
val record = TokenUsageRecord(
|
||||
TokenUsageStore.record(
|
||||
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)
|
||||
}
|
||||
|
||||
// 处理最后一个工具调用
|
||||
@@ -799,6 +830,9 @@ object JChatGPT : KotlinPlugin(
|
||||
// 记忆修改
|
||||
MemoryReplace(),
|
||||
|
||||
// 搜索聊天历史
|
||||
SearchChatHistory(),
|
||||
|
||||
// 网页搜索
|
||||
WebSearch(),
|
||||
|
||||
@@ -814,8 +848,8 @@ object JChatGPT : KotlinPlugin(
|
||||
// 视觉代理
|
||||
VisualAgent(),
|
||||
|
||||
// 图像编辑模型
|
||||
ImageEdit(),
|
||||
// 图像生成与编辑
|
||||
ImageAgent(),
|
||||
|
||||
// 天气服务
|
||||
WeatherService(),
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package top.jie65535.mirai
|
||||
|
||||
import com.aallam.openai.api.http.Timeout
|
||||
import com.aallam.openai.client.Chat
|
||||
import com.aallam.openai.client.OpenAI
|
||||
import com.aallam.openai.client.OpenAIHost
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object LargeLanguageModels {
|
||||
|
||||
|
||||
/**
|
||||
* 系统提示词
|
||||
*/
|
||||
@@ -18,46 +16,66 @@ object LargeLanguageModels {
|
||||
/**
|
||||
* 聊天助手
|
||||
*/
|
||||
var chat: Chat? = null
|
||||
var chat: ModelService? = null
|
||||
|
||||
/**
|
||||
* 推理模型
|
||||
*/
|
||||
var reasoning: Chat? = null
|
||||
var reasoning: ModelService? = null
|
||||
|
||||
/**
|
||||
* 视觉模型
|
||||
*/
|
||||
var visual: Chat? = null
|
||||
var visual: ModelService? = null
|
||||
|
||||
private val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
|
||||
private fun parseExtraBody(raw: String): JsonObject? {
|
||||
if (raw.isBlank()) return null
|
||||
return try {
|
||||
json.parseToJsonElement(raw).jsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun reload() {
|
||||
// 载入超时时间
|
||||
val timeout = PluginConfig.timeout.milliseconds
|
||||
val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds
|
||||
|
||||
// 初始化聊天模型
|
||||
if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) {
|
||||
chat = OpenAI(
|
||||
chat = ModelService(
|
||||
baseUrl = PluginConfig.openAiApi,
|
||||
token = PluginConfig.openAiToken,
|
||||
host = OpenAIHost(baseUrl = PluginConfig.openAiApi),
|
||||
timeout = Timeout(request = timeout, connect = timeout, socket = timeout)
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
extraBody = parseExtraBody(PluginConfig.chatModelExtraBody)
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化推理模型
|
||||
if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) {
|
||||
reasoning = OpenAI(
|
||||
reasoning = ModelService(
|
||||
baseUrl = PluginConfig.reasoningModelApi,
|
||||
token = PluginConfig.reasoningModelToken,
|
||||
host = OpenAIHost(baseUrl = PluginConfig.reasoningModelApi),
|
||||
timeout = Timeout(request = timeout, connect = timeout, socket = timeout)
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
extraBody = parseExtraBody(PluginConfig.reasoningModelExtraBody)
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化视觉模型
|
||||
if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) {
|
||||
visual = OpenAI(
|
||||
visual = ModelService(
|
||||
baseUrl = PluginConfig.visualModelApi,
|
||||
token = PluginConfig.visualModelToken,
|
||||
host = OpenAIHost(baseUrl = PluginConfig.visualModelApi),
|
||||
timeout = Timeout(request = timeout, connect = timeout, socket = timeout)
|
||||
timeout = timeout,
|
||||
firstChunkTimeout = firstChunkTimeout,
|
||||
extraBody = parseExtraBody(PluginConfig.visualModelExtraBody)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,4 +96,4 @@ object LargeLanguageModels {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
108
src/main/kotlin/ModelService.kt
Normal file
108
src/main/kotlin/ModelService.kt
Normal file
@@ -0,0 +1,108 @@
|
||||
package top.jie65535.mirai
|
||||
|
||||
import com.aallam.openai.api.chat.ChatCompletionChunk
|
||||
import com.aallam.openai.api.chat.ChatCompletionRequest
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.plugins.*
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import io.ktor.utils.io.*
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.*
|
||||
import kotlin.time.Duration
|
||||
|
||||
class ModelService(
|
||||
val baseUrl: String,
|
||||
val token: String,
|
||||
val timeout: Duration,
|
||||
val firstChunkTimeout: Duration,
|
||||
val extraBody: JsonObject? = null
|
||||
) {
|
||||
val httpClient: HttpClient by lazy {
|
||||
HttpClient(OkHttp) {
|
||||
install(HttpTimeout) {
|
||||
// 总请求/socket 超时保持长值,允许慢速流式输出;连接握手则用短超时。
|
||||
requestTimeoutMillis = timeout.inWholeMilliseconds
|
||||
socketTimeoutMillis = timeout.inWholeMilliseconds
|
||||
connectTimeoutMillis = firstChunkTimeout.inWholeMilliseconds
|
||||
}
|
||||
defaultRequest {
|
||||
url(baseUrl)
|
||||
bearerAuth(token)
|
||||
}
|
||||
expectSuccess = true
|
||||
}
|
||||
}
|
||||
|
||||
private val json = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
fun chatCompletions(request: ChatCompletionRequest): Flow<ChatCompletionChunk> {
|
||||
val requestJson = json.encodeToJsonElement(ChatCompletionRequest.serializer(), request)
|
||||
.jsonObject.toMutableMap()
|
||||
requestJson["stream"] = JsonPrimitive(true)
|
||||
extraBody?.forEach { (key, value) ->
|
||||
requestJson[key] = value
|
||||
}
|
||||
val body = JsonObject(requestJson).toString()
|
||||
|
||||
return flow {
|
||||
httpClient.post("chat/completions") {
|
||||
setBody(body)
|
||||
contentType(ContentType.Application.Json)
|
||||
accept(ContentType.Text.EventStream)
|
||||
headers {
|
||||
append(HttpHeaders.CacheControl, "no-cache")
|
||||
append(HttpHeaders.Connection, "keep-alive")
|
||||
}
|
||||
}.let { response ->
|
||||
val channel: ByteReadChannel = response.body()
|
||||
try {
|
||||
// 首块 data: 必须在 firstChunkTimeout 内到达,否则抛 TimeoutCancellationException
|
||||
// 走 JChatGPT 的重试流程;之后的流式读取不再有应用层超时,由 socketTimeoutMillis 兜底。
|
||||
val firstDataLine: String? = withTimeout(firstChunkTimeout) {
|
||||
var found: String? = null
|
||||
while (currentCoroutineContext().isActive && !channel.isClosedForRead) {
|
||||
val line = channel.readUTF8Line() ?: continue
|
||||
if (line.startsWith("data: ")) {
|
||||
found = line
|
||||
break
|
||||
}
|
||||
// 心跳/空行/注释行,不计为首块,继续等
|
||||
}
|
||||
found
|
||||
}
|
||||
|
||||
if (firstDataLine != null) {
|
||||
if (!firstDataLine.startsWith("data: [DONE]")) {
|
||||
emit(json.decodeFromString(firstDataLine.removePrefix("data: ")))
|
||||
|
||||
while (currentCoroutineContext().isActive && !channel.isClosedForRead) {
|
||||
val line = channel.readUTF8Line() ?: continue
|
||||
when {
|
||||
line.startsWith("data: [DONE]") -> break
|
||||
line.startsWith("data: ") -> {
|
||||
emit(json.decodeFromString(line.removePrefix("data: ")))
|
||||
}
|
||||
else -> continue
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
channel.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,7 @@ 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.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.LocalDate
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
object PluginCommands : CompositeCommand(
|
||||
JChatGPT, "jgpt", description = "J OpenAI ChatGPT"
|
||||
@@ -81,29 +78,27 @@ object PluginCommands : CompositeCommand(
|
||||
suspend fun CommandSender.tokens(days: Int = 7) {
|
||||
validateDays(days)
|
||||
|
||||
if (PluginData.tokenUsageRecords.isEmpty()) {
|
||||
if (TokenUsageStore.all.isEmpty()) {
|
||||
sendMessage("暂无 Token 使用记录")
|
||||
return
|
||||
}
|
||||
|
||||
val cutoff = calculateCutoffTimestamp(days)
|
||||
val todayStart = calculateTodayStartTimestamp()
|
||||
val cutoff = calculateCutoffDate(days)
|
||||
val today = LocalDate.now().toString()
|
||||
|
||||
// 一次遍历计算所有统计数据
|
||||
data class Statistics(
|
||||
var totalTokens: Int = 0,
|
||||
var todayTokens: Int = 0,
|
||||
val userTotals: MutableMap<Long, Pair<String, Int>> = mutableMapOf(),
|
||||
val groupTotals: MutableMap<Long, Int> = mutableMapOf(),
|
||||
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 = PluginData.tokenUsageRecords.fold(Statistics()) { acc, record ->
|
||||
if (record.timestamp >= cutoff) {
|
||||
val stats = TokenUsageStore.all.fold(Statistics()) { acc, record ->
|
||||
if (record.date >= cutoff) {
|
||||
acc.totalTokens += record.totalTokens
|
||||
acc.users.add(record.userId)
|
||||
|
||||
// 累计用户Token
|
||||
val existing = acc.userTotals[record.userId]
|
||||
if (existing == null) {
|
||||
acc.userTotals[record.userId] = record.userNickname to record.totalTokens
|
||||
@@ -111,13 +106,12 @@ object PluginCommands : CompositeCommand(
|
||||
acc.userTotals[record.userId] = existing.first to (existing.second + record.totalTokens)
|
||||
}
|
||||
|
||||
// 累计群组Token
|
||||
record.groupId?.let { groupId ->
|
||||
acc.groupTotals[groupId] = acc.groupTotals.getOrDefault(groupId, 0) + record.totalTokens
|
||||
acc.groupTotals[groupId] = acc.groupTotals.getOrDefault(groupId, 0L) + record.totalTokens
|
||||
}
|
||||
}
|
||||
|
||||
if (record.timestamp >= todayStart) {
|
||||
if (record.date == today) {
|
||||
acc.todayTokens += record.totalTokens
|
||||
}
|
||||
|
||||
@@ -151,7 +145,7 @@ object PluginCommands : CompositeCommand(
|
||||
appendLine(" /jgpt tokensDaily [days] - 每日统计")
|
||||
appendLine(" /jgpt tokensUsers [limit] - 用户排名")
|
||||
appendLine(" /jgpt tokensGroups [limit] - 群组排名")
|
||||
appendLine(" /jgpt tokensQuery [userId] [days] - 详细记录")
|
||||
appendLine(" /jgpt tokensQuery [userId] [days] - 每日逐人记录")
|
||||
appendLine(" /jgpt tokensUserDaily <userId> [days] - 用户日统计")
|
||||
}
|
||||
|
||||
@@ -162,19 +156,12 @@ object PluginCommands : CompositeCommand(
|
||||
suspend fun CommandSender.tokensDaily(days: Int = 7) {
|
||||
validateDays(days)
|
||||
|
||||
val cutoff = calculateCutoffTimestamp(days)
|
||||
val cutoff = calculateCutoffDate(days)
|
||||
|
||||
val dailyStats = PluginData.tokenUsageRecords
|
||||
.filter { it.timestamp >= cutoff }
|
||||
.groupBy {
|
||||
LocalDate.ofInstant(
|
||||
Instant.ofEpochSecond(it.timestamp),
|
||||
ZoneId.systemDefault()
|
||||
)
|
||||
}
|
||||
.mapValues { (_, records) ->
|
||||
records.sumOf { it.totalTokens }
|
||||
}
|
||||
val dailyStats = TokenUsageStore.all
|
||||
.filter { it.date >= cutoff }
|
||||
.groupBy { it.date }
|
||||
.mapValues { (_, records) -> records.sumOf { it.totalTokens } }
|
||||
.toSortedMap()
|
||||
|
||||
if (dailyStats.isEmpty()) {
|
||||
@@ -196,13 +183,11 @@ object PluginCommands : CompositeCommand(
|
||||
suspend fun CommandSender.tokensUsers(limit: Int = 10) {
|
||||
require(limit > 0) { "limit must be positive: $limit" }
|
||||
|
||||
val userStats = PluginData.tokenUsageRecords
|
||||
val userStats = TokenUsageStore.all
|
||||
.groupBy { it.userId }
|
||||
.mapValues { (_, records) ->
|
||||
Pair(
|
||||
records.first().userNickname,
|
||||
records.sumOf { it.totalTokens }
|
||||
)
|
||||
val latest = records.maxByOrNull { it.date }!!
|
||||
Pair(latest.userNickname, records.sumOf { it.totalTokens })
|
||||
}
|
||||
.toList()
|
||||
.sortedByDescending { it.second.second }
|
||||
@@ -227,12 +212,10 @@ object PluginCommands : CompositeCommand(
|
||||
suspend fun CommandSender.tokensGroups(limit: Int = 10) {
|
||||
require(limit > 0) { "limit must be positive: $limit" }
|
||||
|
||||
val groupStats = PluginData.tokenUsageRecords
|
||||
val groupStats = TokenUsageStore.all
|
||||
.filter { it.groupId != null }
|
||||
.groupBy { it.groupId!! }
|
||||
.mapValues { (_, records) ->
|
||||
records.sumOf { it.totalTokens }
|
||||
}
|
||||
.mapValues { (_, records) -> records.sumOf { it.totalTokens } }
|
||||
.toList()
|
||||
.sortedByDescending { it.second }
|
||||
.take(limit)
|
||||
@@ -256,12 +239,12 @@ object PluginCommands : CompositeCommand(
|
||||
suspend fun CommandSender.tokensQuery(userId: Long?, days: Int = 7) {
|
||||
validateDays(days)
|
||||
|
||||
val cutoff = calculateCutoffTimestamp(days)
|
||||
val cutoff = calculateCutoffDate(days)
|
||||
|
||||
val filtered = PluginData.tokenUsageRecords
|
||||
.filter { it.timestamp >= cutoff }
|
||||
val filtered = TokenUsageStore.all
|
||||
.filter { it.date >= cutoff }
|
||||
.filter { userId == null || it.userId == userId }
|
||||
.sortedByDescending { it.timestamp }
|
||||
.sortedWith(compareByDescending<TokenUsageDailyRecord> { it.date }.thenByDescending { it.totalTokens })
|
||||
.take(DEFAULT_QUERY_LIMIT)
|
||||
|
||||
if (filtered.isEmpty()) {
|
||||
@@ -270,15 +253,12 @@ object PluginCommands : CompositeCommand(
|
||||
}
|
||||
|
||||
val response = buildString {
|
||||
appendLine("最近 $days 天使用记录(最多显示${DEFAULT_QUERY_LIMIT}条):")
|
||||
appendLine("最近 $days 天使用记录(最多显示${DEFAULT_QUERY_LIMIT}条,按日聚合):")
|
||||
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: ${formatNumber(record.totalTokens)} " +
|
||||
appendLine("[${record.date}] $location - ${record.userNickname}")
|
||||
appendLine(" 调用 ${record.callCount} 次, Tokens: ${formatNumber(record.totalTokens)} " +
|
||||
"(输入: ${formatNumber(record.promptTokens)}, 输出: ${formatNumber(record.completionTokens)})")
|
||||
appendLine()
|
||||
}
|
||||
@@ -290,36 +270,28 @@ object PluginCommands : CompositeCommand(
|
||||
suspend fun CommandSender.tokensUserDaily(userId: Long, days: Int = 7) {
|
||||
validateDays(days)
|
||||
|
||||
val cutoff = calculateCutoffTimestamp(days)
|
||||
val cutoff = calculateCutoffDate(days)
|
||||
|
||||
// 先过滤用户记录,同时获取昵称
|
||||
val userRecords = PluginData.tokenUsageRecords
|
||||
.filter { it.timestamp >= cutoff && it.userId == userId }
|
||||
val userRecords = TokenUsageStore.all
|
||||
.filter { it.date >= cutoff && it.userId == userId }
|
||||
|
||||
if (userRecords.isEmpty()) {
|
||||
sendMessage("用户 $userId 在指定时间范围内无使用记录")
|
||||
return
|
||||
}
|
||||
|
||||
val userNickname = userRecords.first().userNickname
|
||||
val userNickname = userRecords.maxByOrNull { it.date }!!.userNickname
|
||||
|
||||
val userDailyStats = userRecords
|
||||
.groupBy {
|
||||
LocalDate.ofInstant(
|
||||
Instant.ofEpochSecond(it.timestamp),
|
||||
ZoneId.systemDefault()
|
||||
)
|
||||
}
|
||||
.mapValues { (_, records) ->
|
||||
records.sumOf { it.totalTokens }
|
||||
}
|
||||
.groupBy { it.date }
|
||||
.mapValues { (_, records) -> records.sumOf { it.totalTokens } }
|
||||
.toSortedMap()
|
||||
|
||||
val response = buildString {
|
||||
appendLine("用户 $userNickname 最近 $days 天 Token 使用统计:")
|
||||
appendLine()
|
||||
userDailyStats.forEach { (date, total) ->
|
||||
appendLine("$date: $total tokens")
|
||||
appendLine("$date: ${formatNumber(total)} tokens")
|
||||
}
|
||||
appendLine()
|
||||
appendLine("总计: ${formatNumber(userDailyStats.values.sum())} tokens")
|
||||
@@ -330,23 +302,10 @@ object PluginCommands : CompositeCommand(
|
||||
// ==================== 辅助函数 ====================
|
||||
|
||||
/**
|
||||
* 计算截止时间戳(指定天数前的起始时间 00:00:00)
|
||||
* 最近N天包含今天,所以要从 (N-1) 天前开始算
|
||||
* 计算截止日期字符串(指定天数前的日期,含今天共 days 天)
|
||||
*/
|
||||
private fun calculateCutoffTimestamp(days: Int): Long {
|
||||
return LocalDate.now()
|
||||
.minusDays((days - 1).toLong())
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toEpochSecond()
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算今天的起始时间戳(00:00:00)
|
||||
*/
|
||||
private fun calculateTodayStartTimestamp(): Long {
|
||||
return LocalDate.now()
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toEpochSecond()
|
||||
private fun calculateCutoffDate(days: Int): String {
|
||||
return LocalDate.now().minusDays((days - 1).toLong()).toString()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -38,14 +38,26 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("视觉模型")
|
||||
var visualModel: String by value("qwen-vl-plus")
|
||||
|
||||
@ValueDescription("聊天模型额外请求体JSON,会合并到请求体中。例如DeepSeek关闭思维: {\"thinking\": {\"type\": \"disabled\"}}")
|
||||
val chatModelExtraBody: String by value("")
|
||||
|
||||
@ValueDescription("推理模型额外请求体JSON,会合并到请求体中。例如DeepSeek启用思维: {\"thinking\": {\"type\": \"enabled\"}}")
|
||||
val reasoningModelExtraBody: String by value("")
|
||||
|
||||
@ValueDescription("视觉模型额外请求体JSON,会合并到请求体中。")
|
||||
val visualModelExtraBody: String by value("")
|
||||
|
||||
@ValueDescription("百炼平台API KEY")
|
||||
val dashScopeApiKey: String by value("")
|
||||
|
||||
@ValueDescription("百炼平台图片编辑模型")
|
||||
val imageEditModel: String by value("qwen-image-edit")
|
||||
@ValueDescription("百炼平台图像模型,支持文生图与图像编辑。可选:qwen-image-2.0 / qwen-image-2.0-pro / qwen-image-edit-max / qwen-image-edit-plus 等")
|
||||
val imageModel: String by value("qwen-image-2.0")
|
||||
|
||||
@ValueDescription("百炼平台TTS模型")
|
||||
val ttsModel: String by value("qwen-tts")
|
||||
@ValueDescription("是否在生成的图片右下角添加 Qwen-Image 水印")
|
||||
val imageWatermark: Boolean by value(false)
|
||||
|
||||
@ValueDescription("百炼平台TTS模型。qwen3-tts-instruct-flash 支持 instructions 指令控制;纯发音可用 qwen3-tts-flash 或 qwen-tts")
|
||||
val ttsModel: String by value("qwen3-tts-instruct-flash")
|
||||
|
||||
@ValueDescription("Jina API Key")
|
||||
val jinaApiKey by value("")
|
||||
@@ -68,9 +80,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。")
|
||||
val temperaturePermission: Int by value(50)
|
||||
|
||||
@ValueDescription("等待响应超时时间,单位毫秒,默认60秒")
|
||||
@ValueDescription("等待响应超时时间(整个请求的总超时与socket读超时),单位毫秒,默认60秒")
|
||||
val timeout: Long by value(60000L)
|
||||
|
||||
@ValueDescription("首块响应超时时间,单位毫秒,默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试")
|
||||
val firstChunkTimeout: Long by value(10000L)
|
||||
|
||||
@Deprecated("使用外部文件而不是在配置文件内保存提示词")
|
||||
@ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改")
|
||||
var prompt: String by value("你是一个乐于助人的助手")
|
||||
@@ -122,4 +137,10 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
|
||||
@ValueDescription("单个工具调用返回内容的最大字符数,超过将被截断并标注")
|
||||
val maxToolOutputLength: Int by value(15000)
|
||||
|
||||
@ValueDescription("聊天记录搜索最大天数")
|
||||
val searchHistoryMaxDays: Int by value(30)
|
||||
|
||||
@ValueDescription("聊天记录搜索最大查询条数,防止内存溢出")
|
||||
val searchHistoryMaxRecords: Int by value(5000)
|
||||
}
|
||||
@@ -10,22 +10,27 @@ import net.mamoe.mirai.console.data.value
|
||||
* @param value 好感度值 (-100 ~ 100)
|
||||
* @param reasons 调整原因列表,用于溯源
|
||||
* @param impression 对用户的印象/画像
|
||||
* @param name Bot给此人起的代号
|
||||
* @param tags 标签列表,最多5个
|
||||
*/
|
||||
@Serializable
|
||||
data class FavorabilityInfo(
|
||||
val userId: Long,
|
||||
val value: Int = 0,
|
||||
val reasons: List<String> = emptyList(),
|
||||
val impression: String = ""
|
||||
val impression: String = "",
|
||||
val name: String = "",
|
||||
val tags: List<String> = emptyList()
|
||||
) {
|
||||
override fun toString(): String {
|
||||
return buildString {
|
||||
append("好感度:$value")
|
||||
if (impression.isNotEmpty()) {
|
||||
append("\t印象:$impression")
|
||||
}
|
||||
if (name.isNotEmpty()) append(",代号:$name")
|
||||
if (tags.isNotEmpty()) append(",标签:[${tags.joinToString(", ")}]")
|
||||
if (impression.isNotEmpty()) append(",印象:$impression")
|
||||
if (reasons.isNotEmpty()) {
|
||||
appendLine("\t调整原因:")
|
||||
appendLine()
|
||||
appendLine("调整原因:")
|
||||
reasons.forEach { reason ->
|
||||
appendLine("* $reason")
|
||||
}
|
||||
@@ -35,26 +40,26 @@ data class FavorabilityInfo(
|
||||
}
|
||||
|
||||
/**
|
||||
* Token使用记录数据类
|
||||
* @param timestamp Unix时间戳
|
||||
* @param userId 用户QQ号
|
||||
* @param userNickname 用户昵称
|
||||
* Token使用日聚合记录。按 (date, userId, groupId) 维度合并。由 [TokenUsageStore] 持久化到独立 JSON 文件。
|
||||
* @param date 本地时区下的日期,格式 yyyy-MM-dd
|
||||
* @param userId QQ
|
||||
* @param userNickname 最近一次记录到的昵称
|
||||
* @param groupId 群号(私聊时为null)
|
||||
* @param model 模型名称
|
||||
* @param promptTokens 输入token数
|
||||
* @param completionTokens 输出token数
|
||||
* @param totalTokens 总token数
|
||||
* @param promptTokens 当天累计输入token
|
||||
* @param completionTokens 当天累计输出token
|
||||
* @param totalTokens 当天累计总token
|
||||
* @param callCount 当天调用次数
|
||||
*/
|
||||
@Serializable
|
||||
data class TokenUsageRecord(
|
||||
val timestamp: Long,
|
||||
data class TokenUsageDailyRecord(
|
||||
val date: String,
|
||||
val userId: Long,
|
||||
val userNickname: String,
|
||||
val groupId: Long?,
|
||||
val model: String,
|
||||
val promptTokens: Int,
|
||||
val completionTokens: Int,
|
||||
val totalTokens: Int
|
||||
val promptTokens: Long = 0,
|
||||
val completionTokens: Long = 0,
|
||||
val totalTokens: Long = 0,
|
||||
val callCount: Int = 0
|
||||
)
|
||||
|
||||
object PluginData : AutoSavePluginData("data") {
|
||||
@@ -70,11 +75,6 @@ object PluginData : AutoSavePluginData("data") {
|
||||
*/
|
||||
val userFavorability by value(mutableMapOf<Long, FavorabilityInfo>())
|
||||
|
||||
/**
|
||||
* Token使用记录
|
||||
*/
|
||||
val tokenUsageRecords by value(mutableListOf<TokenUsageRecord>())
|
||||
|
||||
/**
|
||||
* 添加对话记忆
|
||||
*/
|
||||
@@ -99,4 +99,4 @@ object PluginData : AutoSavePluginData("data") {
|
||||
.replace("\n\n", "\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// 写盘失败由日志/上层关心,这里不抛断对话流程
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
@@ -11,6 +12,7 @@ import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.PluginData
|
||||
@@ -21,9 +23,20 @@ import java.time.format.DateTimeFormatter
|
||||
class AdjustUserFavorabilityAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "adjustUserFavorability",
|
||||
description = "可根据网友行为调整对其好感度,范围从-100到100。" +
|
||||
"默认为0表示陌生人,100表示非常好的朋友,-100表示已拉黑。" +
|
||||
"当好感度低于0时,有一定概率忽略该用户的消息,-100则100%忽略其消息。",
|
||||
description = """
|
||||
维护你对群友的认识(好感度、印象、标签、代号)。
|
||||
每次和某人有实质交流后建议调用一次,可与发言工具在同一轮发出,几乎不产生额外成本。
|
||||
|
||||
触发场景:
|
||||
- 首次有像样的对话(建立初始印象和代号)
|
||||
- 对方透露身份/职业/偏好/技术栈(加 tag)
|
||||
- 互动产生明显情绪变化——开心/被逗/被冒犯(调 change)
|
||||
- 已有印象明显不准(更新 impression)
|
||||
|
||||
change 默认为 0,只更新标签/印象时不用填 reason。
|
||||
tags 上限 5 个,满了须先 tags_remove 旧标签才能继续添加。
|
||||
好感度范围 -100 到 100,低于 0 时有概率忽略其消息,-100 则 100% 忽略。
|
||||
""".trimIndent(),
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
put("properties", buildJsonObject {
|
||||
@@ -33,21 +46,33 @@ class AdjustUserFavorabilityAgent : BaseAgent(
|
||||
})
|
||||
put("change", buildJsonObject {
|
||||
put("type", "integer")
|
||||
put("description", "好感度变化值(可正可负)")
|
||||
put("description", "好感度变化值(可正可负),默认为0")
|
||||
})
|
||||
put("reason", buildJsonObject {
|
||||
put("type", "string")
|
||||
put("description", "调整原因(供日志记录和溯源)")
|
||||
put("description", "调整原因(change!=0时建议填写,供溯源)")
|
||||
})
|
||||
put("impression", buildJsonObject {
|
||||
put("type", "string")
|
||||
put("description", "对用户的印象或称呼(可选)")
|
||||
put("description", "对用户的印象描述,覆盖旧值(上限200字符)")
|
||||
})
|
||||
put("name", buildJsonObject {
|
||||
put("type", "string")
|
||||
put("description", "Bot给此人起的代号(非QQ昵称,上限20字符)")
|
||||
})
|
||||
put("tags_add", buildJsonObject {
|
||||
put("type", "array")
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "追加标签(自动去重,总数超5项返错,单项上限20字符)")
|
||||
})
|
||||
put("tags_remove", buildJsonObject {
|
||||
put("type", "array")
|
||||
putJsonObject("items") { put("type", "string") }
|
||||
put("description", "删除标签(不存在的项静默忽略)")
|
||||
})
|
||||
})
|
||||
putJsonArray("required") {
|
||||
add("userId")
|
||||
add("change")
|
||||
add("reason")
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -56,48 +81,66 @@ class AdjustUserFavorabilityAgent : BaseAgent(
|
||||
requireNotNull(args)
|
||||
|
||||
val userId = args["userId"]?.jsonPrimitive?.longOrNull
|
||||
val change = args["change"]?.jsonPrimitive?.intOrNull
|
||||
?: return "错误:userId参数不能为空"
|
||||
|
||||
val change = args["change"]?.jsonPrimitive?.intOrNull ?: 0
|
||||
val reason = args["reason"]?.jsonPrimitive?.contentOrNull
|
||||
val impression = args["impression"]?.jsonPrimitive?.contentOrNull
|
||||
val name = args["name"]?.jsonPrimitive?.contentOrNull
|
||||
val tagsAdd = (args["tags_add"] as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
val tagsRemove = (args["tags_remove"] as? JsonArray)?.mapNotNull { it.jsonPrimitive.contentOrNull }
|
||||
|
||||
if (userId == null || change == null || reason == null) {
|
||||
return "错误:userId、change和reason参数不能为空"
|
||||
// 字段长度校验
|
||||
if (name != null && name.length > 20) return "错误:name不能超过20字符(当前${name.length}字符)"
|
||||
if (impression != null && impression.length > 200) return "错误:impression不能超过200字符(当前${impression.length}字符)"
|
||||
tagsAdd?.forEach { tag ->
|
||||
if (tag.length > 20) return "错误:tag「$tag」不能超过20字符"
|
||||
}
|
||||
|
||||
// 获取当前好感度信息
|
||||
val currentInfo = PluginData.userFavorability[userId] ?: FavorabilityInfo(userId)
|
||||
val currentValue = currentInfo.value
|
||||
|
||||
// 计算新的好感度值,限制在-100~100范围内
|
||||
val newValue = (currentValue + change).coerceIn(-100, 100)
|
||||
|
||||
// 更新原因列表
|
||||
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
val newReason = "${timeFormatter.format(OffsetDateTime.now())}: $reason"
|
||||
val newReasons = if (currentInfo.reasons.size >= 10) {
|
||||
// 保留最近的10条原因记录
|
||||
(currentInfo.reasons.drop(1) + newReason)
|
||||
} else {
|
||||
(currentInfo.reasons + newReason)
|
||||
// 只在 change != 0 时记录原因
|
||||
val newReasons = if (change != 0 && reason != null) {
|
||||
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
val newReason = "${timeFormatter.format(OffsetDateTime.now())}: $reason"
|
||||
if (currentInfo.reasons.size >= 10) {
|
||||
currentInfo.reasons.drop(1) + newReason
|
||||
} else {
|
||||
currentInfo.reasons + newReason
|
||||
}
|
||||
} else currentInfo.reasons
|
||||
|
||||
// 处理标签
|
||||
val newTags = currentInfo.tags.toMutableList()
|
||||
tagsRemove?.forEach { tag -> newTags.remove(tag) }
|
||||
if (tagsAdd != null) {
|
||||
val toAdd = tagsAdd.filter { it !in newTags }
|
||||
if (newTags.size + toAdd.size > 5) {
|
||||
return "错误:标签已满(当前${newTags.size}项),须先用tags_remove删除旧标签。当前标签:[${newTags.joinToString(", ")}]"
|
||||
}
|
||||
newTags.addAll(toAdd)
|
||||
}
|
||||
|
||||
// 更新印象/画像
|
||||
val newImpression = impression ?: currentInfo.impression
|
||||
|
||||
// 创建新的好感度信息
|
||||
val newInfo = FavorabilityInfo(
|
||||
userId = userId,
|
||||
value = newValue,
|
||||
reasons = newReasons,
|
||||
impression = newImpression
|
||||
impression = impression ?: currentInfo.impression,
|
||||
name = name ?: currentInfo.name,
|
||||
tags = newTags
|
||||
)
|
||||
|
||||
// 更新好感度
|
||||
PluginData.userFavorability[userId] = newInfo
|
||||
JChatGPT.logger.info("用户 $userId 画像已更新:好感度($currentValue -> $newValue),原因:$reason")
|
||||
|
||||
// 记录日志
|
||||
JChatGPT.logger.info("用户 $userId 的好感度 ($currentValue -> $newValue),原因:$reason")
|
||||
|
||||
return "用户 $userId 的好感度已更新为 $newValue"
|
||||
return buildString {
|
||||
append("用户 $userId 画像已更新:好感度=$newValue")
|
||||
if (newTags.isNotEmpty()) append(",标签=[${newTags.joinToString(", ")}]")
|
||||
if (newInfo.name.isNotEmpty()) append(",代号=${newInfo.name}")
|
||||
if (newInfo.impression.isNotEmpty()) append(",印象=${newInfo.impression}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,29 +22,30 @@ import kotlinx.serialization.json.putJsonObject
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
|
||||
class ImageEdit : BaseAgent(
|
||||
class ImageAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "imageEdit",
|
||||
description = "可通过调用图像编辑模型来修改图片。备注:该方法成本较高,非必要尽量不要调用。编辑图片前无需识别图片内容,图像编辑模型自己会理解图片内容!",
|
||||
name = "imageAgent",
|
||||
description = "调用千问图像模型生成或编辑图片。不传 image_urls 即纯文生图;" +
|
||||
"传 1~3 张图片可进行编辑、修改或多图融合。" +
|
||||
"备注:该方法成本较高,非必要尽量不要调用。" +
|
||||
"编辑图片前无需识别图片内容,模型自己会理解图片内容。",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("image_url") {
|
||||
put("type", "string")
|
||||
put("description", "原始图片地址")
|
||||
putJsonObject("image_urls") {
|
||||
put("type", "array")
|
||||
putJsonObject("items") {
|
||||
put("type", "string")
|
||||
}
|
||||
put("description", "参考图片地址列表,可传 0~3 张。" +
|
||||
"不传或为空即纯文生图;传 1 张为编辑;多张为融合,输出比例与最后一张对齐。")
|
||||
}
|
||||
putJsonObject("prompt") {
|
||||
put("type", "string")
|
||||
put("description", "正向提示词,用来描述需要对图片进行修改的要求。")
|
||||
put("description", "提示词,描述期望生成或修改的画面内容。")
|
||||
}
|
||||
// putJsonObject("negative_prompt") {
|
||||
// put("type", "string")
|
||||
// put("description", "反向提示词,用来描述不希望在画面中看到的内容,可以对画面进行限制。" +
|
||||
// "示例值:低分辨率、错误、最差质量、低质量、残缺、多余的手指、比例不良等。")
|
||||
// }
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("image_url")
|
||||
add("prompt")
|
||||
}
|
||||
}
|
||||
@@ -58,25 +59,29 @@ class ImageEdit : BaseAgent(
|
||||
get() = PluginConfig.dashScopeApiKey.isNotEmpty()
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "改图中..."
|
||||
get() = "作图中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
requireNotNull(args)
|
||||
val imageUrl = args.getValue("image_url").jsonPrimitive.content
|
||||
val prompt = args.getValue("prompt").jsonPrimitive.content
|
||||
// val negativePrompt = args["negative_prompt"]?.jsonPrimitive?.content
|
||||
val imageUrls = args["image_urls"]?.jsonArray
|
||||
?.map { it.jsonPrimitive.content }
|
||||
?: emptyList()
|
||||
|
||||
val response = httpClient.post(API_URL) {
|
||||
contentType(ContentType("application", "json"))
|
||||
header("Authorization", "Bearer " + PluginConfig.dashScopeApiKey)
|
||||
setBody(buildJsonObject {
|
||||
put("model", PluginConfig.imageEditModel)
|
||||
put("model", PluginConfig.imageModel)
|
||||
putJsonObject("input") {
|
||||
putJsonArray("messages") {
|
||||
addJsonObject {
|
||||
put("role", "user")
|
||||
putJsonArray("content") {
|
||||
addJsonObject {
|
||||
put("image", imageUrl)
|
||||
for (url in imageUrls) {
|
||||
addJsonObject {
|
||||
put("image", url)
|
||||
}
|
||||
}
|
||||
addJsonObject {
|
||||
put("text", prompt)
|
||||
@@ -85,11 +90,11 @@ class ImageEdit : BaseAgent(
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (negativePrompt != null) {
|
||||
// putJsonObject("parameters") {
|
||||
// put("negative_prompt", negativePrompt)
|
||||
// }
|
||||
// }
|
||||
putJsonObject("parameters") {
|
||||
put("n", 1)
|
||||
put("prompt_extend", true)
|
||||
put("watermark", PluginConfig.imageWatermark)
|
||||
}
|
||||
}.toString())
|
||||
}
|
||||
|
||||
@@ -102,10 +107,10 @@ class ImageEdit : BaseAgent(
|
||||
.getValue("message").jsonObject
|
||||
.getValue("content").jsonArray[0].jsonObject
|
||||
.getValue("image").jsonPrimitive.content
|
||||
"图片已编辑完成,发送时请务必包含完整的url和查询参数,因为下载地址存在鉴权:"
|
||||
"图片已生成,发送时请务必包含完整的url和查询参数,因为下载地址存在鉴权:"
|
||||
} catch (e: Throwable) {
|
||||
JChatGPT.logger.error("图像编辑结果解析异常", e)
|
||||
JChatGPT.logger.error("图像生成结果解析异常", e)
|
||||
responseJson
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,18 +39,28 @@ class ReasoningAgent : BaseAgent(
|
||||
|
||||
val prompt = args.getValue("prompt").jsonPrimitive.content
|
||||
val answerContent = StringBuilder()
|
||||
val reasoningContent = StringBuilder()
|
||||
llm.chatCompletions(ChatCompletionRequest(
|
||||
model = ModelId(PluginConfig.reasoningModel),
|
||||
messages = listOf(ChatMessage.User(prompt))
|
||||
)).collect {
|
||||
if (it.choices.isNotEmpty()) {
|
||||
val delta = it.choices[0].delta ?: return@collect
|
||||
if (!delta.reasoningContent.isNullOrEmpty()) {
|
||||
reasoningContent.append(delta.reasoningContent)
|
||||
}
|
||||
if (!delta.content.isNullOrEmpty()) {
|
||||
answerContent.append(delta.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return answerContent.toString().ifEmpty { "推理出错,结果为空" }
|
||||
val result = answerContent.toString()
|
||||
val reasoning = reasoningContent.toString()
|
||||
return when {
|
||||
result.isNotEmpty() -> result
|
||||
reasoning.isNotEmpty() -> reasoning
|
||||
else -> "推理出错,结果为空"
|
||||
}
|
||||
}
|
||||
}
|
||||
214
src/main/kotlin/tools/SearchChatHistory.kt
Normal file
214
src/main/kotlin/tools/SearchChatHistory.kt
Normal file
@@ -0,0 +1,214 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import kotlinx.serialization.json.*
|
||||
import net.mamoe.mirai.contact.Group
|
||||
import net.mamoe.mirai.contact.nameCardOrNick
|
||||
import net.mamoe.mirai.event.events.GroupMessageEvent
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import net.mamoe.mirai.message.data.Image
|
||||
import net.mamoe.mirai.message.data.Image.Key.queryUrl
|
||||
import net.mamoe.mirai.message.data.SingleMessage
|
||||
import net.mamoe.mirai.message.data.content
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder
|
||||
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.time.format.DateTimeParseException
|
||||
|
||||
class SearchChatHistory : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "searchChatHistory",
|
||||
description = "搜索群聊消息历史,可按关键词、发送者、时间范围筛选。用于回溯之前的讨论、查找某人说过的话、统计话题等。" +
|
||||
"不指定时间范围时默认搜索最近30天。指定时间时范围不能超过30天,如需更长跨度可分多次查询。" +
|
||||
"可以通过多轮搜索来实现找到某条消息的上下文。",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("keyword") {
|
||||
put("type", "string")
|
||||
put("description", "消息内容关键词,人名请用sender")
|
||||
}
|
||||
putJsonObject("sender") {
|
||||
put("type", "string")
|
||||
put("description", "发送者名称或QQ号,查找某人的发言")
|
||||
}
|
||||
putJsonObject("startTime") {
|
||||
put("type", "string")
|
||||
put("description", "起始时间,格式:yyyy-MM-dd HH:mm,不填则默认为7天前")
|
||||
}
|
||||
putJsonObject("endTime") {
|
||||
put("type", "string")
|
||||
put("description", "结束时间,格式同上,不填则默认到当前时间")
|
||||
}
|
||||
putJsonObject("limit") {
|
||||
put("type", "integer")
|
||||
put("description", "返回消息数量上限,默认20,最大200")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
override val isEnabled: Boolean
|
||||
get() = JChatGPT.includeHistory
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "搜索聊天记录中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
|
||||
val keyword = args["keyword"]?.jsonPrimitive?.contentOrNull
|
||||
val sender = args["sender"]?.jsonPrimitive?.contentOrNull
|
||||
|
||||
val maxDays = PluginConfig.searchHistoryMaxDays
|
||||
val now = OffsetDateTime.now()
|
||||
|
||||
val startTime = args["startTime"]?.jsonPrimitive?.contentOrNull?.let {
|
||||
parseTime(it) ?: return "startTime 格式错误,请使用 yyyy-MM-dd HH:mm"
|
||||
} ?: now.minusDays(maxDays.toLong())
|
||||
|
||||
val endTime = args["endTime"]?.jsonPrimitive?.contentOrNull?.let {
|
||||
parseTime(it) ?: return "endTime 格式错误,请使用 yyyy-MM-dd HH:mm"
|
||||
} ?: now
|
||||
|
||||
if (startTime >= endTime) {
|
||||
return "起始时间必须早于结束时间"
|
||||
}
|
||||
|
||||
if (java.time.Duration.between(startTime, endTime).toDays() > maxDays) {
|
||||
return "搜索时间范围不能超过 ${maxDays}天,请缩小范围后重试"
|
||||
}
|
||||
|
||||
val senderQq = resolveSenderQq(sender, event)
|
||||
val startEpoch = startTime.toEpochSecond().toInt()
|
||||
val endEpoch = endTime.toEpochSecond().toInt()
|
||||
val maxRecords = PluginConfig.searchHistoryMaxRecords
|
||||
|
||||
val records = try {
|
||||
// 有 sender 时用 Member 重载,在数据库层过滤 fromId;否则用 Contact 重载
|
||||
if (senderQq != null && event is GroupMessageEvent) {
|
||||
val member = event.group[senderQq]
|
||||
if (member != null) {
|
||||
MiraiHibernateRecorder[member, startEpoch, endEpoch]
|
||||
} else {
|
||||
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
|
||||
}
|
||||
} else {
|
||||
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
|
||||
}.take(maxRecords).sortedBy { it.time }
|
||||
} catch (e: Throwable) {
|
||||
JChatGPT.logger.warning("查询消息历史失败", e)
|
||||
return "查询消息历史失败: ${e.message}"
|
||||
}
|
||||
|
||||
var filtered = records
|
||||
|
||||
// 消息内容在数据库中是序列化存储的,关键词只能在内存中过滤
|
||||
if (keyword != null) {
|
||||
filtered = filtered.filter {
|
||||
it.toMessageChain().content.contains(keyword, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
if (filtered.isEmpty()) {
|
||||
return "未找到匹配的聊天记录"
|
||||
}
|
||||
|
||||
val limit = args["limit"]?.jsonPrimitive?.intOrNull?.coerceIn(1, 200) ?: 20
|
||||
val total = filtered.size
|
||||
val result = filtered.takeLast(limit)
|
||||
|
||||
return buildString {
|
||||
appendLine("找到 $total 条匹配记录,显示最近 ${result.size} 条:")
|
||||
appendLine()
|
||||
appendHistory(this, result, event)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 sender 解析为 QQ 号,优先尝试纯数字,再尝试群成员名称匹配
|
||||
*/
|
||||
private fun resolveSenderQq(sender: String?, event: MessageEvent): Long? {
|
||||
if (sender == null) return null
|
||||
sender.toLongOrNull()?.let { return it }
|
||||
if (event is GroupMessageEvent) {
|
||||
return event.group.members.firstOrNull {
|
||||
it.nameCardOrNick.contains(sender, ignoreCase = true)
|
||||
}?.id
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private suspend fun appendHistory(
|
||||
sb: StringBuilder,
|
||||
records: List<MessageRecord>,
|
||||
event: MessageEvent
|
||||
) {
|
||||
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||
var lastFromId = 0L
|
||||
|
||||
for (record in records) {
|
||||
val showSender = lastFromId != record.fromId
|
||||
if (showSender) {
|
||||
sb.appendLine()
|
||||
if (event is GroupMessageEvent) {
|
||||
if (event.bot.id == record.fromId) {
|
||||
sb.append("**你** ").append(event.bot.nameCardOrNick)
|
||||
} else {
|
||||
sb.append(getNameCard(event.group, record.fromId))
|
||||
}
|
||||
}
|
||||
sb.append(" ")
|
||||
.append(timeFormatter.format(
|
||||
Instant.ofEpochSecond(record.time.toLong()).atZone(ZoneId.systemDefault())
|
||||
))
|
||||
.append(":")
|
||||
}
|
||||
for (msg in record.toMessageChain()) {
|
||||
sb.append(singleMessageToText(msg))
|
||||
}
|
||||
sb.appendLine()
|
||||
lastFromId = record.fromId
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun singleMessageToText(msg: SingleMessage): String {
|
||||
return when (msg) {
|
||||
is Image -> {
|
||||
try {
|
||||
val url = msg.queryUrl()
|
||||
""
|
||||
} catch (_: Throwable) {
|
||||
msg.content
|
||||
}
|
||||
}
|
||||
else -> msg.content
|
||||
}
|
||||
}
|
||||
|
||||
private fun getNameCard(group: Group, qq: Long): String {
|
||||
val member = group[qq]
|
||||
return member?.nameCardOrNick ?: "未知群员($qq)"
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||
|
||||
fun parseTime(text: String): OffsetDateTime? {
|
||||
return try {
|
||||
LocalDateTime.parse(text, timeFormatter)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toOffsetDateTime()
|
||||
} catch (_: DateTimeParseException) {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import kotlin.time.measureTime
|
||||
class SendVoiceMessage : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "sendVoiceMessage",
|
||||
description = "发送一条文本转语音消息。",
|
||||
description = "发送一条文本转语音消息。可选传入 instructions 用自然语言指令控制语气、语速、情感等表达方式,让语音更贴合当前对话氛围。",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
@@ -29,6 +29,10 @@ class SendVoiceMessage : BaseAgent(
|
||||
put("type", "string")
|
||||
put("description", "语音消息文本内容")
|
||||
}
|
||||
putJsonObject("instructions") {
|
||||
put("type", "string")
|
||||
put("description", "可选。自然语言描述本句话的表达方式,例如\"语速较快,带有明显的上扬语调\"或\"温柔知性,语调平和\"。仅支持中英文。")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("content")
|
||||
@@ -52,6 +56,7 @@ class SendVoiceMessage : BaseAgent(
|
||||
if (event.subject !is AudioSupported) return "当前聊天环境不支持发送语音!"
|
||||
|
||||
val content = args.getValue("content").jsonPrimitive.content
|
||||
val instructions = args["instructions"]?.jsonPrimitive?.content?.takeIf { it.isNotBlank() }
|
||||
|
||||
// https://help.aliyun.com/zh/model-studio/qwen-tts
|
||||
val response = httpClient.post(API_URL) {
|
||||
@@ -62,6 +67,10 @@ class SendVoiceMessage : BaseAgent(
|
||||
putJsonObject("input") {
|
||||
put("text", content)
|
||||
put("voice", "Chelsie") // Chelsie(女) Cherry(女) Ethan(男) Serena(女)
|
||||
if (instructions != null) {
|
||||
put("instructions", instructions)
|
||||
put("optimize_instructions", true)
|
||||
}
|
||||
}
|
||||
}.toString())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user