Compare commits

..

20 Commits

Author SHA1 Message Date
cfc61c52ba Re-attach timestamp on continuation lines after a time gap
Consecutive messages from the same sender are collapsed under one
name+time header to save context, with follow-ups rendered as bare
"└" continuation lines. When a sender resumed minutes later, the
follow-up still inherited the original timestamp, so the model judged a
just-sent message as having happened long ago and skipped replying.

Track the previous record time and re-print the timestamp on a
continuation line once the gap exceeds CONTINUATION_TIME_GAP_SECONDS
(60s). Short bursts stay timeless to keep context lean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:24:21 +08:00
ebb1fbe10c Guard group active-level fetch in name card serialization
member.active.temperature relies on OneBot pulling group honor data,
which throws "Error code: 2" when the upstream is busy/unavailable. That
access sat outside the existing title try/catch, so a failure propagated
through getSystemPrompt -> onMessage and aborted the entire reply.

Wrap the active.temperature access in its own try/catch and degrade
gracefully (omit the lv segment) instead of breaking the whole turn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-21 20:15:34 +08:00
a29cf17361 Release ByteReadChannel when first-chunk timeout fires
The streaming refactor moved post()+body() inside withTimeout, so a
first-chunk timeout threw before `channel` was bound and the finally
guard never ran, leaking the connection on every slow-API retry. Hold
the channel in an outer nullable var and wrap the whole flow in
try/finally so an acquired channel is always cancelled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:56:11 +08:00
4307019ee8 Add global skill system for self-accumulated knowledge
Introduce a cross-group skill system that lets the bot distill reusable
knowledge into markdown docs and load them on demand, keeping day-to-day
context pollution low.

