Compare commits

..

12 Commits

Author SHA1 Message Date
92acbb2310 Upgrade TTS to qwen3-tts-instruct-flash with instruction control
Adds an optional instructions parameter to sendVoiceMessage so the model
can describe tone, pace and emotion in natural language. Defaults the
TTS model to qwen3-tts-instruct-flash; Chelsie voice is unchanged.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:57:15 +08:00
39b49bb302 Replace ImageEdit with ImageAgent for Qwen Image 2.0
Supports text-to-image, single-image edit and multi-image fusion (0-3
reference images) via a single tool. Renames imageEditModel config to
imageModel (default qwen-image-2.0) and adds imageWatermark toggle.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-22 15:47:08 +08:00
98bb1066c4 Update README 2026-05-22 14:16:18 +08:00
4dde523dfc Move token usage to JSON store 2026-05-22 14:16:12 +08:00
f17adee4ba Extend favorability into user profile system 2026-05-22 14:16:06 +08:00
72892336bc Add first-chunk timeout 2026-05-22 14:16:00 +08:00
e629d37fa8 Relax searchChatHistory limits 2026-05-22 14:15:54 +08:00
200a404927 Update README for v1.11.0 2026-05-22 14:15:48 +08:00
3ed54dae0e Update version to v1.11.0 2026-05-22 14:15:42 +08:00
417b5f631f Add searchChatHistory tool 2026-05-22 14:15:34 +08:00
bc2ab553b9 Add reasoning_content replay for tool calls 2026-05-22 14:15:19 +08:00
e5d5445a1f Add ModelService with extra body support 2026-05-22 14:15:12 +08:00
16 changed files with 888 additions and 259 deletions

1
.gitignore vendored
View File

@@ -1,6 +1,7 @@
# User-specific stuff # User-specific stuff
.idea/ .idea/
.run/ .run/
build-with-jdk17.bat
*.iml *.iml
*.ipr *.ipr

114
README.md
View File

