Compare commits

...
4 Commits
Author SHA1 Message Date
jie65535 07d11c2b16 history: replace external recorder with SQLite 2026-07-27 22:19:35 +08:00
jie65535 c328a798f7 tools: resolve images by short references 2026-07-27 14:01:12 +08:00
jie65535 a4f5bad322 tools: improve visual image handling 2026-07-26 14:43:34 +08:00
jie65535 7b5a83ba9c tools: integrate QWeather JWT weather service
Replace the unavailable weather endpoint with QWeather location, forecast, minutely precipitation, and alert APIs. Add configurable JWT credentials, automatic alert lookup, setup documentation, and bump the plugin to 1.13.0.
2026-07-24 11:13:26 +08:00
15 changed files with 2045 additions and 132 deletions
+40 -3
View File
@@ -14,7 +14,7 @@ JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模
- **LaTeX 渲染**:自动将数学表达式渲染为图片
- **灵活的触发方式**@机器人、关键字触发、回复消息等
- **权限控制**:细粒度的权限管理系统
- **历史消息集成**可选的历史消息上下文(需配合 mirai-hibernate-plugin
- **内置历史消息**使用插件自维护的 SQLite 保存、检索群聊和私聊消息
## 用法
@@ -102,6 +102,10 @@ fallbackCooldownMinutes: 5
reasoningModelExtraBody: ''
# 视觉模型额外请求体JSON,会合并到请求体中。
visualModelExtraBody: ''
# 是否先由机器人下载视觉图片并以Base64上传;建议保持开启,避免百炼下载QQ临时链接失败
visualImageBase64Enabled: true
# 视觉模型最大尝试次数,取值1~3;重试时复用已下载的图片
visualRetryMax: 2
# 百炼平台API KEY
dashScopeApiKey: ''
# 百炼平台图像模型(文生图 + 图像编辑)
@@ -116,6 +120,14 @@ jinaApiKey: ''
searXngUrl: ''
# 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。
glotToken: ''
# 和风天气专属 API Host,可在和风天气控制台的设置页面查看
qWeatherApiHost: ''
# 和风天气项目 ID
qWeatherProjectId: ''
# 和风天气 JWT 凭据 ID
qWeatherCredentialId: ''
# Ed25519 私钥路径,相对于插件配置目录,也可以填写绝对路径
qWeatherPrivateKeyPath: 'qweather-ed25519-private.pem'
# 群管理是否自动拥有对话权限,默认是
groupOpHasChatPermission: true
# 好友是否自动拥有对话权限,默认是
@@ -160,6 +172,28 @@ searchHistoryMaxDays: 30
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` 文件中。
@@ -286,6 +320,10 @@ JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型
当然,也可以配置为使用其他兼容 OpenAI API 的模型,如 GPT 系列模型。
### 视觉图片传输
视觉工具支持单图和多图,默认由机器人下载后以 Base64 上传,长图会按顺序切片,避免百炼无法下载 QQ 临时图片链接。如兼容服务不支持 Base64,可将 `visualImageBase64Enabled` 设为 `false`。
## 接入点容灾
聊天模型支持配置多个**备用接入点**,当主接入点连续调用失败(key 到期、用量超限、服务不稳定、超时等)时自动切换,提升可用性。
@@ -332,7 +370,7 @@ fallbackCooldownMinutes: 5
9. **SendVoiceMessage** - 发送语音消息
10. **ImageAgent** - 图像生成与编辑(文生图、单图编辑、多图融合)
11. **WeatherService** - 天气查询
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索群聊消息历史(依赖 mirai-hibernate-plugin
12. **SearchChatHistory** - 按关键词、发送者、时间范围搜索插件内置 SQLite 聊天历史
## 用户画像系统
@@ -558,7 +596,6 @@ JChatGPT 按 (日期, userId, groupId) 三元组聚合每次对话的 Token 消
- Java 11 或更高版本
- Mirai Console 2.16.0 或更高版本
- 可选:mirai-hibernate-plugin(用于历史消息上下文)
- 相关 API Tokens(根据需要启用的功能配置)
## 备注
+10 -4
View File
@@ -7,7 +7,7 @@ plugins {
}
group = "top.jie65535.mirai"
version = "1.12.0"
version = "1.14.0"
mirai {
jvmTarget = JavaVersion.VERSION_11
@@ -29,17 +29,23 @@ val openaiClientVersion = "4.1.0"
val ktorVersion = "3.0.3"
val jLatexMathVersion = "1.0.7"
val commonTextVersion = "1.13.0"
val hibernateVersion = "2.9.0"
val sqliteVersion = "3.46.1.0"
val overflowVersion = "1.0.7"
val eddsaVersion = "0.3.0"
dependencies {
implementation("com.aallam.openai:openai-client:$openaiClientVersion")
implementation("io.ktor:ktor-client-okhttp:$ktorVersion")
implementation("net.i2p.crypto:eddsa:$eddsaVersion")
implementation("org.scilab.forge:jlatexmath:$jLatexMathVersion")
implementation("org.apache.commons:commons-text:$commonTextVersion")
implementation("org.xerial:sqlite-jdbc:$sqliteVersion")
// 聊天记录插件
compileOnly("xyz.cssxsh.mirai:mirai-hibernate-plugin:$hibernateVersion")
testImplementation(kotlin("test-junit5"))
testConsoleRuntime("top.mrxiaom.mirai:overflow-core:$overflowVersion")
}
tasks.test {
useJUnitPlatform()
}
+368
View File
@@ -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"),
)
}
}
+71
View File
@@ -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()
}
}
}
}
+29
View File
@@ -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
View File
@@ -24,17 +24,18 @@ import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription
import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin
import net.mamoe.mirai.contact.*
import net.mamoe.mirai.contact.MemberPermission.*
import net.mamoe.mirai.event.EventPriority
import net.mamoe.mirai.event.GlobalEventChannel
import net.mamoe.mirai.event.events.FriendMessageEvent
import net.mamoe.mirai.event.events.GroupMessageEvent
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.Image.Key.queryUrl
import net.mamoe.mirai.utils.info
import top.jie65535.mirai.tools.*
import util.LunarDateUtil
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord
import java.io.File
import java.time.Instant
import java.time.OffsetDateTime
@@ -53,10 +54,9 @@ object JChatGPT : KotlinPlugin(
JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT",
version = "1.12.0",
version = "1.14.0",
) {
author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
}
) {
/**
@@ -86,26 +86,39 @@ object JChatGPT : KotlinPlugin(
// 初始化技能存储(data/skills/ 下的 markdown 文件,全局跨群)
SkillStore.init(dataFolder)
// 初始化插件自维护的 SQLite 聊天记录
includeHistory = try {
ChatHistoryStore.init(dataFolder)
true
} catch (e: Throwable) {
logger.error("初始化 SQLite 聊天记录失败,历史上下文与搜索将暂时禁用", e)
false
}
// 设置Token
LargeLanguageModels.reload()
// 注册插件命令
PluginCommands.register()
// 检查消息记录插件是否存在
includeHistory = try {
MiraiHibernateRecorder
true
} catch (_: Throwable) {
false
}
if (PluginConfig.callKeyword.isNotEmpty()) {
keyword = Regex(PluginConfig.callKeyword)
}
GlobalEventChannel.parentScope(this)
.subscribeAlways<MessageEvent> { event -> onMessage(event) }
val eventChannel = GlobalEventChannel.parentScope(this)
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) {
@@ -120,6 +133,10 @@ object JChatGPT : KotlinPlugin(
logger.info { "Plugin loaded" }
}
override fun onDisable() {
ChatHistoryStore.close()
}
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
private val requestMap = ConcurrentSet<Long>()
@@ -144,7 +161,8 @@ object JChatGPT : KotlinPlugin(
private data class ConversationCache(
val history: MutableList<ChatMessage>,
val lastActivityAt: Int,
val replyIndex: ReplyIndex
val replyIndex: ReplyIndex,
val imageIndex: ImageIndex,
) {
fun isExpired(ttlSeconds: Int): Boolean {
return OffsetDateTime.now().toEpochSecond().toInt() - lastActivityAt > ttlSeconds
@@ -157,11 +175,11 @@ object JChatGPT : KotlinPlugin(
* 编号按消息出现顺序递增,跨「初始历史」与「新增消息」连续编号;同一条消息(ids 相同)复用既有编号。
*/
class ReplyIndex {
private val byIndex = LinkedHashMap<Int, MessageRecord>()
private val byIndex = LinkedHashMap<Int, ChatMessageRecord>()
private val indexByIds = HashMap<String, Int>()
private var counter = 0
fun add(record: MessageRecord): Int {
fun add(record: ChatMessageRecord): Int {
// ids 可能为 null(如发送失败的记录),此时无法去重/被引用匹配,但仍分配编号
val ids = record.ids
if (ids != null) {
@@ -175,17 +193,28 @@ object JChatGPT : KotlinPlugin(
return i
}
fun get(index: Int): MessageRecord? = byIndex[index]
fun get(index: Int): ChatMessageRecord? = byIndex[index]
fun indexOfIds(ids: String): Int? = indexByIds[ids]
}
/** 各会话的回复索引,startChat 开始时重建,结束时清理 */
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)
/** 将从原消息图片取得的精确 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")
.withZone(ZoneOffset.systemDefault())
@@ -339,8 +368,9 @@ object JChatGPT : KotlinPlugin(
* @return 如果未获取到则返回空字符串
*/
private fun getHistory(event: MessageEvent): String {
val imageIndex = imageIndexMap.getOrPut(event.subject.id) { ImageIndex() }
if (!includeHistory) {
return event.message.content
return formatRecordContent(event.message, event.subject, imageIndex)
}
val now = OffsetDateTime.now()
// 一段时间内的消息
@@ -361,15 +391,22 @@ object JChatGPT : KotlinPlugin(
// 现在时间
val nowTimestamp = OffsetDateTime.now().toEpochSecond().toInt()
// 最近这段时间的历史对话
val history = MiraiHibernateRecorder[event.subject, time, nowTimestamp]
.take(PluginConfig.historyMessageLimit) // 只取最近的部分消息,避免上下文过长
.sortedBy { it.time } // 按时间排序
.toMutableList()
val history = try {
ChatHistoryStore.query(
contact = event.subject,
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(",")
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
// 本轮回复索引,逐条登记消息编号供 [n] 引用
val replyIndex = replyIndexMap.getOrPut(event.subject.id) { ReplyIndex() }
val imageIndex = imageIndexMap.getOrPut(event.subject.id) { ImageIndex() }
if (event is GroupMessageEvent) {
if (PluginConfig.enableFavorabilitySystem) {
val knownUsers = history.asSequence()
@@ -404,12 +442,12 @@ object JChatGPT : KotlinPlugin(
}
}
historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号,可用于引用回复")
historyText.appendLine("## 近期群消息(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号")
for (record in history) {
// 同一人发言不要反复出现这人的名字,减少上下文
val showSender = lastId != record.fromId
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
lastTime = record.time.toLong()
}
@@ -428,12 +466,12 @@ object JChatGPT : KotlinPlugin(
}
}
historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号,可用于引用回复")
historyText.appendLine("## 近期对话(更早已隐藏,行首[n]为消息编号;正文[图片n]/[表情包n]中的n为识图或图片编辑编号")
for (record in history) {
// 同一人发言不要反复出现这人的名字,减少上下文
val showSender = lastId != record.fromId
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
lastTime = record.time.toLong()
}
@@ -448,11 +486,12 @@ object JChatGPT : KotlinPlugin(
* @param record 群消息记录
* @param event 群消息事件
*/
fun appendGroupMessageRecord(
private fun appendGroupMessageRecord(
historyText: StringBuilder,
record: MessageRecord,
record: ChatMessageRecord,
event: GroupMessageEvent,
replyIndex: ReplyIndex,
imageIndex: ImageIndex,
showSender: Boolean,
showTime: Boolean,
) {
@@ -481,10 +520,10 @@ object JChatGPT : KotlinPlugin(
// 引用:用编号指针替代内联原文,避免被误认为是本人发言
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,
quote: QuoteReply,
contact: Contact,
replyIndex: ReplyIndex
replyIndex: ReplyIndex,
imageIndex: ImageIndex,
) {
val srcIds = quote.source.ids.joinToString(",")
val idx = replyIndex.indexOfIds(srcIds)
@@ -507,7 +547,7 @@ object JChatGPT : KotlinPlugin(
quote.source.fromId.toString()
}
val snippet = quote.source.originalMessage
.joinToString("", transform = ::singleMessageToText)
.joinToString("") { singleMessageToText(it, imageIndex) }
.replace("\n", " ")
.let { if (it.length > 20) it.take(20) + "" else it }
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()
.filterNot { it is QuoteReply || it is MessageSource }
.joinToString("") {
when (it) {
is At -> if (contact is Group) it.getDisplay(contact) else it.content
else -> singleMessageToText(it)
else -> singleMessageToText(it, imageIndex)
}
}
@@ -542,11 +582,12 @@ object JChatGPT : KotlinPlugin(
* @param record 消息记录
* @param event 消息事件
*/
fun appendMessageRecord(
private fun appendMessageRecord(
historyText: StringBuilder,
record: MessageRecord,
record: ChatMessageRecord,
event: MessageEvent,
replyIndex: ReplyIndex,
imageIndex: ImageIndex,
showSender: Boolean,
showTime: Boolean,
) {
@@ -573,24 +614,23 @@ object JChatGPT : KotlinPlugin(
}
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) {
// 完整展开合并转发内容,便于 LLM 阅读分析转发的对话(依赖大上下文+缓存,不做截断)
is ForwardMessage -> formatForward(it, 1)
is ForwardMessage -> formatForward(it, 1, imageIndex)
// 图片格式化
is Image -> {
try {
val imageUrl = runBlocking {
it.queryUrl()
}
"![${if (it.isEmoji) "表情包" else "图片"}]($imageUrl)"
val imageUrl = runBlocking { it.queryUrl() }
val index = imageIndex.add(it.imageId, imageUrl)
"[${if (it.isEmoji) "表情包" else "图片"}$index]"
} catch (e: Throwable) {
logger.warning("图片地址获取失败", e)
it.content
@@ -605,7 +645,7 @@ object JChatGPT : KotlinPlugin(
* 递归展开合并转发消息,用 Markdown 引用块表示:每加深一层嵌套多一个 `>`>、>>、>>>…)。
* @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) + " "
append("[转发消息·").append(forward.nodeList.size).append("")
if (forward.title.isNotEmpty()) append(':').append(forward.title)
@@ -618,10 +658,10 @@ object JChatGPT : KotlinPlugin(
node.messageChain.forEach { sub ->
if (sub is ForwardMessage) {
// 嵌套转发:层级加深,自带更深的 `>` 前缀,无需再次缩进
append(formatForward(sub, depth + 1))
append(formatForward(sub, depth + 1, imageIndex))
} 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] 编号连续不串号;
// 否则新建(供 sendSingleMessage 的 replyTo 按编号引用历史消息)
val replyIndex = if (reuseCache) cache!!.replyIndex else ReplyIndex()
val imageIndex = if (reuseCache) cache!!.imageIndex else ImageIndex()
replyIndexMap[subjectId] = replyIndex
imageIndexMap[subjectId] = imageIndex
val history = if (reuseCache) {
// 缓存有效,复用历史
logger.info("使用缓存的对话上下文,包含 ${cache!!.history.size} 条互动消息")
@@ -868,7 +910,8 @@ object JChatGPT : KotlinPlugin(
contextCache[subjectId] = ConversationCache(
history = history,
lastActivityAt = startedAt,
replyIndex = replyIndex
replyIndex = replyIndex,
imageIndex = imageIndex,
)
logger.debug("已保存对话上下文到缓存")
}
@@ -901,6 +944,7 @@ object JChatGPT : KotlinPlugin(
} finally {
// 清理本轮回复索引
replyIndexMap.remove(event.subject.id)
imageIndexMap.remove(event.subject.id)
// 一段时间后才允许再次提问,防止高频对话
launch {
delay(500.milliseconds)
+18
View File
@@ -66,6 +66,12 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("视觉模型额外请求体JSON,会合并到请求体中。")
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")
val dashScopeApiKey: String by value("")
@@ -87,6 +93,18 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。")
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("群管理是否自动拥有对话权限,默认是")
val groupOpHasChatPermission: Boolean by value(true)
+15 -7
View File
@@ -13,31 +13,34 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.addJsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
import kotlinx.serialization.json.putJsonObject
import net.mamoe.mirai.event.events.MessageEvent
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig
class ImageAgent : BaseAgent(
tool = Tool.function(
name = "imageAgent",
description = "调用千问图像模型生成或编辑图片。不传 image_urls 即纯文生图;" +
description = "调用千问图像模型生成或编辑图片。不传 image_indices 即纯文生图;" +
"传 1~3 张图片可进行编辑、修改或多图融合。" +
"备注:该方法成本较高,非必要尽量不要调用。" +
"编辑图片前无需识别图片内容,模型自己会理解图片内容。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("image_urls") {
putJsonObject("image_indices") {
put("type", "array")
putJsonObject("items") {
put("type", "string")
put("type", "integer")
put("minimum", 1)
}
put("description", "参考图片地址列表,可传 0~3 张。" +
put("description", "用户消息中[图片n]或[表情包n]标记的参考图片编号,可传 0~3 张。" +
"不传或为空即纯文生图;传 1 张为编辑;多张为融合,输出比例与最后一张对齐。")
}
putJsonObject("prompt") {
@@ -61,12 +64,17 @@ class ImageAgent : BaseAgent(
override val loadingMessage: String
get() = "作图中..."
override suspend fun execute(args: JsonObject?): String {
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val prompt = args.getValue("prompt").jsonPrimitive.content
val imageUrls = args["image_urls"]?.jsonArray
?.map { it.jsonPrimitive.content }
val imageIndices = args["image_indices"]?.jsonArray
?.map { it.jsonPrimitive.int }
?: 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) {
contentType(ContentType("application", "json"))
+16 -18
View File
@@ -13,8 +13,8 @@ import net.mamoe.mirai.message.data.SingleMessage
import net.mamoe.mirai.message.data.content
import top.jie65535.mirai.JChatGPT
import top.jie65535.mirai.PluginConfig
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord
import top.jie65535.mirai.ChatHistoryStore
import top.jie65535.mirai.ChatMessageRecord
import java.time.Instant
import java.time.LocalDateTime
import java.time.OffsetDateTime
@@ -92,17 +92,13 @@ class SearchChatHistory : BaseAgent(
val maxRecords = PluginConfig.searchHistoryMaxRecords
val records = try {
// 有 sender 时用 Member 重载,在数据库层过滤 fromId;否则用 Contact 重载
if (senderQq != null && event is GroupMessageEvent) {
val member = event.group[senderQq]
if (member != null) {
MiraiHibernateRecorder[member, startEpoch, endEpoch]
} else {
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
}
} else {
MiraiHibernateRecorder[event.subject, startEpoch, endEpoch]
}.take(maxRecords).sortedBy { it.time }
ChatHistoryStore.query(
contact = event.subject,
start = startEpoch,
end = endEpoch,
limit = maxRecords,
fromId = senderQq,
).sortedBy { it.time }
} catch (e: Throwable) {
JChatGPT.logger.warning("查询消息历史失败", e)
return "查询消息历史失败: ${e.message}"
@@ -148,7 +144,7 @@ class SearchChatHistory : BaseAgent(
private suspend fun appendHistory(
sb: StringBuilder,
records: List<MessageRecord>,
records: List<ChatMessageRecord>,
event: MessageEvent
) {
val timeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
@@ -172,19 +168,21 @@ class SearchChatHistory : BaseAgent(
.append("")
}
for (msg in record.toMessageChain()) {
sb.append(singleMessageToText(msg))
sb.append(singleMessageToText(msg, event.subject.id))
}
sb.appendLine()
lastFromId = record.fromId
}
}
private suspend fun singleMessageToText(msg: SingleMessage): String {
private suspend fun singleMessageToText(msg: SingleMessage, subjectId: Long): String {
return when (msg) {
is Image -> {
try {
val url = msg.queryUrl()
"![${if (msg.isEmoji) "表情包" else "图片"}]($url)"
val imageUrl = msg.queryUrl()
val index = JChatGPT.registerImage(subjectId, msg.imageId, imageUrl)
?: return msg.content
"[${if (msg.isEmoji) "表情包" else "图片"}$index]"
} catch (_: Throwable) {
msg.content
}
+144 -14
View File
@@ -2,30 +2,47 @@ package top.jie65535.mirai.tools
import com.aallam.openai.api.chat.ChatCompletionRequest
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.TextPart
import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import com.aallam.openai.api.model.ModelId
import io.ktor.client.plugins.ClientRequestException
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import kotlinx.serialization.json.putJsonArray
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.PluginConfig
import java.net.URI
class VisualAgent : BaseAgent(
tool = Tool.function(
name = "imageRecognition",
description = "可通过调用视觉模型识别图片内容。备注:该方法成本较高,非必要尽量不要调用。",
description = "可通过调用视觉模型识别一张或多张图片,并进行比较、关联或顺序理解。备注:该方法成本较高,非必要尽量不要调用。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("image_url") {
put("type", "string")
put("description", "图片地址")
putJsonObject("image_indices") {
put("type", "array")
put("description", "用户消息中[图片n]或[表情包n]标记的图片编号数组,按需要理解的顺序传入")
put("minItems", 1)
put("maxItems", MAX_SOURCE_IMAGES)
putJsonObject("items") {
put("type", "integer")
put("minimum", 1)
}
}
putJsonObject("prompt") {
put("type", "string")
@@ -33,37 +50,88 @@ class VisualAgent : BaseAgent(
}
}
putJsonArray("required") {
add("image_url")
add("image_indices")
add("prompt")
}
}
)
) {
private val imageResolver = VisualImageResolver()
private val concurrencyLimiter = Semaphore(VISUAL_MAX_CONCURRENCY)
override val loadingMessage: String
get() = "识别中..."
override val isEnabled: Boolean
get() = LargeLanguageModels.visual != null
override suspend fun execute(args: JsonObject?): String {
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
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
return concurrencyLimiter.withPermit {
val imageGroups = imageUrls.mapIndexed { index, imageUrl ->
if (PluginConfig.visualImageBase64Enabled) {
val host = runCatching { URI(imageUrl).host }.getOrNull() ?: "unknown"
val resolved = try {
imageResolver.resolve(imageUrl)
} catch (e: Throwable) {
JChatGPT.logger.error(
"视觉图片下载失败: 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 },
orderHint = resolved.orderHint,
payloadSize = resolved.payloadSize,
)
} else {
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(
llm.chatCompletions(
ChatCompletionRequest(
model = ModelId(PluginConfig.visualModel),
messages = listOf(
ChatMessage.System("You are a helpful assistant."),
ChatMessage.User(
content = listOf(
ImagePart(imageUrl),
TextPart(prompt)
content = messageContent
)
)
)
)).collect {
).collect {
if (it.choices.isNotEmpty()) {
val delta = it.choices[0].delta ?: return@collect
if (!delta.content.isNullOrEmpty()) {
@@ -71,6 +139,68 @@ class VisualAgent : BaseAgent(
}
}
}
return answerContent.toString().ifEmpty { "识图异常,结果为空" }
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
}
}
}
@@ -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
}
}
}
+257 -18
View File
@@ -4,27 +4,63 @@ import com.aallam.openai.api.chat.Tool
import com.aallam.openai.api.core.Parameters
import io.ktor.client.request.*
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 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(
tool = Tool.function(
name = "queryWeather",
description = "可用于查询某城市地区天气.",
description = "查询指定地区的和风天气数据,包括实时天气、每日预报、逐小时预报、分钟级降水和正在生效的官方天气预警。" +
"普通天气查询也会同时返回当地正在生效的预警。",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("city") {
put("type", "string")
put("description", "城市地区,如\"深圳市\"")
put("description", "城市、区县或地区名称,如\"深圳市\"\"深圳南山区\"")
}
putJsonObject("time_range") {
putJsonObject("adm") {
put("type", "string")
put("description", "可选的上级行政区名称,用于区分重名地区,如\"北京市\"\"广东省\"")
}
putJsonObject("query_type") {
put("type", "string")
putJsonArray("enum") {
add("day")
add("three")
add("many")
add("now")
add("daily")
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默认3dhourly默认24h")
}
}
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
get() = "观天中..."
override suspend fun execute(args: JsonObject?): String {
requireNotNull(args)
val city = args.getValue("city").jsonPrimitive.content
val timeRange = args["time_range"]?.jsonPrimitive?.contentOrNull
val response = httpClient.get(
buildString {
append(when (timeRange) {
"many" -> "https://api.52vmy.cn/api/query/tian/many"
"three" -> "https://api.52vmy.cn/api/query/tian/three"
else -> "https://api.52vmy.cn/api/query/tian"
})
append("?city=")
append(city)
val adm = args["adm"]?.jsonPrimitive?.contentOrNull
val queryType = args["query_type"]?.jsonPrimitive?.contentOrNull ?: "now"
val range = args["range"]?.jsonPrimitive?.contentOrNull
require(queryType in setOf("now", "daily", "hourly", "minutely", "warning")) {
"不支持的天气查询类型:$queryType"
}
val location = resolveLocation(city, adm)
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")
)
return response.bodyAsText()
else -> request("/v7/weather/now", mapOf("location" to locationId, "lang" to "zh"))
}
}
val warningDeferred = async {
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 = ""
}
}
+67
View File
@@ -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()
}
}
}
+32
View File
@@ -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()))
}
}