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
.idea/
.run/
build-with-jdk17.bat
*.iml
*.ipr

114
README.md
View File

@@ -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数
## 部署要求

View File

@@ -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"

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(
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(),

View File

@@ -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 {
}
}
}
}
}

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.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()
}
/**

View File

@@ -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)
}

View File

@@ -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")
}
}
}
}

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.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}")
}
}
}
}

View File

@@ -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)"
"图片已发送时请务必包含完整的url和查询参数因为下载地址存在鉴权![图片]($url)"
} catch (e: Throwable) {
JChatGPT.logger.error("图像编辑结果解析异常", e)
JChatGPT.logger.error("图像生成结果解析异常", e)
responseJson
}
}
}
}

View File

@@ -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 -> "推理出错,结果为空"
}
}
}

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