mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-06-23 00:49:31 +08:00
Compare commits
20 Commits
2d3045e110
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| cfc61c52ba | |||
| ebb1fbe10c | |||
| a29cf17361 | |||
| 4307019ee8 | |||
| 538fe563a0 | |||
| 890ccb10d5 | |||
| d2bdd273b2 | |||
| eedbd55f62 | |||
| 92acbb2310 | |||
| 39b49bb302 | |||
| 98bb1066c4 | |||
| 4dde523dfc | |||
| f17adee4ba | |||
| 72892336bc | |||
| e629d37fa8 | |||
| 200a404927 | |||
| 3ed54dae0e | |||
| 417b5f631f | |||
| bc2ab553b9 | |||
| e5d5445a1f |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,6 +1,7 @@
|
|||||||
# User-specific stuff
|
# User-specific stuff
|
||||||
.idea/
|
.idea/
|
||||||
.run/
|
.run/
|
||||||
|
build-with-jdk17.bat
|
||||||
|
|
||||||
*.iml
|
*.iml
|
||||||
*.ipr
|
*.ipr
|
||||||
|
|||||||
163
README.md
163
README.md
@@ -7,8 +7,9 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
|
|||||||
- **多模型支持**:支持聊天模型、推理模型和视觉模型
|
- **多模型支持**:支持聊天模型、推理模型和视觉模型
|
||||||
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
|
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
|
||||||
- **上下文记忆**:支持持久化记忆存储
|
- **上下文记忆**:支持持久化记忆存储
|
||||||
- **好感度系统**:基于用户行为的好感度管理机制
|
- **技能系统**:Bot 可在群聊中自我沉淀可复用知识,全局跨群、按需加载、低上下文污染
|
||||||
- **Token消耗统计**:记录每次对话的Token消耗,支持多维度统计查询
|
- **用户画像系统**:好感度、印象、标签、Bot 自定义代号
|
||||||
|
- **Token消耗统计**:按天 × 用户 × 群聚合记录,支持多维度统计查询
|
||||||
- **LaTeX 渲染**:自动将数学表达式渲染为图片
|
- **LaTeX 渲染**:自动将数学表达式渲染为图片
|
||||||
- **灵活的触发方式**:@机器人、关键字触发、回复消息等
|
- **灵活的触发方式**:@机器人、关键字触发、回复消息等
|
||||||
- **权限控制**:细粒度的权限管理系统
|
- **权限控制**:细粒度的权限管理系统
|
||||||
@@ -30,6 +31,8 @@ AI 可以自动调用多种工具来完成复杂任务:
|
|||||||
- 推理思考(需要配置推理模型)
|
- 推理思考(需要配置推理模型)
|
||||||
- 群管理(禁言等,需启用相应权限)
|
- 群管理(禁言等,需启用相应权限)
|
||||||
- 记忆管理(添加和修改对话记忆)
|
- 记忆管理(添加和修改对话记忆)
|
||||||
|
- 技能管理(沉淀、加载、迭代、删除可复用知识技能)
|
||||||
|
- 聊天历史搜索(按关键词、发送者、时间范围检索群聊消息,需启用历史消息上下文)
|
||||||
|
|
||||||
## 权限列表
|
## 权限列表
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ AI 可以自动调用多种工具来完成复杂任务:
|
|||||||
- `/jgpt reload` - 重载配置文件
|
- `/jgpt reload` - 重载配置文件
|
||||||
- `/jgpt clearMemory` - 清空所有对话记忆
|
- `/jgpt clearMemory` - 清空所有对话记忆
|
||||||
- `/jgpt clearContextCache` - 清空所有对话上下文缓存
|
- `/jgpt clearContextCache` - 清空所有对话上下文缓存
|
||||||
|
- `/jgpt skills` - 列出当前所有技能(名称 + 简介)
|
||||||
|
|
||||||
### 好感度管理
|
### 好感度管理
|
||||||
- `/jgpt setFavor <user> <value>` - 设置指定用户的好感度值(-100~100)
|
- `/jgpt setFavor <user> <value>` - 设置指定用户的好感度值(-100~100)
|
||||||
@@ -54,7 +58,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 +86,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 +114,10 @@ friendHasChatPermission: true
|
|||||||
canMute: false
|
canMute: false
|
||||||
# 群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。
|
# 群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。
|
||||||
temperaturePermission: 50
|
temperaturePermission: 50
|
||||||
# 等待响应超时时间,单位毫秒,默认60秒
|
# 等待响应超时时间(整个请求的总超时与socket读超时),单位毫秒,默认60秒
|
||||||
timeout: 60000
|
timeout: 60000
|
||||||
|
# 首块响应超时时间,单位毫秒,默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试
|
||||||
|
firstChunkTimeout: 10000
|
||||||
# 系统提示词,该字段已弃用,使用提示词文件而不是在这里修改
|
# 系统提示词,该字段已弃用,使用提示词文件而不是在这里修改
|
||||||
prompt: '你是一个乐于助人的助手'
|
prompt: '你是一个乐于助人的助手'
|
||||||
# 系统提示词文件路径,相对于插件配置目录
|
# 系统提示词文件路径,相对于插件配置目录
|
||||||
@@ -124,10 +138,16 @@ callKeyword: '[小筱][林淋月玥]'
|
|||||||
showToolCallingMessage: true
|
showToolCallingMessage: true
|
||||||
# 是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆
|
# 是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆
|
||||||
memoryEnabled: true
|
memoryEnabled: true
|
||||||
|
# 是否启用技能系统,技能存在data/skills目录(全局跨群),提示词中需要加上{skills}来注入技能索引
|
||||||
|
skillsEnabled: true
|
||||||
# 是否启用好感度系统
|
# 是否启用好感度系统
|
||||||
enableFavorabilitySystem: true
|
enableFavorabilitySystem: true
|
||||||
# 好感度每日基础偏移速度(点/天)
|
# 好感度每日基础偏移速度(点/天)
|
||||||
favorabilityBaseShiftSpeed: 2.0
|
favorabilityBaseShiftSpeed: 2.0
|
||||||
|
# 聊天记录搜索最大天数
|
||||||
|
searchHistoryMaxDays: 30
|
||||||
|
# 聊天记录搜索最大查询条数,防止内存溢出
|
||||||
|
searchHistoryMaxRecords: 5000
|
||||||
```
|
```
|
||||||
|
|
||||||
## 系统提示词
|
## 系统提示词
|
||||||
@@ -150,6 +170,7 @@ JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件
|
|||||||
- `{time}` - 当前时间(格式:yyyy年MM月dd E HH:mm:ss)
|
- `{time}` - 当前时间(格式:yyyy年MM月dd E HH:mm:ss)
|
||||||
- `{subject}` - 当前聊天环境信息(群聊名称或私聊信息)
|
- `{subject}` - 当前聊天环境信息(群聊名称或私聊信息)
|
||||||
- `{memory}` - 当前联系人的记忆内容
|
- `{memory}` - 当前联系人的记忆内容
|
||||||
|
- `{skills}` - 全局技能索引(仅名称 + 一句话简介,正文按需加载)
|
||||||
|
|
||||||
### 示例提示词
|
### 示例提示词
|
||||||
|
|
||||||
@@ -264,58 +285,98 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型
|
|||||||
3. **VisualAgent** - 图像识别和理解
|
3. **VisualAgent** - 图像识别和理解
|
||||||
4. **ReasoningAgent** - 深度思考和推理
|
4. **ReasoningAgent** - 深度思考和推理
|
||||||
5. **MemoryAppend/Replace** - 对话记忆管理
|
5. **MemoryAppend/Replace** - 对话记忆管理
|
||||||
6. **GroupManageAgent** - 群管理功能(如禁言)
|
6. **LoadSkill/SaveSkill/DeleteSkill** - 技能管理(加载、沉淀/迭代、删除全局技能)
|
||||||
7. **SendSingleMessage/CompositeMessage** - 发送消息
|
7. **GroupManageAgent** - 群管理功能(如禁言)
|
||||||
8. **SendVoiceMessage** - 发送语音消息
|
8. **SendSingleMessage/CompositeMessage** - 发送消息
|
||||||
9. **ImageEdit** - 图像编辑
|
9. **SendVoiceMessage** - 发送语音消息
|
||||||
10. **WeatherService** - 天气查询
|
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
|
||||||
|
11. **WeatherService** - 天气查询
|
||||||
|
12. **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)
|
||||||
|
|
||||||
|
## 技能系统
|
||||||
|
|
||||||
|
JChatGPT 允许 Bot 在群聊中**自我沉淀可复用的知识和经验**,存成"技能"(本质是带简介的提示词文档),并在需要时按需加载。例如群友反复问到某个软件/模组的用法、常见报错排查,Bot 在回答或被纠正的过程中学到的内容可以沉淀成技能,跨群复用。
|
||||||
|
|
||||||
|
### 设计要点
|
||||||
|
- **全局跨群**:技能不按群隔离,任何群学到的都能在其它群复用。
|
||||||
|
- **低上下文污染**:日常对话只在系统提示词里常驻"技能名 + 一句话简介"的索引,正文不进上下文。
|
||||||
|
- **按需加载**:当话题命中某个技能时,Bot 才用 `loadSkill` 把正文读入上下文。
|
||||||
|
- **自我迭代**:Bot 通过 `saveSkill` 沉淀/更新技能,过时的用 `deleteSkill` 删除,无需人工介入(也支持手动编辑文件)。
|
||||||
|
|
||||||
|
### 存储格式
|
||||||
|
每个技能 = `data/skills/` 下的一个 markdown 文件,带 frontmatter:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: kubejs-basics
|
||||||
|
description: KubeJS 基础语法、常见报错与排查方法
|
||||||
|
---
|
||||||
|
|
||||||
|
(正文:沉淀下来的知识、经验或提示词)
|
||||||
|
```
|
||||||
|
|
||||||
|
技能名为 kebab-case,只能包含字母、数字、下划线、连字符(用于校验防止路径穿越)。
|
||||||
|
|
||||||
|
### 相关工具
|
||||||
|
- **loadSkill(name)** - 加载某技能正文到上下文
|
||||||
|
- **saveSkill(name, description, content)** - 新增或整篇覆盖一个技能(迭代 = 先 loadSkill 读全文,改好后同名写回)
|
||||||
|
- **deleteSkill(name)** - 删除过时或失效的技能
|
||||||
|
|
||||||
|
### 配置与命令
|
||||||
|
- 配置项 `skillsEnabled`(默认 true)控制是否启用技能系统
|
||||||
|
- 系统提示词中需包含 `{skills}` 占位符以注入技能索引
|
||||||
|
- `/jgpt skills` - 列出当前所有技能;`/jgpt reload` 会重新扫描技能目录
|
||||||
|
|
||||||
## 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 +459,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 +494,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 +510,7 @@ JChatGPT 插件内置了Token消耗统计功能,可以记录每次对话的Tok
|
|||||||
### 注意事项
|
### 注意事项
|
||||||
- 仅统计聊天模型的Token消耗
|
- 仅统计聊天模型的Token消耗
|
||||||
- 推理模型和视觉模型的消耗不在统计范围内
|
- 推理模型和视觉模型的消耗不在统计范围内
|
||||||
- 每次对话轮次都会单独记录
|
- 同一用户同一天在同一群的多次调用合并为一行(callCount 自增)
|
||||||
- 统计数据基于实际API返回的Token数
|
- 统计数据基于实际API返回的Token数
|
||||||
|
|
||||||
## 部署要求
|
## 部署要求
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "top.jie65535.mirai"
|
group = "top.jie65535.mirai"
|
||||||
version = "1.10.0"
|
version = "1.12.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"
|
||||||
|
|||||||
72
scripts/recover_data_yml.py
Normal file
72
scripts/recover_data_yml.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
恢复 data.yml:把 tokenUsageDailyRecords 抽出成 token_usage.json,
|
||||||
|
顺手清理 tokenUsageRecords,把 data.yml 重写成合法的、yamlkt 能读回的 JSON。
|
||||||
|
|
||||||
|
用法(在 data.yml 所在目录运行):
|
||||||
|
python3 recover_data_yml.py /path/to/top.jie65535.mirai.JChatGPT/
|
||||||
|
|
||||||
|
会做:
|
||||||
|
1. 备份原 data.yml -> data.yml.bak-<timestamp>
|
||||||
|
2. 读 data.yml(按 JSON 解析,目前文件就是 JSON-flow YAML)
|
||||||
|
3. 把 tokenUsageDailyRecords 写到 token_usage.json
|
||||||
|
4. 删除 tokenUsageRecords 和 tokenUsageDailyRecords 字段
|
||||||
|
5. 重写 data.yml(保留 contactMemory / userFavorability 等)
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
def main(target_dir: str) -> int:
|
||||||
|
data_path = os.path.join(target_dir, "data.yml")
|
||||||
|
if not os.path.exists(data_path):
|
||||||
|
print(f"NOT FOUND: {data_path}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
with open(data_path, "r", encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = json.loads(text)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f"data.yml 不是合法 JSON:{e}", file=sys.stderr)
|
||||||
|
print("如果文件其实是 block-style YAML,请先用 yq/python yaml 转换", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
print(f"顶层不是 map,是 {type(data).__name__}", file=sys.stderr)
|
||||||
|
return 3
|
||||||
|
|
||||||
|
ts = int(time.time())
|
||||||
|
backup_path = os.path.join(target_dir, f"data.yml.bak-{ts}")
|
||||||
|
with open(backup_path, "w", encoding="utf-8") as f:
|
||||||
|
f.write(text)
|
||||||
|
print(f"已备份 -> {backup_path}")
|
||||||
|
|
||||||
|
daily_records = data.pop("tokenUsageDailyRecords", [])
|
||||||
|
raw_records = data.pop("tokenUsageRecords", [])
|
||||||
|
print(f"提取 tokenUsageDailyRecords: {len(daily_records)} 条")
|
||||||
|
print(f"丢弃 tokenUsageRecords (legacy): {len(raw_records)} 条")
|
||||||
|
|
||||||
|
token_path = os.path.join(target_dir, "token_usage.json")
|
||||||
|
if os.path.exists(token_path):
|
||||||
|
token_backup = os.path.join(target_dir, f"token_usage.json.bak-{ts}")
|
||||||
|
os.rename(token_path, token_backup)
|
||||||
|
print(f"已备份现有 token_usage.json -> {token_backup}")
|
||||||
|
|
||||||
|
with open(token_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(daily_records, f, ensure_ascii=False, indent=2)
|
||||||
|
print(f"写入 -> {token_path} ({len(daily_records)} 条)")
|
||||||
|
|
||||||
|
with open(data_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||||
|
print(f"重写 -> {data_path}(剩余字段: {list(data.keys())})")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print(__doc__, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
sys.exit(main(sys.argv[1]))
|
||||||
@@ -52,7 +52,7 @@ object JChatGPT : KotlinPlugin(
|
|||||||
JvmPluginDescription(
|
JvmPluginDescription(
|
||||||
id = "top.jie65535.mirai.JChatGPT",
|
id = "top.jie65535.mirai.JChatGPT",
|
||||||
name = "J ChatGPT",
|
name = "J ChatGPT",
|
||||||
version = "1.10.0",
|
version = "1.12.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,12 @@ object JChatGPT : KotlinPlugin(
|
|||||||
PluginConfig.reload()
|
PluginConfig.reload()
|
||||||
PluginData.reload()
|
PluginData.reload()
|
||||||
|
|
||||||
|
// 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug)
|
||||||
|
TokenUsageStore.init(dataFolder)
|
||||||
|
|
||||||
|
// 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群)
|
||||||
|
SkillStore.init(dataFolder)
|
||||||
|
|
||||||
// 设置Token
|
// 设置Token
|
||||||
LargeLanguageModels.reload()
|
LargeLanguageModels.reload()
|
||||||
|
|
||||||
@@ -113,8 +119,6 @@ object JChatGPT : KotlinPlugin(
|
|||||||
logger.info { "Plugin loaded" }
|
logger.info { "Plugin loaded" }
|
||||||
}
|
}
|
||||||
|
|
||||||
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
|
||||||
.withZone(ZoneOffset.systemDefault())
|
|
||||||
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
|
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
|
||||||
|
|
||||||
private val requestMap = ConcurrentSet<Long>()
|
private val requestMap = ConcurrentSet<Long>()
|
||||||
@@ -138,13 +142,56 @@ object JChatGPT : KotlinPlugin(
|
|||||||
*/
|
*/
|
||||||
private data class ConversationCache(
|
private data class ConversationCache(
|
||||||
val history: MutableList<ChatMessage>,
|
val history: MutableList<ChatMessage>,
|
||||||
val lastActivityAt: Int
|
val lastActivityAt: Int,
|
||||||
|
val replyIndex: ReplyIndex
|
||||||
) {
|
) {
|
||||||
fun isExpired(ttlSeconds: Int): Boolean {
|
fun isExpired(ttlSeconds: Int): Boolean {
|
||||||
return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds
|
return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 回复索引:每个会话(subject)在一次对话期间维护一份「短编号 -> 消息记录」映射,
|
||||||
|
* 让 LLM 能用历史里每行行首的 [n] 来引用回复某条消息。
|
||||||
|
* 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。
|
||||||
|
*/
|
||||||
|
class ReplyIndex {
|
||||||
|
private val byIndex = LinkedHashMap<Int, MessageRecord>()
|
||||||
|
private val indexByIds = HashMap<String, Int>()
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
|
fun add(record: MessageRecord): Int {
|
||||||
|
// ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号
|
||||||
|
val ids = record.ids
|
||||||
|
if (ids != null) {
|
||||||
|
indexByIds[ids]?.let { return it }
|
||||||
|
}
|
||||||
|
val i = ++counter
|
||||||
|
byIndex[i] = record
|
||||||
|
if (ids != null) {
|
||||||
|
indexByIds[ids] = i
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(index: Int): MessageRecord? = byIndex[index]
|
||||||
|
fun indexOfIds(ids: String): Int? = indexByIds[ids]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 各会话的回复索引,startChat 开始时重建,结束时清理 */
|
||||||
|
private val replyIndexMap = ConcurrentMap<Long, ReplyIndex>()
|
||||||
|
|
||||||
|
/** 供发言工具按编号查找被引用的历史消息 */
|
||||||
|
internal fun lookupReplyTarget(subjectId: Long, index: Int): MessageRecord? =
|
||||||
|
replyIndexMap[subjectId]?.get(index)
|
||||||
|
|
||||||
|
private val shortTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
.withZone(ZoneOffset.systemDefault())
|
||||||
|
|
||||||
|
// 同一发言者连续消息默认省略时间以节省上下文;但间隔超过此阈值(秒)时仍补回时间,
|
||||||
|
// 避免模型把刚发的续行消息误判为很久以前发生。
|
||||||
|
private const val CONTINUATION_TIME_GAP_SECONDS = 60L
|
||||||
|
|
||||||
private suspend fun onMessage(event: MessageEvent) {
|
private suspend fun onMessage(event: MessageEvent) {
|
||||||
// 检查Token是否设置
|
// 检查Token是否设置
|
||||||
if (LargeLanguageModels.chat == null) return
|
if (LargeLanguageModels.chat == null) return
|
||||||
@@ -229,6 +276,12 @@ object JChatGPT : KotlinPlugin(
|
|||||||
} else memoryText
|
} else memoryText
|
||||||
}
|
}
|
||||||
|
|
||||||
|
replace("{skills}") {
|
||||||
|
if (PluginConfig.skillsEnabled) {
|
||||||
|
SkillStore.buildIndexPrompt()
|
||||||
|
} else "暂无技能"
|
||||||
|
}
|
||||||
|
|
||||||
replace("{meme}") {
|
replace("{meme}") {
|
||||||
memePrompt?.let { return@replace it }
|
memePrompt?.let { return@replace it }
|
||||||
|
|
||||||
@@ -316,43 +369,67 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 构造历史消息
|
// 构造历史消息
|
||||||
val historyText = StringBuilder()
|
val historyText = StringBuilder()
|
||||||
var lastId = 0L
|
var lastId = 0L
|
||||||
|
var lastTime = 0L
|
||||||
|
// 本轮回复索引,逐条登记消息编号供 [n] 引用
|
||||||
|
val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() }
|
||||||
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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine("## 近期群消息(更早已隐藏)")
|
historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
||||||
for (record in history) {
|
for (record in history) {
|
||||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||||
appendGroupMessageRecord(historyText, record, event, lastId != record.fromId)
|
val showSender = lastId != record.fromId
|
||||||
|
val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS
|
||||||
|
appendGroupMessageRecord(historyText, record, event, replyIndex, showSender, showTime)
|
||||||
lastId = record.fromId
|
lastId = record.fromId
|
||||||
|
lastTime = record.time.toLong()
|
||||||
}
|
}
|
||||||
} 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine("## 近期对话(更早已隐藏)")
|
historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
||||||
for (record in history) {
|
for (record in history) {
|
||||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||||
appendMessageRecord(historyText, record, event, lastId != record.fromId)
|
val showSender = lastId != record.fromId
|
||||||
|
val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS
|
||||||
|
appendMessageRecord(historyText, record, event, replyIndex, showSender, showTime)
|
||||||
lastId = record.fromId
|
lastId = record.fromId
|
||||||
|
lastTime = record.time.toLong()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -369,45 +446,81 @@ object JChatGPT : KotlinPlugin(
|
|||||||
historyText: StringBuilder,
|
historyText: StringBuilder,
|
||||||
record: MessageRecord,
|
record: MessageRecord,
|
||||||
event: GroupMessageEvent,
|
event: GroupMessageEvent,
|
||||||
|
replyIndex: ReplyIndex,
|
||||||
showSender: Boolean,
|
showSender: Boolean,
|
||||||
|
showTime: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val index = replyIndex.add(record)
|
||||||
|
val recordMessage = record.toMessageChain()
|
||||||
|
|
||||||
|
historyText.append('[').append(index).append("] ")
|
||||||
if (showSender) {
|
if (showSender) {
|
||||||
// 名字前空行
|
// 新发言者:[n] 名称 时间
|
||||||
historyText.appendLine()
|
|
||||||
// 名称显示
|
|
||||||
if (event.bot.id == record.fromId) {
|
if (event.bot.id == record.fromId) {
|
||||||
historyText.append("**你** " + getNameCard(event.subject.botAsMember))
|
historyText.append("**你** ").append(getNameCard(event.subject.botAsMember))
|
||||||
} else {
|
} else {
|
||||||
historyText.append(getNameCard(event.subject, record.fromId))
|
historyText.append(getNameCard(event.subject, record.fromId))
|
||||||
}
|
}
|
||||||
// 发言时间
|
|
||||||
historyText.append(' ')
|
historyText.append(' ')
|
||||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
}
|
.append(' ')
|
||||||
|
} else {
|
||||||
|
// 同一发言者续行;间隔过久则补回时间,避免被误判为很久以前发生
|
||||||
val recordMessage = record.toMessageChain()
|
historyText.append(" └ ")
|
||||||
recordMessage[QuoteReply.Key]?.let {
|
if (showTime) {
|
||||||
historyText.append(" 引用 ${getNameCard(event.subject, it.source.fromId)} 说的\n > ")
|
historyText.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
.appendLine(it.source.originalMessage.content.replace("\n", "\n > "))
|
.append(' ')
|
||||||
}
|
|
||||||
|
|
||||||
if (showSender) {
|
|
||||||
// 消息内容
|
|
||||||
historyText.append(" 说:")
|
|
||||||
}
|
|
||||||
|
|
||||||
historyText.appendLine(record.toMessageChain().joinToString("") {
|
|
||||||
when (it) {
|
|
||||||
is At -> {
|
|
||||||
it.getDisplay(event.subject)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> singleMessageToText(it)
|
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
// 引用:用编号指针替代内联原文,避免被误认为是本人发言
|
||||||
|
recordMessage[QuoteReply.Key]?.let {
|
||||||
|
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化「引用回复」标记:被引用消息在窗口内时用 ↩[编号],否则内联简短原文并标注原作者。
|
||||||
|
*/
|
||||||
|
private fun appendQuoteMarker(
|
||||||
|
sb: StringBuilder,
|
||||||
|
quote: QuoteReply,
|
||||||
|
contact: Contact,
|
||||||
|
replyIndex: ReplyIndex
|
||||||
|
) {
|
||||||
|
val srcIds = quote.source.ids.joinToString(",")
|
||||||
|
val idx = replyIndex.indexOfIds(srcIds)
|
||||||
|
if (idx != null) {
|
||||||
|
sb.append("↩[").append(idx).append("] ")
|
||||||
|
} else {
|
||||||
|
val author = if (contact is Group) {
|
||||||
|
contact[quote.source.fromId]?.nameCardOrNick ?: "未知(${quote.source.fromId})"
|
||||||
|
} else {
|
||||||
|
quote.source.fromId.toString()
|
||||||
|
}
|
||||||
|
val snippet = quote.source.originalMessage
|
||||||
|
.joinToString("", transform = ::singleMessageToText)
|
||||||
|
.replace("\n", " ")
|
||||||
|
.let { if (it.length > 20) it.take(20) + "…" else it }
|
||||||
|
sb.append("↩(").append(author).append(":\"").append(snippet).append("\") ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 序列化消息正文(剔除引用/源元数据,@显示为名称,转发折叠)。
|
||||||
|
*/
|
||||||
|
private fun formatRecordContent(chain: MessageChain, contact: Contact): String =
|
||||||
|
chain.asSequence()
|
||||||
|
.filterNot { it is QuoteReply || it is MessageSource }
|
||||||
|
.joinToString("") {
|
||||||
|
when (it) {
|
||||||
|
is At -> if (contact is Group) it.getDisplay(contact) else it.content
|
||||||
|
else -> singleMessageToText(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun getNameCard(group: Group, qq: Long): String {
|
private fun getNameCard(group: Group, qq: Long): String {
|
||||||
val member = group[qq]
|
val member = group[qq]
|
||||||
return if (member == null) {
|
return if (member == null) {
|
||||||
@@ -427,42 +540,43 @@ object JChatGPT : KotlinPlugin(
|
|||||||
historyText: StringBuilder,
|
historyText: StringBuilder,
|
||||||
record: MessageRecord,
|
record: MessageRecord,
|
||||||
event: MessageEvent,
|
event: MessageEvent,
|
||||||
showSender: Boolean
|
replyIndex: ReplyIndex,
|
||||||
|
showSender: Boolean,
|
||||||
|
showTime: Boolean,
|
||||||
) {
|
) {
|
||||||
|
val index = replyIndex.add(record)
|
||||||
|
val recordMessage = record.toMessageChain()
|
||||||
|
|
||||||
|
historyText.append('[').append(index).append("] ")
|
||||||
if (showSender) {
|
if (showSender) {
|
||||||
if (event.bot.id == record.fromId) {
|
if (event.bot.id == record.fromId) {
|
||||||
historyText.append("**你** " + event.bot.nameCardOrNick)
|
historyText.append("**你** ").append(event.bot.nameCardOrNick)
|
||||||
} else {
|
} else {
|
||||||
historyText.append(event.senderName)
|
historyText.append(event.senderName)
|
||||||
}
|
}
|
||||||
historyText
|
historyText.append(' ')
|
||||||
.append(" ")
|
.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
// 发言时间
|
.append(' ')
|
||||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
} else {
|
||||||
|
// 同一发言者续行;间隔过久则补回时间,避免被误判为很久以前发生
|
||||||
|
historyText.append(" └ ")
|
||||||
|
if (showTime) {
|
||||||
|
historyText.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||||
|
.append(' ')
|
||||||
|
}
|
||||||
}
|
}
|
||||||
val recordMessage = record.toMessageChain()
|
|
||||||
recordMessage[QuoteReply.Key]?.let {
|
recordMessage[QuoteReply.Key]?.let {
|
||||||
historyText.append(" 引用\n > ")
|
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
||||||
.appendLine(
|
|
||||||
it.source.originalMessage
|
|
||||||
.joinToString("", transform = ::singleMessageToText)
|
|
||||||
.replace("\n", "\n > ")
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (showSender) {
|
|
||||||
historyText.append(" 说:")
|
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
||||||
}
|
|
||||||
// 消息内容
|
|
||||||
historyText.appendLine(
|
|
||||||
record.toMessageChain().joinToString("", transform = ::singleMessageToText)
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun singleMessageToText(it: SingleMessage): String {
|
private fun singleMessageToText(it: SingleMessage): String {
|
||||||
return when (it) {
|
return when (it) {
|
||||||
is ForwardMessage -> {
|
// 完整展开合并转发内容,便于 LLM 阅读分析转发的对话(依赖大上下文+缓存,不做截断)
|
||||||
it.title + "\n " + it.preview
|
is ForwardMessage -> formatForward(it, 1)
|
||||||
}
|
|
||||||
|
|
||||||
// 图片格式化
|
// 图片格式化
|
||||||
is Image -> {
|
is Image -> {
|
||||||
@@ -481,6 +595,32 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 递归展开合并转发消息,用 Markdown 引用块表示:每加深一层嵌套多一个 `>`(>、>>、>>>…)。
|
||||||
|
* @param depth 当前嵌套层级,从 1 开始
|
||||||
|
*/
|
||||||
|
private fun formatForward(forward: ForwardMessage, depth: Int): String = buildString {
|
||||||
|
val quote = ">".repeat(depth) + " "
|
||||||
|
append("[转发消息·").append(forward.nodeList.size).append("条")
|
||||||
|
if (forward.title.isNotEmpty()) append(':').append(forward.title)
|
||||||
|
append(']')
|
||||||
|
for (node in forward.nodeList) {
|
||||||
|
append('\n').append(quote)
|
||||||
|
.append(node.senderName).append(' ')
|
||||||
|
.append(shortTimeFormatter.format(Instant.ofEpochSecond(node.time.toLong())))
|
||||||
|
.append(": ")
|
||||||
|
node.messageChain.forEach { sub ->
|
||||||
|
if (sub is ForwardMessage) {
|
||||||
|
// 嵌套转发:层级加深,自带更深的 `>` 前缀,无需再次缩进
|
||||||
|
append(formatForward(sub, depth + 1))
|
||||||
|
} else {
|
||||||
|
// 其它内容:多行正文对齐到当前引用层级
|
||||||
|
append(singleMessageToText(sub).replace("\n", "\n$quote"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// endregion - 历史消息相关 -
|
// endregion - 历史消息相关 -
|
||||||
|
|
||||||
private val thinkRegex = Regex("<think>[\\s\\S]*?</think>")
|
private val thinkRegex = Regex("<think>[\\s\\S]*?</think>")
|
||||||
@@ -506,12 +646,16 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 尝试从缓存加载上下文
|
// 尝试从缓存加载上下文
|
||||||
val subjectId = event.subject.id
|
val subjectId = event.subject.id
|
||||||
val cache = contextCache[subjectId]
|
val cache = contextCache[subjectId]
|
||||||
val history = if (PluginConfig.enableContextCache
|
val reuseCache = PluginConfig.enableContextCache
|
||||||
&& cache != null
|
&& cache != null
|
||||||
&& !cache.isExpired(PluginConfig.contextCacheTimeoutMinutes * 60)
|
&& !cache.isExpired(PluginConfig.contextCacheTimeoutMinutes * 60)
|
||||||
) {
|
// 回复索引与对话上下文同寿命:复用缓存时沿用旧索引,保证 LLM 看到的 [n] 编号连续不串号;
|
||||||
|
// 否则新建(供 sendSingleMessage 的 replyTo 按编号引用历史消息)
|
||||||
|
val replyIndex = if (reuseCache) cache!!.replyIndex else ReplyIndex()
|
||||||
|
replyIndexMap[subjectId] = replyIndex
|
||||||
|
val history = if (reuseCache) {
|
||||||
// 缓存有效,复用历史
|
// 缓存有效,复用历史
|
||||||
logger.info("使用缓存的对话上下文,包含 ${cache.history.size} 条互动消息")
|
logger.info("使用缓存的对话上下文,包含 ${cache!!.history.size} 条互动消息")
|
||||||
cache.history
|
cache.history
|
||||||
} else {
|
} else {
|
||||||
// 缓存无效或不存在,创建新上下文
|
// 缓存无效或不存在,创建新上下文
|
||||||
@@ -547,6 +691,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 +699,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 +774,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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理最后一个工具调用
|
// 处理最后一个工具调用
|
||||||
@@ -689,7 +846,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
if (PluginConfig.enableContextCache) {
|
if (PluginConfig.enableContextCache) {
|
||||||
contextCache[subjectId] = ConversationCache(
|
contextCache[subjectId] = ConversationCache(
|
||||||
history = history,
|
history = history,
|
||||||
lastActivityAt = startedAt
|
lastActivityAt = startedAt,
|
||||||
|
replyIndex = replyIndex
|
||||||
)
|
)
|
||||||
logger.debug("已保存对话上下文到缓存")
|
logger.debug("已保存对话上下文到缓存")
|
||||||
}
|
}
|
||||||
@@ -708,6 +866,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
logger.warning(ex)
|
logger.warning(ex)
|
||||||
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
||||||
} finally {
|
} finally {
|
||||||
|
// 清理本轮回复索引
|
||||||
|
replyIndexMap.remove(event.subject.id)
|
||||||
// 一段时间后才允许再次提问,防止高频对话
|
// 一段时间后才允许再次提问,防止高频对话
|
||||||
launch {
|
launch {
|
||||||
delay(500.milliseconds)
|
delay(500.milliseconds)
|
||||||
@@ -799,6 +959,18 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 记忆修改
|
// 记忆修改
|
||||||
MemoryReplace(),
|
MemoryReplace(),
|
||||||
|
|
||||||
|
// 技能:加载
|
||||||
|
LoadSkill(),
|
||||||
|
|
||||||
|
// 技能:沉淀/迭代
|
||||||
|
SaveSkill(),
|
||||||
|
|
||||||
|
// 技能:删除
|
||||||
|
DeleteSkill(),
|
||||||
|
|
||||||
|
// 搜索聊天历史
|
||||||
|
SearchChatHistory(),
|
||||||
|
|
||||||
// 网页搜索
|
// 网页搜索
|
||||||
WebSearch(),
|
WebSearch(),
|
||||||
|
|
||||||
@@ -814,8 +986,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 视觉代理
|
// 视觉代理
|
||||||
VisualAgent(),
|
VisualAgent(),
|
||||||
|
|
||||||
// 图像编辑模型
|
// 图像生成与编辑
|
||||||
ImageEdit(),
|
ImageAgent(),
|
||||||
|
|
||||||
// 天气服务
|
// 天气服务
|
||||||
WeatherService(),
|
WeatherService(),
|
||||||
@@ -873,21 +1045,29 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun getNameCard(member: Member): String {
|
private fun getNameCard(member: Member): String {
|
||||||
val nameCard = StringBuilder()
|
val nameCard = StringBuilder("【")
|
||||||
// 群活跃等级
|
// 群活跃等级:active 依赖 OneBot 拉取群荣誉数据,繁忙/失败时会抛 "Error code: 2",
|
||||||
nameCard.append("【lv").append(member.active.temperature).append(" ")
|
// 必须兜底,否则整次回复都会因取名片失败而中断。
|
||||||
|
try {
|
||||||
|
nameCard.append("lv").append(member.active.temperature).append(' ')
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
logger.warning("获取群活跃等级失败", e)
|
||||||
|
}
|
||||||
|
// 真实群身份:始终按实际权限显示,不会被专属头衔覆盖
|
||||||
|
nameCard.append(
|
||||||
|
when (member.permission) {
|
||||||
|
OWNER -> "群主"
|
||||||
|
ADMINISTRATOR -> "管理员"
|
||||||
|
MEMBER -> "群员"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
// 头衔:有专属头衔则显示专属头衔(群主可任意赋予,可能与真实身份不符,故标注"头衔"以区分),
|
||||||
|
// 否则回退到聊天窗口可见的活跃等级称号
|
||||||
try {
|
try {
|
||||||
// 群头衔
|
|
||||||
if (member.specialTitle.isNotEmpty()) {
|
if (member.specialTitle.isNotEmpty()) {
|
||||||
nameCard.append(member.specialTitle)
|
nameCard.append(" 头衔\"").append(member.specialTitle).append('"')
|
||||||
} else {
|
} else if (member.temperatureTitle.isNotEmpty()) {
|
||||||
nameCard.append(
|
nameCard.append(' ').append(member.temperatureTitle)
|
||||||
when (member.permission) {
|
|
||||||
OWNER -> "群主"
|
|
||||||
ADMINISTRATOR -> "管理员"
|
|
||||||
MEMBER -> member.temperatureTitle
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
logger.warning("获取群头衔失败", e)
|
logger.warning("获取群头衔失败", e)
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
115
src/main/kotlin/ModelService.kt
Normal file
115
src/main/kotlin/ModelService.kt
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
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) {
|
||||||
|
// 流式响应的「首 token」与「token 间隔」超时统一由应用层 withTimeout 管控(见 chatCompletions)。
|
||||||
|
// 这里特意不设 requestTimeoutMillis:否则正常但耗时较长的流式输出会被 Ktor 在中途整体掐断。
|
||||||
|
// socket 超时作为字节级兜底,连接超时只覆盖 TCP 握手。
|
||||||
|
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() 自身阻塞在等待响应的阶段,
|
||||||
|
// 因此必须把 post() 连同首个 data 块的读取一起包进 withTimeout。
|
||||||
|
// 否则首 token 超时永远不会触发(post() 还没返回,根本进不到读取循环),
|
||||||
|
// 只能落到 Ktor 的兜底超时(很久)后再重试,表现为「等很久才报异常」。
|
||||||
|
// channel 在 withTimeout 外层持有:哪怕首块读取在 withTimeout 内超时,
|
||||||
|
// 只要 response.body() 已拿到通道,finally 也能释放它,避免慢速 API 重试时连接泄漏。
|
||||||
|
var channel: ByteReadChannel? = null
|
||||||
|
try {
|
||||||
|
val firstDataLine = withTimeout(firstChunkTimeout) {
|
||||||
|
val response = 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val ch: ByteReadChannel = response.body()
|
||||||
|
channel = ch
|
||||||
|
var found: String? = null
|
||||||
|
while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
|
||||||
|
val line = ch.readUTF8Line() ?: continue
|
||||||
|
if (line.startsWith("data: ")) {
|
||||||
|
found = line
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 心跳/空行/注释行,不计为首块,继续等
|
||||||
|
}
|
||||||
|
found
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstDataLine != null && !firstDataLine.startsWith("data: [DONE]")) {
|
||||||
|
emit(json.decodeFromString(firstDataLine.removePrefix("data: ")))
|
||||||
|
|
||||||
|
val ch = channel!!
|
||||||
|
while (currentCoroutineContext().isActive && !ch.isClosedForRead) {
|
||||||
|
// 流式期间同样对每次读取设「token 间隔」超时,避免中途卡死后干等兜底超时,
|
||||||
|
// 从而能快速失败并交给上层重试。正常流式 token 间隔远小于 firstChunkTimeout。
|
||||||
|
val line = withTimeout(firstChunkTimeout) { ch.readUTF8Line() } ?: continue
|
||||||
|
when {
|
||||||
|
line.startsWith("data: [DONE]") -> break
|
||||||
|
line.startsWith("data: ") -> {
|
||||||
|
emit(json.decodeFromString(line.removePrefix("data: ")))
|
||||||
|
}
|
||||||
|
else -> continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
channel?.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,10 +10,7 @@ import net.mamoe.mirai.contact.Group
|
|||||||
import net.mamoe.mirai.contact.Member
|
import net.mamoe.mirai.contact.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"
|
||||||
@@ -24,9 +21,24 @@ object PluginCommands : CompositeCommand(
|
|||||||
PluginConfig.reload()
|
PluginConfig.reload()
|
||||||
PluginData.reload()
|
PluginData.reload()
|
||||||
LargeLanguageModels.reload()
|
LargeLanguageModels.reload()
|
||||||
|
SkillStore.reload()
|
||||||
sendMessage("OK")
|
sendMessage("OK")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubCommand
|
||||||
|
suspend fun CommandSender.skills() {
|
||||||
|
val all = SkillStore.all
|
||||||
|
if (all.isEmpty()) {
|
||||||
|
sendMessage("暂无技能")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val response = buildString {
|
||||||
|
appendLine("当前技能(共 ${all.size} 个):")
|
||||||
|
all.forEach { appendLine("- ${it.name}: ${it.description}") }
|
||||||
|
}
|
||||||
|
sendMessage(response.trim())
|
||||||
|
}
|
||||||
|
|
||||||
@SubCommand
|
@SubCommand
|
||||||
suspend fun CommandSender.enable(contact: Contact) {
|
suspend fun CommandSender.enable(contact: Contact) {
|
||||||
when (contact) {
|
when (contact) {
|
||||||
@@ -81,29 +93,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 +121,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 +160,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 +171,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 +198,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 +227,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 +254,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 +268,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 +285,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 +317,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()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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("你是一个乐于助人的助手")
|
||||||
@@ -108,6 +123,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
|||||||
@ValueDescription("是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆")
|
@ValueDescription("是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆")
|
||||||
val memoryEnabled by value(true)
|
val memoryEnabled by value(true)
|
||||||
|
|
||||||
|
@ValueDescription("是否启用技能系统,技能存在data/skills目录(全局跨群),提示词中需要加上{skills}来注入技能索引")
|
||||||
|
val skillsEnabled by value(true)
|
||||||
|
|
||||||
@ValueDescription("是否启用好感度系统")
|
@ValueDescription("是否启用好感度系统")
|
||||||
val enableFavorabilitySystem by value(true)
|
val enableFavorabilitySystem by value(true)
|
||||||
|
|
||||||
@@ -122,4 +140,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)
|
||||||
}
|
}
|
||||||
@@ -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>())
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 添加对话记忆
|
* 添加对话记忆
|
||||||
*/
|
*/
|
||||||
|
|||||||
169
src/main/kotlin/SkillStore.kt
Normal file
169
src/main/kotlin/SkillStore.kt
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
package top.jie65535.mirai
|
||||||
|
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 技能元信息:用于索引展示,不含正文。
|
||||||
|
* @param name 技能名(kebab-case,同时是文件名),唯一
|
||||||
|
* @param description 一句话简介,决定 bot 何时按需加载
|
||||||
|
*/
|
||||||
|
data class SkillMeta(
|
||||||
|
val name: String,
|
||||||
|
val description: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 技能存储。每个技能 = 一个带 frontmatter 的 markdown 文件,放在 data/skills/ 下,全局跨群共享。
|
||||||
|
*
|
||||||
|
* 文件格式:
|
||||||
|
* ```
|
||||||
|
* ---
|
||||||
|
* name: kubejs-basics
|
||||||
|
* description: KubeJS 基础语法、常见报错与排查方法
|
||||||
|
* ---
|
||||||
|
*
|
||||||
|
* (正文:沉淀下来的知识/经验/提示词)
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* 索引(name + description)常驻系统提示词,正文按需通过 loadSkill 工具加载,
|
||||||
|
* 以此实现"低上下文污染 + 可自我沉淀迭代"。
|
||||||
|
*/
|
||||||
|
object SkillStore {
|
||||||
|
private lateinit var dir: File
|
||||||
|
|
||||||
|
/** 内存索引缓存,key 为技能名。仅缓存元信息,正文每次按需读盘。 */
|
||||||
|
private val index = linkedMapOf<String, SkillMeta>()
|
||||||
|
|
||||||
|
/** 合法技能名:字母数字、下划线、连字符,防止路径穿越。 */
|
||||||
|
private val nameRegex = Regex("^[A-Za-z0-9_-]+$")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 onEnable 中调用一次,传入插件数据目录。随后可通过 [reload] 刷新。
|
||||||
|
*/
|
||||||
|
fun init(dataFolder: File) {
|
||||||
|
dir = File(dataFolder, "skills")
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 重新扫描技能目录,重建内存索引。/jgpt reload 时调用。 */
|
||||||
|
@Synchronized
|
||||||
|
fun reload() {
|
||||||
|
if (!::dir.isInitialized) return
|
||||||
|
index.clear()
|
||||||
|
if (!dir.exists()) {
|
||||||
|
dir.mkdirs()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dir.listFiles { f -> f.isFile && f.extension.equals("md", ignoreCase = true) }
|
||||||
|
?.sortedBy { it.name }
|
||||||
|
?.forEach { file ->
|
||||||
|
try {
|
||||||
|
val (meta, _) = parse(file.readText())
|
||||||
|
val name = file.nameWithoutExtension
|
||||||
|
val desc = meta["description"].orEmpty()
|
||||||
|
index[name] = SkillMeta(name, desc)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// 单个文件解析失败不影响其它技能加载
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 当前所有技能元信息。 */
|
||||||
|
val all: List<SkillMeta> @Synchronized get() = index.values.toList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建注入到系统提示词 {skills} 占位符的索引文本,仅含 name + description。
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun buildIndexPrompt(): String {
|
||||||
|
if (index.isEmpty()) return "暂无技能"
|
||||||
|
return index.values.joinToString("\n") { "- ${it.name}: ${it.description}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取技能正文(不含 frontmatter)。技能不存在返回 null。
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun load(name: String): String? {
|
||||||
|
if (!isValidName(name)) return null
|
||||||
|
val file = File(dir, "$name.md")
|
||||||
|
if (!file.exists()) return null
|
||||||
|
return try {
|
||||||
|
parse(file.readText()).second
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增或整篇覆盖一个技能(upsert)。迭代即"读全文→改→整篇写回"。
|
||||||
|
* @return 失败时返回错误信息,成功返回 null
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun save(name: String, description: String, content: String): String? {
|
||||||
|
if (!isValidName(name)) {
|
||||||
|
return "技能名非法,只能包含字母、数字、下划线、连字符:$name"
|
||||||
|
}
|
||||||
|
if (!::dir.isInitialized) return "技能目录未初始化"
|
||||||
|
if (!dir.exists()) dir.mkdirs()
|
||||||
|
val file = File(dir, "$name.md")
|
||||||
|
val safeDesc = description.replace('\n', ' ').trim()
|
||||||
|
val text = buildString {
|
||||||
|
appendLine("---")
|
||||||
|
appendLine("name: $name")
|
||||||
|
appendLine("description: $safeDesc")
|
||||||
|
appendLine("---")
|
||||||
|
appendLine()
|
||||||
|
append(content.trim())
|
||||||
|
appendLine()
|
||||||
|
}
|
||||||
|
return try {
|
||||||
|
file.writeText(text)
|
||||||
|
index[name] = SkillMeta(name, safeDesc)
|
||||||
|
null
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"写入技能文件失败:${e.message}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除一个技能。
|
||||||
|
* @return 是否删除成功(技能不存在返回 false)
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun delete(name: String): Boolean {
|
||||||
|
if (!isValidName(name)) return false
|
||||||
|
val file = File(dir, "$name.md")
|
||||||
|
index.remove(name)
|
||||||
|
return if (file.exists()) file.delete() else false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isValidName(name: String): Boolean = nameRegex.matches(name)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 frontmatter。返回 (元信息键值对, 正文)。
|
||||||
|
* 无 frontmatter 时元信息为空,正文为全文。
|
||||||
|
*/
|
||||||
|
private fun parse(raw: String): Pair<Map<String, String>, String> {
|
||||||
|
val text = raw.replace("\r\n", "\n")
|
||||||
|
if (!text.startsWith("---")) {
|
||||||
|
return emptyMap<String, String>() to text.trim()
|
||||||
|
}
|
||||||
|
val lines = text.split("\n")
|
||||||
|
// 第一行是 ---,找到下一处 --- 作为 frontmatter 结束
|
||||||
|
val endIdx = (1 until lines.size).firstOrNull { lines[it].trim() == "---" }
|
||||||
|
?: return emptyMap<String, String>() to text.trim()
|
||||||
|
val meta = mutableMapOf<String, String>()
|
||||||
|
for (i in 1 until endIdx) {
|
||||||
|
val line = lines[i]
|
||||||
|
val sep = line.indexOf(':')
|
||||||
|
if (sep > 0) {
|
||||||
|
val key = line.substring(0, sep).trim()
|
||||||
|
val value = line.substring(sep + 1).trim()
|
||||||
|
meta[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val body = lines.subList(endIdx + 1, lines.size).joinToString("\n").trim()
|
||||||
|
return meta to body
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/main/kotlin/TokenUsageStore.kt
Normal file
113
src/main/kotlin/TokenUsageStore.kt
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package top.jie65535.mirai
|
||||||
|
|
||||||
|
import kotlinx.serialization.builtins.ListSerializer
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.io.File
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token使用日聚合存储。独立于 mamoe 的 plugin data 系统,直接管 JSON 文件,
|
||||||
|
* 避免 yamlkt 在大数据量下编/解码不互通的 bug。
|
||||||
|
*/
|
||||||
|
object TokenUsageStore {
|
||||||
|
private val json = Json {
|
||||||
|
prettyPrint = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
private val dateFmt = DateTimeFormatter.ISO_LOCAL_DATE
|
||||||
|
private val listSerializer = ListSerializer(TokenUsageDailyRecord.serializer())
|
||||||
|
|
||||||
|
private lateinit var file: File
|
||||||
|
private val records = mutableListOf<TokenUsageDailyRecord>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 onEnable 中调用一次,传入插件数据目录。
|
||||||
|
*/
|
||||||
|
fun init(dataFolder: File) {
|
||||||
|
file = File(dataFolder, "token_usage.json")
|
||||||
|
records.clear()
|
||||||
|
if (file.exists() && file.length() > 0) {
|
||||||
|
try {
|
||||||
|
records.addAll(json.decodeFromString(listSerializer, file.readText()))
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// 加载失败不阻塞插件启动,备份原文件后从空开始
|
||||||
|
val backup = File(file.parentFile, "token_usage.json.broken-${System.currentTimeMillis()}")
|
||||||
|
file.copyTo(backup, overwrite = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val all: List<TokenUsageDailyRecord> get() = records
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将一次调用的 token 用量累加到当日聚合行;若不存在则创建。写盘失败不抛。
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun record(
|
||||||
|
timestamp: Long,
|
||||||
|
userId: Long,
|
||||||
|
userNickname: String,
|
||||||
|
groupId: Long?,
|
||||||
|
promptTokens: Int,
|
||||||
|
completionTokens: Int,
|
||||||
|
totalTokens: Int
|
||||||
|
) {
|
||||||
|
val date = LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault())
|
||||||
|
.format(dateFmt)
|
||||||
|
val nickname = sanitizeNickname(userNickname)
|
||||||
|
val idx = records.indexOfFirst {
|
||||||
|
it.date == date && it.userId == userId && it.groupId == groupId
|
||||||
|
}
|
||||||
|
if (idx >= 0) {
|
||||||
|
val r = records[idx]
|
||||||
|
records[idx] = r.copy(
|
||||||
|
userNickname = nickname.ifEmpty { r.userNickname },
|
||||||
|
promptTokens = r.promptTokens + promptTokens,
|
||||||
|
completionTokens = r.completionTokens + completionTokens,
|
||||||
|
totalTokens = r.totalTokens + totalTokens,
|
||||||
|
callCount = r.callCount + 1
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
records.add(
|
||||||
|
TokenUsageDailyRecord(
|
||||||
|
date = date,
|
||||||
|
userId = userId,
|
||||||
|
userNickname = nickname,
|
||||||
|
groupId = groupId,
|
||||||
|
promptTokens = promptTokens.toLong(),
|
||||||
|
completionTokens = completionTokens.toLong(),
|
||||||
|
totalTokens = totalTokens.toLong(),
|
||||||
|
callCount = 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把控制字符压成空格,避免昵称里的换行/零宽字符把 JSON/展示弄乱。 */
|
||||||
|
private fun sanitizeNickname(s: String): String {
|
||||||
|
if (s.isEmpty()) return s
|
||||||
|
val cleaned = buildString(s.length) {
|
||||||
|
for (c in s) {
|
||||||
|
if (c == ' ' || (!c.isISOControl() && c.category != CharCategory.FORMAT)) append(c)
|
||||||
|
else append(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleaned.trim().replace(Regex(" {2,}"), " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun save() {
|
||||||
|
try {
|
||||||
|
val tmp = File(file.parentFile, "${file.name}.tmp")
|
||||||
|
tmp.writeText(json.encodeToString(listSerializer, records))
|
||||||
|
tmp.copyTo(file, overwrite = true)
|
||||||
|
tmp.delete()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// 写盘失败由日志/上层关心,这里不抛断对话流程
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package top.jie65535.mirai.tools
|
|||||||
|
|
||||||
import com.aallam.openai.api.chat.Tool
|
import com.aallam.openai.api.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}")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
50
src/main/kotlin/tools/DeleteSkill.kt
Normal file
50
src/main/kotlin/tools/DeleteSkill.kt
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package top.jie65535.mirai.tools
|
||||||
|
|
||||||
|
import com.aallam.openai.api.chat.Tool
|
||||||
|
import com.aallam.openai.api.core.Parameters
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.add
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
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.PluginConfig
|
||||||
|
import top.jie65535.mirai.SkillStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除一个过时或失效的技能。
|
||||||
|
*/
|
||||||
|
class DeleteSkill : BaseAgent(
|
||||||
|
tool = Tool.function(
|
||||||
|
name = "deleteSkill",
|
||||||
|
description = "删除一个已过时或失效的技能。",
|
||||||
|
parameters = Parameters.buildJsonObject {
|
||||||
|
put("type", "object")
|
||||||
|
putJsonObject("properties") {
|
||||||
|
putJsonObject("name") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "要删除的技能名")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putJsonArray("required") {
|
||||||
|
add("name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
override val isEnabled: Boolean
|
||||||
|
get() = PluginConfig.skillsEnabled
|
||||||
|
|
||||||
|
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||||
|
requireNotNull(args)
|
||||||
|
val name = args.getValue("name").jsonPrimitive.content
|
||||||
|
JChatGPT.logger.info("Delete skill: \"$name\"")
|
||||||
|
return if (SkillStore.delete(name)) {
|
||||||
|
"OK,技能 \"$name\" 已删除。"
|
||||||
|
} else {
|
||||||
|
"技能 \"$name\" 不存在。"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,9 +107,9 @@ 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和查询参数,因为下载地址存在鉴权:"
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
JChatGPT.logger.error("图像编辑结果解析异常", e)
|
JChatGPT.logger.error("图像生成结果解析异常", e)
|
||||||
responseJson
|
responseJson
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
50
src/main/kotlin/tools/LoadSkill.kt
Normal file
50
src/main/kotlin/tools/LoadSkill.kt
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package top.jie65535.mirai.tools
|
||||||
|
|
||||||
|
import com.aallam.openai.api.chat.Tool
|
||||||
|
import com.aallam.openai.api.core.Parameters
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.add
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
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.PluginConfig
|
||||||
|
import top.jie65535.mirai.SkillStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按需加载某个技能的正文进上下文。技能索引(name+简介)常驻系统提示词,
|
||||||
|
* 当话题命中某技能时调用本工具读取其完整内容。
|
||||||
|
*/
|
||||||
|
class LoadSkill : BaseAgent(
|
||||||
|
tool = Tool.function(
|
||||||
|
name = "loadSkill",
|
||||||
|
description = "当话题命中某个技能时,加载该技能的完整内容到上下文。可用技能见系统提示词中的技能索引。",
|
||||||
|
parameters = Parameters.buildJsonObject {
|
||||||
|
put("type", "object")
|
||||||
|
putJsonObject("properties") {
|
||||||
|
putJsonObject("name") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "技能名(技能索引中的 name)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putJsonArray("required") {
|
||||||
|
add("name")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
override val isEnabled: Boolean
|
||||||
|
get() = PluginConfig.skillsEnabled
|
||||||
|
|
||||||
|
override val loadingMessage: String = "翻阅资料中..."
|
||||||
|
|
||||||
|
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||||
|
requireNotNull(args)
|
||||||
|
val name = args.getValue("name").jsonPrimitive.content
|
||||||
|
JChatGPT.logger.info("Load skill: \"$name\"")
|
||||||
|
val content = SkillStore.load(name)
|
||||||
|
return content ?: "技能 \"$name\" 不存在,可用技能见系统提示词中的技能索引。"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 -> "推理出错,结果为空"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
62
src/main/kotlin/tools/SaveSkill.kt
Normal file
62
src/main/kotlin/tools/SaveSkill.kt
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package top.jie65535.mirai.tools
|
||||||
|
|
||||||
|
import com.aallam.openai.api.chat.Tool
|
||||||
|
import com.aallam.openai.api.core.Parameters
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.add
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
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.PluginConfig
|
||||||
|
import top.jie65535.mirai.SkillStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增或整篇覆盖一个技能(全局,跨群共享)。
|
||||||
|
* 用于把群里学到/被纠正的知识沉淀下来;迭代时先用 loadSkill 读全文,改好后整篇写回。
|
||||||
|
*/
|
||||||
|
class SaveSkill : BaseAgent(
|
||||||
|
tool = Tool.function(
|
||||||
|
name = "saveSkill",
|
||||||
|
description = "沉淀或更新一个技能(知识文档),全局跨群共享。新增直接写;迭代时先 loadSkill 读全文,修改后整篇写回。技能名相同则覆盖。",
|
||||||
|
parameters = Parameters.buildJsonObject {
|
||||||
|
put("type", "object")
|
||||||
|
putJsonObject("properties") {
|
||||||
|
putJsonObject("name") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "技能名,kebab-case,只能含字母/数字/下划线/连字符,如 kubejs-basics。相同则覆盖")
|
||||||
|
}
|
||||||
|
putJsonObject("description") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "一句话简介,会常驻技能索引,决定你以后何时加载它")
|
||||||
|
}
|
||||||
|
putJsonObject("content") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "技能正文(markdown),沉淀的知识、经验或提示词。整篇内容,会覆盖旧版本")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
putJsonArray("required") {
|
||||||
|
add("name")
|
||||||
|
add("description")
|
||||||
|
add("content")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
override val isEnabled: Boolean
|
||||||
|
get() = PluginConfig.skillsEnabled
|
||||||
|
|
||||||
|
override val loadingMessage: String = "记下来了..."
|
||||||
|
|
||||||
|
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||||
|
requireNotNull(args)
|
||||||
|
val name = args.getValue("name").jsonPrimitive.content
|
||||||
|
val description = args.getValue("description").jsonPrimitive.content
|
||||||
|
val content = args.getValue("content").jsonPrimitive.content
|
||||||
|
JChatGPT.logger.info("Save skill: \"$name\" - \"$description\"")
|
||||||
|
val error = SkillStore.save(name, description, content)
|
||||||
|
return error ?: "OK,技能 \"$name\" 已保存。"
|
||||||
|
}
|
||||||
|
}
|
||||||
214
src/main/kotlin/tools/SearchChatHistory.kt
Normal file
214
src/main/kotlin/tools/SearchChatHistory.kt
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package top.jie65535.mirai.tools
|
||||||
|
|
||||||
|
import com.aallam.openai.api.chat.Tool
|
||||||
|
import com.aallam.openai.api.core.Parameters
|
||||||
|
import kotlinx.serialization.json.*
|
||||||
|
import net.mamoe.mirai.contact.Group
|
||||||
|
import net.mamoe.mirai.contact.nameCardOrNick
|
||||||
|
import net.mamoe.mirai.event.events.GroupMessageEvent
|
||||||
|
import net.mamoe.mirai.event.events.MessageEvent
|
||||||
|
import net.mamoe.mirai.message.data.Image
|
||||||
|
import net.mamoe.mirai.message.data.Image.Key.queryUrl
|
||||||
|
import net.mamoe.mirai.message.data.SingleMessage
|
||||||
|
import net.mamoe.mirai.message.data.content
|
||||||
|
import top.jie65535.mirai.JChatGPT
|
||||||
|
import top.jie65535.mirai.PluginConfig
|
||||||
|
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder
|
||||||
|
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord
|
||||||
|
import java.time.Instant
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
|
||||||
|
class SearchChatHistory : BaseAgent(
|
||||||
|
tool = Tool.function(
|
||||||
|
name = "searchChatHistory",
|
||||||
|
description = "搜索群聊消息历史,可按关键词、发送者、时间范围筛选。用于回溯之前的讨论、查找某人说过的话、统计话题等。" +
|
||||||
|
"不指定时间范围时默认搜索最近30天。指定时间时范围不能超过30天,如需更长跨度可分多次查询。" +
|
||||||
|
"可以通过多轮搜索来实现找到某条消息的上下文。",
|
||||||
|
parameters = Parameters.buildJsonObject {
|
||||||
|
put("type", "object")
|
||||||
|
putJsonObject("properties") {
|
||||||
|
putJsonObject("keyword") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "消息内容关键词,人名请用sender")
|
||||||
|
}
|
||||||
|
putJsonObject("sender") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "发送者名称或QQ号,查找某人的发言")
|
||||||
|
}
|
||||||
|
putJsonObject("startTime") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "起始时间,格式:yyyy-MM-dd HH:mm,不填则默认为7天前")
|
||||||
|
}
|
||||||
|
putJsonObject("endTime") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "结束时间,格式同上,不填则默认到当前时间")
|
||||||
|
}
|
||||||
|
putJsonObject("limit") {
|
||||||
|
put("type", "integer")
|
||||||
|
put("description", "返回消息数量上限,默认20,最大200")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
override val isEnabled: Boolean
|
||||||
|
get() = JChatGPT.includeHistory
|
||||||
|
|
||||||
|
override val loadingMessage: String
|
||||||
|
get() = "搜索聊天记录中..."
|
||||||
|
|
||||||
|
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||||
|
requireNotNull(args)
|
||||||
|
|
||||||
|
val keyword = args["keyword"]?.jsonPrimitive?.contentOrNull
|
||||||
|
val sender = args["sender"]?.jsonPrimitive?.contentOrNull
|
||||||
|
|
||||||
|
val maxDays = PluginConfig.searchHistoryMaxDays
|
||||||
|
val now = OffsetDateTime.now()
|
||||||
|
|
||||||
|
val startTime = args["startTime"]?.jsonPrimitive?.contentOrNull?.let {
|
||||||
|
parseTime(it) ?: return "startTime 格式错误,请使用 yyyy-MM-dd HH:mm"
|
||||||
|
} ?: now.minusDays(maxDays.toLong())
|
||||||
|
|
||||||
|
val endTime = args["endTime"]?.jsonPrimitive?.contentOrNull?.let {
|
||||||
|
parseTime(it) ?: return "endTime 格式错误,请使用 yyyy-MM-dd HH:mm"
|
||||||
|
} ?: now
|
||||||
|
|
||||||
|
if (startTime >= endTime) {
|
||||||
|
return "起始时间必须早于结束时间"
|
||||||
|
}
|
||||||
|
|
||||||
|
if (java.time.Duration.between(startTime, endTime).toDays() > maxDays) {
|
||||||
|
return "搜索时间范围不能超过 ${maxDays}天,请缩小范围后重试"
|
||||||
|
}
|
||||||
|
|
||||||
|
val senderQq = resolveSenderQq(sender, event)
|
||||||
|
val startEpoch = startTime.toEpochSecond().toInt()
|
||||||
|
val endEpoch = endTime.toEpochSecond().toInt()
|
||||||
|
val maxRecords = PluginConfig.searchHistoryMaxRecords
|
||||||
|
|
||||||
|
val records = try {
|
||||||
|
// 有 sender 时用 Member 重载,在数据库层过滤 fromId;否则用 Contact 重载
|
||||||
|
if (senderQq != null && event is GroupMessageEvent) {
|
||||||
|
val member = event.group[senderQq]
|
||||||
|
if (member != null) {
|
||||||
|
MiraiHibernateRecorder[member, startEpoch, endEpoch]
|
||||||
|
} else {
|
||||||
|
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
|
||||||
|
}.take(maxRecords).sortedBy { it.time }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
JChatGPT.logger.warning("查询消息历史失败", e)
|
||||||
|
return "查询消息历史失败: ${e.message}"
|
||||||
|
}
|
||||||
|
|
||||||
|
var filtered = records
|
||||||
|
|
||||||
|
// 消息内容在数据库中是序列化存储的,关键词只能在内存中过滤
|
||||||
|
if (keyword != null) {
|
||||||
|
filtered = filtered.filter {
|
||||||
|
it.toMessageChain().content.contains(keyword, ignoreCase = true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtered.isEmpty()) {
|
||||||
|
return "未找到匹配的聊天记录"
|
||||||
|
}
|
||||||
|
|
||||||
|
val limit = args["limit"]?.jsonPrimitive?.intOrNull?.coerceIn(1, 200) ?: 20
|
||||||
|
val total = filtered.size
|
||||||
|
val result = filtered.takeLast(limit)
|
||||||
|
|
||||||
|
return buildString {
|
||||||
|
appendLine("找到 $total 条匹配记录,显示最近 ${result.size} 条:")
|
||||||
|
appendLine()
|
||||||
|
appendHistory(this, result, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 sender 解析为 QQ 号,优先尝试纯数字,再尝试群成员名称匹配
|
||||||
|
*/
|
||||||
|
private fun resolveSenderQq(sender: String?, event: MessageEvent): Long? {
|
||||||
|
if (sender == null) return null
|
||||||
|
sender.toLongOrNull()?.let { return it }
|
||||||
|
if (event is GroupMessageEvent) {
|
||||||
|
return event.group.members.firstOrNull {
|
||||||
|
it.nameCardOrNick.contains(sender, ignoreCase = true)
|
||||||
|
}?.id
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun appendHistory(
|
||||||
|
sb: StringBuilder,
|
||||||
|
records: List<MessageRecord>,
|
||||||
|
event: MessageEvent
|
||||||
|
) {
|
||||||
|
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
|
var lastFromId = 0L
|
||||||
|
|
||||||
|
for (record in records) {
|
||||||
|
val showSender = lastFromId != record.fromId
|
||||||
|
if (showSender) {
|
||||||
|
sb.appendLine()
|
||||||
|
if (event is GroupMessageEvent) {
|
||||||
|
if (event.bot.id == record.fromId) {
|
||||||
|
sb.append("**你** ").append(event.bot.nameCardOrNick)
|
||||||
|
} else {
|
||||||
|
sb.append(getNameCard(event.group, record.fromId))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append(" ")
|
||||||
|
.append(timeFormatter.format(
|
||||||
|
Instant.ofEpochSecond(record.time.toLong()).atZone(ZoneId.systemDefault())
|
||||||
|
))
|
||||||
|
.append(":")
|
||||||
|
}
|
||||||
|
for (msg in record.toMessageChain()) {
|
||||||
|
sb.append(singleMessageToText(msg))
|
||||||
|
}
|
||||||
|
sb.appendLine()
|
||||||
|
lastFromId = record.fromId
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun singleMessageToText(msg: SingleMessage): String {
|
||||||
|
return when (msg) {
|
||||||
|
is Image -> {
|
||||||
|
try {
|
||||||
|
val url = msg.queryUrl()
|
||||||
|
""
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
msg.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> msg.content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getNameCard(group: Group, qq: Long): String {
|
||||||
|
val member = group[qq]
|
||||||
|
return member?.nameCardOrNick ?: "未知群员($qq)"
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
|
||||||
|
|
||||||
|
fun parseTime(text: String): OffsetDateTime? {
|
||||||
|
return try {
|
||||||
|
LocalDateTime.parse(text, timeFormatter)
|
||||||
|
.atZone(ZoneId.systemDefault())
|
||||||
|
.toOffsetDateTime()
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ 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.*
|
import kotlinx.serialization.json.*
|
||||||
import net.mamoe.mirai.event.events.MessageEvent
|
import net.mamoe.mirai.event.events.MessageEvent
|
||||||
|
import net.mamoe.mirai.message.data.Message
|
||||||
|
import net.mamoe.mirai.message.data.QuoteReply
|
||||||
import top.jie65535.mirai.JChatGPT
|
import top.jie65535.mirai.JChatGPT
|
||||||
|
|
||||||
class SendSingleMessageAgent : BaseAgent(
|
class SendSingleMessageAgent : BaseAgent(
|
||||||
@@ -17,6 +19,10 @@ class SendSingleMessageAgent : BaseAgent(
|
|||||||
put("type", "string")
|
put("type", "string")
|
||||||
put("description", "消息内容")
|
put("description", "消息内容")
|
||||||
}
|
}
|
||||||
|
putJsonObject("replyTo") {
|
||||||
|
put("type", "integer")
|
||||||
|
put("description", "可选。要引用回复的历史消息编号(即历史记录中每行行首的[n])。不需要回复具体某条消息时省略此参数。")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
putJsonArray("required") {
|
putJsonArray("required") {
|
||||||
add("content")
|
add("content")
|
||||||
@@ -27,7 +33,28 @@ class SendSingleMessageAgent : BaseAgent(
|
|||||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||||
requireNotNull(args)
|
requireNotNull(args)
|
||||||
val content = args.getValue("content").jsonPrimitive.content
|
val content = args.getValue("content").jsonPrimitive.content
|
||||||
event.subject.sendMessage(JChatGPT.toMessage(event.subject, content))
|
val replyTo = args["replyTo"]?.jsonPrimitive?.intOrNull
|
||||||
return "OK"
|
|
||||||
|
val baseMsg = JChatGPT.toMessage(event.subject, content)
|
||||||
|
var note = ""
|
||||||
|
val message: Message = if (replyTo != null) {
|
||||||
|
val record = JChatGPT.lookupReplyTarget(event.subject.id, replyTo)
|
||||||
|
val source = try {
|
||||||
|
record?.toMessageSource()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (source != null) {
|
||||||
|
QuoteReply(source) + baseMsg
|
||||||
|
} else {
|
||||||
|
note = "(编号${replyTo}对应的消息已失效,未能引用,已直接发送)"
|
||||||
|
baseMsg
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
baseMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
event.subject.sendMessage(message)
|
||||||
|
return "OK$note"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user