mirror of
https://github.com/jie65535/JChatGPT.git
synced 2026-09-22 03:06:10 +08:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07d11c2b16 | ||
|
|
c328a798f7 | ||
|
|
a4f5bad322 | ||
|
|
7b5a83ba9c |
@@ -14,7 +14,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
|
|||||||
- **LaTeX 渲染**:自动将数学表达式渲染为图片
|
- **LaTeX 渲染**:自动将数学表达式渲染为图片
|
||||||
- **灵活的触发方式**:@机器人、关键字触发、回复消息等
|
- **灵活的触发方式**:@机器人、关键字触发、回复消息等
|
||||||
- **权限控制**:细粒度的权限管理系统
|
- **权限控制**:细粒度的权限管理系统
|
||||||
- **历史消息集成**:可选的历史消息上下文(需配合 mirai-hibernate-plugin)
|
- **内置历史消息**:使用插件自维护的 SQLite 保存、检索群聊和私聊消息
|
||||||
|
|
||||||
## 用法
|
## 用法
|
||||||
|
|
||||||
@@ -102,6 +102,10 @@ fallbackCooldownMinutes: 5
|
|||||||
reasoningModelExtraBody: ''
|
reasoningModelExtraBody: ''
|
||||||
# 视觉模型额外请求体JSON,会合并到请求体中。
|
# 视觉模型额外请求体JSON,会合并到请求体中。
|
||||||
visualModelExtraBody: ''
|
visualModelExtraBody: ''
|
||||||
|
# 是否先由机器人下载视觉图片并以Base64上传;建议保持开启,避免百炼下载QQ临时链接失败
|
||||||
|
visualImageBase64Enabled: true
|
||||||
|
# 视觉模型最大尝试次数,取值1~3;重试时复用已下载的图片
|
||||||
|
visualRetryMax: 2
|
||||||
# 百炼平台API KEY
|
# 百炼平台API KEY
|
||||||
dashScopeApiKey: ''
|
dashScopeApiKey: ''
|
||||||
# 百炼平台图像模型(文生图 + 图像编辑)
|
# 百炼平台图像模型(文生图 + 图像编辑)
|
||||||
@@ -116,6 +120,14 @@ jinaApiKey: ''
|
|||||||
searXngUrl: ''
|
searXngUrl: ''
|
||||||
# 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。
|
# 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。
|
||||||
glotToken: ''
|
glotToken: ''
|
||||||
|
# 和风天气专属 API Host,可在和风天气控制台的设置页面查看
|
||||||
|
qWeatherApiHost: ''
|
||||||
|
# 和风天气项目 ID
|
||||||
|
qWeatherProjectId: ''
|
||||||
|
# 和风天气 JWT 凭据 ID
|
||||||
|
qWeatherCredentialId: ''
|
||||||
|
# Ed25519 私钥路径,相对于插件配置目录,也可以填写绝对路径
|
||||||
|
qWeatherPrivateKeyPath: 'qweather-ed25519-private.pem'
|
||||||
# 群管理是否自动拥有对话权限,默认是
|
# 群管理是否自动拥有对话权限,默认是
|
||||||
groupOpHasChatPermission: true
|
groupOpHasChatPermission: true
|
||||||
# 好友是否自动拥有对话权限,默认是
|
# 好友是否自动拥有对话权限,默认是
|
||||||
@@ -160,6 +172,28 @@ searchHistoryMaxDays: 30
|
|||||||
searchHistoryMaxRecords: 5000
|
searchHistoryMaxRecords: 5000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
聊天记录保存在插件数据目录的 `chat-history.sqlite` 中,并使用 SQLite WAL 模式支持记录与查询并行进行。
|
||||||
|
数据库由插件在首次启动时自动创建和维护,无需安装额外的聊天记录插件。
|
||||||
|
|
||||||
|
### 和风天气
|
||||||
|
|
||||||
|
天气工具使用[和风天气开发服务](https://dev.qweather.com/docs/start/)和 JWT 凭据,简单配置流程如下:
|
||||||
|
|
||||||
|
1. 注册和风天气开发者帐号,在控制台创建项目,并在“设置”中查看专属 API Host。
|
||||||
|
2. 使用 OpenSSL 在本地生成 Ed25519 密钥:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
openssl genpkey -algorithm ED25519 -out qweather-ed25519-private.pem
|
||||||
|
openssl pkey -pubout -in qweather-ed25519-private.pem -out qweather-ed25519-public.pem
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 在项目中添加凭据,认证方式选择 `JSON Web Token`,上传公钥并勾选需要使用的天气、GeoAPI 和预警接口。
|
||||||
|
4. 将私钥放到插件配置目录,例如 `config/top.jie65535.mirai.JChatGPT/qweather-ed25519-private.pem`,然后在 `Config.yml` 填写 API Host、项目 ID、凭据 ID 和私钥路径。
|
||||||
|
|
||||||
|
私钥只保存在部署机器上,请勿上传或提交到 Git。配置不完整时天气工具不会启用,修改后执行 `/jgpt reload`。
|
||||||
|
|
||||||
|
天气工具支持实时天气、每日预报、逐小时预报、分钟级降水和官方天气预警。
|
||||||
|
|
||||||
## 系统提示词
|
## 系统提示词
|
||||||
|
|
||||||
JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件位于插件配置目录下的 `SystemPrompt.md` 文件中。
|
JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件位于插件配置目录下的 `SystemPrompt.md` 文件中。
|
||||||
@@ -286,6 +320,10 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型
|
|||||||
|
|
||||||
当然,也可以配置为使用其他兼容 OpenAI API 的模型,如 GPT 系列模型。
|
当然,也可以配置为使用其他兼容 OpenAI API 的模型,如 GPT 系列模型。
|
||||||
|
|
||||||
|
### 视觉图片传输
|
||||||
|
|
||||||
|
视觉工具支持单图和多图,默认由机器人下载后以 Base64 上传,长图会按顺序切片,避免百炼无法下载 QQ 临时图片链接。如兼容服务不支持 Base64,可将 `visualImageBase64Enabled` 设为 `false`。
|
||||||
|
|
||||||
## 接入点容灾
|
## 接入点容灾
|
||||||
|
|
||||||
聊天模型支持配置多个**备用接入点**,当主接入点连续调用失败(key 到期、用量超限、服务不稳定、超时等)时自动切换,提升可用性。
|
聊天模型支持配置多个**备用接入点**,当主接入点连续调用失败(key 到期、用量超限、服务不稳定、超时等)时自动切换,提升可用性。
|
||||||
@@ -332,7 +370,7 @@ fallbackCooldownMinutes: 5
|
|||||||
9. **SendVoiceMessage** - 发送语音消息
|
9. **SendVoiceMessage** - 发送语音消息
|
||||||
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
|
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
|
||||||
11. **WeatherService** - 天气查询
|
11. **WeatherService** - 天气查询
|
||||||
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin)
|
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索插件内置 SQLite 聊天历史
|
||||||
|
|
||||||
## 用户画像系统
|
## 用户画像系统
|
||||||
|
|
||||||
@@ -558,7 +596,6 @@ JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消
|
|||||||
|
|
||||||
- Java 11 或更高版本
|
- Java 11 或更高版本
|
||||||
- Mirai Console 2.16.0 或更高版本
|
- Mirai Console 2.16.0 或更高版本
|
||||||
- 可选:mirai-hibernate-plugin(用于历史消息上下文)
|
|
||||||
- 相关 API Tokens(根据需要启用的功能配置)
|
- 相关 API Tokens(根据需要启用的功能配置)
|
||||||
|
|
||||||
## 备注
|
## 备注
|
||||||
|
|||||||
+10
-4
@@ -7,7 +7,7 @@ plugins {
|
|||||||
}
|
}
|
||||||
|
|
||||||
group = "top.jie65535.mirai"
|
group = "top.jie65535.mirai"
|
||||||
version = "1.12.0"
|
version = "1.14.0"
|
||||||
|
|
||||||
mirai {
|
mirai {
|
||||||
jvmTarget = JavaVersion.VERSION_11
|
jvmTarget = JavaVersion.VERSION_11
|
||||||
@@ -29,17 +29,23 @@ 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"
|
||||||
val hibernateVersion = "2.9.0"
|
val sqliteVersion = "3.46.1.0"
|
||||||
val overflowVersion = "1.0.7"
|
val overflowVersion = "1.0.7"
|
||||||
|
val eddsaVersion = "0.3.0"
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation("com.aallam.openai:openai-client:$openaiClientVersion")
|
implementation("com.aallam.openai:openai-client:$openaiClientVersion")
|
||||||
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
|
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
|
||||||
|
implementation("net.i2p.crypto:eddsa:$eddsaVersion")
|
||||||
implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion")
|
implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion")
|
||||||
implementation("org.apache.commons:commons-text:$commonTextVersion")
|
implementation("org.apache.commons:commons-text:$commonTextVersion")
|
||||||
|
implementation("org.xerial:sqlite-jdbc:$sqliteVersion")
|
||||||
|
|
||||||
// 聊天记录插件
|
testImplementation(kotlin("test-junit5"))
|
||||||
compileOnly("xyz.cssxsh.mirai:mirai-hibernate-plugin:$hibernateVersion")
|
|
||||||
|
|
||||||
testConsoleRuntime("top.mrxiaom.mirai:overflow-core:$overflowVersion")
|
testConsoleRuntime("top.mrxiaom.mirai:overflow-core:$overflowVersion")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tasks.test {
|
||||||
|
useJUnitPlatform()
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,368 @@
|
|||||||
|
package top.jie65535.mirai
|
||||||
|
|
||||||
|
import net.mamoe.mirai.Bot
|
||||||
|
import net.mamoe.mirai.contact.Contact
|
||||||
|
import net.mamoe.mirai.contact.Friend
|
||||||
|
import net.mamoe.mirai.contact.Group
|
||||||
|
import net.mamoe.mirai.contact.Member
|
||||||
|
import net.mamoe.mirai.contact.Stranger
|
||||||
|
import net.mamoe.mirai.event.events.MessageEvent
|
||||||
|
import net.mamoe.mirai.event.events.MessagePostSendEvent
|
||||||
|
import net.mamoe.mirai.event.events.MessageRecallEvent
|
||||||
|
import net.mamoe.mirai.message.data.MessageSource
|
||||||
|
import net.mamoe.mirai.message.data.MessageSourceKind
|
||||||
|
import net.mamoe.mirai.message.data.source
|
||||||
|
import net.mamoe.mirai.message.data.toMessageChain
|
||||||
|
import java.io.File
|
||||||
|
import java.sql.Connection
|
||||||
|
import java.sql.DriverManager
|
||||||
|
import java.sql.ResultSet
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于 SQLite 的聊天记录存储。
|
||||||
|
*
|
||||||
|
* 写连接由 [writeLock] 串行保护;SQLite 使用 WAL + NORMAL synchronous,允许并发读取。
|
||||||
|
*/
|
||||||
|
object ChatHistoryStore {
|
||||||
|
private const val SCHEMA_VERSION = 2
|
||||||
|
private const val BUSY_TIMEOUT_MS = 30_000
|
||||||
|
|
||||||
|
private val lifecycleLock = Any()
|
||||||
|
private val writeLock = Any()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var initialized = false
|
||||||
|
private lateinit var databaseFile: File
|
||||||
|
private var writeConnection: Connection? = null
|
||||||
|
|
||||||
|
val isAvailable: Boolean
|
||||||
|
get() = initialized
|
||||||
|
|
||||||
|
fun init(dataFolder: File) {
|
||||||
|
synchronized(lifecycleLock) {
|
||||||
|
if (initialized) return
|
||||||
|
|
||||||
|
Class.forName("org.sqlite.JDBC")
|
||||||
|
dataFolder.mkdirs()
|
||||||
|
databaseFile = dataFolder.resolve("chat-history.sqlite")
|
||||||
|
|
||||||
|
val connection = openConnection()
|
||||||
|
try {
|
||||||
|
configureWriteConnection(connection)
|
||||||
|
createSchema(connection)
|
||||||
|
writeConnection = connection
|
||||||
|
initialized = true
|
||||||
|
} catch (cause: Throwable) {
|
||||||
|
connection.close()
|
||||||
|
throw cause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun close() {
|
||||||
|
synchronized(lifecycleLock) {
|
||||||
|
if (!initialized) return
|
||||||
|
synchronized(writeLock) {
|
||||||
|
writeConnection?.let { connection ->
|
||||||
|
runCatching {
|
||||||
|
connection.createStatement().use { statement ->
|
||||||
|
statement.execute("PRAGMA wal_checkpoint(TRUNCATE)")
|
||||||
|
}
|
||||||
|
}.onFailure { JChatGPT.logger.warning("SQLite WAL checkpoint 失败", it) }
|
||||||
|
connection.close()
|
||||||
|
}
|
||||||
|
writeConnection = null
|
||||||
|
initialized = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun record(event: MessageEvent) {
|
||||||
|
if (!initialized) return
|
||||||
|
val message = event.message.asSequence()
|
||||||
|
.filterNot { it is MessageSource }
|
||||||
|
.toMessageChain()
|
||||||
|
insert(ChatMessageRecord.fromSuccess(event.message.source, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun record(event: MessagePostSendEvent<*>) {
|
||||||
|
if (!initialized) return
|
||||||
|
val source = event.receipt?.source ?: return
|
||||||
|
val message = event.message.asSequence()
|
||||||
|
.filterNot { it is MessageSource }
|
||||||
|
.toMessageChain()
|
||||||
|
insert(ChatMessageRecord.fromSuccess(source, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun markRecalled(event: MessageRecallEvent) {
|
||||||
|
if (!initialized) return
|
||||||
|
val (kind, targetId, recalled) = when (event) {
|
||||||
|
is MessageRecallEvent.FriendRecall -> Triple(
|
||||||
|
MessageSourceKind.FRIEND,
|
||||||
|
event.bot.id,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
is MessageRecallEvent.GroupRecall -> Triple(
|
||||||
|
MessageSourceKind.GROUP,
|
||||||
|
event.group.id,
|
||||||
|
if ((event.operator?.id ?: event.bot.id) == event.authorId) 2 else 3,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val messageIds = event.messageIds.joinToString(",")
|
||||||
|
val messageInternalIds = event.messageInternalIds.joinToString(",")
|
||||||
|
|
||||||
|
withWriteConnection { connection ->
|
||||||
|
connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
UPDATE message_record
|
||||||
|
SET recalled = ?
|
||||||
|
WHERE id = (
|
||||||
|
SELECT id
|
||||||
|
FROM message_record
|
||||||
|
WHERE bot_id = ?
|
||||||
|
AND kind = ?
|
||||||
|
AND from_id = ?
|
||||||
|
AND target_id = ?
|
||||||
|
AND (ids = ? OR internal_ids = ?)
|
||||||
|
ORDER BY ABS(time - ?) ASC, id DESC
|
||||||
|
LIMIT 1
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
statement.setInt(1, recalled)
|
||||||
|
statement.setLong(2, event.bot.id)
|
||||||
|
statement.setInt(3, kind.ordinal)
|
||||||
|
statement.setLong(4, event.authorId)
|
||||||
|
statement.setLong(5, targetId)
|
||||||
|
statement.setString(6, messageIds)
|
||||||
|
statement.setString(7, messageInternalIds)
|
||||||
|
statement.setInt(8, event.messageTime)
|
||||||
|
val updated = statement.executeUpdate()
|
||||||
|
if (updated == 0) {
|
||||||
|
JChatGPT.logger.warning(
|
||||||
|
"未在 SQLite 中找到撤回消息: bot=${event.bot.id}, " +
|
||||||
|
"author=${event.authorId}, target=$targetId, ids=$messageIds, " +
|
||||||
|
"internalIds=$messageInternalIds, " +
|
||||||
|
"time=${event.messageTime}"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun query(
|
||||||
|
contact: Contact,
|
||||||
|
start: Int,
|
||||||
|
end: Int,
|
||||||
|
limit: Int,
|
||||||
|
fromId: Long? = null,
|
||||||
|
): List<ChatMessageRecord> {
|
||||||
|
check(initialized) { "聊天记录数据库尚未初始化" }
|
||||||
|
require(start <= end) { "start must not be after end" }
|
||||||
|
|
||||||
|
val conditions = mutableListOf<String>()
|
||||||
|
val parameters = mutableListOf<Any>()
|
||||||
|
|
||||||
|
conditions += "bot_id = ?"
|
||||||
|
parameters += contact.bot.id
|
||||||
|
conditions += "time BETWEEN ? AND ?"
|
||||||
|
parameters += start
|
||||||
|
parameters += end
|
||||||
|
|
||||||
|
when (contact) {
|
||||||
|
is Group -> {
|
||||||
|
conditions += "kind = ?"
|
||||||
|
parameters += MessageSourceKind.GROUP.ordinal
|
||||||
|
conditions += "target_id = ?"
|
||||||
|
parameters += contact.id
|
||||||
|
}
|
||||||
|
is Member -> {
|
||||||
|
conditions += "kind = ?"
|
||||||
|
parameters += MessageSourceKind.GROUP.ordinal
|
||||||
|
conditions += "target_id = ?"
|
||||||
|
parameters += contact.group.id
|
||||||
|
conditions += "from_id = ?"
|
||||||
|
parameters += contact.id
|
||||||
|
}
|
||||||
|
is Friend -> {
|
||||||
|
conditions += "kind = ?"
|
||||||
|
parameters += MessageSourceKind.FRIEND.ordinal
|
||||||
|
conditions += "(from_id = ? OR target_id = ?)"
|
||||||
|
parameters += contact.id
|
||||||
|
parameters += contact.id
|
||||||
|
}
|
||||||
|
is Stranger -> {
|
||||||
|
conditions += "kind = ?"
|
||||||
|
parameters += MessageSourceKind.STRANGER.ordinal
|
||||||
|
conditions += "(from_id = ? OR target_id = ?)"
|
||||||
|
parameters += contact.id
|
||||||
|
parameters += contact.id
|
||||||
|
}
|
||||||
|
is Bot -> Unit
|
||||||
|
else -> error("不支持查询的联系人 $contact")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (fromId != null && contact !is Member) {
|
||||||
|
conditions += "from_id = ?"
|
||||||
|
parameters += fromId
|
||||||
|
}
|
||||||
|
|
||||||
|
val sql = buildString {
|
||||||
|
append(
|
||||||
|
"""
|
||||||
|
SELECT id, bot_id, from_id, target_id, ids, internal_ids,
|
||||||
|
time, kind, code, recalled
|
||||||
|
FROM message_record
|
||||||
|
WHERE
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
append(' ')
|
||||||
|
append(conditions.joinToString(" AND "))
|
||||||
|
append(" ORDER BY time DESC, id DESC LIMIT ?")
|
||||||
|
}
|
||||||
|
|
||||||
|
return openReadConnection().use { connection ->
|
||||||
|
connection.prepareStatement(sql).use { statement ->
|
||||||
|
parameters.forEachIndexed { index, value ->
|
||||||
|
when (value) {
|
||||||
|
is Int -> statement.setInt(index + 1, value)
|
||||||
|
is Long -> statement.setLong(index + 1, value)
|
||||||
|
else -> error("不支持的查询参数类型 ${value::class}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
statement.setInt(parameters.size + 1, limit.coerceAtLeast(1))
|
||||||
|
statement.executeQuery().use { results ->
|
||||||
|
buildList {
|
||||||
|
while (results.next()) {
|
||||||
|
add(results.toRecord())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun insert(record: ChatMessageRecord) {
|
||||||
|
withWriteConnection { connection ->
|
||||||
|
connection.prepareStatement(
|
||||||
|
"""
|
||||||
|
INSERT INTO message_record(
|
||||||
|
bot_id, from_id, target_id, ids, internal_ids,
|
||||||
|
time, kind, code, recalled
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""".trimIndent()
|
||||||
|
).use { statement ->
|
||||||
|
statement.setLong(1, record.botId)
|
||||||
|
statement.setLong(2, record.fromId)
|
||||||
|
statement.setLong(3, record.targetId)
|
||||||
|
statement.setString(4, record.ids)
|
||||||
|
statement.setString(5, record.internalIds)
|
||||||
|
statement.setInt(6, record.time)
|
||||||
|
statement.setInt(7, record.kind.ordinal)
|
||||||
|
statement.setString(8, record.code)
|
||||||
|
statement.setInt(9, record.recalled)
|
||||||
|
statement.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withWriteConnection(block: (Connection) -> Unit) {
|
||||||
|
synchronized(writeLock) {
|
||||||
|
check(initialized) { "聊天记录数据库尚未初始化" }
|
||||||
|
val connection = writeConnection?.takeUnless(Connection::isClosed)
|
||||||
|
?: openConnection().also {
|
||||||
|
configureWriteConnection(it)
|
||||||
|
writeConnection = it
|
||||||
|
}
|
||||||
|
block(connection)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openConnection(): Connection {
|
||||||
|
return DriverManager.getConnection("jdbc:sqlite:${databaseFile.absolutePath}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openReadConnection(): Connection {
|
||||||
|
return openConnection().also { connection ->
|
||||||
|
connection.createStatement().use { statement ->
|
||||||
|
statement.execute("PRAGMA busy_timeout=$BUSY_TIMEOUT_MS")
|
||||||
|
statement.execute("PRAGMA query_only=ON")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun configureWriteConnection(connection: Connection) {
|
||||||
|
connection.createStatement().use { statement ->
|
||||||
|
statement.execute("PRAGMA journal_mode=WAL")
|
||||||
|
statement.execute("PRAGMA synchronous=NORMAL")
|
||||||
|
statement.execute("PRAGMA busy_timeout=$BUSY_TIMEOUT_MS")
|
||||||
|
statement.execute("PRAGMA wal_autocheckpoint=1000")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createSchema(connection: Connection) {
|
||||||
|
connection.createStatement().use { statement ->
|
||||||
|
statement.executeUpdate(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS message_record(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
bot_id INTEGER NOT NULL,
|
||||||
|
from_id INTEGER NOT NULL,
|
||||||
|
target_id INTEGER NOT NULL,
|
||||||
|
ids TEXT,
|
||||||
|
internal_ids TEXT,
|
||||||
|
time INTEGER NOT NULL,
|
||||||
|
kind INTEGER NOT NULL,
|
||||||
|
code TEXT NOT NULL,
|
||||||
|
recalled INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
statement.executeUpdate(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_message_subject_time " +
|
||||||
|
"ON message_record(bot_id, kind, target_id, time DESC, id DESC)"
|
||||||
|
)
|
||||||
|
statement.executeUpdate(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_message_sender_subject_time " +
|
||||||
|
"ON message_record(bot_id, kind, target_id, from_id, time DESC, id DESC)"
|
||||||
|
)
|
||||||
|
statement.executeUpdate(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_message_recall_identity " +
|
||||||
|
"ON message_record(bot_id, kind, target_id, from_id, time, ids)"
|
||||||
|
)
|
||||||
|
statement.executeUpdate(
|
||||||
|
"""
|
||||||
|
CREATE TABLE IF NOT EXISTS chat_history_meta(
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
connection.prepareStatement(
|
||||||
|
"INSERT INTO chat_history_meta(key, value) VALUES ('schema_version', ?) " +
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||||
|
).use { statement ->
|
||||||
|
statement.setString(1, SCHEMA_VERSION.toString())
|
||||||
|
statement.executeUpdate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ResultSet.toRecord(): ChatMessageRecord {
|
||||||
|
val kindOrdinal = getInt("kind")
|
||||||
|
val kind = MessageSourceKind.values().getOrNull(kindOrdinal)
|
||||||
|
?: error("未知的消息类型序号 $kindOrdinal")
|
||||||
|
return ChatMessageRecord(
|
||||||
|
id = getLong("id"),
|
||||||
|
botId = getLong("bot_id"),
|
||||||
|
fromId = getLong("from_id"),
|
||||||
|
targetId = getLong("target_id"),
|
||||||
|
ids = getString("ids"),
|
||||||
|
internalIds = getString("internal_ids"),
|
||||||
|
time = getInt("time"),
|
||||||
|
kind = kind,
|
||||||
|
code = getString("code"),
|
||||||
|
recalled = getInt("recalled"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
package top.jie65535.mirai
|
||||||
|
|
||||||
|
import kotlinx.serialization.SerializationException
|
||||||
|
import net.mamoe.mirai.Mirai
|
||||||
|
import net.mamoe.mirai.message.code.MiraiCode
|
||||||
|
import net.mamoe.mirai.message.data.MessageChain
|
||||||
|
import net.mamoe.mirai.message.data.MessageSource
|
||||||
|
import net.mamoe.mirai.message.data.MessageSourceKind
|
||||||
|
import net.mamoe.mirai.message.data.buildMessageSource
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 插件自维护的聊天消息记录。
|
||||||
|
*
|
||||||
|
* [recalled]:0=正常、1=发送失败、2=自行撤回、3=管理员撤回。
|
||||||
|
*/
|
||||||
|
data class ChatMessageRecord(
|
||||||
|
val id: Long = 0,
|
||||||
|
val botId: Long,
|
||||||
|
val fromId: Long,
|
||||||
|
val targetId: Long,
|
||||||
|
val ids: String?,
|
||||||
|
val internalIds: String?,
|
||||||
|
val time: Int,
|
||||||
|
val kind: MessageSourceKind,
|
||||||
|
val code: String,
|
||||||
|
val recalled: Int = 0,
|
||||||
|
) {
|
||||||
|
fun toMessageSource(): MessageSource {
|
||||||
|
return Mirai.buildMessageSource(botId, kind) {
|
||||||
|
fromId = this@ChatMessageRecord.fromId
|
||||||
|
targetId = this@ChatMessageRecord.targetId
|
||||||
|
ids = this@ChatMessageRecord.ids.toIntArray()
|
||||||
|
internalIds = this@ChatMessageRecord.internalIds.toIntArray()
|
||||||
|
time = this@ChatMessageRecord.time
|
||||||
|
messages(messages = toMessageChain())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toMessageChain(): MessageChain {
|
||||||
|
return try {
|
||||||
|
MessageChain.deserializeFromJsonString(code)
|
||||||
|
} catch (cause: SerializationException) {
|
||||||
|
try {
|
||||||
|
MiraiCode.deserializeMiraiCode(code)
|
||||||
|
} catch (_: Throwable) {
|
||||||
|
throw cause
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromSuccess(source: MessageSource, message: MessageChain): ChatMessageRecord = ChatMessageRecord(
|
||||||
|
botId = source.botId,
|
||||||
|
fromId = source.fromId,
|
||||||
|
targetId = source.targetId,
|
||||||
|
ids = source.ids.joinToString(","),
|
||||||
|
internalIds = source.internalIds.joinToString(","),
|
||||||
|
time = source.time,
|
||||||
|
kind = source.kind,
|
||||||
|
code = with(MessageChain) { message.serializeToJsonString() },
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun String?.toIntArray(): IntArray {
|
||||||
|
return if (isNullOrEmpty()) {
|
||||||
|
IntArray(0)
|
||||||
|
} else {
|
||||||
|
split(',').map { it.trim().toInt() }.toIntArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package top.jie65535.mirai
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 会话内图片短索引:向 LLM 暴露递增整数,内部保留从原消息图片取得的精确 URL。
|
||||||
|
* 同一 imageId 重复出现在上下文中时复用原编号,并用最新取得的 URL 刷新映射。
|
||||||
|
*/
|
||||||
|
internal class ImageIndex {
|
||||||
|
private val imageUrlByIndex = LinkedHashMap<Int, String>()
|
||||||
|
private val indexByImageId = HashMap<String, Int>()
|
||||||
|
private var counter = 0
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun add(imageId: String, imageUrl: String): Int {
|
||||||
|
require(imageId.isNotBlank()) { "图片ID不能为空" }
|
||||||
|
require(imageUrl.isNotBlank()) { "图片URL不能为空" }
|
||||||
|
indexByImageId[imageId]?.let { index ->
|
||||||
|
imageUrlByIndex[index] = imageUrl
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
val index = ++counter
|
||||||
|
imageUrlByIndex[index] = imageUrl
|
||||||
|
indexByImageId[imageId] = index
|
||||||
|
return index
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun getUrl(index: Int): String? = imageUrlByIndex[index]
|
||||||
|
}
|
||||||
+95
-51
@@ -24,17 +24,18 @@ import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription
|
|||||||
import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin
|
import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin
|
||||||
import net.mamoe.mirai.contact.*
|
import net.mamoe.mirai.contact.*
|
||||||
import net.mamoe.mirai.contact.MemberPermission.*
|
import net.mamoe.mirai.contact.MemberPermission.*
|
||||||
|
import net.mamoe.mirai.event.EventPriority
|
||||||
import net.mamoe.mirai.event.GlobalEventChannel
|
import net.mamoe.mirai.event.GlobalEventChannel
|
||||||
import net.mamoe.mirai.event.events.FriendMessageEvent
|
import net.mamoe.mirai.event.events.FriendMessageEvent
|
||||||
import net.mamoe.mirai.event.events.GroupMessageEvent
|
import net.mamoe.mirai.event.events.GroupMessageEvent
|
||||||
import net.mamoe.mirai.event.events.MessageEvent
|
import net.mamoe.mirai.event.events.MessageEvent
|
||||||
|
import net.mamoe.mirai.event.events.MessagePostSendEvent
|
||||||
|
import net.mamoe.mirai.event.events.MessageRecallEvent
|
||||||
import net.mamoe.mirai.message.data.*
|
import net.mamoe.mirai.message.data.*
|
||||||
import net.mamoe.mirai.message.data.Image.Key.queryUrl
|
import net.mamoe.mirai.message.data.Image.Key.queryUrl
|
||||||
import net.mamoe.mirai.utils.info
|
import net.mamoe.mirai.utils.info
|
||||||
import top.jie65535.mirai.tools.*
|
import top.jie65535.mirai.tools.*
|
||||||
import util.LunarDateUtil
|
import util.LunarDateUtil
|
||||||
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder
|
|
||||||
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@@ -53,10 +54,9 @@ object JChatGPT : KotlinPlugin(
|
|||||||
JvmPluginDescription(
|
JvmPluginDescription(
|
||||||
id = "top.jie65535.mirai.JChatGPT",
|
id = "top.jie65535.mirai.JChatGPT",
|
||||||
name = "J ChatGPT",
|
name = "J ChatGPT",
|
||||||
version = "1.12.0",
|
version = "1.14.0",
|
||||||
) {
|
) {
|
||||||
author("jie65535")
|
author("jie65535")
|
||||||
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
/**
|
/**
|
||||||
@@ -86,26 +86,39 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群)
|
// 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群)
|
||||||
SkillStore.init(dataFolder)
|
SkillStore.init(dataFolder)
|
||||||
|
|
||||||
|
// 初始化插件自维护的 SQLite 聊天记录
|
||||||
|
includeHistory = try {
|
||||||
|
ChatHistoryStore.init(dataFolder)
|
||||||
|
true
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
logger.error("初始化 SQLite 聊天记录失败,历史上下文与搜索将暂时禁用", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
// 设置Token
|
// 设置Token
|
||||||
LargeLanguageModels.reload()
|
LargeLanguageModels.reload()
|
||||||
|
|
||||||
// 注册插件命令
|
// 注册插件命令
|
||||||
PluginCommands.register()
|
PluginCommands.register()
|
||||||
|
|
||||||
// 检查消息记录插件是否存在
|
|
||||||
includeHistory = try {
|
|
||||||
MiraiHibernateRecorder
|
|
||||||
true
|
|
||||||
} catch (_: Throwable) {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
if (PluginConfig.callKeyword.isNotEmpty()) {
|
if (PluginConfig.callKeyword.isNotEmpty()) {
|
||||||
keyword = Regex(PluginConfig.callKeyword)
|
keyword = Regex(PluginConfig.callKeyword)
|
||||||
}
|
}
|
||||||
|
|
||||||
GlobalEventChannel.parentScope(this)
|
val eventChannel = GlobalEventChannel.parentScope(this)
|
||||||
.subscribeAlways<MessageEvent> { event -> onMessage(event) }
|
eventChannel.subscribeAlways<MessageEvent>(priority = EventPriority.HIGHEST) { event ->
|
||||||
|
runCatching { ChatHistoryStore.record(event) }
|
||||||
|
.onFailure { logger.warning("保存接收消息到 SQLite 失败", it) }
|
||||||
|
}
|
||||||
|
eventChannel.subscribeAlways<MessagePostSendEvent<*>>(priority = EventPriority.HIGHEST) { event ->
|
||||||
|
runCatching { ChatHistoryStore.record(event) }
|
||||||
|
.onFailure { logger.warning("保存发送消息到 SQLite 失败", it) }
|
||||||
|
}
|
||||||
|
eventChannel.subscribeAlways<MessageRecallEvent>(priority = EventPriority.HIGHEST) { event ->
|
||||||
|
runCatching { ChatHistoryStore.markRecalled(event) }
|
||||||
|
.onFailure { logger.warning("更新 SQLite 消息撤回状态失败", it) }
|
||||||
|
}
|
||||||
|
eventChannel.subscribeAlways<MessageEvent> { event -> onMessage(event) }
|
||||||
|
|
||||||
// 启动定时任务处理好感度时间偏移
|
// 启动定时任务处理好感度时间偏移
|
||||||
if (PluginConfig.enableFavorabilitySystem) {
|
if (PluginConfig.enableFavorabilitySystem) {
|
||||||
@@ -120,6 +133,10 @@ object JChatGPT : KotlinPlugin(
|
|||||||
logger.info { "Plugin loaded" }
|
logger.info { "Plugin loaded" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onDisable() {
|
||||||
|
ChatHistoryStore.close()
|
||||||
|
}
|
||||||
|
|
||||||
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>()
|
||||||
@@ -144,7 +161,8 @@ 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
|
val replyIndex: ReplyIndex,
|
||||||
|
val imageIndex: ImageIndex,
|
||||||
) {
|
) {
|
||||||
fun isExpired(ttlSeconds: Int): Boolean {
|
fun isExpired(ttlSeconds: Int): Boolean {
|
||||||
return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds
|
return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds
|
||||||
@@ -157,11 +175,11 @@ object JChatGPT : KotlinPlugin(
|
|||||||
* 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。
|
* 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。
|
||||||
*/
|
*/
|
||||||
class ReplyIndex {
|
class ReplyIndex {
|
||||||
private val byIndex = LinkedHashMap<Int, MessageRecord>()
|
private val byIndex = LinkedHashMap<Int, ChatMessageRecord>()
|
||||||
private val indexByIds = HashMap<String, Int>()
|
private val indexByIds = HashMap<String, Int>()
|
||||||
private var counter = 0
|
private var counter = 0
|
||||||
|
|
||||||
fun add(record: MessageRecord): Int {
|
fun add(record: ChatMessageRecord): Int {
|
||||||
// ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号
|
// ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号
|
||||||
val ids = record.ids
|
val ids = record.ids
|
||||||
if (ids != null) {
|
if (ids != null) {
|
||||||
@@ -175,17 +193,28 @@ object JChatGPT : KotlinPlugin(
|
|||||||
return i
|
return i
|
||||||
}
|
}
|
||||||
|
|
||||||
fun get(index: Int): MessageRecord? = byIndex[index]
|
fun get(index: Int): ChatMessageRecord? = byIndex[index]
|
||||||
fun indexOfIds(ids: String): Int? = indexByIds[ids]
|
fun indexOfIds(ids: String): Int? = indexByIds[ids]
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 各会话的回复索引,startChat 开始时重建,结束时清理 */
|
/** 各会话的回复索引,startChat 开始时重建,结束时清理 */
|
||||||
private val replyIndexMap = ConcurrentMap<Long, ReplyIndex>()
|
private val replyIndexMap = ConcurrentMap<Long, ReplyIndex>()
|
||||||
|
|
||||||
|
/** 各会话的图片索引,生命周期与回复索引、对话缓存一致。 */
|
||||||
|
private val imageIndexMap = ConcurrentMap<Long, ImageIndex>()
|
||||||
|
|
||||||
/** 供发言工具按编号查找被引用的历史消息 */
|
/** 供发言工具按编号查找被引用的历史消息 */
|
||||||
internal fun lookupReplyTarget(subjectId: Long, index: Int): MessageRecord? =
|
internal fun lookupReplyTarget(subjectId: Long, index: Int): ChatMessageRecord? =
|
||||||
replyIndexMap[subjectId]?.get(index)
|
replyIndexMap[subjectId]?.get(index)
|
||||||
|
|
||||||
|
/** 将从原消息图片取得的精确 URL 登记为短编号,供历史搜索等工具追加图片引用。 */
|
||||||
|
internal fun registerImage(subjectId: Long, imageId: String, imageUrl: String): Int? =
|
||||||
|
imageIndexMap[subjectId]?.add(imageId, imageUrl)
|
||||||
|
|
||||||
|
/** 按会话内短编号获取原消息解析出的 URL,避免根据 imageId 二次构造和查询。 */
|
||||||
|
internal fun lookupImageUrl(subjectId: Long, index: Int): String? =
|
||||||
|
imageIndexMap[subjectId]?.getUrl(index)
|
||||||
|
|
||||||
private val shortTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
private val shortTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
.withZone(ZoneOffset.systemDefault())
|
.withZone(ZoneOffset.systemDefault())
|
||||||
|
|
||||||
@@ -339,8 +368,9 @@ object JChatGPT : KotlinPlugin(
|
|||||||
* @return 如果未获取到则返回空字符串
|
* @return 如果未获取到则返回空字符串
|
||||||
*/
|
*/
|
||||||
private fun getHistory(event: MessageEvent): String {
|
private fun getHistory(event: MessageEvent): String {
|
||||||
|
val imageIndex = imageIndexMap.getOrPut(event.subject.id) { ImageIndex() }
|
||||||
if (!includeHistory) {
|
if (!includeHistory) {
|
||||||
return event.message.content
|
return formatRecordContent(event.message, event.subject, imageIndex)
|
||||||
}
|
}
|
||||||
val now = OffsetDateTime.now()
|
val now = OffsetDateTime.now()
|
||||||
// 一段时间内的消息
|
// 一段时间内的消息
|
||||||
@@ -361,15 +391,22 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 现在时间
|
// 现在时间
|
||||||
val nowTimestamp = OffsetDateTime.now().toEpochSecond().toInt()
|
val nowTimestamp = OffsetDateTime.now().toEpochSecond().toInt()
|
||||||
// 最近这段时间的历史对话
|
// 最近这段时间的历史对话
|
||||||
val history = MiraiHibernateRecorder[event.subject, time, nowTimestamp]
|
val history = try {
|
||||||
.take(PluginConfig.historyMessageLimit) // 只取最近的部分消息,避免上下文过长
|
ChatHistoryStore.query(
|
||||||
.sortedBy { it.time } // 按时间排序
|
contact = event.subject,
|
||||||
.toMutableList()
|
start = time,
|
||||||
|
end = nowTimestamp,
|
||||||
|
limit = PluginConfig.historyMessageLimit,
|
||||||
|
).sortedBy { it.time }.toMutableList()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
logger.warning("查询 SQLite 消息历史失败", e)
|
||||||
|
mutableListOf()
|
||||||
|
}
|
||||||
|
|
||||||
// 有一定概率最后一条消息没加入,这里检查然后补充一下
|
// 有一定概率最后一条消息没加入,这里检查然后补充一下
|
||||||
val msgIds = event.message.ids.joinToString(",")
|
val msgIds = event.message.ids.joinToString(",")
|
||||||
if (!history.any { it.ids == msgIds }) {
|
if (!history.any { it.ids == msgIds }) {
|
||||||
history.add(MessageRecord.fromSuccess(event.message.source, event.message))
|
history.add(ChatMessageRecord.fromSuccess(event.message.source, event.message))
|
||||||
}
|
}
|
||||||
|
|
||||||
// 构造历史消息
|
// 构造历史消息
|
||||||
@@ -378,6 +415,7 @@ object JChatGPT : KotlinPlugin(
|
|||||||
var lastTime = 0L
|
var lastTime = 0L
|
||||||
// 本轮回复索引,逐条登记消息编号供 [n] 引用
|
// 本轮回复索引,逐条登记消息编号供 [n] 引用
|
||||||
val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() }
|
val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() }
|
||||||
|
val imageIndex = imageIndexMap.getOrPut(event.subject.id) { ImageIndex() }
|
||||||
if (event is GroupMessageEvent) {
|
if (event is GroupMessageEvent) {
|
||||||
if (PluginConfig.enableFavorabilitySystem) {
|
if (PluginConfig.enableFavorabilitySystem) {
|
||||||
val knownUsers = history.asSequence()
|
val knownUsers = history.asSequence()
|
||||||
@@ -404,12 +442,12 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号)")
|
||||||
for (record in history) {
|
for (record in history) {
|
||||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||||
val showSender = lastId != record.fromId
|
val showSender = lastId != record.fromId
|
||||||
val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS
|
val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS
|
||||||
appendGroupMessageRecord(historyText, record, event, replyIndex, showSender, showTime)
|
appendGroupMessageRecord(historyText, record, event, replyIndex, imageIndex, showSender, showTime)
|
||||||
lastId = record.fromId
|
lastId = record.fromId
|
||||||
lastTime = record.time.toLong()
|
lastTime = record.time.toLong()
|
||||||
}
|
}
|
||||||
@@ -428,12 +466,12 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号,可用于引用回复)")
|
historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号)")
|
||||||
for (record in history) {
|
for (record in history) {
|
||||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||||
val showSender = lastId != record.fromId
|
val showSender = lastId != record.fromId
|
||||||
val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS
|
val showTime = showSender || record.time.toLong() - lastTime > CONTINUATION_TIME_GAP_SECONDS
|
||||||
appendMessageRecord(historyText, record, event, replyIndex, showSender, showTime)
|
appendMessageRecord(historyText, record, event, replyIndex, imageIndex, showSender, showTime)
|
||||||
lastId = record.fromId
|
lastId = record.fromId
|
||||||
lastTime = record.time.toLong()
|
lastTime = record.time.toLong()
|
||||||
}
|
}
|
||||||
@@ -448,11 +486,12 @@ object JChatGPT : KotlinPlugin(
|
|||||||
* @param record 群消息记录
|
* @param record 群消息记录
|
||||||
* @param event 群消息事件
|
* @param event 群消息事件
|
||||||
*/
|
*/
|
||||||
fun appendGroupMessageRecord(
|
private fun appendGroupMessageRecord(
|
||||||
historyText: StringBuilder,
|
historyText: StringBuilder,
|
||||||
record: MessageRecord,
|
record: ChatMessageRecord,
|
||||||
event: GroupMessageEvent,
|
event: GroupMessageEvent,
|
||||||
replyIndex: ReplyIndex,
|
replyIndex: ReplyIndex,
|
||||||
|
imageIndex: ImageIndex,
|
||||||
showSender: Boolean,
|
showSender: Boolean,
|
||||||
showTime: Boolean,
|
showTime: Boolean,
|
||||||
) {
|
) {
|
||||||
@@ -481,10 +520,10 @@ object JChatGPT : KotlinPlugin(
|
|||||||
|
|
||||||
// 引用:用编号指针替代内联原文,避免被误认为是本人发言
|
// 引用:用编号指针替代内联原文,避免被误认为是本人发言
|
||||||
recordMessage[QuoteReply.Key]?.let {
|
recordMessage[QuoteReply.Key]?.let {
|
||||||
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
appendQuoteMarker(historyText, it, event.subject, replyIndex, imageIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
historyText.appendLine(formatRecordContent(recordMessage, event.subject, imageIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -494,7 +533,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
sb: StringBuilder,
|
sb: StringBuilder,
|
||||||
quote: QuoteReply,
|
quote: QuoteReply,
|
||||||
contact: Contact,
|
contact: Contact,
|
||||||
replyIndex: ReplyIndex
|
replyIndex: ReplyIndex,
|
||||||
|
imageIndex: ImageIndex,
|
||||||
) {
|
) {
|
||||||
val srcIds = quote.source.ids.joinToString(",")
|
val srcIds = quote.source.ids.joinToString(",")
|
||||||
val idx = replyIndex.indexOfIds(srcIds)
|
val idx = replyIndex.indexOfIds(srcIds)
|
||||||
@@ -507,7 +547,7 @@ object JChatGPT : KotlinPlugin(
|
|||||||
quote.source.fromId.toString()
|
quote.source.fromId.toString()
|
||||||
}
|
}
|
||||||
val snippet = quote.source.originalMessage
|
val snippet = quote.source.originalMessage
|
||||||
.joinToString("", transform = ::singleMessageToText)
|
.joinToString("") { singleMessageToText(it, imageIndex) }
|
||||||
.replace("\n", " ")
|
.replace("\n", " ")
|
||||||
.let { if (it.length > 20) it.take(20) + "…" else it }
|
.let { if (it.length > 20) it.take(20) + "…" else it }
|
||||||
sb.append("↩(").append(author).append(":\"").append(snippet).append("\") ")
|
sb.append("↩(").append(author).append(":\"").append(snippet).append("\") ")
|
||||||
@@ -517,13 +557,13 @@ object JChatGPT : KotlinPlugin(
|
|||||||
/**
|
/**
|
||||||
* 序列化消息正文(剔除引用/源元数据,@显示为名称,转发折叠)。
|
* 序列化消息正文(剔除引用/源元数据,@显示为名称,转发折叠)。
|
||||||
*/
|
*/
|
||||||
private fun formatRecordContent(chain: MessageChain, contact: Contact): String =
|
private fun formatRecordContent(chain: MessageChain, contact: Contact, imageIndex: ImageIndex): String =
|
||||||
chain.asSequence()
|
chain.asSequence()
|
||||||
.filterNot { it is QuoteReply || it is MessageSource }
|
.filterNot { it is QuoteReply || it is MessageSource }
|
||||||
.joinToString("") {
|
.joinToString("") {
|
||||||
when (it) {
|
when (it) {
|
||||||
is At -> if (contact is Group) it.getDisplay(contact) else it.content
|
is At -> if (contact is Group) it.getDisplay(contact) else it.content
|
||||||
else -> singleMessageToText(it)
|
else -> singleMessageToText(it, imageIndex)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,11 +582,12 @@ object JChatGPT : KotlinPlugin(
|
|||||||
* @param record 消息记录
|
* @param record 消息记录
|
||||||
* @param event 消息事件
|
* @param event 消息事件
|
||||||
*/
|
*/
|
||||||
fun appendMessageRecord(
|
private fun appendMessageRecord(
|
||||||
historyText: StringBuilder,
|
historyText: StringBuilder,
|
||||||
record: MessageRecord,
|
record: ChatMessageRecord,
|
||||||
event: MessageEvent,
|
event: MessageEvent,
|
||||||
replyIndex: ReplyIndex,
|
replyIndex: ReplyIndex,
|
||||||
|
imageIndex: ImageIndex,
|
||||||
showSender: Boolean,
|
showSender: Boolean,
|
||||||
showTime: Boolean,
|
showTime: Boolean,
|
||||||
) {
|
) {
|
||||||
@@ -573,24 +614,23 @@ object JChatGPT : KotlinPlugin(
|
|||||||
}
|
}
|
||||||
|
|
||||||
recordMessage[QuoteReply.Key]?.let {
|
recordMessage[QuoteReply.Key]?.let {
|
||||||
appendQuoteMarker(historyText, it, event.subject, replyIndex)
|
appendQuoteMarker(historyText, it, event.subject, replyIndex, imageIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
historyText.appendLine(formatRecordContent(recordMessage, event.subject))
|
historyText.appendLine(formatRecordContent(recordMessage, event.subject, imageIndex))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun singleMessageToText(it: SingleMessage): String {
|
private fun singleMessageToText(it: SingleMessage, imageIndex: ImageIndex): String {
|
||||||
return when (it) {
|
return when (it) {
|
||||||
// 完整展开合并转发内容,便于 LLM 阅读分析转发的对话(依赖大上下文+缓存,不做截断)
|
// 完整展开合并转发内容,便于 LLM 阅读分析转发的对话(依赖大上下文+缓存,不做截断)
|
||||||
is ForwardMessage -> formatForward(it, 1)
|
is ForwardMessage -> formatForward(it, 1, imageIndex)
|
||||||
|
|
||||||
// 图片格式化
|
// 图片格式化
|
||||||
is Image -> {
|
is Image -> {
|
||||||
try {
|
try {
|
||||||
val imageUrl = runBlocking {
|
val imageUrl = runBlocking { it.queryUrl() }
|
||||||
it.queryUrl()
|
val index = imageIndex.add(it.imageId, imageUrl)
|
||||||
}
|
"[${if (it.isEmoji) "表情包" else "图片"}$index]"
|
||||||
""
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
logger.warning("图片地址获取失败", e)
|
logger.warning("图片地址获取失败", e)
|
||||||
it.content
|
it.content
|
||||||
@@ -605,7 +645,7 @@ object JChatGPT : KotlinPlugin(
|
|||||||
* 递归展开合并转发消息,用 Markdown 引用块表示:每加深一层嵌套多一个 `>`(>、>>、>>>…)。
|
* 递归展开合并转发消息,用 Markdown 引用块表示:每加深一层嵌套多一个 `>`(>、>>、>>>…)。
|
||||||
* @param depth 当前嵌套层级,从 1 开始
|
* @param depth 当前嵌套层级,从 1 开始
|
||||||
*/
|
*/
|
||||||
private fun formatForward(forward: ForwardMessage, depth: Int): String = buildString {
|
private fun formatForward(forward: ForwardMessage, depth: Int, imageIndex: ImageIndex): String = buildString {
|
||||||
val quote = ">".repeat(depth) + " "
|
val quote = ">".repeat(depth) + " "
|
||||||
append("[转发消息·").append(forward.nodeList.size).append("条")
|
append("[转发消息·").append(forward.nodeList.size).append("条")
|
||||||
if (forward.title.isNotEmpty()) append(':').append(forward.title)
|
if (forward.title.isNotEmpty()) append(':').append(forward.title)
|
||||||
@@ -618,10 +658,10 @@ object JChatGPT : KotlinPlugin(
|
|||||||
node.messageChain.forEach { sub ->
|
node.messageChain.forEach { sub ->
|
||||||
if (sub is ForwardMessage) {
|
if (sub is ForwardMessage) {
|
||||||
// 嵌套转发:层级加深,自带更深的 `>` 前缀,无需再次缩进
|
// 嵌套转发:层级加深,自带更深的 `>` 前缀,无需再次缩进
|
||||||
append(formatForward(sub, depth + 1))
|
append(formatForward(sub, depth + 1, imageIndex))
|
||||||
} else {
|
} else {
|
||||||
// 其它内容:多行正文对齐到当前引用层级
|
// 其它内容:多行正文对齐到当前引用层级
|
||||||
append(singleMessageToText(sub).replace("\n", "\n$quote"))
|
append(singleMessageToText(sub, imageIndex).replace("\n", "\n$quote"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -658,7 +698,9 @@ object JChatGPT : KotlinPlugin(
|
|||||||
// 回复索引与对话上下文同寿命:复用缓存时沿用旧索引,保证 LLM 看到的 [n] 编号连续不串号;
|
// 回复索引与对话上下文同寿命:复用缓存时沿用旧索引,保证 LLM 看到的 [n] 编号连续不串号;
|
||||||
// 否则新建(供 sendSingleMessage 的 replyTo 按编号引用历史消息)
|
// 否则新建(供 sendSingleMessage 的 replyTo 按编号引用历史消息)
|
||||||
val replyIndex = if (reuseCache) cache!!.replyIndex else ReplyIndex()
|
val replyIndex = if (reuseCache) cache!!.replyIndex else ReplyIndex()
|
||||||
|
val imageIndex = if (reuseCache) cache!!.imageIndex else ImageIndex()
|
||||||
replyIndexMap[subjectId] = replyIndex
|
replyIndexMap[subjectId] = replyIndex
|
||||||
|
imageIndexMap[subjectId] = imageIndex
|
||||||
val history = if (reuseCache) {
|
val history = if (reuseCache) {
|
||||||
// 缓存有效,复用历史
|
// 缓存有效,复用历史
|
||||||
logger.info("使用缓存的对话上下文,包含 ${cache!!.history.size} 条互动消息")
|
logger.info("使用缓存的对话上下文,包含 ${cache!!.history.size} 条互动消息")
|
||||||
@@ -868,7 +910,8 @@ object JChatGPT : KotlinPlugin(
|
|||||||
contextCache[subjectId] = ConversationCache(
|
contextCache[subjectId] = ConversationCache(
|
||||||
history = history,
|
history = history,
|
||||||
lastActivityAt = startedAt,
|
lastActivityAt = startedAt,
|
||||||
replyIndex = replyIndex
|
replyIndex = replyIndex,
|
||||||
|
imageIndex = imageIndex,
|
||||||
)
|
)
|
||||||
logger.debug("已保存对话上下文到缓存")
|
logger.debug("已保存对话上下文到缓存")
|
||||||
}
|
}
|
||||||
@@ -901,6 +944,7 @@ object JChatGPT : KotlinPlugin(
|
|||||||
} finally {
|
} finally {
|
||||||
// 清理本轮回复索引
|
// 清理本轮回复索引
|
||||||
replyIndexMap.remove(event.subject.id)
|
replyIndexMap.remove(event.subject.id)
|
||||||
|
imageIndexMap.remove(event.subject.id)
|
||||||
// 一段时间后才允许再次提问,防止高频对话
|
// 一段时间后才允许再次提问,防止高频对话
|
||||||
launch {
|
launch {
|
||||||
delay(500.milliseconds)
|
delay(500.milliseconds)
|
||||||
|
|||||||
@@ -66,6 +66,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
|||||||
@ValueDescription("视觉模型额外请求体JSON,会合并到请求体中。")
|
@ValueDescription("视觉模型额外请求体JSON,会合并到请求体中。")
|
||||||
val visualModelExtraBody: String by value("")
|
val visualModelExtraBody: String by value("")
|
||||||
|
|
||||||
|
@ValueDescription("视觉模型是否先由机器人下载图片并以Base64上传。建议开启,可避免百炼下载QQ临时图片链接失败")
|
||||||
|
val visualImageBase64Enabled: Boolean by value(true)
|
||||||
|
|
||||||
|
@ValueDescription("视觉模型单次工具调用的最大尝试次数,取值1~3,默认2次。图片只下载和编码一次,重试仅重新请求模型")
|
||||||
|
val visualRetryMax: Int by value(2)
|
||||||
|
|
||||||
@ValueDescription("百炼平台API KEY")
|
@ValueDescription("百炼平台API KEY")
|
||||||
val dashScopeApiKey: String by value("")
|
val dashScopeApiKey: String by value("")
|
||||||
|
|
||||||
@@ -87,6 +93,18 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
|||||||
@ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。")
|
@ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。")
|
||||||
val glotToken: String by value("")
|
val glotToken: String by value("")
|
||||||
|
|
||||||
|
@ValueDescription("和风天气专属 API Host,例如 abc1234xyz.def.qweatherapi.com")
|
||||||
|
val qWeatherApiHost: String by value("")
|
||||||
|
|
||||||
|
@ValueDescription("和风天气项目 ID,用于 JWT 的 sub")
|
||||||
|
val qWeatherProjectId: String by value("")
|
||||||
|
|
||||||
|
@ValueDescription("和风天气凭据 ID,用于 JWT 的 kid")
|
||||||
|
val qWeatherCredentialId: String by value("")
|
||||||
|
|
||||||
|
@ValueDescription("和风天气 Ed25519 私钥文件路径,相对于插件配置目录,也可以填写绝对路径")
|
||||||
|
val qWeatherPrivateKeyPath: String by value("qweather-ed25519-private.pem")
|
||||||
|
|
||||||
@ValueDescription("群管理是否自动拥有对话权限,默认是")
|
@ValueDescription("群管理是否自动拥有对话权限,默认是")
|
||||||
val groupOpHasChatPermission: Boolean by value(true)
|
val groupOpHasChatPermission: Boolean by value(true)
|
||||||
|
|
||||||
|
|||||||
@@ -13,31 +13,34 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
import kotlinx.serialization.json.add
|
import kotlinx.serialization.json.add
|
||||||
import kotlinx.serialization.json.addJsonObject
|
import kotlinx.serialization.json.addJsonObject
|
||||||
import kotlinx.serialization.json.buildJsonObject
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
import kotlinx.serialization.json.jsonArray
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonObject
|
import kotlinx.serialization.json.jsonObject
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
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 kotlinx.serialization.json.putJsonObject
|
||||||
|
import net.mamoe.mirai.event.events.MessageEvent
|
||||||
import top.jie65535.mirai.JChatGPT
|
import top.jie65535.mirai.JChatGPT
|
||||||
import top.jie65535.mirai.PluginConfig
|
import top.jie65535.mirai.PluginConfig
|
||||||
|
|
||||||
class ImageAgent : BaseAgent(
|
class ImageAgent : BaseAgent(
|
||||||
tool = Tool.function(
|
tool = Tool.function(
|
||||||
name = "imageAgent",
|
name = "imageAgent",
|
||||||
description = "调用千问图像模型生成或编辑图片。不传 image_urls 即纯文生图;" +
|
description = "调用千问图像模型生成或编辑图片。不传 image_indices 即纯文生图;" +
|
||||||
"传 1~3 张图片可进行编辑、修改或多图融合。" +
|
"传 1~3 张图片可进行编辑、修改或多图融合。" +
|
||||||
"备注:该方法成本较高,非必要尽量不要调用。" +
|
"备注:该方法成本较高,非必要尽量不要调用。" +
|
||||||
"编辑图片前无需识别图片内容,模型自己会理解图片内容。",
|
"编辑图片前无需识别图片内容,模型自己会理解图片内容。",
|
||||||
parameters = Parameters.buildJsonObject {
|
parameters = Parameters.buildJsonObject {
|
||||||
put("type", "object")
|
put("type", "object")
|
||||||
putJsonObject("properties") {
|
putJsonObject("properties") {
|
||||||
putJsonObject("image_urls") {
|
putJsonObject("image_indices") {
|
||||||
put("type", "array")
|
put("type", "array")
|
||||||
putJsonObject("items") {
|
putJsonObject("items") {
|
||||||
put("type", "string")
|
put("type", "integer")
|
||||||
|
put("minimum", 1)
|
||||||
}
|
}
|
||||||
put("description", "参考图片地址列表,可传 0~3 张。" +
|
put("description", "用户消息中[图片n]或[表情包n]标记的参考图片编号,可传 0~3 张。" +
|
||||||
"不传或为空即纯文生图;传 1 张为编辑;多张为融合,输出比例与最后一张对齐。")
|
"不传或为空即纯文生图;传 1 张为编辑;多张为融合,输出比例与最后一张对齐。")
|
||||||
}
|
}
|
||||||
putJsonObject("prompt") {
|
putJsonObject("prompt") {
|
||||||
@@ -61,12 +64,17 @@ class ImageAgent : BaseAgent(
|
|||||||
override val loadingMessage: String
|
override val loadingMessage: String
|
||||||
get() = "作图中..."
|
get() = "作图中..."
|
||||||
|
|
||||||
override suspend fun execute(args: JsonObject?): String {
|
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||||
requireNotNull(args)
|
requireNotNull(args)
|
||||||
val prompt = args.getValue("prompt").jsonPrimitive.content
|
val prompt = args.getValue("prompt").jsonPrimitive.content
|
||||||
val imageUrls = args["image_urls"]?.jsonArray
|
val imageIndices = args["image_indices"]?.jsonArray
|
||||||
?.map { it.jsonPrimitive.content }
|
?.map { it.jsonPrimitive.int }
|
||||||
?: emptyList()
|
?: emptyList()
|
||||||
|
require(imageIndices.size <= 3) { "参考图片最多只能传3张" }
|
||||||
|
val imageUrls = imageIndices.map { imageIndex ->
|
||||||
|
JChatGPT.lookupImageUrl(event.subject.id, imageIndex)
|
||||||
|
?: throw IllegalArgumentException("图片编号[$imageIndex]不存在或已失效")
|
||||||
|
}
|
||||||
|
|
||||||
val response = httpClient.post(API_URL) {
|
val response = httpClient.post(API_URL) {
|
||||||
contentType(ContentType("application", "json"))
|
contentType(ContentType("application", "json"))
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import net.mamoe.mirai.message.data.SingleMessage
|
|||||||
import net.mamoe.mirai.message.data.content
|
import net.mamoe.mirai.message.data.content
|
||||||
import top.jie65535.mirai.JChatGPT
|
import top.jie65535.mirai.JChatGPT
|
||||||
import top.jie65535.mirai.PluginConfig
|
import top.jie65535.mirai.PluginConfig
|
||||||
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder
|
import top.jie65535.mirai.ChatHistoryStore
|
||||||
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord
|
import top.jie65535.mirai.ChatMessageRecord
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.LocalDateTime
|
import java.time.LocalDateTime
|
||||||
import java.time.OffsetDateTime
|
import java.time.OffsetDateTime
|
||||||
@@ -92,17 +92,13 @@ class SearchChatHistory : BaseAgent(
|
|||||||
val maxRecords = PluginConfig.searchHistoryMaxRecords
|
val maxRecords = PluginConfig.searchHistoryMaxRecords
|
||||||
|
|
||||||
val records = try {
|
val records = try {
|
||||||
// 有 sender 时用 Member 重载,在数据库层过滤 fromId;否则用 Contact 重载
|
ChatHistoryStore.query(
|
||||||
if (senderQq != null && event is GroupMessageEvent) {
|
contact = event.subject,
|
||||||
val member = event.group[senderQq]
|
start = startEpoch,
|
||||||
if (member != null) {
|
end = endEpoch,
|
||||||
MiraiHibernateRecorder[member, startEpoch, endEpoch]
|
limit = maxRecords,
|
||||||
} else {
|
fromId = senderQq,
|
||||||
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
|
).sortedBy { it.time }
|
||||||
}
|
|
||||||
} else {
|
|
||||||
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
|
|
||||||
}.take(maxRecords).sortedBy { it.time }
|
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
JChatGPT.logger.warning("查询消息历史失败", e)
|
JChatGPT.logger.warning("查询消息历史失败", e)
|
||||||
return "查询消息历史失败: ${e.message}"
|
return "查询消息历史失败: ${e.message}"
|
||||||
@@ -148,7 +144,7 @@ class SearchChatHistory : BaseAgent(
|
|||||||
|
|
||||||
private suspend fun appendHistory(
|
private suspend fun appendHistory(
|
||||||
sb: StringBuilder,
|
sb: StringBuilder,
|
||||||
records: List<MessageRecord>,
|
records: List<ChatMessageRecord>,
|
||||||
event: MessageEvent
|
event: MessageEvent
|
||||||
) {
|
) {
|
||||||
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
|
||||||
@@ -172,19 +168,21 @@ class SearchChatHistory : BaseAgent(
|
|||||||
.append(":")
|
.append(":")
|
||||||
}
|
}
|
||||||
for (msg in record.toMessageChain()) {
|
for (msg in record.toMessageChain()) {
|
||||||
sb.append(singleMessageToText(msg))
|
sb.append(singleMessageToText(msg, event.subject.id))
|
||||||
}
|
}
|
||||||
sb.appendLine()
|
sb.appendLine()
|
||||||
lastFromId = record.fromId
|
lastFromId = record.fromId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun singleMessageToText(msg: SingleMessage): String {
|
private suspend fun singleMessageToText(msg: SingleMessage, subjectId: Long): String {
|
||||||
return when (msg) {
|
return when (msg) {
|
||||||
is Image -> {
|
is Image -> {
|
||||||
try {
|
try {
|
||||||
val url = msg.queryUrl()
|
val imageUrl = msg.queryUrl()
|
||||||
""
|
val index = JChatGPT.registerImage(subjectId, msg.imageId, imageUrl)
|
||||||
|
?: return msg.content
|
||||||
|
"[${if (msg.isEmoji) "表情包" else "图片"}$index]"
|
||||||
} catch (_: Throwable) {
|
} catch (_: Throwable) {
|
||||||
msg.content
|
msg.content
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,30 +2,47 @@ package top.jie65535.mirai.tools
|
|||||||
|
|
||||||
import com.aallam.openai.api.chat.ChatCompletionRequest
|
import com.aallam.openai.api.chat.ChatCompletionRequest
|
||||||
import com.aallam.openai.api.chat.ChatMessage
|
import com.aallam.openai.api.chat.ChatMessage
|
||||||
|
import com.aallam.openai.api.chat.ContentPart
|
||||||
import com.aallam.openai.api.chat.ImagePart
|
import com.aallam.openai.api.chat.ImagePart
|
||||||
import com.aallam.openai.api.chat.TextPart
|
import com.aallam.openai.api.chat.TextPart
|
||||||
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 com.aallam.openai.api.model.ModelId
|
import com.aallam.openai.api.model.ModelId
|
||||||
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
import kotlinx.serialization.json.JsonObject
|
import kotlinx.serialization.json.JsonObject
|
||||||
import kotlinx.serialization.json.add
|
import kotlinx.serialization.json.add
|
||||||
|
import kotlinx.serialization.json.int
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
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 kotlinx.serialization.json.putJsonObject
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.sync.Semaphore
|
||||||
|
import kotlinx.coroutines.sync.withPermit
|
||||||
|
import net.mamoe.mirai.event.events.MessageEvent
|
||||||
|
import top.jie65535.mirai.JChatGPT
|
||||||
import top.jie65535.mirai.LargeLanguageModels
|
import top.jie65535.mirai.LargeLanguageModels
|
||||||
import top.jie65535.mirai.PluginConfig
|
import top.jie65535.mirai.PluginConfig
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
class VisualAgent : BaseAgent(
|
class VisualAgent : BaseAgent(
|
||||||
tool = Tool.function(
|
tool = Tool.function(
|
||||||
name = "imageRecognition",
|
name = "imageRecognition",
|
||||||
description = "可通过调用视觉模型来识别图片内容。备注:该方法成本较高,非必要尽量不要调用。",
|
description = "可通过调用视觉模型识别一张或多张图片,并进行比较、关联或顺序理解。备注:该方法成本较高,非必要尽量不要调用。",
|
||||||
parameters = Parameters.buildJsonObject {
|
parameters = Parameters.buildJsonObject {
|
||||||
put("type", "object")
|
put("type", "object")
|
||||||
putJsonObject("properties") {
|
putJsonObject("properties") {
|
||||||
putJsonObject("image_url") {
|
putJsonObject("image_indices") {
|
||||||
put("type", "string")
|
put("type", "array")
|
||||||
put("description", "图片地址")
|
put("description", "用户消息中[图片n]或[表情包n]标记的图片编号数组,按需要理解的顺序传入")
|
||||||
|
put("minItems", 1)
|
||||||
|
put("maxItems", MAX_SOURCE_IMAGES)
|
||||||
|
putJsonObject("items") {
|
||||||
|
put("type", "integer")
|
||||||
|
put("minimum", 1)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
putJsonObject("prompt") {
|
putJsonObject("prompt") {
|
||||||
put("type", "string")
|
put("type", "string")
|
||||||
@@ -33,44 +50,157 @@ class VisualAgent : BaseAgent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
putJsonArray("required") {
|
putJsonArray("required") {
|
||||||
add("image_url")
|
add("image_indices")
|
||||||
add("prompt")
|
add("prompt")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
private val imageResolver = VisualImageResolver()
|
||||||
|
private val concurrencyLimiter = Semaphore(VISUAL_MAX_CONCURRENCY)
|
||||||
|
|
||||||
override val loadingMessage: String
|
override val loadingMessage: String
|
||||||
get() = "识别中..."
|
get() = "识别中..."
|
||||||
|
|
||||||
override val isEnabled: Boolean
|
override val isEnabled: Boolean
|
||||||
get() = LargeLanguageModels.visual != null
|
get() = LargeLanguageModels.visual != null
|
||||||
|
|
||||||
override suspend fun execute(args: JsonObject?): String {
|
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||||
requireNotNull(args)
|
requireNotNull(args)
|
||||||
val llm = LargeLanguageModels.visual ?: return "未配置llm,无法进行识别。"
|
val llm = LargeLanguageModels.visual ?: return "未配置llm,无法进行识别。"
|
||||||
val imageUrl = args.getValue("image_url").jsonPrimitive.content
|
val imageIndices = args["image_indices"]?.jsonArray
|
||||||
|
?.map { it.jsonPrimitive.int }
|
||||||
|
?.ifEmpty { null }
|
||||||
|
?: throw IllegalArgumentException("至少需要提供一张图片")
|
||||||
|
require(imageIndices.size <= MAX_SOURCE_IMAGES) { "单次最多处理 $MAX_SOURCE_IMAGES 张用户图片" }
|
||||||
|
val imageUrls = imageIndices.map { imageIndex ->
|
||||||
|
JChatGPT.lookupImageUrl(event.subject.id, imageIndex)
|
||||||
|
?: throw IllegalArgumentException("图片编号[$imageIndex]不存在或已失效")
|
||||||
|
}
|
||||||
val prompt = args.getValue("prompt").jsonPrimitive.content
|
val prompt = args.getValue("prompt").jsonPrimitive.content
|
||||||
|
|
||||||
val answerContent = StringBuilder()
|
return concurrencyLimiter.withPermit {
|
||||||
llm.chatCompletions(ChatCompletionRequest(
|
val imageGroups = imageUrls.mapIndexed { index, imageUrl ->
|
||||||
model = ModelId(PluginConfig.visualModel),
|
if (PluginConfig.visualImageBase64Enabled) {
|
||||||
messages = listOf(
|
val host = runCatching { URI(imageUrl).host }.getOrNull() ?: "unknown"
|
||||||
ChatMessage.System("You are a helpful assistant."),
|
val resolved = try {
|
||||||
ChatMessage.User(
|
imageResolver.resolve(imageUrl)
|
||||||
content = listOf(
|
} catch (e: Throwable) {
|
||||||
ImagePart(imageUrl),
|
JChatGPT.logger.error(
|
||||||
TextPart(prompt)
|
"视觉图片下载失败: image=${imageIndices[index]}, url=$imageUrl"
|
||||||
|
)
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
val mimeTypes = resolved.images.map { it.mimeType }.distinct().joinToString()
|
||||||
|
JChatGPT.logger.info(
|
||||||
|
"视觉图片已本地化: image=${imageIndices[index]}, source=${index + 1}/${imageUrls.size}, host=$host, " +
|
||||||
|
"parts=${resolved.images.size}, mime=$mimeTypes, " +
|
||||||
|
"sourceBytes=${resolved.sourceSize}, payloadChars=${resolved.payloadSize}, " +
|
||||||
|
"transcoded=${resolved.transcoded}"
|
||||||
)
|
)
|
||||||
)
|
PreparedImageGroup(
|
||||||
)
|
inputs = resolved.images.map { it.dataUrl },
|
||||||
)).collect {
|
orderHint = resolved.orderHint,
|
||||||
if (it.choices.isNotEmpty()) {
|
payloadSize = resolved.payloadSize,
|
||||||
val delta = it.choices[0].delta ?: return@collect
|
)
|
||||||
if (!delta.content.isNullOrEmpty()) {
|
} else {
|
||||||
answerContent.append(delta.content)
|
PreparedImageGroup(inputs = listOf(imageUrl), orderHint = null, payloadSize = 0)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
val modelImageCount = imageGroups.sumOf { it.inputs.size }
|
||||||
|
val totalPayloadSize = imageGroups.sumOf { it.payloadSize }
|
||||||
|
require(modelImageCount <= MAX_MODEL_IMAGES) {
|
||||||
|
"图片及长图切片共 $modelImageCount 张,超过单次工程限制 $MAX_MODEL_IMAGES 张"
|
||||||
|
}
|
||||||
|
require(totalPayloadSize <= MAX_TOTAL_PAYLOAD_CHARS) {
|
||||||
|
"图片 Base64 总大小超过 ${MAX_TOTAL_PAYLOAD_CHARS / 1_000_000}MB 工程限制"
|
||||||
|
}
|
||||||
|
val messageContent = buildMessageContent(imageGroups, prompt)
|
||||||
|
|
||||||
|
val maxAttempts = PluginConfig.visualRetryMax.coerceIn(1, 3)
|
||||||
|
var lastError: Throwable? = null
|
||||||
|
repeat(maxAttempts) { attempt ->
|
||||||
|
try {
|
||||||
|
val answerContent = StringBuilder()
|
||||||
|
llm.chatCompletions(
|
||||||
|
ChatCompletionRequest(
|
||||||
|
model = ModelId(PluginConfig.visualModel),
|
||||||
|
messages = listOf(
|
||||||
|
ChatMessage.User(
|
||||||
|
content = messageContent
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).collect {
|
||||||
|
if (it.choices.isNotEmpty()) {
|
||||||
|
val delta = it.choices[0].delta ?: return@collect
|
||||||
|
if (!delta.content.isNullOrEmpty()) {
|
||||||
|
answerContent.append(delta.content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (answerContent.isNotEmpty()) {
|
||||||
|
return@withPermit answerContent.toString()
|
||||||
|
}
|
||||||
|
throw IllegalStateException("识图异常,结果为空")
|
||||||
|
} catch (e: CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
if (!isRetryable(e)) throw e
|
||||||
|
lastError = e
|
||||||
|
if (attempt + 1 < maxAttempts) {
|
||||||
|
JChatGPT.logger.warning(
|
||||||
|
"视觉模型调用失败,将进行第 ${attempt + 2}/$maxAttempts 次尝试",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
delay(RETRY_BASE_DELAY_MILLIS * (attempt + 1L))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw lastError ?: IllegalStateException("视觉模型调用失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val VISUAL_MAX_CONCURRENCY = 2
|
||||||
|
private const val RETRY_BASE_DELAY_MILLIS = 800L
|
||||||
|
private const val MAX_SOURCE_IMAGES = 16
|
||||||
|
private const val MAX_MODEL_IMAGES = 32
|
||||||
|
private const val MAX_TOTAL_PAYLOAD_CHARS = 48_000_000
|
||||||
|
|
||||||
|
private data class PreparedImageGroup(
|
||||||
|
val inputs: List<String>,
|
||||||
|
val orderHint: String?,
|
||||||
|
val payloadSize: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildMessageContent(groups: List<PreparedImageGroup>, prompt: String): List<ContentPart> {
|
||||||
|
if (groups.size == 1 && groups[0].inputs.size == 1) {
|
||||||
|
return listOf(ImagePart(groups[0].inputs[0]), TextPart(prompt))
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildList {
|
||||||
|
groups.forEachIndexed { groupIndex, group ->
|
||||||
|
add(
|
||||||
|
TextPart(
|
||||||
|
"用户图片 ${groupIndex + 1}/${groups.size}" +
|
||||||
|
if (group.inputs.size > 1) ",已切分为 ${group.inputs.size} 张连续切片:" else ":"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
group.inputs.forEach { add(ImagePart(it)) }
|
||||||
|
group.orderHint?.let { add(TextPart(it)) }
|
||||||
|
}
|
||||||
|
add(TextPart("请结合以上所有用户图片回答:$prompt"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isRetryable(error: Throwable): Boolean {
|
||||||
|
if (error is ClientRequestException) {
|
||||||
|
return error.response.status.value in setOf(408, 409, 425, 429)
|
||||||
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
return answerContent.toString().ifEmpty { "识图异常,结果为空" }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,695 @@
|
|||||||
|
package top.jie65535.mirai.tools
|
||||||
|
|
||||||
|
import io.ktor.client.HttpClient
|
||||||
|
import io.ktor.client.engine.okhttp.OkHttp
|
||||||
|
import io.ktor.client.plugins.HttpTimeout
|
||||||
|
import io.ktor.client.request.get
|
||||||
|
import io.ktor.client.request.header
|
||||||
|
import io.ktor.client.statement.bodyAsChannel
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.isSuccess
|
||||||
|
import io.ktor.utils.io.cancel
|
||||||
|
import io.ktor.utils.io.readAvailable
|
||||||
|
import okhttp3.Dns
|
||||||
|
import java.awt.Color
|
||||||
|
import java.awt.Rectangle
|
||||||
|
import java.awt.RenderingHints
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import java.io.ByteArrayInputStream
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.net.Inet4Address
|
||||||
|
import java.net.Inet6Address
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.UnknownHostException
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.imageio.IIOImage
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
import javax.imageio.ImageReader
|
||||||
|
import javax.imageio.ImageWriteParam
|
||||||
|
import kotlin.math.ceil
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将公网图片安全下载到机器人侧,并转换为视觉模型可直接接收的 Base64 Data URL。
|
||||||
|
*
|
||||||
|
* 百炼通过公网 URL 拉取图片时要求源站返回正确的 Content-Length 与 Content-Type,
|
||||||
|
* QQ CDN 链接不总能满足该条件。改由机器人下载后上传可避免百炼二次拉取失败。
|
||||||
|
*/
|
||||||
|
internal class VisualImageResolver {
|
||||||
|
data class ImagePayload(
|
||||||
|
val dataUrl: String,
|
||||||
|
val mimeType: String,
|
||||||
|
val payloadSize: Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class Result(
|
||||||
|
val images: List<ImagePayload>,
|
||||||
|
val sourceSize: Int,
|
||||||
|
val transcoded: Boolean,
|
||||||
|
val orderHint: String? = null,
|
||||||
|
) {
|
||||||
|
val payloadSize: Int
|
||||||
|
get() = images.sumOf { it.payloadSize }
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ImageInfo(
|
||||||
|
val width: Int,
|
||||||
|
val height: Int,
|
||||||
|
val readerFormat: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
private enum class ImageFormat(val mimeType: String) {
|
||||||
|
BMP("image/bmp"),
|
||||||
|
JPEG("image/jpeg"),
|
||||||
|
PNG("image/png"),
|
||||||
|
TIFF("image/tiff"),
|
||||||
|
WEBP("image/webp"),
|
||||||
|
HEIC("image/heic"),
|
||||||
|
GIF("image/gif"),
|
||||||
|
}
|
||||||
|
|
||||||
|
private val httpClient = HttpClient(OkHttp) {
|
||||||
|
followRedirects = false
|
||||||
|
expectSuccess = false
|
||||||
|
install(HttpTimeout) {
|
||||||
|
requestTimeoutMillis = DOWNLOAD_TIMEOUT_MILLIS
|
||||||
|
connectTimeoutMillis = CONNECT_TIMEOUT_MILLIS
|
||||||
|
socketTimeoutMillis = DOWNLOAD_TIMEOUT_MILLIS
|
||||||
|
}
|
||||||
|
engine {
|
||||||
|
config {
|
||||||
|
dns(PublicOnlyDns)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun resolve(rawUrl: String): Result {
|
||||||
|
var currentUrl = validateUrl(rawUrl)
|
||||||
|
|
||||||
|
repeat(MAX_REDIRECTS + 1) { redirectCount ->
|
||||||
|
val response = httpClient.get(currentUrl.toASCIIString()) {
|
||||||
|
header(HttpHeaders.Accept, "image/*")
|
||||||
|
header(HttpHeaders.UserAgent, USER_AGENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status.value in REDIRECT_STATUS_CODES) {
|
||||||
|
response.bodyAsChannel().cancel()
|
||||||
|
if (redirectCount >= MAX_REDIRECTS) {
|
||||||
|
throw IllegalArgumentException("图片下载重定向次数过多")
|
||||||
|
}
|
||||||
|
val location = response.headers[HttpHeaders.Location]
|
||||||
|
?: throw IllegalArgumentException("图片下载重定向缺少 Location")
|
||||||
|
currentUrl = validateUrl(currentUrl.resolve(location).toString())
|
||||||
|
return@repeat
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.status.isSuccess()) {
|
||||||
|
val errorBody = readErrorBody(response.bodyAsChannel())
|
||||||
|
val errorNumber = response.headers["X-ErrNo"]
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
buildString {
|
||||||
|
append("图片下载失败:HTTP ").append(response.status.value)
|
||||||
|
if (!errorNumber.isNullOrBlank()) append(",X-ErrNo=").append(errorNumber)
|
||||||
|
if (errorBody.isNotBlank()) append(",响应=").append(errorBody)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val declaredLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull()
|
||||||
|
if (declaredLength != null && declaredLength > MAX_DOWNLOAD_BYTES) {
|
||||||
|
response.bodyAsChannel().cancel()
|
||||||
|
throw IllegalArgumentException("图片文件过大:$declaredLength 字节,下载上限为 $MAX_DOWNLOAD_BYTES 字节")
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytes = readBodyLimited(response.bodyAsChannel())
|
||||||
|
val declaredContentType = response.headers[HttpHeaders.ContentType]?.substringBefore(';')?.trim()
|
||||||
|
return prepare(bytes, declaredContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
error("无法完成图片下载")
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun prepare(bytes: ByteArray, declaredContentType: String? = null): Result {
|
||||||
|
require(bytes.isNotEmpty()) { "下载到的图片内容为空" }
|
||||||
|
|
||||||
|
val info = inspectImage(bytes)
|
||||||
|
val format = detectFormat(bytes)
|
||||||
|
?: info?.readerFormat?.let(::formatFromReaderName)
|
||||||
|
?: throw IllegalArgumentException(
|
||||||
|
"无法识别图片格式${declaredContentType?.let { "(响应类型为 $it)" } ?: ""}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (info == null && format in IMAGE_IO_FORMATS) {
|
||||||
|
throw IllegalArgumentException("图片文件已损坏或无法解码:${format.mimeType}")
|
||||||
|
}
|
||||||
|
|
||||||
|
validateDimensions(info)
|
||||||
|
|
||||||
|
if (shouldSplitLongImage(info)) {
|
||||||
|
return splitLongImage(bytes, info!!, format)
|
||||||
|
}
|
||||||
|
|
||||||
|
val needsTranscode = format == ImageFormat.GIF ||
|
||||||
|
!fitsDataUrl(bytes, format.mimeType) ||
|
||||||
|
needsGeometryNormalization(info)
|
||||||
|
|
||||||
|
if (!needsTranscode) {
|
||||||
|
return Result(
|
||||||
|
images = listOf(buildPayload(bytes, format.mimeType)),
|
||||||
|
sourceSize = bytes.size,
|
||||||
|
transcoded = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val decoded = decodeImage(bytes, info)
|
||||||
|
?: throw IllegalArgumentException("图片需要转换,但当前 JVM 无法解码 ${format.mimeType} 格式")
|
||||||
|
val normalized = normalizeSize(decoded)
|
||||||
|
val preferPng = format == ImageFormat.PNG || format == ImageFormat.GIF || normalized.colorModel.hasAlpha()
|
||||||
|
return Result(
|
||||||
|
images = listOf(encodeTranscoded(normalized, preferPng)),
|
||||||
|
sourceSize = bytes.size,
|
||||||
|
transcoded = true,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readBodyLimited(channel: io.ktor.utils.io.ByteReadChannel): ByteArray {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
|
||||||
|
var total = 0
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
val count = channel.readAvailable(buffer)
|
||||||
|
if (count < 0) break
|
||||||
|
if (count == 0) continue
|
||||||
|
total += count
|
||||||
|
if (total > MAX_DOWNLOAD_BYTES) {
|
||||||
|
throw IllegalArgumentException("图片文件超过下载上限 $MAX_DOWNLOAD_BYTES 字节")
|
||||||
|
}
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
}
|
||||||
|
return output.toByteArray()
|
||||||
|
} finally {
|
||||||
|
channel.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun readErrorBody(channel: io.ktor.utils.io.ByteReadChannel): String {
|
||||||
|
val output = ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(DOWNLOAD_BUFFER_SIZE)
|
||||||
|
var total = 0
|
||||||
|
try {
|
||||||
|
while (total < MAX_ERROR_RESPONSE_BYTES) {
|
||||||
|
val count = channel.readAvailable(
|
||||||
|
buffer,
|
||||||
|
0,
|
||||||
|
min(buffer.size, MAX_ERROR_RESPONSE_BYTES - total)
|
||||||
|
)
|
||||||
|
if (count < 0) break
|
||||||
|
if (count == 0) continue
|
||||||
|
output.write(buffer, 0, count)
|
||||||
|
total += count
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
channel.cancel()
|
||||||
|
}
|
||||||
|
return output.toByteArray()
|
||||||
|
.toString(Charsets.UTF_8)
|
||||||
|
.replace(Regex("[\\r\\n]+"), " ")
|
||||||
|
.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPayload(bytes: ByteArray, mimeType: String): ImagePayload {
|
||||||
|
val encoded = Base64.getEncoder().encodeToString(bytes)
|
||||||
|
val dataUrl = "data:$mimeType;base64,$encoded"
|
||||||
|
require(dataUrl.length <= MAX_DATA_URL_LENGTH) {
|
||||||
|
"图片 Base64 编码后超过百炼 10MB 限制"
|
||||||
|
}
|
||||||
|
return ImagePayload(
|
||||||
|
dataUrl = dataUrl,
|
||||||
|
mimeType = mimeType,
|
||||||
|
payloadSize = dataUrl.length,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeTranscoded(image: BufferedImage, preferPng: Boolean): ImagePayload {
|
||||||
|
// PNG 常用于截图、表情和带透明通道的图片,先尝试无损编码,避免文字细节被 JPEG 损伤。
|
||||||
|
if (preferPng || image.colorModel.hasAlpha()) {
|
||||||
|
val png = encodePng(image)
|
||||||
|
if (fitsDataUrl(png, ImageFormat.PNG.mimeType)) {
|
||||||
|
return buildPayload(png, ImageFormat.PNG.mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = image
|
||||||
|
repeat(MAX_COMPRESSION_ROUNDS) {
|
||||||
|
for (quality in JPEG_QUALITIES) {
|
||||||
|
val jpeg = encodeJpeg(candidate, quality)
|
||||||
|
if (fitsDataUrl(jpeg, ImageFormat.JPEG.mimeType)) {
|
||||||
|
return buildPayload(jpeg, ImageFormat.JPEG.mimeType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val nextWidth = max(MIN_IMAGE_DIMENSION + 1, (candidate.width * DOWNSCALE_FACTOR).roundToInt())
|
||||||
|
val nextHeight = max(MIN_IMAGE_DIMENSION + 1, (candidate.height * DOWNSCALE_FACTOR).roundToInt())
|
||||||
|
if (nextWidth == candidate.width && nextHeight == candidate.height) {
|
||||||
|
return@repeat
|
||||||
|
}
|
||||||
|
candidate = scale(candidate, nextWidth, nextHeight, alpha = false)
|
||||||
|
}
|
||||||
|
|
||||||
|
throw IllegalArgumentException("图片压缩后仍超过百炼 Base64 10MB 限制")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fitsDataUrl(bytes: ByteArray, mimeType: String): Boolean {
|
||||||
|
val prefixLength = "data:$mimeType;base64,".length
|
||||||
|
val encodedLength = 4L * ((bytes.size.toLong() + 2L) / 3L)
|
||||||
|
return prefixLength + encodedLength <= MAX_DATA_URL_LENGTH
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun inspectImage(bytes: ByteArray): ImageInfo? {
|
||||||
|
return try {
|
||||||
|
ImageIO.createImageInputStream(ByteArrayInputStream(bytes)).use { input ->
|
||||||
|
val readers = ImageIO.getImageReaders(input)
|
||||||
|
if (!readers.hasNext()) return null
|
||||||
|
val reader = readers.next()
|
||||||
|
try {
|
||||||
|
reader.input = input
|
||||||
|
ImageInfo(
|
||||||
|
width = reader.getWidth(0),
|
||||||
|
height = reader.getHeight(0),
|
||||||
|
readerFormat = reader.formatName,
|
||||||
|
)
|
||||||
|
} finally {
|
||||||
|
reader.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateDimensions(info: ImageInfo?) {
|
||||||
|
if (info == null) return
|
||||||
|
require(info.width > 0 && info.height > 0) { "图片宽高无效" }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun needsGeometryNormalization(info: ImageInfo?): Boolean {
|
||||||
|
if (info == null) return false
|
||||||
|
val pixels = info.width.toLong() * info.height.toLong()
|
||||||
|
val ratio = max(info.width, info.height).toDouble() / min(info.width, info.height).toDouble()
|
||||||
|
return min(info.width, info.height) < NORMALIZED_MIN_EDGE ||
|
||||||
|
ratio > MAX_ASPECT_RATIO ||
|
||||||
|
max(info.width, info.height) > NORMALIZED_MAX_EDGE ||
|
||||||
|
pixels > NORMALIZED_MAX_PIXELS
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun shouldSplitLongImage(info: ImageInfo?): Boolean {
|
||||||
|
if (info == null) return false
|
||||||
|
val longEdge = max(info.width, info.height)
|
||||||
|
val shortEdge = min(info.width, info.height)
|
||||||
|
val splitRatio = if (info.height > info.width) {
|
||||||
|
VERTICAL_LONG_IMAGE_SPLIT_RATIO
|
||||||
|
} else {
|
||||||
|
HORIZONTAL_LONG_IMAGE_SPLIT_RATIO
|
||||||
|
}
|
||||||
|
return longEdge >= LONG_IMAGE_MIN_EDGE &&
|
||||||
|
longEdge.toDouble() / shortEdge.toDouble() >= splitRatio
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun splitLongImage(bytes: ByteArray, info: ImageInfo, format: ImageFormat): Result {
|
||||||
|
val vertical = info.height > info.width
|
||||||
|
val regions = calculateTileRegions(info.width, info.height, vertical)
|
||||||
|
val payloads = mutableListOf<ImagePayload>()
|
||||||
|
|
||||||
|
ImageIO.createImageInputStream(ByteArrayInputStream(bytes)).use { input ->
|
||||||
|
val readers = ImageIO.getImageReaders(input)
|
||||||
|
require(readers.hasNext()) { "当前 JVM 无法解码长图 ${format.mimeType}" }
|
||||||
|
val reader = readers.next()
|
||||||
|
try {
|
||||||
|
reader.input = input
|
||||||
|
for (region in regions) {
|
||||||
|
val tile = readRegion(reader, region)
|
||||||
|
val normalized = normalizeSize(tile)
|
||||||
|
val preferPng = format == ImageFormat.PNG || format == ImageFormat.GIF ||
|
||||||
|
normalized.colorModel.hasAlpha()
|
||||||
|
payloads += encodeTranscoded(normalized, preferPng)
|
||||||
|
require(payloads.sumOf { it.payloadSize } <= MAX_TOTAL_DATA_URL_LENGTH) {
|
||||||
|
"长图切片后的 Base64 总大小超过 ${MAX_TOTAL_DATA_URL_LENGTH / 1_000_000}MB 限制"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result(
|
||||||
|
images = payloads,
|
||||||
|
sourceSize = bytes.size,
|
||||||
|
transcoded = true,
|
||||||
|
orderHint = if (vertical) {
|
||||||
|
"这些图片是同一张长图按从上到下顺序切分的,相邻图片有少量重叠,请按顺序连续理解。"
|
||||||
|
} else {
|
||||||
|
"这些图片是同一张宽图按从左到右顺序切分的,相邻图片有少量重叠,请按顺序连续理解。"
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateTileRegions(width: Int, height: Int, vertical: Boolean): List<Rectangle> {
|
||||||
|
val longEdge = if (vertical) height else width
|
||||||
|
val shortEdge = if (vertical) width else height
|
||||||
|
val overlap = (shortEdge * LONG_IMAGE_OVERLAP_RATIO).roundToInt()
|
||||||
|
.coerceIn(LONG_IMAGE_MIN_OVERLAP, LONG_IMAGE_MAX_OVERLAP)
|
||||||
|
.coerceAtMost(max(1, longEdge / 4))
|
||||||
|
val idealTileLength = max(
|
||||||
|
LONG_IMAGE_MIN_TILE_LENGTH,
|
||||||
|
(shortEdge * LONG_IMAGE_TILE_RATIO).roundToInt()
|
||||||
|
).coerceAtMost(longEdge)
|
||||||
|
val idealStep = max(1, idealTileLength - overlap)
|
||||||
|
val requiredParts = ceil((longEdge - idealTileLength).coerceAtLeast(0).toDouble() / idealStep).toInt() + 1
|
||||||
|
val partCount = requiredParts.coerceIn(2, MAX_LONG_IMAGE_PARTS)
|
||||||
|
val tileLength = if (requiredParts <= MAX_LONG_IMAGE_PARTS) {
|
||||||
|
idealTileLength
|
||||||
|
} else {
|
||||||
|
ceil((longEdge + overlap * (partCount - 1)).toDouble() / partCount).toInt()
|
||||||
|
}.coerceAtMost(longEdge)
|
||||||
|
val availableStartRange = longEdge - tileLength
|
||||||
|
|
||||||
|
return List(partCount) { index ->
|
||||||
|
val start = if (partCount == 1) {
|
||||||
|
0
|
||||||
|
} else {
|
||||||
|
(availableStartRange.toDouble() * index / (partCount - 1)).roundToInt()
|
||||||
|
}
|
||||||
|
if (vertical) {
|
||||||
|
Rectangle(0, start, width, min(tileLength, height - start))
|
||||||
|
} else {
|
||||||
|
Rectangle(start, 0, min(tileLength, width - start), height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readRegion(reader: ImageReader, region: Rectangle): BufferedImage {
|
||||||
|
val param = reader.defaultReadParam
|
||||||
|
param.sourceRegion = region
|
||||||
|
val downscale = calculateDownscale(region.width, region.height)
|
||||||
|
if (downscale < 1.0) {
|
||||||
|
val subsampling = ceil(1.0 / downscale).toInt().coerceAtLeast(1)
|
||||||
|
param.setSourceSubsampling(subsampling, subsampling, 0, 0)
|
||||||
|
}
|
||||||
|
return reader.read(0, param)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeImage(bytes: ByteArray, info: ImageInfo?): BufferedImage? {
|
||||||
|
return try {
|
||||||
|
ImageIO.createImageInputStream(ByteArrayInputStream(bytes)).use { input ->
|
||||||
|
val readers = ImageIO.getImageReaders(input)
|
||||||
|
if (!readers.hasNext()) return null
|
||||||
|
val reader = readers.next()
|
||||||
|
try {
|
||||||
|
reader.input = input
|
||||||
|
val width = info?.width ?: reader.getWidth(0)
|
||||||
|
val height = info?.height ?: reader.getHeight(0)
|
||||||
|
val scale = calculateScale(width, height)
|
||||||
|
val targetWidth = max(MIN_IMAGE_DIMENSION + 1, (width * scale).roundToInt())
|
||||||
|
val targetHeight = max(MIN_IMAGE_DIMENSION + 1, (height * scale).roundToInt())
|
||||||
|
val subsampling = max(
|
||||||
|
1,
|
||||||
|
min(width / targetWidth.coerceAtLeast(1), height / targetHeight.coerceAtLeast(1))
|
||||||
|
)
|
||||||
|
val param = reader.defaultReadParam
|
||||||
|
if (subsampling > 1) {
|
||||||
|
param.setSourceSubsampling(subsampling, subsampling, 0, 0)
|
||||||
|
}
|
||||||
|
reader.read(0, param)
|
||||||
|
} finally {
|
||||||
|
reader.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun normalizeSize(image: BufferedImage): BufferedImage {
|
||||||
|
val padded = padToAllowedAspectRatio(image)
|
||||||
|
val scale = calculateScale(padded.width, padded.height)
|
||||||
|
if (scale == 1.0) return padded
|
||||||
|
val targetWidth = max(MIN_IMAGE_DIMENSION + 1, (padded.width * scale).roundToInt())
|
||||||
|
val targetHeight = max(MIN_IMAGE_DIMENSION + 1, (padded.height * scale).roundToInt())
|
||||||
|
val scaled = scale(padded, targetWidth, targetHeight, padded.colorModel.hasAlpha())
|
||||||
|
// 缩放后的整数取整可能让宽高比略微越过 200:1,再补一次边保证最终输入合规。
|
||||||
|
return padToAllowedAspectRatio(scaled)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateScale(width: Int, height: Int): Double {
|
||||||
|
val upperScale = calculateUpperScale(width, height)
|
||||||
|
val lowerScale = NORMALIZED_MIN_EDGE.toDouble() / min(width, height).toDouble()
|
||||||
|
return when {
|
||||||
|
lowerScale > 1.0 -> min(lowerScale, upperScale)
|
||||||
|
upperScale < 1.0 -> upperScale
|
||||||
|
else -> 1.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateUpperScale(width: Int, height: Int): Double {
|
||||||
|
val edgeScale = NORMALIZED_MAX_EDGE.toDouble() / max(width, height).toDouble()
|
||||||
|
val pixelScale = sqrt(NORMALIZED_MAX_PIXELS.toDouble() / (width.toLong() * height.toLong()).toDouble())
|
||||||
|
return min(edgeScale, pixelScale)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun calculateDownscale(width: Int, height: Int): Double {
|
||||||
|
return min(1.0, calculateUpperScale(width, height))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun padToAllowedAspectRatio(source: BufferedImage): BufferedImage {
|
||||||
|
val longEdge = max(source.width, source.height)
|
||||||
|
val shortEdge = min(source.width, source.height)
|
||||||
|
val requiredShortEdge = ceil(longEdge / MAX_ASPECT_RATIO).toInt()
|
||||||
|
if (shortEdge >= requiredShortEdge) return source
|
||||||
|
|
||||||
|
val targetWidth = if (source.width < source.height) requiredShortEdge else source.width
|
||||||
|
val targetHeight = if (source.height < source.width) requiredShortEdge else source.height
|
||||||
|
val alpha = source.colorModel.hasAlpha()
|
||||||
|
val type = if (alpha) BufferedImage.TYPE_INT_ARGB else BufferedImage.TYPE_INT_RGB
|
||||||
|
val target = BufferedImage(targetWidth, targetHeight, type)
|
||||||
|
val graphics = target.createGraphics()
|
||||||
|
try {
|
||||||
|
if (!alpha) {
|
||||||
|
graphics.color = Color.WHITE
|
||||||
|
graphics.fillRect(0, 0, targetWidth, targetHeight)
|
||||||
|
}
|
||||||
|
val offsetX = (targetWidth - source.width) / 2
|
||||||
|
val offsetY = (targetHeight - source.height) / 2
|
||||||
|
graphics.drawImage(source, offsetX, offsetY, null)
|
||||||
|
} finally {
|
||||||
|
graphics.dispose()
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun scale(source: BufferedImage, width: Int, height: Int, alpha: Boolean): BufferedImage {
|
||||||
|
val type = if (alpha) BufferedImage.TYPE_INT_ARGB else BufferedImage.TYPE_INT_RGB
|
||||||
|
val target = BufferedImage(width, height, type)
|
||||||
|
val graphics = target.createGraphics()
|
||||||
|
try {
|
||||||
|
if (!alpha) {
|
||||||
|
graphics.color = Color.WHITE
|
||||||
|
graphics.fillRect(0, 0, width, height)
|
||||||
|
}
|
||||||
|
val isSmallUpscale = (width > source.width || height > source.height) &&
|
||||||
|
source.width <= SMALL_IMAGE_EDGE && source.height <= SMALL_IMAGE_EDGE
|
||||||
|
graphics.setRenderingHint(
|
||||||
|
RenderingHints.KEY_INTERPOLATION,
|
||||||
|
if (isSmallUpscale) {
|
||||||
|
RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR
|
||||||
|
} else {
|
||||||
|
RenderingHints.VALUE_INTERPOLATION_BICUBIC
|
||||||
|
}
|
||||||
|
)
|
||||||
|
graphics.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY)
|
||||||
|
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)
|
||||||
|
graphics.drawImage(source, 0, 0, width, height, null)
|
||||||
|
} finally {
|
||||||
|
graphics.dispose()
|
||||||
|
}
|
||||||
|
return target
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodePng(image: BufferedImage): ByteArray {
|
||||||
|
return ByteArrayOutputStream().use { output ->
|
||||||
|
check(ImageIO.write(image, "png", output)) { "当前 JVM 不支持 PNG 编码" }
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeJpeg(image: BufferedImage, quality: Float): ByteArray {
|
||||||
|
val rgb = if (image.type == BufferedImage.TYPE_INT_RGB && !image.colorModel.hasAlpha()) {
|
||||||
|
image
|
||||||
|
} else {
|
||||||
|
scale(image, image.width, image.height, alpha = false)
|
||||||
|
}
|
||||||
|
val writer = ImageIO.getImageWritersByFormatName("jpeg").asSequence().firstOrNull()
|
||||||
|
?: error("当前 JVM 不支持 JPEG 编码")
|
||||||
|
return try {
|
||||||
|
ByteArrayOutputStream().use { output ->
|
||||||
|
ImageIO.createImageOutputStream(output).use { imageOutput ->
|
||||||
|
writer.output = imageOutput
|
||||||
|
val params = writer.defaultWriteParam
|
||||||
|
if (params.canWriteCompressed()) {
|
||||||
|
params.compressionMode = ImageWriteParam.MODE_EXPLICIT
|
||||||
|
params.compressionQuality = quality
|
||||||
|
}
|
||||||
|
writer.write(null, IIOImage(rgb, null, null), params)
|
||||||
|
}
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
writer.dispose()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun detectFormat(bytes: ByteArray): ImageFormat? {
|
||||||
|
return when {
|
||||||
|
bytes.startsWith(0xFF, 0xD8, 0xFF) -> ImageFormat.JPEG
|
||||||
|
bytes.startsWith(0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A) -> ImageFormat.PNG
|
||||||
|
bytes.startsWithAscii("GIF87a") || bytes.startsWithAscii("GIF89a") -> ImageFormat.GIF
|
||||||
|
bytes.startsWithAscii("BM") -> ImageFormat.BMP
|
||||||
|
bytes.startsWith(0x49, 0x49, 0x2A, 0x00) || bytes.startsWith(0x4D, 0x4D, 0x00, 0x2A) -> ImageFormat.TIFF
|
||||||
|
bytes.size >= 12 && bytes.startsWithAscii("RIFF") && bytes.asciiAt(8, 4) == "WEBP" -> ImageFormat.WEBP
|
||||||
|
bytes.size >= 12 && bytes.asciiAt(4, 4) == "ftyp" && bytes.asciiAt(8, 4) in HEIC_BRANDS -> ImageFormat.HEIC
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatFromReaderName(name: String): ImageFormat? {
|
||||||
|
return when (name.lowercase()) {
|
||||||
|
"bmp" -> ImageFormat.BMP
|
||||||
|
"jpeg", "jpg" -> ImageFormat.JPEG
|
||||||
|
"png" -> ImageFormat.PNG
|
||||||
|
"tif", "tiff" -> ImageFormat.TIFF
|
||||||
|
"webp" -> ImageFormat.WEBP
|
||||||
|
"heic", "heif" -> ImageFormat.HEIC
|
||||||
|
"gif" -> ImageFormat.GIF
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun validateUrl(rawUrl: String): URI {
|
||||||
|
val uri = try {
|
||||||
|
URI(rawUrl.trim())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw IllegalArgumentException("图片地址格式无效", e)
|
||||||
|
}
|
||||||
|
require(uri.scheme?.lowercase() in setOf("http", "https")) { "图片地址仅支持 HTTP/HTTPS" }
|
||||||
|
require(!uri.host.isNullOrBlank()) { "图片地址缺少有效主机名" }
|
||||||
|
require(uri.userInfo == null) { "图片地址不能包含用户凭据" }
|
||||||
|
val host = uri.host.lowercase()
|
||||||
|
require(host != "localhost" && !host.endsWith(".localhost") && !host.endsWith(".local")) {
|
||||||
|
"禁止访问本机或局域网图片地址"
|
||||||
|
}
|
||||||
|
return uri.normalize()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.startsWith(vararg expected: Int): Boolean {
|
||||||
|
if (size < expected.size) return false
|
||||||
|
return expected.indices.all { index -> this[index].toInt() and 0xFF == expected[index] }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ByteArray.startsWithAscii(expected: String): Boolean = asciiAt(0, expected.length) == expected
|
||||||
|
|
||||||
|
private fun ByteArray.asciiAt(offset: Int, length: Int): String? {
|
||||||
|
if (offset < 0 || length < 0 || size < offset + length) return null
|
||||||
|
return String(this, offset, length, Charsets.US_ASCII)
|
||||||
|
}
|
||||||
|
|
||||||
|
private object PublicOnlyDns : Dns {
|
||||||
|
override fun lookup(hostname: String): List<InetAddress> {
|
||||||
|
val addresses = try {
|
||||||
|
Dns.SYSTEM.lookup(hostname)
|
||||||
|
} catch (e: UnknownHostException) {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
if (addresses.isEmpty() || addresses.any { !isPublicAddress(it) }) {
|
||||||
|
throw UnknownHostException("图片地址解析到非公网地址,已拒绝访问")
|
||||||
|
}
|
||||||
|
return addresses
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val DOWNLOAD_TIMEOUT_MILLIS = 30_000L
|
||||||
|
private const val CONNECT_TIMEOUT_MILLIS = 10_000L
|
||||||
|
private const val MAX_REDIRECTS = 3
|
||||||
|
private const val MAX_DOWNLOAD_BYTES = 20_000_000
|
||||||
|
private const val MAX_DATA_URL_LENGTH = 10_000_000L
|
||||||
|
private const val MAX_TOTAL_DATA_URL_LENGTH = 48_000_000
|
||||||
|
private const val DOWNLOAD_BUFFER_SIZE = 16 * 1024
|
||||||
|
private const val MIN_IMAGE_DIMENSION = 10
|
||||||
|
private const val MAX_ASPECT_RATIO = 200.0
|
||||||
|
private const val NORMALIZED_MIN_EDGE = 32
|
||||||
|
private const val NORMALIZED_MAX_EDGE = 4096
|
||||||
|
private const val NORMALIZED_MAX_PIXELS = 16_000_000L
|
||||||
|
private const val SMALL_IMAGE_EDGE = 64
|
||||||
|
private const val LONG_IMAGE_MIN_EDGE = 2048
|
||||||
|
private const val VERTICAL_LONG_IMAGE_SPLIT_RATIO = 3.0
|
||||||
|
private const val HORIZONTAL_LONG_IMAGE_SPLIT_RATIO = 6.0
|
||||||
|
private const val LONG_IMAGE_TILE_RATIO = 2.2
|
||||||
|
private const val LONG_IMAGE_OVERLAP_RATIO = 0.10
|
||||||
|
private const val LONG_IMAGE_MIN_TILE_LENGTH = 512
|
||||||
|
private const val LONG_IMAGE_MIN_OVERLAP = 32
|
||||||
|
private const val LONG_IMAGE_MAX_OVERLAP = 256
|
||||||
|
private const val MAX_LONG_IMAGE_PARTS = 16
|
||||||
|
private const val MAX_COMPRESSION_ROUNDS = 6
|
||||||
|
private const val MAX_ERROR_RESPONSE_BYTES = 4096
|
||||||
|
private const val DOWNSCALE_FACTOR = 0.82
|
||||||
|
private const val USER_AGENT = "JChatGPT/1.13 image-fetcher"
|
||||||
|
private val JPEG_QUALITIES = floatArrayOf(0.90f, 0.82f, 0.74f, 0.66f)
|
||||||
|
private val REDIRECT_STATUS_CODES = setOf(301, 302, 303, 307, 308)
|
||||||
|
private val HEIC_BRANDS = setOf("heic", "heix", "hevc", "hevx", "heim", "heis", "mif1", "msf1")
|
||||||
|
private val IMAGE_IO_FORMATS = setOf(
|
||||||
|
ImageFormat.BMP,
|
||||||
|
ImageFormat.JPEG,
|
||||||
|
ImageFormat.PNG,
|
||||||
|
ImageFormat.TIFF,
|
||||||
|
ImageFormat.GIF,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun isPublicAddress(address: InetAddress): Boolean {
|
||||||
|
if (address.isAnyLocalAddress || address.isLoopbackAddress || address.isLinkLocalAddress ||
|
||||||
|
address.isSiteLocalAddress || address.isMulticastAddress
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val bytes = address.address
|
||||||
|
if (address is Inet4Address && bytes.size == 4) {
|
||||||
|
val first = bytes[0].toInt() and 0xFF
|
||||||
|
val second = bytes[1].toInt() and 0xFF
|
||||||
|
return when {
|
||||||
|
first == 0 -> false
|
||||||
|
first == 10 -> false
|
||||||
|
first == 100 && second in 64..127 -> false
|
||||||
|
first == 127 -> false
|
||||||
|
first == 169 && second == 254 -> false
|
||||||
|
first == 172 && second in 16..31 -> false
|
||||||
|
first == 192 && second == 168 -> false
|
||||||
|
first == 198 && second in 18..19 -> false
|
||||||
|
first >= 224 -> false
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (address is Inet6Address && bytes.isNotEmpty()) {
|
||||||
|
val first = bytes[0].toInt() and 0xFF
|
||||||
|
// fc00::/7 为 IPv6 唯一本地地址,JDK 的 isSiteLocalAddress 不覆盖该范围。
|
||||||
|
if (first and 0xFE == 0xFC) return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,27 +4,63 @@ import com.aallam.openai.api.chat.Tool
|
|||||||
import com.aallam.openai.api.core.Parameters
|
import com.aallam.openai.api.core.Parameters
|
||||||
import io.ktor.client.request.*
|
import io.ktor.client.request.*
|
||||||
import io.ktor.client.statement.*
|
import io.ktor.client.statement.*
|
||||||
|
import io.ktor.http.HttpHeaders
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.http.isSuccess
|
||||||
|
import kotlinx.coroutines.async
|
||||||
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.serialization.json.*
|
import kotlinx.serialization.json.*
|
||||||
|
import net.i2p.crypto.eddsa.EdDSAEngine
|
||||||
|
import net.i2p.crypto.eddsa.EdDSAPrivateKey
|
||||||
|
import net.i2p.crypto.eddsa.spec.EdDSANamedCurveTable
|
||||||
|
import top.jie65535.mirai.JChatGPT
|
||||||
|
import top.jie65535.mirai.PluginConfig
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.spec.PKCS8EncodedKeySpec
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.Base64
|
||||||
|
|
||||||
class WeatherService : BaseAgent(
|
class WeatherService : BaseAgent(
|
||||||
tool = Tool.function(
|
tool = Tool.function(
|
||||||
name = "queryWeather",
|
name = "queryWeather",
|
||||||
description = "可用于查询某城市地区天气.",
|
description = "查询指定地区的和风天气数据,包括实时天气、每日预报、逐小时预报、分钟级降水和正在生效的官方天气预警。" +
|
||||||
|
"普通天气查询也会同时返回当地正在生效的预警。",
|
||||||
parameters = Parameters.buildJsonObject {
|
parameters = Parameters.buildJsonObject {
|
||||||
put("type", "object")
|
put("type", "object")
|
||||||
putJsonObject("properties") {
|
putJsonObject("properties") {
|
||||||
putJsonObject("city") {
|
putJsonObject("city") {
|
||||||
put("type", "string")
|
put("type", "string")
|
||||||
put("description", "城市地区,如\"深圳市\"")
|
put("description", "城市、区县或地区名称,如\"深圳市\"、\"深圳南山区\"")
|
||||||
}
|
}
|
||||||
putJsonObject("time_range") {
|
putJsonObject("adm") {
|
||||||
|
put("type", "string")
|
||||||
|
put("description", "可选的上级行政区名称,用于区分重名地区,如\"北京市\"、\"广东省\"")
|
||||||
|
}
|
||||||
|
putJsonObject("query_type") {
|
||||||
put("type", "string")
|
put("type", "string")
|
||||||
putJsonArray("enum") {
|
putJsonArray("enum") {
|
||||||
add("day")
|
add("now")
|
||||||
add("three")
|
add("daily")
|
||||||
add("many")
|
add("hourly")
|
||||||
|
add("minutely")
|
||||||
|
add("warning")
|
||||||
}
|
}
|
||||||
put("description", "时间范围,仅当天天气可获得最详细信息,三天和更多只能获得简单信息。")
|
put("description", "查询类型:实时天气、每日预报、逐小时预报、未来2小时分钟级降水或官方天气预警,默认now")
|
||||||
|
}
|
||||||
|
putJsonObject("range") {
|
||||||
|
put("type", "string")
|
||||||
|
putJsonArray("enum") {
|
||||||
|
add("3d")
|
||||||
|
add("7d")
|
||||||
|
add("10d")
|
||||||
|
add("15d")
|
||||||
|
add("30d")
|
||||||
|
add("24h")
|
||||||
|
add("72h")
|
||||||
|
add("168h")
|
||||||
|
}
|
||||||
|
put("description", "daily或hourly的预报范围;daily默认3d,hourly默认24h")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
putJsonArray("required") {
|
putJsonArray("required") {
|
||||||
@@ -33,24 +69,227 @@ class WeatherService : BaseAgent(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
companion object {
|
||||||
|
private const val JWT_LIFETIME_SECONDS = 900L
|
||||||
|
private const val JWT_REFRESH_AHEAD_SECONDS = 60L
|
||||||
|
private val DAILY_RANGES = setOf("3d", "7d", "10d", "15d", "30d")
|
||||||
|
private val HOURLY_RANGES = setOf("24h", "72h", "168h")
|
||||||
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedJwt: String? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedJwtExpiresAt: Long = 0L
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var cachedJwtConfig: String = ""
|
||||||
|
|
||||||
|
private val jwtLock = Any()
|
||||||
|
|
||||||
|
override val isEnabled: Boolean
|
||||||
|
get() = PluginConfig.qWeatherApiHost.isNotBlank() &&
|
||||||
|
PluginConfig.qWeatherProjectId.isNotBlank() &&
|
||||||
|
PluginConfig.qWeatherCredentialId.isNotBlank() &&
|
||||||
|
PluginConfig.qWeatherPrivateKeyPath.isNotBlank() &&
|
||||||
|
JChatGPT.resolveConfigFile(PluginConfig.qWeatherPrivateKeyPath).isFile
|
||||||
|
|
||||||
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 city = args.getValue("city").jsonPrimitive.content
|
val city = args.getValue("city").jsonPrimitive.content
|
||||||
val timeRange = args["time_range"]?.jsonPrimitive?.contentOrNull
|
val adm = args["adm"]?.jsonPrimitive?.contentOrNull
|
||||||
val response = httpClient.get(
|
val queryType = args["query_type"]?.jsonPrimitive?.contentOrNull ?: "now"
|
||||||
buildString {
|
val range = args["range"]?.jsonPrimitive?.contentOrNull
|
||||||
append(when (timeRange) {
|
|
||||||
"many" -> "https://api.52vmy.cn/api/query/tian/many"
|
require(queryType in setOf("now", "daily", "hourly", "minutely", "warning")) {
|
||||||
"three" -> "https://api.52vmy.cn/api/query/tian/three"
|
"不支持的天气查询类型:$queryType"
|
||||||
else -> "https://api.52vmy.cn/api/query/tian"
|
}
|
||||||
})
|
|
||||||
append("?city=")
|
val location = resolveLocation(city, adm)
|
||||||
append(city)
|
val locationId = location.getValue("id").jsonPrimitive.content
|
||||||
|
val latitude = location.getValue("lat").jsonPrimitive.content
|
||||||
|
val longitude = location.getValue("lon").jsonPrimitive.content
|
||||||
|
|
||||||
|
val warningPath = "/weatheralert/v1/current/$latitude/$longitude"
|
||||||
|
val (data, activeWarning) = if (queryType == "warning") {
|
||||||
|
request(warningPath, mapOf("lang" to "zh")) to null
|
||||||
|
} else coroutineScope {
|
||||||
|
val weatherDeferred = async {
|
||||||
|
when (queryType) {
|
||||||
|
"daily" -> {
|
||||||
|
val days = range?.takeIf { it in DAILY_RANGES } ?: "3d"
|
||||||
|
request("/v7/weather/$days", mapOf("location" to locationId, "lang" to "zh"))
|
||||||
|
}
|
||||||
|
|
||||||
|
"hourly" -> {
|
||||||
|
val hours = range?.takeIf { it in HOURLY_RANGES } ?: "24h"
|
||||||
|
request("/v7/weather/$hours", mapOf("location" to locationId, "lang" to "zh"))
|
||||||
|
}
|
||||||
|
|
||||||
|
"minutely" -> request(
|
||||||
|
"/v7/minutely/5m",
|
||||||
|
mapOf("location" to "$longitude,$latitude", "lang" to "zh")
|
||||||
|
)
|
||||||
|
|
||||||
|
else -> request("/v7/weather/now", mapOf("location" to locationId, "lang" to "zh"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
)
|
val warningDeferred = async {
|
||||||
return response.bodyAsText()
|
try {
|
||||||
|
request(warningPath, mapOf("lang" to "zh"))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
JChatGPT.logger.warning("天气预警查询失败,继续返回天气:${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
weatherDeferred.await() to warningDeferred.await()?.takeIf(::hasActiveWarnings)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildJsonObject {
|
||||||
|
put("queryType", queryType)
|
||||||
|
putJsonObject("location") {
|
||||||
|
put("name", location["name"]?.jsonPrimitive?.contentOrNull ?: city)
|
||||||
|
put("adm2", location["adm2"]?.jsonPrimitive?.contentOrNull ?: "")
|
||||||
|
put("adm1", location["adm1"]?.jsonPrimitive?.contentOrNull ?: "")
|
||||||
|
put("country", location["country"]?.jsonPrimitive?.contentOrNull ?: "")
|
||||||
|
}
|
||||||
|
put("attribution", "天气服务由和风天气驱动")
|
||||||
|
put("data", data)
|
||||||
|
activeWarning?.let { put("warning", it) }
|
||||||
|
}.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasActiveWarnings(response: JsonObject): Boolean {
|
||||||
|
return response["alerts"]?.jsonArray?.isNotEmpty() == true
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun resolveLocation(city: String, adm: String?): JsonObject {
|
||||||
|
val parameters = buildMap {
|
||||||
|
put("location", city)
|
||||||
|
put("number", "1")
|
||||||
|
put("lang", "zh")
|
||||||
|
if (!adm.isNullOrBlank()) put("adm", adm)
|
||||||
|
}
|
||||||
|
val response = request("/geo/v2/city/lookup", parameters)
|
||||||
|
val locations = response["location"]?.jsonArray
|
||||||
|
require(!locations.isNullOrEmpty()) { "未找到地区:$city" }
|
||||||
|
return locations.first().jsonObject
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun request(path: String, parameters: Map<String, String>): JsonObject {
|
||||||
|
var response = requestOnce(path, parameters, forceRefreshJwt = false)
|
||||||
|
if (response.first == HttpStatusCode.Unauthorized) {
|
||||||
|
invalidateJwt()
|
||||||
|
response = requestOnce(path, parameters, forceRefreshJwt = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
val status = response.first
|
||||||
|
val body = response.second
|
||||||
|
require(status.isSuccess()) {
|
||||||
|
"和风天气请求失败:HTTP ${status.value} ${status.description},响应:${body.take(500)}"
|
||||||
|
}
|
||||||
|
|
||||||
|
val result = try {
|
||||||
|
json.parseToJsonElement(body).jsonObject
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
throw IllegalStateException("和风天气返回了无法解析的数据:${body.take(500)}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
val code = result["code"]?.jsonPrimitive?.contentOrNull
|
||||||
|
require(code == null || code == "200") {
|
||||||
|
"和风天气返回错误码 $code:${body.take(500)}"
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun requestOnce(
|
||||||
|
path: String,
|
||||||
|
parameters: Map<String, String>,
|
||||||
|
forceRefreshJwt: Boolean
|
||||||
|
): Pair<HttpStatusCode, String> {
|
||||||
|
val response = httpClient.get(apiBaseUrl() + path) {
|
||||||
|
header(HttpHeaders.Authorization, "Bearer ${jwt(forceRefreshJwt)}")
|
||||||
|
parameters.forEach { (name, value) -> parameter(name, value) }
|
||||||
|
}
|
||||||
|
return response.status to response.bodyAsText()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun apiBaseUrl(): String {
|
||||||
|
val host = PluginConfig.qWeatherApiHost.trim().trimEnd('/')
|
||||||
|
require(!host.startsWith("http://", ignoreCase = true)) {
|
||||||
|
"和风天气 API Host 必须使用 HTTPS"
|
||||||
|
}
|
||||||
|
return when {
|
||||||
|
host.startsWith("https://", ignoreCase = true) -> host
|
||||||
|
else -> "https://$host"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun jwt(forceRefresh: Boolean): String = synchronized(jwtLock) {
|
||||||
|
val now = Instant.now().epochSecond
|
||||||
|
val privateKeyFile = JChatGPT.resolveConfigFile(PluginConfig.qWeatherPrivateKeyPath)
|
||||||
|
val config = listOf(
|
||||||
|
PluginConfig.qWeatherProjectId,
|
||||||
|
PluginConfig.qWeatherCredentialId,
|
||||||
|
privateKeyFile.absolutePath,
|
||||||
|
privateKeyFile.lastModified().toString()
|
||||||
|
).joinToString("|")
|
||||||
|
|
||||||
|
cachedJwt?.takeIf {
|
||||||
|
!forceRefresh && cachedJwtConfig == config && now < cachedJwtExpiresAt - JWT_REFRESH_AHEAD_SECONDS
|
||||||
|
}?.let { return@synchronized it }
|
||||||
|
|
||||||
|
require(privateKeyFile.isFile) { "和风天气私钥文件不存在:${privateKeyFile.absolutePath}" }
|
||||||
|
val privateKeyPem = privateKeyFile.readText()
|
||||||
|
val privateKeyBase64 = privateKeyPem
|
||||||
|
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||||
|
.replace("-----END PRIVATE KEY-----", "")
|
||||||
|
.filterNot(Char::isWhitespace)
|
||||||
|
require(privateKeyBase64.isNotEmpty()) { "和风天气私钥文件内容为空" }
|
||||||
|
|
||||||
|
val privateKey = try {
|
||||||
|
EdDSAPrivateKey(PKCS8EncodedKeySpec(Base64.getDecoder().decode(privateKeyBase64)))
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
throw IllegalArgumentException("无法读取和风天气 Ed25519 私钥:${privateKeyFile.absolutePath}", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
val issuedAt = now - 30
|
||||||
|
val expiresAt = issuedAt + JWT_LIFETIME_SECONDS
|
||||||
|
val header = buildJsonObject {
|
||||||
|
put("alg", "EdDSA")
|
||||||
|
put("kid", PluginConfig.qWeatherCredentialId)
|
||||||
|
}.toString()
|
||||||
|
val payload = buildJsonObject {
|
||||||
|
put("sub", PluginConfig.qWeatherProjectId)
|
||||||
|
put("iat", issuedAt)
|
||||||
|
put("exp", expiresAt)
|
||||||
|
}.toString()
|
||||||
|
|
||||||
|
val encoder = Base64.getUrlEncoder().withoutPadding()
|
||||||
|
val encodedHeader = encoder.encodeToString(header.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
val encodedPayload = encoder.encodeToString(payload.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
val signingInput = "$encodedHeader.$encodedPayload"
|
||||||
|
|
||||||
|
val spec = EdDSANamedCurveTable.ED_25519_CURVE_SPEC
|
||||||
|
val signer = EdDSAEngine(MessageDigest.getInstance(spec.hashAlgorithm))
|
||||||
|
signer.initSign(privateKey)
|
||||||
|
signer.update(signingInput.toByteArray(StandardCharsets.UTF_8))
|
||||||
|
val signature = encoder.encodeToString(signer.sign())
|
||||||
|
|
||||||
|
"$signingInput.$signature".also {
|
||||||
|
cachedJwt = it
|
||||||
|
cachedJwtExpiresAt = expiresAt
|
||||||
|
cachedJwtConfig = config
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun invalidateJwt() = synchronized(jwtLock) {
|
||||||
|
cachedJwt = null
|
||||||
|
cachedJwtExpiresAt = 0L
|
||||||
|
cachedJwtConfig = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package top.jie65535.mirai
|
||||||
|
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.sql.DriverManager
|
||||||
|
import kotlin.io.path.absolutePathString
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ChatHistoryStoreTest {
|
||||||
|
@Test
|
||||||
|
fun initializesSQLiteSchemaAndWalMode() {
|
||||||
|
val directory = Files.createTempDirectory("jchatgpt-history-test-")
|
||||||
|
try {
|
||||||
|
ChatHistoryStore.init(directory.toFile())
|
||||||
|
assertTrue(ChatHistoryStore.isAvailable)
|
||||||
|
|
||||||
|
val database = directory.resolve("chat-history.sqlite")
|
||||||
|
assertTrue(Files.isRegularFile(database))
|
||||||
|
|
||||||
|
DriverManager.getConnection("jdbc:sqlite:${database.absolutePathString()}").use { connection ->
|
||||||
|
connection.createStatement().use { statement ->
|
||||||
|
statement.executeQuery("PRAGMA journal_mode").use { results ->
|
||||||
|
assertTrue(results.next())
|
||||||
|
assertEquals("wal", results.getString(1).lowercase())
|
||||||
|
}
|
||||||
|
statement.executeQuery(
|
||||||
|
"SELECT COUNT(*) FROM sqlite_master " +
|
||||||
|
"WHERE type = 'table' AND name IN ('message_record', 'chat_history_meta')"
|
||||||
|
).use { results ->
|
||||||
|
assertTrue(results.next())
|
||||||
|
assertEquals(2, results.getInt(1))
|
||||||
|
}
|
||||||
|
statement.executeQuery("PRAGMA table_info(message_record)").use { results ->
|
||||||
|
val columns = buildSet {
|
||||||
|
while (results.next()) add(results.getString("name"))
|
||||||
|
}
|
||||||
|
assertEquals(
|
||||||
|
setOf(
|
||||||
|
"id",
|
||||||
|
"bot_id",
|
||||||
|
"from_id",
|
||||||
|
"target_id",
|
||||||
|
"ids",
|
||||||
|
"internal_ids",
|
||||||
|
"time",
|
||||||
|
"kind",
|
||||||
|
"code",
|
||||||
|
"recalled",
|
||||||
|
),
|
||||||
|
columns,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
statement.executeQuery(
|
||||||
|
"SELECT value FROM chat_history_meta WHERE key = 'schema_version'"
|
||||||
|
).use { results ->
|
||||||
|
assertTrue(results.next())
|
||||||
|
assertEquals("2", results.getString(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
ChatHistoryStore.close()
|
||||||
|
directory.toFile().deleteRecursively()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package top.jie65535.mirai
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class ImageIndexTest {
|
||||||
|
@Test
|
||||||
|
fun `assigns short sequential indices`() {
|
||||||
|
val index = ImageIndex()
|
||||||
|
|
||||||
|
assertEquals(1, index.add("image-a", "https://example.com/a"))
|
||||||
|
assertEquals(2, index.add("image-b", "https://example.com/b"))
|
||||||
|
assertEquals("https://example.com/a", index.getUrl(1))
|
||||||
|
assertEquals("https://example.com/b", index.getUrl(2))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `reuses index for repeated image id`() {
|
||||||
|
val index = ImageIndex()
|
||||||
|
|
||||||
|
assertEquals(1, index.add("image-a", "https://example.com/a-old"))
|
||||||
|
assertEquals(1, index.add("image-a", "https://example.com/a-new"))
|
||||||
|
assertEquals(2, index.add("image-b", "https://example.com/b"))
|
||||||
|
assertEquals("https://example.com/a-new", index.getUrl(1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `returns null for unknown index`() {
|
||||||
|
assertNull(ImageIndex().getUrl(1))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package top.jie65535.mirai.tools
|
||||||
|
|
||||||
|
import java.awt.Color
|
||||||
|
import java.awt.image.BufferedImage
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.util.Base64
|
||||||
|
import javax.imageio.ImageIO
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class VisualImageResolverTest {
|
||||||
|
private val resolver = VisualImageResolver()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `png is preserved as png data url`() {
|
||||||
|
val source = createImage("png")
|
||||||
|
|
||||||
|
val result = resolver.prepare(source, "application/octet-stream")
|
||||||
|
val payload = result.images.single()
|
||||||
|
|
||||||
|
assertEquals("image/png", payload.mimeType)
|
||||||
|
assertFalse(result.transcoded)
|
||||||
|
assertTrue(payload.dataUrl.startsWith("data:image/png;base64,"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `gif is converted to supported png`() {
|
||||||
|
val source = createImage("gif")
|
||||||
|
|
||||||
|
val result = resolver.prepare(source, "image/gif")
|
||||||
|
val payload = result.images.single()
|
||||||
|
|
||||||
|
assertEquals("image/png", payload.mimeType)
|
||||||
|
assertTrue(result.transcoded)
|
||||||
|
assertTrue(payload.dataUrl.startsWith("data:image/png;base64,"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `unknown content is rejected`() {
|
||||||
|
assertFailsWith<IllegalArgumentException> {
|
||||||
|
resolver.prepare("not an image".toByteArray(), "application/octet-stream")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `small image is enlarged above model minimum`() {
|
||||||
|
val source = createImage("png", width = 8, height = 6)
|
||||||
|
|
||||||
|
val result = resolver.prepare(source, "image/png")
|
||||||
|
val normalized = decodeDataUrl(result.images.single().dataUrl)
|
||||||
|
|
||||||
|
assertTrue(result.transcoded)
|
||||||
|
assertTrue(normalized.width >= 32)
|
||||||
|
assertTrue(normalized.height >= 32)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `extreme aspect ratio is padded without stretching content`() {
|
||||||
|
val source = createImage("png", width = 1000, height = 2)
|
||||||
|
|
||||||
|
val result = resolver.prepare(source, "image/png")
|
||||||
|
val normalized = decodeDataUrl(result.images.single().dataUrl)
|
||||||
|
val ratio = maxOf(normalized.width, normalized.height).toDouble() /
|
||||||
|
minOf(normalized.width, normalized.height).toDouble()
|
||||||
|
|
||||||
|
assertTrue(result.transcoded)
|
||||||
|
assertTrue(normalized.width > 10 && normalized.height > 10)
|
||||||
|
assertTrue(ratio <= 200.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `large image is reduced to normalized bounds`() {
|
||||||
|
val source = createImage("png", width = 4100, height = 1200)
|
||||||
|
|
||||||
|
val result = resolver.prepare(source, "image/png")
|
||||||
|
val normalized = decodeDataUrl(result.images.single().dataUrl)
|
||||||
|
|
||||||
|
assertTrue(result.transcoded)
|
||||||
|
assertTrue(maxOf(normalized.width, normalized.height) <= 4096)
|
||||||
|
assertTrue(normalized.width.toLong() * normalized.height <= 16_000_000L)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `long screenshot is split into overlapping ordered parts`() {
|
||||||
|
val source = createImage("png", width = 400, height = 2400)
|
||||||
|
|
||||||
|
val result = resolver.prepare(source, "image/png")
|
||||||
|
|
||||||
|
assertTrue(result.transcoded)
|
||||||
|
assertTrue(result.images.size > 1)
|
||||||
|
assertTrue(result.images.size <= 16)
|
||||||
|
assertTrue(result.orderHint?.contains("从上到下") == true)
|
||||||
|
result.images.forEach { payload ->
|
||||||
|
val tile = decodeDataUrl(payload.dataUrl)
|
||||||
|
assertTrue(tile.width > 10 && tile.height > 10)
|
||||||
|
assertTrue(maxOf(tile.width, tile.height) <= 4096)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `vertical chat screenshot is split from three to one`() {
|
||||||
|
val source = createImage("png", width = 800, height = 2500)
|
||||||
|
|
||||||
|
val result = resolver.prepare(source, "image/png")
|
||||||
|
|
||||||
|
assertTrue(result.images.size > 1)
|
||||||
|
assertTrue(result.orderHint?.contains("从上到下") == true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `normal phone screenshot is not split`() {
|
||||||
|
val source = createImage("png", width = 1080, height = 2400)
|
||||||
|
|
||||||
|
val result = resolver.prepare(source, "image/png")
|
||||||
|
|
||||||
|
assertEquals(1, result.images.size)
|
||||||
|
assertEquals(null, result.orderHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `horizontal panorama below six to one is not split`() {
|
||||||
|
val source = createImage("png", width = 2500, height = 500)
|
||||||
|
|
||||||
|
val result = resolver.prepare(source, "image/png")
|
||||||
|
|
||||||
|
assertEquals(1, result.images.size)
|
||||||
|
assertEquals(null, result.orderHint)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `private and special addresses are rejected`() {
|
||||||
|
assertFalse(VisualImageResolver.isPublicAddress(InetAddress.getByName("127.0.0.1")))
|
||||||
|
assertFalse(VisualImageResolver.isPublicAddress(InetAddress.getByName("192.168.1.10")))
|
||||||
|
assertFalse(VisualImageResolver.isPublicAddress(InetAddress.getByName("100.64.0.1")))
|
||||||
|
assertFalse(VisualImageResolver.isPublicAddress(InetAddress.getByName("::1")))
|
||||||
|
assertFalse(VisualImageResolver.isPublicAddress(InetAddress.getByName("fd00::1")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `public addresses are accepted`() {
|
||||||
|
assertTrue(VisualImageResolver.isPublicAddress(InetAddress.getByName("8.8.8.8")))
|
||||||
|
assertTrue(VisualImageResolver.isPublicAddress(InetAddress.getByName("2606:4700:4700::1111")))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createImage(format: String, width: Int = 64, height: Int = 48): ByteArray {
|
||||||
|
val image = BufferedImage(width, height, BufferedImage.TYPE_INT_RGB)
|
||||||
|
val graphics = image.createGraphics()
|
||||||
|
try {
|
||||||
|
graphics.color = Color.WHITE
|
||||||
|
graphics.fillRect(0, 0, image.width, image.height)
|
||||||
|
graphics.color = Color.BLUE
|
||||||
|
graphics.fillRect(0, 0, maxOf(1, width / 2), maxOf(1, height / 2))
|
||||||
|
} finally {
|
||||||
|
graphics.dispose()
|
||||||
|
}
|
||||||
|
return ByteArrayOutputStream().use { output ->
|
||||||
|
check(ImageIO.write(image, format, output))
|
||||||
|
output.toByteArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeDataUrl(dataUrl: String): BufferedImage {
|
||||||
|
val encoded = dataUrl.substringAfter(',')
|
||||||
|
val bytes = Base64.getDecoder().decode(encoded)
|
||||||
|
return checkNotNull(ImageIO.read(bytes.inputStream()))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user