- SkillStore manages data/skills/*.md files with name/description
  frontmatter and an in-memory index cache (rebuilt on init/reload)
- Only the skill index (name + one-line description) is injected via the
  new {skills} system-prompt placeholder; bodies load on demand
- New tools: loadSkill / saveSkill (upsert, iterate = load+overwrite) /
  deleteSkill, gated by PluginConfig.skillsEnabled
- Skill names validated against ^[A-Za-z0-9_-]+$ to prevent traversal
- Wire SkillStore.init into onEnable and refresh on /jgpt reload; add
  /jgpt skills listing command
- Bump version to 1.12.0; update README

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:37:44 +08:00
538fe563a0 Expand forwarded messages into context with nested support
Forwarded messages were collapsed to a placeholder, so the LLM couldn't
read a forwarded conversation a user asked it to look at. Now forwards
are fully expanded (no truncation, relying on the large context window
and cache hits) as Markdown blockquotes, with each nesting level adding
another ">". Nested forwards recurse by depth for clean, unambiguous
indentation; node bodies (including images) render inline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 23:03:26 +08:00
890ccb10d5 Separate real group role from title in serialized name card
A custom special title (e.g. a troll "群主" given to a regular member)
used to override the permission-based role, misleading the LLM about
who actually holds authority. Now the real role (群主/管理员/群员) from
member.permission is always shown, with the title slot showing the
special title (labeled 头衔"…") when present, otherwise falling back to
the activity-level temperatureTitle that everyone sees in chat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:57:52 +08:00
d2bdd273b2 Add reply-to-message capability with ID-addressable history
Lets the bot quote-reply to a specific message via an optional replyTo
on sendSingleMessage, and reworks history serialization so the LLM can
address messages and stop confusing quoted content with the quoter.

- Serialize each history line with a short [n] id; consecutive messages
  from the same sender continue under "[n]  └". A per-subject ReplyIndex
  maps [n] -> MessageRecord, kept alive with the context cache so ids
  stay continuous across cached turns.
- Replace inlined quote text with a reference: "↩[k]" when the quoted
  message is in-window, otherwise "↩(author:"snippet…")". This removes
  the ambiguity where A quoting B looked like A's own speech.
- Collapse forwarded messages to "[转发消息·N条:title]".
- sendSingleMessage accepts replyTo (the [n]); it resolves the record via
  MessageRecord.toMessageSource() and prepends a QuoteReply, falling back
  to a plain send with a note if the source is gone. ids may be null, so
  numbering still happens but such records can't be reply targets.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:46:59 +08:00
eedbd55f62 Fix streaming first-token timeout never firing on slow API
The firstChunkTimeout only wrapped the response-body read, but when the
upstream (e.g. DeepSeek under load) stalls before sending response
headers, httpClient.post() itself blocks and the withTimeout block is
never reached. Every slow request fell through to Ktor's
requestTimeoutMillis (120s) and was retried up to retryMax times,
causing multi-minute waits before any reply.

- Move post() inside withTimeout(firstChunkTimeout) so the entire
  request-to-first-data-chunk window is bounded and fails fast.
- Apply withTimeout(firstChunkTimeout) to each streaming read so a
  mid-stream stall is also caught quickly instead of waiting on the
  socket/request backstop.
- Drop requestTimeoutMillis so legitimately long streams are no longer
  killed at 120s; TTFT and inter-token gaps are now governed at the
  application layer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 22:01:44 +08:00
92acbb2310 Upgrade TTS to qwen3-tts-instruct-flash with instruction control
Adds an optional instructions parameter to sendVoiceMessage so the model
can describe tone, pace and emotion in natural language. Defaults the
TTS model to qwen3-tts-instruct-flash; Chelsie voice is unchanged.

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

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

1
.gitignore vendored
View File

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

163
README.md
View File

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

View File

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

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
恢复 data.yml把 tokenUsageDailyRecords 抽出成 token_usage.json
顺手清理 tokenUsageRecords把 data.yml 重写成合法的、yamlkt 能读回的 JSON。
用法(在 data.yml 所在目录运行):
python3 recover_data_yml.py /path/to/top.jie65535.mirai.JChatGPT/
会做:
1. 备份原 data.yml -> data.yml.bak-<timestamp>
2. 读 data.yml按 JSON 解析,目前文件就是 JSON-flow YAML
3. 把 tokenUsageDailyRecords 写到 token_usage.json
4. 删除 tokenUsageRecords 和 tokenUsageDailyRecords 字段
5. 重写 data.yml保留 contactMemory / userFavorability 等)
"""
import json
import os
import sys
import time
def main(target_dir: str) -> int:
data_path = os.path.join(target_dir, "data.yml")
if not os.path.exists(data_path):
print(f"NOT FOUND: {data_path}", file=sys.stderr)
return 1
with open(data_path, "r", encoding="utf-8") as f:
text = f.read()
try:
data = json.loads(text)
except json.JSONDecodeError as e:
print(f"data.yml 不是合法 JSON{e}", file=sys.stderr)
print("如果文件其实是 block-style YAML请先用 yq/python yaml 转换", file=sys.stderr)
return 2
if not isinstance(data, dict):
print(f"顶层不是 map{type(data).__name__}", file=sys.stderr)
return 3
ts = int(time.time())
backup_path = os.path.join(target_dir, f"data.yml.bak-{ts}")
with open(backup_path, "w", encoding="utf-8") as f:
f.write(text)
print(f"已备份 -> {backup_path}")
daily_records = data.pop("tokenUsageDailyRecords", [])
raw_records = data.pop("tokenUsageRecords", [])
print(f"提取 tokenUsageDailyRecords: {len(daily_records)}")
print(f"丢弃 tokenUsageRecords (legacy): {len(raw_records)}")
token_path = os.path.join(target_dir, "token_usage.json")
if os.path.exists(token_path):
token_backup = os.path.join(target_dir, f"token_usage.json.bak-{ts}")
os.rename(token_path, token_backup)
print(f"已备份现有 token_usage.json -> {token_backup}")
with open(token_path, "w", encoding="utf-8") as f:
json.dump(daily_records, f, ensure_ascii=False, indent=2)
print(f"写入 -> {token_path} ({len(daily_records)} 条)")
with open(data_path, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
print(f"重写 -> {data_path}(剩余字段: {list(data.keys())}")
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__, file=sys.stderr)
sys.exit(1)
sys.exit(main(sys.argv[1]))

View File

@@ -52,7 +52,7 @@ object JChatGPT : KotlinPlugin(
JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT",
version = "1.10.0",
version = "1.12.0",
) {
author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
@@ -61,7 +61,7 @@ object JChatGPT : KotlinPlugin(
/**
* 是否包含历史对话
*/
private var includeHistory: Boolean = false
internal var includeHistory: Boolean = false
/**
* 聊天权限
@@ -79,6 +79,12 @@ object JChatGPT : KotlinPlugin(
PluginConfig.reload()
PluginData.reload()
// 初始化 token 使用日聚合存储(独立 JSON 文件,绕开 yamlkt 大数据 bug
TokenUsageStore.init(dataFolder)
// 初始化技能存储data/skills/ 下的 markdown 文件,全局跨群)
SkillStore.init(dataFolder)
// 设置Token
LargeLanguageModels.reload()
@@ -113,8 +119,6 @@ object JChatGPT : KotlinPlugin(
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 requestMap = ConcurrentSet<Long>()
@@ -138,13 +142,56 @@ object JChatGPT : KotlinPlugin(
*/
private data class ConversationCache(
val history: MutableList<ChatMessage>,
val lastActivityAt: Int
val lastActivityAt: Int,
val replyIndex: ReplyIndex
) {
fun isExpired(ttlSeconds: Int): Boolean {
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) {
// 检查Token是否设置
if (LargeLanguageModels.chat == null) return
@@ -229,6 +276,12 @@ object JChatGPT : KotlinPlugin(
} else memoryText
}
replace("{skills}") {
if (PluginConfig.skillsEnabled) {
SkillStore.buildIndexPrompt()
} else "暂无技能"
}
replace("{meme}") {
memePrompt?.let { return@replace it }
@@ -316,43 +369,67 @@ object JChatGPT : KotlinPlugin(
// 构造历史消息
val historyText = StringBuilder()
var lastId = 0L
var lastTime = 0L
// 本轮回复索引,逐条登记消息编号供 [n] 引用
val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() }
if (event is GroupMessageEvent) {
if (PluginConfig.enableFavorabilitySystem) {
val favorabilityInfos = history.map { it.fromId }
val knownUsers = history.asSequence()
.map { it.fromId }
.filter { it != event.bot.id }
.distinct()
.mapNotNull { PluginData.userFavorability[it] }
if (favorabilityInfos.isNotEmpty()) {
historyText.appendLine("## 相关成员的好感信息")
for (info in favorabilityInfos) {
historyText.append(getNameCard(event.group, info.userId)).append('\t')
.appendLine(info).appendLine()
.filter { it.name.isNotEmpty() || it.tags.isNotEmpty() || it.impression.isNotEmpty() }
.sortedBy { it.userId }
.toList()
if (knownUsers.isNotEmpty()) {
historyText.appendLine("【你认识的群友】")
for (info in knownUsers) {
val displayName = if (info.name.isNotEmpty()) info.name
else getNameCard(event.subject, info.userId)
historyText.append("- ").append(displayName)
.append("(${info.userId})")
.append(" 好感度${if (info.value >= 0) "+" else ""}${info.value}")
if (info.tags.isNotEmpty()) historyText.append(" [${info.tags.joinToString(", ")}]")
if (info.impression.isNotEmpty()) historyText.append(" ${info.impression}")
historyText.appendLine()
}
historyText.appendLine("---").appendLine()
historyText.appendLine()
}
}
historyText.appendLine("## 近期群消息(更早已隐藏)")
historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号,可用于引用回复")
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
lastTime = record.time.toLong()
}
} else {
if (PluginConfig.enableFavorabilitySystem) {
val favorabilityInfo = PluginData.userFavorability[event.sender.id]
if (favorabilityInfo != null) {
historyText.append("你对\"").append(event.senderName).append("\"的好感信息如下: ")
.appendLine(favorabilityInfo).appendLine()
historyText.appendLine("---").appendLine()
if (favorabilityInfo != null && (favorabilityInfo.name.isNotEmpty() || favorabilityInfo.tags.isNotEmpty() || favorabilityInfo.impression.isNotEmpty())) {
val displayName = if (favorabilityInfo.name.isNotEmpty()) favorabilityInfo.name else event.senderName
historyText.appendLine("【你认识的对方】")
historyText.append("- ").append(displayName)
.append("(${event.sender.id})")
.append(" 好感度${if (favorabilityInfo.value >= 0) "+" else ""}${favorabilityInfo.value}")
if (favorabilityInfo.tags.isNotEmpty()) historyText.append(" [${favorabilityInfo.tags.joinToString(", ")}]")
if (favorabilityInfo.impression.isNotEmpty()) historyText.append(" ${favorabilityInfo.impression}")
historyText.appendLine().appendLine()
}
}
historyText.appendLine("## 近期对话(更早已隐藏)")
historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号,可用于引用回复")
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
lastTime = record.time.toLong()
}
}
@@ -369,45 +446,81 @@ object JChatGPT : KotlinPlugin(
historyText: StringBuilder,
record: MessageRecord,
event: GroupMessageEvent,
replyIndex: ReplyIndex,
showSender: Boolean,
showTime: Boolean,
) {
val index = replyIndex.add(record)
val recordMessage = record.toMessageChain()
historyText.append('[').append(index).append("] ")
if (showSender) {
// 名字前空行
historyText.appendLine()
// 名称显示
// 新发言者:[n] 名称 时间
if (event.bot.id == record.fromId) {
historyText.append("**你** " + getNameCard(event.subject.botAsMember))
historyText.append("**你** ").append(getNameCard(event.subject.botAsMember))
} else {
historyText.append(getNameCard(event.subject, record.fromId))
}
// 发言时间
historyText.append(' ')
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
}
val recordMessage = record.toMessageChain()
recordMessage[QuoteReply.Key]?.let {
historyText.append(" 引用 ${getNameCard(event.subject, it.source.fromId)} 说的\n > ")
.appendLine(it.source.originalMessage.content.replace("\n", "\n > "))
}
if (showSender) {
// 消息内容
historyText.append(" 说:")
}
historyText.appendLine(record.toMessageChain().joinToString("") {
when (it) {
is At -> {
it.getDisplay(event.subject)
}
else -> singleMessageToText(it)
.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
.append(' ')
} else {
// 同一发言者续行;间隔过久则补回时间,避免被误判为很久以前发生
historyText.append("")
if (showTime) {
historyText.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
.append(' ')
}
})
}
// 引用:用编号指针替代内联原文,避免被误认为是本人发言
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 {
val member = group[qq]
return if (member == null) {
@@ -427,42 +540,43 @@ object JChatGPT : KotlinPlugin(
historyText: StringBuilder,
record: MessageRecord,
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 (event.bot.id == record.fromId) {
historyText.append("**你** " + event.bot.nameCardOrNick)
historyText.append("**你** ").append(event.bot.nameCardOrNick)
} else {
historyText.append(event.senderName)
}
historyText
.append(" ")
// 发言时间
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
historyText.append(' ')
.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
.append(' ')
} else {
// 同一发言者续行;间隔过久则补回时间,避免被误判为很久以前发生
historyText.append("")
if (showTime) {
historyText.append(shortTimeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
.append(' ')
}
}
val recordMessage = record.toMessageChain()
recordMessage[QuoteReply.Key]?.let {
historyText.append(" 引用\n > ")
.appendLine(
it.source.originalMessage
.joinToString("", transform = ::singleMessageToText)
.replace("\n", "\n > ")
)
appendQuoteMarker(historyText, it, event.subject, replyIndex)
}
if (showSender) {
historyText.append(" 说:")
}
// 消息内容
historyText.appendLine(
record.toMessageChain().joinToString("", transform = ::singleMessageToText)
)
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
}
private fun singleMessageToText(it: SingleMessage): String {
return when (it) {
is ForwardMessage -> {
it.title + "\n " + it.preview
}
// 完整展开合并转发内容,便于 LLM 阅读分析转发的对话(依赖大上下文+缓存,不做截断)
is ForwardMessage -> formatForward(it, 1)
// 图片格式化
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 - 历史消息相关 -
private val thinkRegex = Regex("<think>[\\s\\S]*?</think>")
@@ -506,12 +646,16 @@ object JChatGPT : KotlinPlugin(
// 尝试从缓存加载上下文
val subjectId = event.subject.id
val cache = contextCache[subjectId]
val history = if (PluginConfig.enableContextCache
val reuseCache = PluginConfig.enableContextCache
&& cache != null
&& !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
} else {
// 缓存无效或不存在,创建新上下文
@@ -547,6 +691,7 @@ object JChatGPT : KotlinPlugin(
val startedAt = OffsetDateTime.now().toEpochSecond().toInt()
val responseFlow = chatCompletions(history)
var responseMessageBuilder: StringBuilder? = null
var reasoningContentBuilder: StringBuilder? = null
val responseToolCalls = mutableListOf<ToolCall.Function>()
val toolCallTasks = mutableListOf<Deferred<ChatMessage>>()
var lastTokenUsage: Usage? = null
@@ -554,6 +699,15 @@ object JChatGPT : KotlinPlugin(
responseFlow.collect { chunk ->
val delta = chunk.choices[0].delta ?: return@collect
// 处理推理内容更新
if (delta.reasoningContent != null) {
if (reasoningContentBuilder == null) {
reasoningContentBuilder = StringBuilder(delta.reasoningContent)
} else {
reasoningContentBuilder.append(delta.reasoningContent)
}
}
// 处理内容更新
if (delta.content != null) {
if (responseMessageBuilder == null) {
@@ -620,28 +774,31 @@ object JChatGPT : KotlinPlugin(
val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim()
logger.info("LLM Response: $responseContent")
// 记录AI回答
// reasoning_content仅在工具调用时需要回传DeepSeek规范否则丢弃
// toolCalls空列表转null避免序列化为"tool_calls":[]导致DeepSeek V4报400
// explicitNulls=false确保null字段不会序列化到JSON中兼容所有API
history.add(
ChatMessage.Assistant(
ChatMessage(
role = ChatRole.Assistant,
content = responseContent,
toolCalls = responseToolCalls
toolCalls = responseToolCalls.ifEmpty { null },
reasoningContent = if (responseToolCalls.isNotEmpty()) reasoningContentBuilder?.toString() else null
)
)
// 记录token使用量
// 记录token使用量按日聚合独立JSON文件
lastTokenUsage?.let { usage ->
val now = OffsetDateTime.now().toEpochSecond()
val groupId = if (event is GroupMessageEvent) event.subject.id else null
val record = TokenUsageRecord(
TokenUsageStore.record(
timestamp = now,
userId = event.sender.id,
userNickname = event.senderName,
groupId = groupId,
model = PluginConfig.chatModel,
promptTokens = usage.promptTokens ?: 0,
completionTokens = usage.completionTokens ?: 0,
totalTokens = usage.totalTokens ?: 0
)
PluginData.tokenUsageRecords.add(record)
}
// 处理最后一个工具调用
@@ -689,7 +846,8 @@ object JChatGPT : KotlinPlugin(
if (PluginConfig.enableContextCache) {
contextCache[subjectId] = ConversationCache(
history = history,
lastActivityAt = startedAt
lastActivityAt = startedAt,
replyIndex = replyIndex
)
logger.debug("已保存对话上下文到缓存")
}
@@ -708,6 +866,8 @@ object JChatGPT : KotlinPlugin(
logger.warning(ex)
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
} finally {
// 清理本轮回复索引
replyIndexMap.remove(event.subject.id)
// 一段时间后才允许再次提问,防止高频对话
launch {
delay(500.milliseconds)
@@ -799,6 +959,18 @@ object JChatGPT : KotlinPlugin(
// 记忆修改
MemoryReplace(),
// 技能:加载
LoadSkill(),
// 技能:沉淀/迭代
SaveSkill(),
// 技能:删除
DeleteSkill(),
// 搜索聊天历史
SearchChatHistory(),
// 网页搜索
WebSearch(),
@@ -814,8 +986,8 @@ object JChatGPT : KotlinPlugin(
// 视觉代理
VisualAgent(),
// 图像编辑模型
ImageEdit(),
// 图像生成与编辑
ImageAgent(),
// 天气服务
WeatherService(),
@@ -873,21 +1045,29 @@ object JChatGPT : KotlinPlugin(
}
private fun getNameCard(member: Member): String {
val nameCard = StringBuilder()
// 群活跃等级
nameCard.append("【lv").append(member.active.temperature).append(" ")
val nameCard = StringBuilder("")
// 群活跃等级active 依赖 OneBot 拉取群荣誉数据,繁忙/失败时会抛 "Error code: 2"
// 必须兜底,否则整次回复都会因取名片失败而中断。
try {
nameCard.append("lv").append(member.active.temperature).append(' ')
} catch (e: Throwable) {
logger.warning("获取群活跃等级失败", e)
}
// 真实群身份:始终按实际权限显示,不会被专属头衔覆盖
nameCard.append(
when (member.permission) {
OWNER -> "群主"
ADMINISTRATOR -> "管理员"
MEMBER -> "群员"
}
)
// 头衔:有专属头衔则显示专属头衔(群主可任意赋予,可能与真实身份不符,故标注"头衔"以区分),
// 否则回退到聊天窗口可见的活跃等级称号
try {
// 群头衔
if (member.specialTitle.isNotEmpty()) {
nameCard.append(member.specialTitle)
} else {
nameCard.append(
when (member.permission) {
OWNER -> "群主"
ADMINISTRATOR -> "管理员"
MEMBER -> member.temperatureTitle
}
)
nameCard.append(" 头衔\"").append(member.specialTitle).append('"')
} else if (member.temperatureTitle.isNotEmpty()) {
nameCard.append(' ').append(member.temperatureTitle)
}
} catch (e: Throwable) {
logger.warning("获取群头衔失败", e)

View File

@@ -1,14 +1,12 @@
package top.jie65535.mirai
import com.aallam.openai.api.http.Timeout
import com.aallam.openai.client.Chat
import com.aallam.openai.client.OpenAI
import com.aallam.openai.client.OpenAIHost
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonObject
import kotlin.time.Duration.Companion.milliseconds
object LargeLanguageModels {
/**
* 系统提示词
*/
@@ -18,46 +16,66 @@ object LargeLanguageModels {
/**
* 聊天助手
*/
var chat: Chat? = null
var chat: ModelService? = null
/**
* 推理模型
*/
var reasoning: Chat? = null
var reasoning: ModelService? = null
/**
* 视觉模型
*/
var visual: Chat? = null
var visual: ModelService? = null
private val json = Json {
isLenient = true
ignoreUnknownKeys = true
}
private fun parseExtraBody(raw: String): JsonObject? {
if (raw.isBlank()) return null
return try {
json.parseToJsonElement(raw).jsonObject
} catch (_: Exception) {
null
}
}
fun reload() {
// 载入超时时间
val timeout = PluginConfig.timeout.milliseconds
val firstChunkTimeout = PluginConfig.firstChunkTimeout.milliseconds
// 初始化聊天模型
if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) {
chat = OpenAI(
chat = ModelService(
baseUrl = PluginConfig.openAiApi,
token = PluginConfig.openAiToken,
host = OpenAIHost(baseUrl = PluginConfig.openAiApi),
timeout = Timeout(request = timeout, connect = timeout, socket = timeout)
timeout = timeout,
firstChunkTimeout = firstChunkTimeout,
extraBody = parseExtraBody(PluginConfig.chatModelExtraBody)
)
}
// 初始化推理模型
if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) {
reasoning = OpenAI(
reasoning = ModelService(
baseUrl = PluginConfig.reasoningModelApi,
token = PluginConfig.reasoningModelToken,
host = OpenAIHost(baseUrl = PluginConfig.reasoningModelApi),
timeout = Timeout(request = timeout, connect = timeout, socket = timeout)
timeout = timeout,
firstChunkTimeout = firstChunkTimeout,
extraBody = parseExtraBody(PluginConfig.reasoningModelExtraBody)
)
}
// 初始化视觉模型
if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) {
visual = OpenAI(
visual = ModelService(
baseUrl = PluginConfig.visualModelApi,
token = PluginConfig.visualModelToken,
host = OpenAIHost(baseUrl = PluginConfig.visualModelApi),
timeout = Timeout(request = timeout, connect = timeout, socket = timeout)
timeout = timeout,
firstChunkTimeout = firstChunkTimeout,
extraBody = parseExtraBody(PluginConfig.visualModelExtraBody)
)
}
@@ -78,4 +96,4 @@ object LargeLanguageModels {
}
}
}
}
}

View File

@@ -0,0 +1,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()
}
}
}
}

View File

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

View File

@@ -38,14 +38,26 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("视觉模型")
var visualModel: String by value("qwen-vl-plus")
@ValueDescription("聊天模型额外请求体JSON会合并到请求体中。例如DeepSeek关闭思维: {\"thinking\": {\"type\": \"disabled\"}}")
val chatModelExtraBody: String by value("")
@ValueDescription("推理模型额外请求体JSON会合并到请求体中。例如DeepSeek启用思维: {\"thinking\": {\"type\": \"enabled\"}}")
val reasoningModelExtraBody: String by value("")
@ValueDescription("视觉模型额外请求体JSON会合并到请求体中。")
val visualModelExtraBody: String by value("")
@ValueDescription("百炼平台API KEY")
val dashScopeApiKey: String by value("")
@ValueDescription("百炼平台图片编辑模型")
val imageEditModel: String by value("qwen-image-edit")
@ValueDescription("百炼平台图像模型支持文生图与图像编辑。可选qwen-image-2.0 / qwen-image-2.0-pro / qwen-image-edit-max / qwen-image-edit-plus 等")
val imageModel: String by value("qwen-image-2.0")
@ValueDescription("百炼平台TTS模型")
val ttsModel: String by value("qwen-tts")
@ValueDescription("是否在生成的图片右下角添加 Qwen-Image 水印")
val imageWatermark: Boolean by value(false)
@ValueDescription("百炼平台TTS模型。qwen3-tts-instruct-flash 支持 instructions 指令控制;纯发音可用 qwen3-tts-flash 或 qwen-tts")
val ttsModel: String by value("qwen3-tts-instruct-flash")
@ValueDescription("Jina API Key")
val jinaApiKey by value("")
@@ -68,9 +80,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。")
val temperaturePermission: Int by value(50)
@ValueDescription("等待响应超时时间单位毫秒默认60秒")
@ValueDescription("等待响应超时时间整个请求的总超时与socket读超时单位毫秒默认60秒")
val timeout: Long by value(60000L)
@ValueDescription("首块响应超时时间单位毫秒默认10秒。若连接建立后在此时间内没收到首块data:则中断走重试")
val firstChunkTimeout: Long by value(10000L)
@Deprecated("使用外部文件而不是在配置文件内保存提示词")
@ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改")
var prompt: String by value("你是一个乐于助人的助手")
@@ -108,6 +123,9 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("是否启用记忆编辑功能记忆存在data目录提示词中需要加上{memory}来填充记忆,每个群都有独立记忆")
val memoryEnabled by value(true)
@ValueDescription("是否启用技能系统技能存在data/skills目录全局跨群提示词中需要加上{skills}来注入技能索引")
val skillsEnabled by value(true)
@ValueDescription("是否启用好感度系统")
val enableFavorabilitySystem by value(true)
@@ -122,4 +140,10 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("单个工具调用返回内容的最大字符数,超过将被截断并标注")
val maxToolOutputLength: Int by value(15000)
@ValueDescription("聊天记录搜索最大天数")
val searchHistoryMaxDays: Int by value(30)
@ValueDescription("聊天记录搜索最大查询条数,防止内存溢出")
val searchHistoryMaxRecords: Int by value(5000)
}

View File

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

View File

@@ -0,0 +1,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
}
}

View File

@@ -0,0 +1,113 @@
package top.jie65535.mirai
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import java.io.File
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
/**
* Token使用日聚合存储。独立于 mamoe 的 plugin data 系统,直接管 JSON 文件,
* 避免 yamlkt 在大数据量下编/解码不互通的 bug。
*/
object TokenUsageStore {
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
encodeDefaults = true
}
private val dateFmt = DateTimeFormatter.ISO_LOCAL_DATE
private val listSerializer = ListSerializer(TokenUsageDailyRecord.serializer())
private lateinit var file: File
private val records = mutableListOf<TokenUsageDailyRecord>()
/**
* 在 onEnable 中调用一次,传入插件数据目录。
*/
fun init(dataFolder: File) {
file = File(dataFolder, "token_usage.json")
records.clear()
if (file.exists() && file.length() > 0) {
try {
records.addAll(json.decodeFromString(listSerializer, file.readText()))
} catch (_: Exception) {
// 加载失败不阻塞插件启动,备份原文件后从空开始
val backup = File(file.parentFile, "token_usage.json.broken-${System.currentTimeMillis()}")
file.copyTo(backup, overwrite = true)
}
}
}
val all: List<TokenUsageDailyRecord> get() = records
/**
* 将一次调用的 token 用量累加到当日聚合行;若不存在则创建。写盘失败不抛。
*/
@Synchronized
fun record(
timestamp: Long,
userId: Long,
userNickname: String,
groupId: Long?,
promptTokens: Int,
completionTokens: Int,
totalTokens: Int
) {
val date = LocalDate.ofInstant(Instant.ofEpochSecond(timestamp), ZoneId.systemDefault())
.format(dateFmt)
val nickname = sanitizeNickname(userNickname)
val idx = records.indexOfFirst {
it.date == date && it.userId == userId && it.groupId == groupId
}
if (idx >= 0) {
val r = records[idx]
records[idx] = r.copy(
userNickname = nickname.ifEmpty { r.userNickname },
promptTokens = r.promptTokens + promptTokens,
completionTokens = r.completionTokens + completionTokens,
totalTokens = r.totalTokens + totalTokens,
callCount = r.callCount + 1
)
} else {
records.add(
TokenUsageDailyRecord(
date = date,
userId = userId,
userNickname = nickname,
groupId = groupId,
promptTokens = promptTokens.toLong(),
completionTokens = completionTokens.toLong(),
totalTokens = totalTokens.toLong(),
callCount = 1
)
)
}
save()
}
/** 把控制字符压成空格,避免昵称里的换行/零宽字符把 JSON/展示弄乱。 */
private fun sanitizeNickname(s: String): String {
if (s.isEmpty()) return s
val cleaned = buildString(s.length) {
for (c in s) {
if (c == ' ' || (!c.isISOControl() && c.category != CharCategory.FORMAT)) append(c)
else append(' ')
}
}
return cleaned.trim().replace(Regex(" {2,}"), " ")
}
private fun save() {
try {
val tmp = File(file.parentFile, "${file.name}.tmp")
tmp.writeText(json.encodeToString(listSerializer, records))
tmp.copyTo(file, overwrite = true)
tmp.delete()
} catch (_: Exception) {
// 写盘失败由日志/上层关心,这里不抛断对话流程
}
}
}

View File

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

View File

@@ -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\" 不存在。"
}
}
}

View File

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

View File

@@ -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\" 不存在,可用技能见系统提示词中的技能索引。"
}
}

View File

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

View File

@@ -0,0 +1,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\" 已保存。"
}
}

View File

@@ -0,0 +1,214 @@
package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.*
import net.mamoe.mirai.contact.Group
import net.mamoe.mirai.contact.nameCardOrNick
import net.mamoe.mirai.event.events.GroupMessageEvent
import net.mamoe.mirai.event.events.MessageEvent
import net.mamoe.mirai.message.data.Image
import net.mamoe.mirai.message.data.Image.Key.queryUrl
import net.mamoe.mirai.message.data.SingleMessage
import net.mamoe.mirai.message.data.content
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException
class SearchChatHistory : BaseAgent(
tool = Tool.function(
name = "searchChatHistory",
description = "搜索群聊消息历史,可按关键词、发送者、时间范围筛选。用于回溯之前的讨论、查找某人说过的话、统计话题等。" +
"不指定时间范围时默认搜索最近30天。指定时间时范围不能超过30天如需更长跨度可分多次查询。" +
"可以通过多轮搜索来实现找到某条消息的上下文。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("keyword") {
put("type", "string")
put("description", "消息内容关键词人名请用sender")
}
putJsonObject("sender") {
put("type", "string")
put("description", "发送者名称或QQ号查找某人的发言")
}
putJsonObject("startTime") {
put("type", "string")
put("description", "起始时间格式yyyy-MM-dd HH:mm不填则默认为7天前")
}
putJsonObject("endTime") {
put("type", "string")
put("description", "结束时间,格式同上,不填则默认到当前时间")
}
putJsonObject("limit") {
put("type", "integer")
put("description", "返回消息数量上限默认20最大200")
}
}
}
)
) {
override val isEnabled: Boolean
get() = JChatGPT.includeHistory
override val loadingMessage: String
get() = "搜索聊天记录中..."
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val keyword = args["keyword"]?.jsonPrimitive?.contentOrNull
val sender = args["sender"]?.jsonPrimitive?.contentOrNull
val maxDays = PluginConfig.searchHistoryMaxDays
val now = OffsetDateTime.now()
val startTime = args["startTime"]?.jsonPrimitive?.contentOrNull?.let {
parseTime(it) ?: return "startTime 格式错误,请使用 yyyy-MM-dd HH:mm"
} ?: now.minusDays(maxDays.toLong())
val endTime = args["endTime"]?.jsonPrimitive?.contentOrNull?.let {
parseTime(it) ?: return "endTime 格式错误,请使用 yyyy-MM-dd HH:mm"
} ?: now
if (startTime >= endTime) {
return "起始时间必须早于结束时间"
}
if (java.time.Duration.between(startTime, endTime).toDays() > maxDays) {
return "搜索时间范围不能超过 ${maxDays}天,请缩小范围后重试"
}
val senderQq = resolveSenderQq(sender, event)
val startEpoch = startTime.toEpochSecond().toInt()
val endEpoch = endTime.toEpochSecond().toInt()
val maxRecords = PluginConfig.searchHistoryMaxRecords
val records = try {
// 有 sender 时用 Member 重载,在数据库层过滤 fromId否则用 Contact 重载
if (senderQq != null && event is GroupMessageEvent) {
val member = event.group[senderQq]
if (member != null) {
MiraiHibernateRecorder[member, startEpoch, endEpoch]
} else {
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
}
} else {
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
}.take(maxRecords).sortedBy { it.time }
} catch (e: Throwable) {
JChatGPT.logger.warning("查询消息历史失败", e)
return "查询消息历史失败: ${e.message}"
}
var filtered = records
// 消息内容在数据库中是序列化存储的,关键词只能在内存中过滤
if (keyword != null) {
filtered = filtered.filter {
it.toMessageChain().content.contains(keyword, ignoreCase = true)
}
}
if (filtered.isEmpty()) {
return "未找到匹配的聊天记录"
}
val limit = args["limit"]?.jsonPrimitive?.intOrNull?.coerceIn(1, 200) ?: 20
val total = filtered.size
val result = filtered.takeLast(limit)
return buildString {
appendLine("找到 $total 条匹配记录,显示最近 ${result.size} 条:")
appendLine()
appendHistory(this, result, event)
}
}
/**
* 将 sender 解析为 QQ 号,优先尝试纯数字,再尝试群成员名称匹配
*/
private fun resolveSenderQq(sender: String?, event: MessageEvent): Long? {
if (sender == null) return null
sender.toLongOrNull()?.let { return it }
if (event is GroupMessageEvent) {
return event.group.members.firstOrNull {
it.nameCardOrNick.contains(sender, ignoreCase = true)
}?.id
}
return null
}
private suspend fun appendHistory(
sb: StringBuilder,
records: List<MessageRecord>,
event: MessageEvent
) {
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
var lastFromId = 0L
for (record in records) {
val showSender = lastFromId != record.fromId
if (showSender) {
sb.appendLine()
if (event is GroupMessageEvent) {
if (event.bot.id == record.fromId) {
sb.append("**你** ").append(event.bot.nameCardOrNick)
} else {
sb.append(getNameCard(event.group, record.fromId))
}
}
sb.append(" ")
.append(timeFormatter.format(
Instant.ofEpochSecond(record.time.toLong()).atZone(ZoneId.systemDefault())
))
.append("")
}
for (msg in record.toMessageChain()) {
sb.append(singleMessageToText(msg))
}
sb.appendLine()
lastFromId = record.fromId
}
}
private suspend fun singleMessageToText(msg: SingleMessage): String {
return when (msg) {
is Image -> {
try {
val url = msg.queryUrl()
"![${if (msg.isEmoji) "表情包" else "图片"}]($url)"
} catch (_: Throwable) {
msg.content
}
}
else -> msg.content
}
}
private fun getNameCard(group: Group, qq: Long): String {
val member = group[qq]
return member?.nameCardOrNick ?: "未知群员($qq)"
}
companion object {
private val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm")
fun parseTime(text: String): OffsetDateTime? {
return try {
LocalDateTime.parse(text, timeFormatter)
.atZone(ZoneId.systemDefault())
.toOffsetDateTime()
} catch (_: DateTimeParseException) {
null
}
}
}
}

View File

@@ -4,6 +4,8 @@ import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import kotlinx.serialization.json.*
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
class SendSingleMessageAgent : BaseAgent(
@@ -17,6 +19,10 @@ class SendSingleMessageAgent : BaseAgent(
put("type", "string")
put("description", "消息内容")
}
putJsonObject("replyTo") {
put("type", "integer")
put("description", "可选。要引用回复的历史消息编号(即历史记录中每行行首的[n])。不需要回复具体某条消息时省略此参数。")
}
}
putJsonArray("required") {
add("content")
@@ -27,7 +33,28 @@ class SendSingleMessageAgent : BaseAgent(
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val content = args.getValue("content").jsonPrimitive.content
event.subject.sendMessage(JChatGPT.toMessage(event.subject, content))
return "OK"
val replyTo = args["replyTo"]?.jsonPrimitive?.intOrNull
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"
}
}
}

View File

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