@@ -7,8 +7,8 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
- **多模型支持**:支持聊天模型、推理模型和视觉模型 - **多模型支持**:支持聊天模型、推理模型和视觉模型
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等 - **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
- **上下文记忆**:支持持久化记忆存储 - **上下文记忆**:支持持久化记忆存储
- **好感度系统**基于用户行为的好感度管理机制 - **用户画像系统**好感度、印象、标签、Bot 自定义代号
- **Token消耗统计**记录每次对话的Token消耗,支持多维度统计查询 - **Token消耗统计**按天 × 用户 × 群聚合记录,支持多维度统计查询
- **LaTeX 渲染**:自动将数学表达式渲染为图片 - **LaTeX 渲染**:自动将数学表达式渲染为图片
- **灵活的触发方式**@机器人、关键字触发、回复消息等 - **灵活的触发方式**@机器人、关键字触发、回复消息等
- **权限控制**:细粒度的权限管理系统 - **权限控制**:细粒度的权限管理系统
@@ -30,6 +30,7 @@ AI 可以自动调用多种工具来完成复杂任务:
- 推理思考(需要配置推理模型) - 推理思考(需要配置推理模型)
- 群管理(禁言等,需启用相应权限) - 群管理(禁言等,需启用相应权限)
- 记忆管理(添加和修改对话记忆) - 记忆管理(添加和修改对话记忆)
- 聊天历史搜索(按关键词、发送者、时间范围检索群聊消息,需启用历史消息上下文)
## 权限列表 ## 权限列表
@@ -54,7 +55,7 @@ AI 可以自动调用多种工具来完成复杂任务:
- `/jgpt tokensDaily [days]` - 查看指定天数的每日Token消耗统计默认7天 - `/jgpt tokensDaily [days]` - 查看指定天数的每日Token消耗统计默认7天
- `/jgpt tokensUsers [limit]` - 查看Token消耗最多的用户排名默认Top 10 - `/jgpt tokensUsers [limit]` - 查看Token消耗最多的用户排名默认Top 10
- `/jgpt tokensGroups [limit]` - 查看Token消耗最多的群组排名默认Top 10 - `/jgpt tokensGroups [limit]` - 查看Token消耗最多的群组排名默认Top 10
- `/jgpt tokensQuery [userId] [days]` - 查询详细的使用记录(可按用户和时间过滤) - `/jgpt tokensQuery [userId] [days]` - 查询日聚合记录(每行一天一人,可按用户和时间过滤)
- `/jgpt tokensUserDaily <userId> [days]` - 查询指定用户每天的消费统计默认7天 - `/jgpt tokensUserDaily <userId> [days]` - 查询指定用户每天的消费统计默认7天
## 配置文件 ## 配置文件
@@ -82,12 +83,20 @@ visualModelApi: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
visualModelToken: '' visualModelToken: ''
# 视觉模型 # 视觉模型
visualModel: 'qwen-vl-plus' visualModel: 'qwen-vl-plus'
# 聊天模型额外请求体JSON会合并到请求体中。例如DeepSeek关闭思维: {"thinking": {"type": "disabled"}}
chatModelExtraBody: ''
# 推理模型额外请求体JSON会合并到请求体中。例如DeepSeek启用思维: {"thinking": {"type": "enabled"}}
reasoningModelExtraBody: ''
# 视觉模型额外请求体JSON会合并到请求体中。
visualModelExtraBody: ''
# 百炼平台API KEY # 百炼平台API KEY
dashScopeApiKey: '' dashScopeApiKey: ''
# 百炼平台图片编辑模型 # 百炼平台图像模型(文生图 + 图像编辑)
imageEditModel: 'qwen-image-edit' imageModel: 'qwen-image-2.0'
# 百炼平台TTS模型 # 是否在生成图片右下角添加 Qwen-Image 水印
ttsModel: 'qwen-tts' imageWatermark: false
# 百炼平台TTS模型qwen3-tts-instruct-flash 支持 instructions 指令控制语气
ttsModel: 'qwen3-tts-instruct-flash'
# Jina API Key # Jina API Key
jinaApiKey: '' jinaApiKey: ''
# SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回 # SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回
@@ -102,8 +111,10 @@ friendHasChatPermission: true
canMute: false canMute: false
# 群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。 # 群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。
temperaturePermission: 50 temperaturePermission: 50
# 等待响应超时时间单位毫秒默认60秒 # 等待响应超时时间整个请求的总超时与socket读超时单位毫秒默认60秒
timeout: 60000 timeout: 60000
# 首块响应超时时间单位毫秒默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试
firstChunkTimeout: 10000
# 系统提示词,该字段已弃用,使用提示词文件而不是在这里修改 # 系统提示词,该字段已弃用,使用提示词文件而不是在这里修改
prompt: '你是一个乐于助人的助手' prompt: '你是一个乐于助人的助手'
# 系统提示词文件路径,相对于插件配置目录 # 系统提示词文件路径,相对于插件配置目录
@@ -128,6 +139,10 @@ memoryEnabled: true
enableFavorabilitySystem: true enableFavorabilitySystem: true
# 好感度每日基础偏移速度(点/天) # 好感度每日基础偏移速度(点/天)
favorabilityBaseShiftSpeed: 2.0 favorabilityBaseShiftSpeed: 2.0
# 聊天记录搜索最大天数
searchHistoryMaxDays: 30
# 聊天记录搜索最大查询条数,防止内存溢出
searchHistoryMaxRecords: 5000
``` ```
## 系统提示词 ## 系统提示词
@@ -267,55 +282,60 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型
6. **GroupManageAgent** - 群管理功能(如禁言) 6. **GroupManageAgent** - 群管理功能(如禁言)
7. **SendSingleMessage/CompositeMessage** - 发送消息 7. **SendSingleMessage/CompositeMessage** - 发送消息
8. **SendVoiceMessage** - 发送语音消息 8. **SendVoiceMessage** - 发送语音消息
9. **ImageEdit** - 图像编辑 9. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
10. **WeatherService** - 天气查询 10. **WeatherService** - 天气查询
11. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin
## 好感度系统 ## 用户画像系统
JChatGPT 插件包含一个可选的好感度系统,用于根据用户行为调整机器人对用户的好感度。该系统有以下特性: JChatGPT 维护对每位用户的画像由好感度、Bot 自定义代号、最多 5 个标签和印象文本组成。模型可以通过 `adjustUserFavorability` 工具在交流后增量更新这些字段(同一次调用可以同时调好感度、加 tag、改印象不必分开
### 核心机制 ### 字段
- 好感度值范围-100完全不理会到 100非常好的朋友 - **value好感度**范围 -100完全不理会到 100非常好的朋友
- 负好感度用户有一定概率不会收到回复,概率为好感度绝对值的百分比 - **name代号**Bot 给此人起的内部代号,区别于 QQ 昵称,长度 ≤20
- 好感度会随时间向0偏移偏移速度与当前好感度绝对值相关 - **tags标签**:最多 5 个简短标签,记录身份/职业/偏好/技术栈等
- **impression印象**:自由文本,长度 ≤200
- **reasons调整原因**:只有 change ≠ 0 的调整才会追加,保留最近 10 条
### 好感度调整规则 ### 好感度机制
- 问正经问题:+好感度 - 负好感度用户有一定概率不会收到回复,概率 = |好感度| / 100
- 问无聊问题:-好感度 - 好感度会随时间向 0 偏移:偏移量 = sign(好感度) × (1 - (|好感度| / 100)²) × 基础偏移速
- 骂人:直接降至-100 - 极端值变化缓慢,-100 需要好几天才能回升100 也不会快速衰减
### 时间偏移机制 ### 注入到上下文
好感度会随时间自然向0回归 - 群聊:列出会话历史中"认识的群友"name/tags/impression 任一非空)
- 偏移公式:偏移量 = sign(好感度) * (1 - (|好感度| / 100)^2) * 基础偏移速度 - 私聊:仅当对方有 name/tags/impression 时注入对方画像
- 极端值变化缓慢,-100可能需要好几天才消气100可能好多天都不会降低 - 仅有好感度数值、其它字段全空的用户不会被列出,避免提示词噪声
### 管理命令 ### 管理命令
- `/jgpt favorability <userId> <value>` - 设置指定用户的好感度值(-100~100 - `/jgpt setFavor <user> <value>` - 设置指定用户的好感度值(-100~100,不改其他字段
- `/jgpt resetFavorability` - 重置所有用户的好感度 - `/jgpt clearFavor` - 清空所有用户画像
### 配置选项 ### 配置选项
- `enableFavorabilitySystem` - 是否启用好感度系统默认true - `enableFavorabilitySystem` - 是否启用画像系统默认true
- `favorabilityBaseShiftSpeed` - 好感度每日基础偏移速度(点/天默认2.0 - `favorabilityBaseShiftSpeed` - 好感度每日基础偏移速度(点/天默认2.0
## Token消耗统计 ## Token消耗统计
JChatGPT 插件内置了Token消耗统计功能可以记录每次对话的Token使用情况,并提供多维度统计查询。 JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消耗,提供多维度统计查询。
> 历史版本曾按每次请求逐条记录,但增长不受控(数千条后会触发 mamoe-yamlkt 的编/解码 bug 导致整个 data.yml 无法加载)。现已改为按天聚合并搬到独立的 `token_usage.json`data.yml 只保留小规模的 memory / favorability 数据。
### 功能特性 ### 功能特性
- **自动记录**:每次对话自动记录Token消耗 - **自动记录**:每次对话累加到当日聚合行
- **详细数据**:记录时间戳、用户、群组、模型、输入/输出Token数 - **聚合维度**:日期 × 用户 × 群(同一人同一天在同一群只占一行)
- **多维统计**:支持按日期、用户、群组进行统计 - **多维统计**:支持按日期、用户、群组进行统计
- **灵活查询**:支持详细记录查询和过滤 - **灵活查询**:支持详细记录查询和过滤
### 记录内容 ### 记录内容
次对话记录包含以下信息 条聚合记录包含:
- 时间戳Unix timestamp - 日期yyyy-MM-dd本地时区
- 用户QQ号和昵称 - 用户QQ号和最近一次记录到的昵称
- 群组ID群聊或null私聊 - 群组ID群聊或null私聊
- 使用的模型名称 - 当日累计输入Token数promptTokens
- 输入Token数promptTokens - 当日累计输出Token数completionTokens
- 输出Token数completionTokens - 当日累计总Token数totalTokens
- 总Token数totalTokens - 当日调用次数callCount
### 统计命令 ### 统计命令
@@ -398,19 +418,19 @@ JChatGPT 插件内置了Token消耗统计功能可以记录每次对话的Tok
``` ```
/jgpt tokensQuery [userId] [days] /jgpt tokensQuery [userId] [days]
``` ```
- 查询详细的使用记录 - 查询日聚合记录(每行 = 某天某人某群的当日合计)
- 可按用户ID过滤可选 - 可按用户ID过滤可选
- 可指定时间范围默认7天 - 可指定时间范围默认7天
- 最多显示20条记录 - 最多显示20条记录,按日期倒序
- 输出示例: - 输出示例:
``` ```
最近 7 天使用记录最多显示20条 最近 7 天使用记录最多显示20条,按日聚合
[03-18 14:35] 群987654321 - 张三 [2026-03-18] 群987654321 - 张三
模型: qwen-max, Tokens: 2,345 (提示: 1,234, 输出: 1,111) 调用 12 次, Tokens: 23,450 (输入: 12,340, 输出: 11,110)
[03-18 14:30] 私聊 - 李四 [2026-03-18] 私聊 - 李四
模型: qwen-max, Tokens: 1,876 (提示: 980, 输出: 896) 调用 5 次, Tokens: 8,760 (输入: 4,800, 输出: 3,960)
``` ```
#### 用户日统计 #### 用户日统计
@@ -433,8 +453,10 @@ JChatGPT 插件内置了Token消耗统计功能可以记录每次对话的Tok
``` ```
### 数据存储 ### 数据存储
- Token记录保存在插件数据目录的 `data/data.json` 文件中 - Token 聚合记录保存在插件数据目录的 `token_usage.json` 文件中
- 使用 `AutoSavePluginData` 自动持久化 - `TokenUsageStore` 直接管,绕开 mamoe 的 plugin data 系统(避免 yamlkt 在大数据量下的编/解码 bug
- 每次记录后写盘(先写 `.tmp` 再覆盖,避免半文件)
- 加载失败会自动备份原文件为 `token_usage.json.broken-<timestamp>` 并从空开始
- 记录永久保存,不会自动删除 - 记录永久保存,不会自动删除
- 数据格式为JSON可手动查看和备份 - 数据格式为JSON可手动查看和备份
@@ -447,7 +469,7 @@ JChatGPT 插件内置了Token消耗统计功能可以记录每次对话的Tok
### 注意事项 ### 注意事项
- 仅统计聊天模型的Token消耗 - 仅统计聊天模型的Token消耗
- 推理模型和视觉模型的消耗不在统计范围内 - 推理模型和视觉模型的消耗不在统计范围内
- 每次对话轮次都会单独记录 - 同一用户同一天在同一群的多次调用合并为一行callCount 自增)
- 统计数据基于实际API返回的Token数 - 统计数据基于实际API返回的Token数
## 部署要求 ## 部署要求

View File

@@ -7,7 +7,7 @@ plugins {
} }
group = "top.jie65535.mirai" group = "top.jie65535.mirai"
version = "1.10.0" version = "1.11.0"
mirai { mirai {
jvmTarget = JavaVersion.VERSION_11 jvmTarget = JavaVersion.VERSION_11
@@ -25,7 +25,7 @@ repositories {
mavenCentral() mavenCentral()
} }
val openaiClientVersion = "4.0.1" val openaiClientVersion = "4.1.0"
val ktorVersion = "3.0.3" val ktorVersion = "3.0.3"
val jLatexMathVersion = "1.0.7" val jLatexMathVersion = "1.0.7"
val commonTextVersion = "1.13.0" val commonTextVersion = "1.13.0"

View 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]))

View File

@@ -52,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.10.0", version = "1.11.0",
) { ) {
author("jie65535") author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true) // 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() PluginConfig.reload()
PluginData.reload() PluginData.reload()
// 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug
TokenUsageStore.init(dataFolder)
// 设置Token // 设置Token
LargeLanguageModels.reload() LargeLanguageModels.reload()
@@ -318,17 +321,27 @@ object JChatGPT : KotlinPlugin(
var lastId = 0L var lastId = 0L
if (event is GroupMessageEvent) { if (event is GroupMessageEvent) {
if (PluginConfig.enableFavorabilitySystem) { if (PluginConfig.enableFavorabilitySystem) {
val favorabilityInfos = history.map { it.fromId } val knownUsers = history.asSequence()
.map { it.fromId }
.filter { it != event.bot.id } .filter { it != event.bot.id }
.distinct() .distinct()
.mapNotNull { PluginData.userFavorability[it] } .mapNotNull { PluginData.userFavorability[it] }
if (favorabilityInfos.isNotEmpty()) { .filter { it.name.isNotEmpty() || it.tags.isNotEmpty() || it.impression.isNotEmpty() }
historyText.appendLine("## 相关成员的好感信息") .sortedBy { it.userId }
for (info in favorabilityInfos) { .toList()
historyText.append(getNameCard(event.group, info.userId)).append('\t') if (knownUsers.isNotEmpty()) {
.appendLine(info).appendLine() 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 { } else {
if (PluginConfig.enableFavorabilitySystem) { if (PluginConfig.enableFavorabilitySystem) {
val favorabilityInfo = PluginData.userFavorability[event.sender.id] val favorabilityInfo = PluginData.userFavorability[event.sender.id]
if (favorabilityInfo != null) { if (favorabilityInfo != null && (favorabilityInfo.name.isNotEmpty() || favorabilityInfo.tags.isNotEmpty() || favorabilityInfo.impression.isNotEmpty())) {
historyText.append("你对\"").append(event.senderName).append("\"的好感信息如下: ") val displayName = if (favorabilityInfo.name.isNotEmpty()) favorabilityInfo.name else event.senderName
.appendLine(favorabilityInfo).appendLine() historyText.appendLine("【你认识的对方】")
historyText.appendLine("---").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 startedAt = OffsetDateTime.now().toEpochSecond().toInt()
val responseFlow = chatCompletions(history) val responseFlow = chatCompletions(history)
var responseMessageBuilder: StringBuilder? = null var responseMessageBuilder: StringBuilder? = null
var reasoningContentBuilder: 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 var lastTokenUsage: Usage? = null
@@ -554,6 +573,15 @@ object JChatGPT : KotlinPlugin(
responseFlow.collect { chunk -> responseFlow.collect { chunk ->
val delta = chunk.choices[0].delta ?: return@collect 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 (delta.content != null) {
if (responseMessageBuilder == null) { if (responseMessageBuilder == null) {
@@ -620,28 +648,31 @@ object JChatGPT : KotlinPlugin(
val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim() val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim()
logger.info("LLM Response: $responseContent") logger.info("LLM Response: $responseContent")
// 记录AI回答 // 记录AI回答
// reasoning_content仅在工具调用时需要回传DeepSeek规范否则丢弃
// toolCalls空列表转null避免序列化为"tool_calls":[]导致DeepSeek V4报400
// explicitNulls=false确保null字段不会序列化到JSON中兼容所有API
history.add( history.add(
ChatMessage.Assistant( ChatMessage(
role = ChatRole.Assistant,
content = responseContent, content = responseContent,
toolCalls = responseToolCalls toolCalls = responseToolCalls.ifEmpty { null },
reasoningContent = if (responseToolCalls.isNotEmpty()) reasoningContentBuilder?.toString() else null
) )
) )
// 记录token使用量 // 记录token使用量按日聚合独立JSON文件
lastTokenUsage?.let { usage -> lastTokenUsage?.let { usage ->
val now = OffsetDateTime.now().toEpochSecond() val now = OffsetDateTime.now().toEpochSecond()
val groupId = if (event is GroupMessageEvent) event.subject.id else null val groupId = if (event is GroupMessageEvent) event.subject.id else null
val record = TokenUsageRecord( TokenUsageStore.record(
timestamp = now, timestamp = now,
userId = event.sender.id, userId = event.sender.id,
userNickname = event.senderName, userNickname = event.senderName,
groupId = groupId, groupId = groupId,
model = PluginConfig.chatModel,
promptTokens = usage.promptTokens ?: 0, promptTokens = usage.promptTokens ?: 0,
completionTokens = usage.completionTokens ?: 0, completionTokens = usage.completionTokens ?: 0,
totalTokens = usage.totalTokens ?: 0 totalTokens = usage.totalTokens ?: 0
) )
PluginData.tokenUsageRecords.add(record)
} }
// 处理最后一个工具调用 // 处理最后一个工具调用
@@ -799,6 +830,9 @@ object JChatGPT : KotlinPlugin(
// 记忆修改 // 记忆修改
MemoryReplace(), MemoryReplace(),
// 搜索聊天历史
SearchChatHistory(),
// 网页搜索 // 网页搜索
WebSearch(), WebSearch(),
@@ -814,8 +848,8 @@ object JChatGPT : KotlinPlugin(
// 视觉代理 // 视觉代理
VisualAgent(), VisualAgent(),
// 图像编辑模型 // 图像生成与编辑
ImageEdit(), ImageAgent(),
// 天气服务 // 天气服务
WeatherService(), WeatherService(),

View File

@@ -1,14 +1,12 @@
package top.jie65535.mirai package top.jie65535.mirai
import com.aallam.openai.api.http.Timeout import kotlinx.serialization.json.Json
import com.aallam.openai.client.Chat import kotlinx.serialization.json.JsonObject
import com.aallam.openai.client.OpenAI import kotlinx.serialization.json.jsonObject
import com.aallam.openai.client.OpenAIHost
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
object LargeLanguageModels { 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() { fun reload() {
// 载入超时时间
val timeout = PluginConfig.timeout.milliseconds val timeout = PluginConfig.timeout.milliseconds
val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds
// 初始化聊天模型 // 初始化聊天模型
if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) { if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) {
chat = OpenAI( chat = ModelService(
baseUrl = PluginConfig.openAiApi,
token = PluginConfig.openAiToken, token = PluginConfig.openAiToken,
host = OpenAIHost(baseUrl = PluginConfig.openAiApi), timeout = timeout,
timeout = Timeout(request = timeout, connect = timeout, socket = timeout) firstChunkTimeout = firstChunkTimeout,
extraBody = parseExtraBody(PluginConfig.chatModelExtraBody)
) )
} }
// 初始化推理模型 // 初始化推理模型
if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) { if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) {
reasoning = OpenAI( reasoning = ModelService(
baseUrl = PluginConfig.reasoningModelApi,
token = PluginConfig.reasoningModelToken, token = PluginConfig.reasoningModelToken,
host = OpenAIHost(baseUrl = PluginConfig.reasoningModelApi), timeout = timeout,
timeout = Timeout(request = timeout, connect = timeout, socket = timeout) firstChunkTimeout = firstChunkTimeout,
extraBody = parseExtraBody(PluginConfig.reasoningModelExtraBody)
) )
} }
// 初始化视觉模型 // 初始化视觉模型
if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) { if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) {
visual = OpenAI( visual = ModelService(
baseUrl = PluginConfig.visualModelApi,
token = PluginConfig.visualModelToken, token = PluginConfig.visualModelToken,
host = OpenAIHost(baseUrl = PluginConfig.visualModelApi), timeout = timeout,
timeout = Timeout(request = timeout, connect = timeout, socket = timeout) firstChunkTimeout = firstChunkTimeout,
extraBody = parseExtraBody(PluginConfig.visualModelExtraBody)
) )
} }
@@ -78,4 +96,4 @@ object LargeLanguageModels {
} }
} }
} }
} }

View 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()
}
}
}
}
}

View File

@@ -10,10 +10,7 @@ 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.LocalDate
import java.time.format.DateTimeFormatter
object PluginCommands : CompositeCommand( object PluginCommands : CompositeCommand(
JChatGPT, "jgpt", description = "J OpenAI ChatGPT" JChatGPT, "jgpt", description = "J OpenAI ChatGPT"
@@ -81,29 +78,27 @@ object PluginCommands : CompositeCommand(
suspend fun CommandSender.tokens(days: Int = 7) { suspend fun CommandSender.tokens(days: Int = 7) {
validateDays(days) validateDays(days)
if (PluginData.tokenUsageRecords.isEmpty()) { if (TokenUsageStore.all.isEmpty()) {
sendMessage("暂无 Token 使用记录") sendMessage("暂无 Token 使用记录")
return return
} }
val cutoff = calculateCutoffTimestamp(days) val cutoff = calculateCutoffDate(days)
val todayStart = calculateTodayStartTimestamp() val today = LocalDate.now().toString()
// 一次遍历计算所有统计数据
data class Statistics( data class Statistics(
var totalTokens: Int = 0, var totalTokens: Long = 0,
var todayTokens: Int = 0, var todayTokens: Long = 0,
val userTotals: MutableMap<Long, Pair<String, Int>> = mutableMapOf(), val userTotals: MutableMap<Long, Pair<String, Long>> = mutableMapOf(),
val groupTotals: MutableMap<Long, Int> = mutableMapOf(), val groupTotals: MutableMap<Long, Long> = mutableMapOf(),
val users: MutableSet<Long> = mutableSetOf() val users: MutableSet<Long> = mutableSetOf()
) )
val stats = PluginData.tokenUsageRecords.fold(Statistics()) { acc, record -> val stats = TokenUsageStore.all.fold(Statistics()) { acc, record ->
if (record.timestamp >= cutoff) { if (record.date >= cutoff) {
acc.totalTokens += record.totalTokens acc.totalTokens += record.totalTokens
acc.users.add(record.userId) acc.users.add(record.userId)
// 累计用户Token
val existing = acc.userTotals[record.userId] val existing = acc.userTotals[record.userId]
if (existing == null) { if (existing == null) {
acc.userTotals[record.userId] = record.userNickname to record.totalTokens 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) acc.userTotals[record.userId] = existing.first to (existing.second + record.totalTokens)
} }
// 累计群组Token
record.groupId?.let { groupId -> 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 acc.todayTokens += record.totalTokens
} }
@@ -151,7 +145,7 @@ object PluginCommands : CompositeCommand(
appendLine(" /jgpt tokensDaily [days] - 每日统计") appendLine(" /jgpt tokensDaily [days] - 每日统计")
appendLine(" /jgpt tokensUsers [limit] - 用户排名") appendLine(" /jgpt tokensUsers [limit] - 用户排名")
appendLine(" /jgpt tokensGroups [limit] - 群组排名") appendLine(" /jgpt tokensGroups [limit] - 群组排名")
appendLine(" /jgpt tokensQuery [userId] [days] - 详细记录") appendLine(" /jgpt tokensQuery [userId] [days] - 每日逐人记录")
appendLine(" /jgpt tokensUserDaily <userId> [days] - 用户日统计") appendLine(" /jgpt tokensUserDaily <userId> [days] - 用户日统计")
} }
@@ -162,19 +156,12 @@ object PluginCommands : CompositeCommand(
suspend fun CommandSender.tokensDaily(days: Int = 7) { suspend fun CommandSender.tokensDaily(days: Int = 7) {
validateDays(days) validateDays(days)
val cutoff = calculateCutoffTimestamp(days) val cutoff = calculateCutoffDate(days)
val dailyStats = PluginData.tokenUsageRecords val dailyStats = TokenUsageStore.all
.filter { it.timestamp >= cutoff } .filter { it.date >= cutoff }
.groupBy { .groupBy { it.date }
LocalDate.ofInstant( .mapValues { (_, records) -> records.sumOf { it.totalTokens } }
Instant.ofEpochSecond(it.timestamp),
ZoneId.systemDefault()
)
}
.mapValues { (_, records) ->
records.sumOf { it.totalTokens }
}
.toSortedMap() .toSortedMap()
if (dailyStats.isEmpty()) { if (dailyStats.isEmpty()) {
@@ -196,13 +183,11 @@ object PluginCommands : CompositeCommand(
suspend fun CommandSender.tokensUsers(limit: Int = 10) { suspend fun CommandSender.tokensUsers(limit: Int = 10) {
require(limit > 0) { "limit must be positive: $limit" } require(limit > 0) { "limit must be positive: $limit" }
val userStats = PluginData.tokenUsageRecords val userStats = TokenUsageStore.all
.groupBy { it.userId } .groupBy { it.userId }
.mapValues { (_, records) -> .mapValues { (_, records) ->
Pair( val latest = records.maxByOrNull { it.date }!!
records.first().userNickname, Pair(latest.userNickname, records.sumOf { it.totalTokens })
records.sumOf { it.totalTokens }
)
} }
.toList() .toList()
.sortedByDescending { it.second.second } .sortedByDescending { it.second.second }
@@ -227,12 +212,10 @@ object PluginCommands : CompositeCommand(
suspend fun CommandSender.tokensGroups(limit: Int = 10) { suspend fun CommandSender.tokensGroups(limit: Int = 10) {
require(limit > 0) { "limit must be positive: $limit" } require(limit > 0) { "limit must be positive: $limit" }
val groupStats = PluginData.tokenUsageRecords val groupStats = TokenUsageStore.all
.filter { it.groupId != null } .filter { it.groupId != null }
.groupBy { it.groupId!! } .groupBy { it.groupId!! }
.mapValues { (_, records) -> .mapValues { (_, records) -> records.sumOf { it.totalTokens } }
records.sumOf { it.totalTokens }
}
.toList() .toList()
.sortedByDescending { it.second } .sortedByDescending { it.second }
.take(limit) .take(limit)
@@ -256,12 +239,12 @@ object PluginCommands : CompositeCommand(
suspend fun CommandSender.tokensQuery(userId: Long?, days: Int = 7) { suspend fun CommandSender.tokensQuery(userId: Long?, days: Int = 7) {
validateDays(days) validateDays(days)
val cutoff = calculateCutoffTimestamp(days) val cutoff = calculateCutoffDate(days)
val filtered = PluginData.tokenUsageRecords val filtered = TokenUsageStore.all
.filter { it.timestamp >= cutoff } .filter { it.date >= cutoff }
.filter { userId == null || it.userId == userId } .filter { userId == null || it.userId == userId }
.sortedByDescending { it.timestamp } .sortedWith(compareByDescending<TokenUsageDailyRecord> { it.date }.thenByDescending { it.totalTokens })
.take(DEFAULT_QUERY_LIMIT) .take(DEFAULT_QUERY_LIMIT)
if (filtered.isEmpty()) { if (filtered.isEmpty()) {
@@ -270,15 +253,12 @@ object PluginCommands : CompositeCommand(
} }
val response = buildString { val response = buildString {
appendLine("最近 $days 天使用记录(最多显示${DEFAULT_QUERY_LIMIT}条):") appendLine("最近 $days 天使用记录(最多显示${DEFAULT_QUERY_LIMIT},按日聚合")
appendLine() appendLine()
filtered.forEach { record -> 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 "私聊" val location = if (record.groupId != null) "${record.groupId}" else "私聊"
appendLine("[$time] $location - ${record.userNickname}") appendLine("[${record.date}] $location - ${record.userNickname}")
appendLine(" 模型: ${record.model}, Tokens: ${formatNumber(record.totalTokens)} " + appendLine(" 调用 ${record.callCount}, Tokens: ${formatNumber(record.totalTokens)} " +
"(输入: ${formatNumber(record.promptTokens)}, 输出: ${formatNumber(record.completionTokens)})") "(输入: ${formatNumber(record.promptTokens)}, 输出: ${formatNumber(record.completionTokens)})")
appendLine() appendLine()
} }
@@ -290,36 +270,28 @@ object PluginCommands : CompositeCommand(
suspend fun CommandSender.tokensUserDaily(userId: Long, days: Int = 7) { suspend fun CommandSender.tokensUserDaily(userId: Long, days: Int = 7) {
validateDays(days) validateDays(days)
val cutoff = calculateCutoffTimestamp(days) val cutoff = calculateCutoffDate(days)
// 先过滤用户记录,同时获取昵称 val userRecords = TokenUsageStore.all
val userRecords = PluginData.tokenUsageRecords .filter { it.date >= cutoff && it.userId == userId }
.filter { it.timestamp >= cutoff && it.userId == userId }
if (userRecords.isEmpty()) { if (userRecords.isEmpty()) {
sendMessage("用户 $userId 在指定时间范围内无使用记录") sendMessage("用户 $userId 在指定时间范围内无使用记录")
return return
} }
val userNickname = userRecords.first().userNickname val userNickname = userRecords.maxByOrNull { it.date }!!.userNickname
val userDailyStats = userRecords val userDailyStats = userRecords
.groupBy { .groupBy { it.date }
LocalDate.ofInstant( .mapValues { (_, records) -> records.sumOf { it.totalTokens } }
Instant.ofEpochSecond(it.timestamp),
ZoneId.systemDefault()
)
}
.mapValues { (_, records) ->
records.sumOf { it.totalTokens }
}
.toSortedMap() .toSortedMap()
val response = buildString { val response = buildString {
appendLine("用户 $userNickname 最近 $days 天 Token 使用统计:") appendLine("用户 $userNickname 最近 $days 天 Token 使用统计:")
appendLine() appendLine()
userDailyStats.forEach { (date, total) -> userDailyStats.forEach { (date, total) ->
appendLine("$date: $total tokens") appendLine("$date: ${formatNumber(total)} tokens")
} }
appendLine() appendLine()
appendLine("总计: ${formatNumber(userDailyStats.values.sum())} tokens") appendLine("总计: ${formatNumber(userDailyStats.values.sum())} tokens")
@@ -330,23 +302,10 @@ object PluginCommands : CompositeCommand(
// ==================== 辅助函数 ==================== // ==================== 辅助函数 ====================
/** /**
* 计算截止时间戳(指定天数前的起始时间 00:00:00 * 计算截止日期字符串(指定天数前的日期,含今天共 days 天
* 最近N天包含今天所以要从 (N-1) 天前开始算
*/ */
private fun calculateCutoffTimestamp(days: Int): Long { private fun calculateCutoffDate(days: Int): String {
return LocalDate.now() return LocalDate.now().minusDays((days - 1).toLong()).toString()
.minusDays((days - 1).toLong())
.atStartOfDay(ZoneId.systemDefault())
.toEpochSecond()
}
/**
* 计算今天的起始时间戳00:00:00
*/
private fun calculateTodayStartTimestamp(): Long {
return LocalDate.now()
.atStartOfDay(ZoneId.systemDefault())
.toEpochSecond()
} }
/** /**

View File

@@ -38,14 +38,26 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("视觉模型") @ValueDescription("视觉模型")
var visualModel: String by value("qwen-vl-plus") 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") @ValueDescription("百炼平台API KEY")
val dashScopeApiKey: String by value("") val dashScopeApiKey: String by value("")
@ValueDescription("百炼平台图片编辑模型") @ValueDescription("百炼平台图像模型支持文生图与图像编辑。可选qwen-image-2.0 / qwen-image-2.0-pro / qwen-image-edit-max / qwen-image-edit-plus 等")
val imageEditModel: String by value("qwen-image-edit") val imageModel: String by value("qwen-image-2.0")
@ValueDescription("百炼平台TTS模型") @ValueDescription("是否在生成的图片右下角添加 Qwen-Image 水印")
val ttsModel: String by value("qwen-tts") 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") @ValueDescription("Jina API Key")
val jinaApiKey by value("") val jinaApiKey by value("")
@@ -68,9 +80,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。") @ValueDescription("群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。")
val temperaturePermission: Int by value(50) val temperaturePermission: Int by value(50)
@ValueDescription("等待响应超时时间单位毫秒默认60秒") @ValueDescription("等待响应超时时间整个请求的总超时与socket读超时单位毫秒默认60秒")
val timeout: Long by value(60000L) val timeout: Long by value(60000L)
@ValueDescription("首块响应超时时间单位毫秒默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试")
val firstChunkTimeout: Long by value(10000L)
@Deprecated("使用外部文件而不是在配置文件内保存提示词") @Deprecated("使用外部文件而不是在配置文件内保存提示词")
@ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改") @ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改")
var prompt: String by value("你是一个乐于助人的助手") var prompt: String by value("你是一个乐于助人的助手")
@@ -122,4 +137,10 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("单个工具调用返回内容的最大字符数,超过将被截断并标注") @ValueDescription("单个工具调用返回内容的最大字符数,超过将被截断并标注")
val maxToolOutputLength: Int by value(15000) val maxToolOutputLength: Int by value(15000)
@ValueDescription("聊天记录搜索最大天数")
val searchHistoryMaxDays: Int by value(30)
@ValueDescription("聊天记录搜索最大查询条数,防止内存溢出")
val searchHistoryMaxRecords: Int by value(5000)
} }

View File

@@ -10,22 +10,27 @@ import net.mamoe.mirai.console.data.value
* @param value 好感度值 (-100 ~ 100) * @param value 好感度值 (-100 ~ 100)
* @param reasons 调整原因列表,用于溯源 * @param reasons 调整原因列表,用于溯源
* @param impression 对用户的印象/画像 * @param impression 对用户的印象/画像
* @param name Bot给此人起的代号
* @param tags 标签列表最多5个
*/ */
@Serializable @Serializable
data class FavorabilityInfo( data class FavorabilityInfo(
val userId: Long, val userId: Long,
val value: Int = 0, val value: Int = 0,
val reasons: List<String> = emptyList(), val reasons: List<String> = emptyList(),
val impression: String = "" val impression: String = "",
val name: String = "",
val tags: List<String> = emptyList()
) { ) {
override fun toString(): String { override fun toString(): String {
return buildString { return buildString {
append("好感度:$value") append("好感度:$value")
if (impression.isNotEmpty()) { if (name.isNotEmpty()) append(",代号:$name")
append("\t印象:$impression") if (tags.isNotEmpty()) append(",标签:[${tags.joinToString(", ")}]")
} if (impression.isNotEmpty()) append(",印象:$impression")
if (reasons.isNotEmpty()) { if (reasons.isNotEmpty()) {
appendLine("\t调整原因:") appendLine()
appendLine("调整原因:")
reasons.forEach { reason -> reasons.forEach { reason ->
appendLine("* $reason") appendLine("* $reason")
} }
@@ -35,26 +40,26 @@ data class FavorabilityInfo(
} }
/** /**
* Token使用记录数据类 * Token使用日聚合记录。按 (date, userId, groupId) 维度合并。由 [TokenUsageStore] 持久化到独立 JSON 文件。
* @param timestamp Unix时间戳 * @param date 本地时区下的日期,格式 yyyy-MM-dd
* @param userId 用户QQ * @param userId QQ
* @param userNickname 用户昵称 * @param userNickname 最近一次记录到的昵称
* @param groupId 群号私聊时为null * @param groupId 群号私聊时为null
* @param model 模型名称 * @param promptTokens 当天累计输入token
* @param promptTokens 输入token * @param completionTokens 当天累计输出token
* @param completionTokens 输出token * @param totalTokens 当天累计总token
* @param totalTokens 总token * @param callCount 当天调用次
*/ */
@Serializable @Serializable
data class TokenUsageRecord( data class TokenUsageDailyRecord(
val timestamp: Long, val date: String,
val userId: Long, val userId: Long,
val userNickname: String, val userNickname: String,
val groupId: Long?, val groupId: Long?,
val model: String, val promptTokens: Long = 0,
val promptTokens: Int, val completionTokens: Long = 0,
val completionTokens: Int, val totalTokens: Long = 0,
val totalTokens: Int val callCount: Int = 0
) )
object PluginData : AutoSavePluginData("data") { object PluginData : AutoSavePluginData("data") {
@@ -70,11 +75,6 @@ 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>())
/** /**
* 添加对话记忆 * 添加对话记忆
*/ */
@@ -99,4 +99,4 @@ object PluginData : AutoSavePluginData("data") {
.replace("\n\n", "\n") .replace("\n\n", "\n")
} }
} }
} }

View 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) {
// 写盘失败由日志/上层关心,这里不抛断对话流程
}
}
}

View File

@@ -2,6 +2,7 @@ package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.buildJsonObject
@@ -11,6 +12,7 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import net.mamoe.mirai.event.events.MessageEvent import net.mamoe.mirai.event.events.MessageEvent
import top.jie65535.mirai.JChatGPT import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginData import top.jie65535.mirai.PluginData
@@ -21,9 +23,20 @@ import java.time.format.DateTimeFormatter
class AdjustUserFavorabilityAgent : BaseAgent( class AdjustUserFavorabilityAgent : BaseAgent(
tool = Tool.function( tool = Tool.function(
name = "adjustUserFavorability", name = "adjustUserFavorability",
description = "可根据网友行为调整对其好感度,范围从-100到100。" + description = """
"默认为0表示陌生人100表示非常好的朋友-100表示已拉黑。" + 维护你对群友的认识(好感度、印象、标签、代号)。
"当好感度低于0时有一定概率忽略该用户的消息-100则100%忽略其消息。", 每次和某人有实质交流后建议调用一次,可与发言工具在同一轮发出,几乎不产生额外成本。
触发场景:
- 首次有像样的对话(建立初始印象和代号)
- 对方透露身份/职业/偏好/技术栈(加 tag
- 互动产生明显情绪变化——开心/被逗/被冒犯(调 change
- 已有印象明显不准(更新 impression
change 默认为 0只更新标签/印象时不用填 reason。
tags 上限 5 个,满了须先 tags_remove 旧标签才能继续添加。
好感度范围 -100 到 100低于 0 时有概率忽略其消息,-100 则 100% 忽略。
""".trimIndent(),
parameters = Parameters.buildJsonObject { parameters = Parameters.buildJsonObject {
put("type", "object") put("type", "object")
put("properties", buildJsonObject { put("properties", buildJsonObject {
@@ -33,21 +46,33 @@ class AdjustUserFavorabilityAgent : BaseAgent(
}) })
put("change", buildJsonObject { put("change", buildJsonObject {
put("type", "integer") put("type", "integer")
put("description", "好感度变化值(可正可负)") put("description", "好感度变化值(可正可负)默认为0")
}) })
put("reason", buildJsonObject { put("reason", buildJsonObject {
put("type", "string") put("type", "string")
put("description", "调整原因(供日志记录和溯源)") put("description", "调整原因(change!=0时建议填写供溯源)")
}) })
put("impression", buildJsonObject { put("impression", buildJsonObject {
put("type", "string") 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") { putJsonArray("required") {
add("userId") add("userId")
add("change")
add("reason")
} }
} }
) )
@@ -56,48 +81,66 @@ class AdjustUserFavorabilityAgent : BaseAgent(
requireNotNull(args) requireNotNull(args)
val userId = args["userId"]?.jsonPrimitive?.longOrNull 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 reason = args["reason"]?.jsonPrimitive?.contentOrNull
val impression = args["impression"]?.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 currentInfo = PluginData.userFavorability[userId] ?: FavorabilityInfo(userId)
val currentValue = currentInfo.value val currentValue = currentInfo.value
// 计算新的好感度值,限制在-100~100范围内
val newValue = (currentValue + change).coerceIn(-100, 100) val newValue = (currentValue + change).coerceIn(-100, 100)
// 更新原因列表 // 只在 change != 0 时记录原因
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm") val newReasons = if (change != 0 && reason != null) {
val newReason = "${timeFormatter.format(OffsetDateTime.now())}: $reason" val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
val newReasons = if (currentInfo.reasons.size >= 10) { val newReason = "${timeFormatter.format(OffsetDateTime.now())}: $reason"
// 保留最近的10条原因记录 if (currentInfo.reasons.size >= 10) {
(currentInfo.reasons.drop(1) + newReason) currentInfo.reasons.drop(1) + newReason
} else { } else {
(currentInfo.reasons + newReason) 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( val newInfo = FavorabilityInfo(
userId = userId, userId = userId,
value = newValue, value = newValue,
reasons = newReasons, reasons = newReasons,
impression = newImpression impression = impression ?: currentInfo.impression,
name = name ?: currentInfo.name,
tags = newTags
) )
// 更新好感度
PluginData.userFavorability[userId] = newInfo PluginData.userFavorability[userId] = newInfo
JChatGPT.logger.info("用户 $userId 画像已更新:好感度($currentValue -> $newValue),原因:$reason")
// 记录日志 return buildString {
JChatGPT.logger.info("用户 $userId 的好感度 ($currentValue -> $newValue),原因:$reason") append("用户 $userId 画像已更新:好感度=$newValue")
if (newTags.isNotEmpty()) append(",标签=[${newTags.joinToString(", ")}]")
return "用户 $userId 的好感度已更新为 $newValue" if (newInfo.name.isNotEmpty()) append(",代号=${newInfo.name}")
if (newInfo.impression.isNotEmpty()) append(",印象=${newInfo.impression}")
}
} }
} }

View File

@@ -22,29 +22,30 @@ import kotlinx.serialization.json.putJsonObject
import top.jie65535.mirai.JChatGPT import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig import top.jie65535.mirai.PluginConfig
class ImageEdit : BaseAgent( class ImageAgent : BaseAgent(
tool = Tool.function( tool = Tool.function(
name = "imageEdit", name = "imageAgent",
description = "可通过调用图像编辑模型来修改图片。备注:该方法成本较高,非必要尽量不要调用。编辑图片前无需识别图片内容,图像编辑模型自己会理解图片内容!", description = "调用千问图像模型生成或编辑图片。不传 image_urls 即纯文生图;" +
"传 1~3 张图片可进行编辑、修改或多图融合。" +
"备注:该方法成本较高,非必要尽量不要调用。" +
"编辑图片前无需识别图片内容,模型自己会理解图片内容。",
parameters = Parameters.buildJsonObject { parameters = Parameters.buildJsonObject {
put("type", "object") put("type", "object")
putJsonObject("properties") { putJsonObject("properties") {
putJsonObject("image_url") { putJsonObject("image_urls") {
put("type", "string") put("type", "array")
put("description", "原始图片地址") putJsonObject("items") {
put("type", "string")
}
put("description", "参考图片地址列表,可传 0~3 张。" +
"不传或为空即纯文生图;传 1 张为编辑;多张为融合,输出比例与最后一张对齐。")
} }
putJsonObject("prompt") { putJsonObject("prompt") {
put("type", "string") put("type", "string")
put("description", "正向提示词,用来描述需要对图片进行修改的要求") put("description", "提示词,描述期望生成或修改的画面内容")
} }
// putJsonObject("negative_prompt") {
// put("type", "string")
// put("description", "反向提示词,用来描述不希望在画面中看到的内容,可以对画面进行限制。" +
// "示例值:低分辨率、错误、最差质量、低质量、残缺、多余的手指、比例不良等。")
// }
} }
putJsonArray("required") { putJsonArray("required") {
add("image_url")
add("prompt") add("prompt")
} }
} }
@@ -58,25 +59,29 @@ class ImageEdit : BaseAgent(
get() = PluginConfig.dashScopeApiKey.isNotEmpty() get() = PluginConfig.dashScopeApiKey.isNotEmpty()
override val loadingMessage: String override val loadingMessage: String
get() = "图中..." get() = "图中..."
override suspend fun execute(args: JsonObject?): String { override suspend fun execute(args: JsonObject?): String {
requireNotNull(args) requireNotNull(args)
val imageUrl = args.getValue("image_url").jsonPrimitive.content
val prompt = args.getValue("prompt").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) { val response = httpClient.post(API_URL) {
contentType(ContentType("application", "json")) contentType(ContentType("application", "json"))
header("Authorization", "Bearer " + PluginConfig.dashScopeApiKey) header("Authorization", "Bearer " + PluginConfig.dashScopeApiKey)
setBody(buildJsonObject { setBody(buildJsonObject {
put("model", PluginConfig.imageEditModel) put("model", PluginConfig.imageModel)
putJsonObject("input") { putJsonObject("input") {
putJsonArray("messages") { putJsonArray("messages") {
addJsonObject { addJsonObject {
put("role", "user") put("role", "user")
putJsonArray("content") { putJsonArray("content") {
addJsonObject { for (url in imageUrls) {
put("image", imageUrl) addJsonObject {
put("image", url)
}
} }
addJsonObject { addJsonObject {
put("text", prompt) put("text", prompt)
@@ -85,11 +90,11 @@ class ImageEdit : BaseAgent(
} }
} }
} }
// if (negativePrompt != null) { putJsonObject("parameters") {
// putJsonObject("parameters") { put("n", 1)
// put("negative_prompt", negativePrompt) put("prompt_extend", true)
// } put("watermark", PluginConfig.imageWatermark)
// } }
}.toString()) }.toString())
} }
@@ -102,10 +107,10 @@ class ImageEdit : BaseAgent(
.getValue("message").jsonObject .getValue("message").jsonObject
.getValue("content").jsonArray[0].jsonObject .getValue("content").jsonArray[0].jsonObject
.getValue("image").jsonPrimitive.content .getValue("image").jsonPrimitive.content
"图片已编辑完发送时请务必包含完整的url和查询参数因为下载地址存在鉴权![图片]($url)" "图片已发送时请务必包含完整的url和查询参数因为下载地址存在鉴权![图片]($url)"
} catch (e: Throwable) { } catch (e: Throwable) {
JChatGPT.logger.error("图像编辑结果解析异常", e) JChatGPT.logger.error("图像生成结果解析异常", e)
responseJson responseJson
} }
} }
} }

View File

@@ -39,18 +39,28 @@ class ReasoningAgent : BaseAgent(
val prompt = args.getValue("prompt").jsonPrimitive.content val prompt = args.getValue("prompt").jsonPrimitive.content
val answerContent = StringBuilder() val answerContent = StringBuilder()
val reasoningContent = StringBuilder()
llm.chatCompletions(ChatCompletionRequest( llm.chatCompletions(ChatCompletionRequest(
model = ModelId(PluginConfig.reasoningModel), model = ModelId(PluginConfig.reasoningModel),
messages = listOf(ChatMessage.User(prompt)) messages = listOf(ChatMessage.User(prompt))
)).collect { )).collect {
if (it.choices.isNotEmpty()) { if (it.choices.isNotEmpty()) {
val delta = it.choices[0].delta ?: return@collect val delta = it.choices[0].delta ?: return@collect
if (!delta.reasoningContent.isNullOrEmpty()) {
reasoningContent.append(delta.reasoningContent)
}
if (!delta.content.isNullOrEmpty()) { if (!delta.content.isNullOrEmpty()) {
answerContent.append(delta.content) 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 -> "推理出错,结果为空"
}
} }
} }

View 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()
"![${if (msg.isEmoji) "表情包" else "图片"}]($url)"
} 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
}
}
}
}

View File

@@ -21,7 +21,7 @@ import kotlin.time.measureTime
class SendVoiceMessage : BaseAgent( class SendVoiceMessage : BaseAgent(
tool = Tool.function( tool = Tool.function(
name = "sendVoiceMessage", name = "sendVoiceMessage",
description = "发送一条文本转语音消息。", description = "发送一条文本转语音消息。可选传入 instructions 用自然语言指令控制语气、语速、情感等表达方式,让语音更贴合当前对话氛围。",
parameters = Parameters.buildJsonObject { parameters = Parameters.buildJsonObject {
put("type", "object") put("type", "object")
putJsonObject("properties") { putJsonObject("properties") {
@@ -29,6 +29,10 @@ class SendVoiceMessage : BaseAgent(
put("type", "string") put("type", "string")
put("description", "语音消息文本内容") put("description", "语音消息文本内容")
} }
putJsonObject("instructions") {
put("type", "string")
put("description", "可选。自然语言描述本句话的表达方式,例如\"语速较快,带有明显的上扬语调\"\"温柔知性,语调平和\"。仅支持中英文。")
}
} }
putJsonArray("required") { putJsonArray("required") {
add("content") add("content")
@@ -52,6 +56,7 @@ class SendVoiceMessage : BaseAgent(
if (event.subject !is AudioSupported) return "当前聊天环境不支持发送语音!" if (event.subject !is AudioSupported) return "当前聊天环境不支持发送语音!"
val content = args.getValue("content").jsonPrimitive.content 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 // https://help.aliyun.com/zh/model-studio/qwen-tts
val response = httpClient.post(API_URL) { val response = httpClient.post(API_URL) {
@@ -62,6 +67,10 @@ class SendVoiceMessage : BaseAgent(
putJsonObject("input") { putJsonObject("input") {
put("text", content) put("text", content)
put("voice", "Chelsie") // Chelsie Cherry Ethan Serena put("voice", "Chelsie") // Chelsie Cherry Ethan Serena
if (instructions != null) {
put("instructions", instructions)
put("optimize_instructions", true)
}
} }
}.toString()) }.toString())
} }