mirror of
https://github.com/jie65535/JChatGPT.git
synced 2025-10-20 17:13:37 +08:00
Compare commits
27 Commits
6c034ab2a7
...
master
Author | SHA1 | Date | |
---|---|---|---|
099625c2f2 | |||
ffa7f78c25 | |||
2c40ef1b16 | |||
e083b7aff9 | |||
e2caba6a10 | |||
af17f1e698 | |||
e79bcd9983 | |||
b6aa638b1a | |||
bc2eb437e6 | |||
b534606bb0 | |||
867d9ad56f | |||
92a6879cc1 | |||
eda932b4e9 | |||
6cba3cca22 | |||
60a48c5211 | |||
dbfdf83dc6 | |||
3a5caf86a1 | |||
c479282fe4 | |||
d1ee8f9fcf | |||
11bdaff54d | |||
7d911f2fb6 | |||
7e1a1ad0aa | |||
679bf15be5 | |||
ea4e6123b3 | |||
f822999ab4 | |||
3c4373e1ff | |||
89794b587e |
252
README.md
252
README.md
@@ -1,43 +1,265 @@
|
||||
# JChatGPT
|
||||
|
||||
JChatGPT 是一个基于 Kotlin 的 Mirai Console 插件,它将大型语言模型(LLM)集成到即时通讯平台中。该插件支持多种 AI 模型和丰富的工具功能,使用户能够在群聊和私聊中与 AI 进行交互。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **多模型支持**:支持聊天模型、推理模型和视觉模型
|
||||
- **丰富的工具系统**:包括网络搜索、代码执行、图像识别、群管理等
|
||||
- **上下文记忆**:支持持久化记忆存储
|
||||
- **LaTeX 渲染**:自动将数学表达式渲染为图片
|
||||
- **灵活的触发方式**:@机器人、关键字触发、回复消息等
|
||||
- **权限控制**:细粒度的权限管理系统
|
||||
- **历史消息集成**:可选的历史消息上下文(需配合 mirai-hibernate-plugin)
|
||||
|
||||
## 用法
|
||||
|
||||
在群内直接@bot即可触发对话
|
||||
### 基本交互
|
||||
- 在群内直接 @bot 即可触发对话
|
||||
- 通过引用群友消息 + @bot 让 Bot 识别引用消息的内容
|
||||
- 回复 bot 的消息即可引用对应的上下文对话(包括这个回复的历史对话)
|
||||
- 使用关键字触发(默认为 "[小筱][林淋月玥]",可在配置中修改)
|
||||
|
||||
你也可以通过引用群友消息+@bot来让Bot识别引用消息的内容
|
||||
|
||||
回复bot的消息即可引用对应的上下文对话(包括这个回复的历史对话)
|
||||
### 工具调用
|
||||
AI 可以自动调用多种工具来完成复杂任务:
|
||||
- 网络搜索(需要配置 SearXNG)
|
||||
- 代码执行(支持多种语言,需要配置 glot.io token)
|
||||
- 图像识别(需要配置视觉模型)
|
||||
- 推理思考(需要配置推理模型)
|
||||
- 群管理(禁言等,需启用相应权限)
|
||||
- 记忆管理(添加和修改对话记忆)
|
||||
|
||||
## 权限列表
|
||||
- `JChatGPT:Chat` 拥有该权限即可使用bot与ChatGPT对话
|
||||
- `top.jie65535.mirai.jchatgpt:command.jgpt` 拥有该权限即可使用`/jgpt`相关命令
|
||||
|
||||
- `JChatGPT:Chat` - 拥有该权限即可使用 bot 与 AI 对话
|
||||
- `top.jie65535.mirai.jchatgpt:command.jgpt` - 拥有该权限即可使用 `/jgpt` 相关命令
|
||||
|
||||
## 命令列表
|
||||
- `/jgpt setToken <token>` - 设置OpenAI API Token
|
||||
|
||||
- `/jgpt setToken <token>` - 设置 OpenAI API Token
|
||||
- `/jgpt enable <contact>` - 启用目标对话权限
|
||||
- `/jgpt disable <contact>` - 禁用目标对话权限
|
||||
- `/jgpt reload` - 重载配置文件
|
||||
|
||||
## 配置文件
|
||||
|
||||
`./config/top.jie65535.mirai.JChatGPT/Config.yml`
|
||||
配置文件位于:`./config/top.jie65535.mirai.JChatGPT/Config.yml`
|
||||
|
||||
```yaml
|
||||
# OpenAI API base url
|
||||
openAiApi: 'https://api.openai.com/v1/'
|
||||
openAiApi: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
|
||||
# OpenAI API Token
|
||||
openAiToken: ''
|
||||
# Chat模型
|
||||
chatModel: 'gpt-3.5-turbo-1106'
|
||||
# Chat默认提示
|
||||
prompt: ''
|
||||
chatModel: 'qwen-max'
|
||||
# Chat模型温度,默认为null
|
||||
chatTemperature: null
|
||||
# 推理模型API
|
||||
reasoningModelApi: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
|
||||
# 推理模型Token
|
||||
reasoningModelToken: ''
|
||||
# 推理模型
|
||||
reasoningModel: 'qwq-plus'
|
||||
# 视觉模型API
|
||||
visualModelApi: 'https://dashscope.aliyuncs.com/compatible-mode/v1/'
|
||||
# 视觉模型Token
|
||||
visualModelToken: ''
|
||||
# 视觉模型
|
||||
visualModel: 'qwen-vl-plus'
|
||||
# 百炼平台API KEY
|
||||
dashScopeApiKey: ''
|
||||
# 百炼平台图片编辑模型
|
||||
imageEditModel: 'qwen-image-edit'
|
||||
# 百炼平台TTS模型
|
||||
ttsModel: 'qwen-tts'
|
||||
# Jina API Key
|
||||
jinaApiKey: ''
|
||||
# SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回
|
||||
searXngUrl: ''
|
||||
# 在线运行代码 glot.io 的 api token,在官网注册账号即可获取。
|
||||
glotToken: ''
|
||||
# 群管理是否自动拥有对话权限,默认是
|
||||
groupOpHasChatPermission: true
|
||||
# 好友是否自动拥有对话权限,默认是
|
||||
friendHasChatPermission: true
|
||||
# 机器人是否可以禁言别人,默认禁止
|
||||
canMute: false
|
||||
# 群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。
|
||||
temperaturePermission: 50
|
||||
# 等待响应超时时间,单位毫秒,默认60秒
|
||||
timeout: 60000
|
||||
# 系统提示词,该字段已弃用,使用提示词文件而不是在这里修改
|
||||
prompt: '你是一个乐于助人的助手'
|
||||
# 系统提示词文件路径,相对于插件配置目录
|
||||
promptFile: 'SystemPrompt.md'
|
||||
# 创建Prompt时取最近多少分钟内的消息
|
||||
historyWindowMin: 10
|
||||
# 创建Prompt时取最多几条消息
|
||||
historyMessageLimit: 20
|
||||
# 是否打印Prompt便于调试
|
||||
logPrompt: false
|
||||
# 达到需要合并转发消息的阈值
|
||||
messageMergeThreshold: 150
|
||||
# 最大循环次数,至少2次
|
||||
retryMax: 5
|
||||
# 关键字呼叫,支持正则表达式
|
||||
callKeyword: '[小筱][林淋月玥]'
|
||||
# 是否显示工具调用消息,默认是
|
||||
showToolCallingMessage: true
|
||||
# 是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆
|
||||
memoryEnabled: true
|
||||
```
|
||||
|
||||
## 系统提示词
|
||||
|
||||
JChatGPT 使用系统提示词来定义 AI 的行为和个性。提示词文件位于插件配置目录下的 `SystemPrompt.md` 文件中。
|
||||
|
||||
### 提示词结构
|
||||
|
||||
系统提示词通常包含以下部分:
|
||||
|
||||
1. **角色定义**:定义 AI 的身份、性格和行为准则
|
||||
2. **功能说明**:描述 AI 可以使用的工具和功能
|
||||
3. **交互规则**:规定 AI 与用户交互的规则和限制
|
||||
4. **占位符**:动态替换的内容,如时间、群信息、记忆等
|
||||
|
||||
### 占位符
|
||||
|
||||
系统提示词支持以下占位符,在运行时会被动态替换:
|
||||
|
||||
- `{time}` - 当前时间(格式:yyyy年MM月dd E HH:mm:ss)
|
||||
- `{subject}` - 当前聊天环境信息(群聊名称或私聊信息)
|
||||
- `{memory}` - 当前联系人的记忆内容
|
||||
|
||||
### 示例提示词
|
||||
|
||||
以下是一个完整的示例提示词,展示如何构建一个个性化的AI角色:
|
||||
|
||||
```markdown
|
||||
你是小灵,一个聪明、友善且乐于助人的AI助手。
|
||||
|
||||
你被设计为帮助用户解答问题、提供信息和完成各种任务。你具有以下特点:
|
||||
- 性格开朗、幽默,但保持礼貌和专业
|
||||
- 喜欢使用轻松的语气,但不会过于随意
|
||||
- 对技术问题有深入的理解,能够提供准确的信息
|
||||
- 对于不确定的问题,会坦诚说明而不是编造答案
|
||||
|
||||
你可以使用的工具包括:
|
||||
1. 网络搜索 - 获取最新的信息
|
||||
2. 代码执行 - 运行和测试代码片段
|
||||
3. 图像识别 - 理解图片内容
|
||||
4. 数学计算 - 解决复杂的数学问题
|
||||
5. 记忆管理 - 保存和回忆重要信息
|
||||
|
||||
重要说明:
|
||||
你所有的输出都是内心思考,用户无法看到。只有当你调用发送消息的工具时,用户才能看到你的回复。
|
||||
- sendSingleMessage - 发送单条消息(适用于简短回复)
|
||||
- sendCompositeMessage - 发送组合消息(适用于长内容或代码)
|
||||
|
||||
交互规则:
|
||||
1. 只有当用户@你或在消息中包含你的名字时才会响应
|
||||
2. 回复应简洁明了,避免长篇大论
|
||||
3. 对于复杂内容,使用组合消息功能发送
|
||||
4. 不主动参与与你无关的对话
|
||||
5. 不会对用户进行人身攻击或使用不当语言
|
||||
|
||||
工具使用原则:
|
||||
- 只在必要时使用工具
|
||||
- 深度思考工具仅用于复杂问题
|
||||
- 代码执行工具用于验证技术问题
|
||||
- **每次对话结束时必须调用 endConversation 工具来结束对话**
|
||||
- **要发送消息给用户必须使用 sendSingleMessage 或 sendCompositeMessage 工具**
|
||||
|
||||
<memory>
|
||||
{memory}
|
||||
</memory>
|
||||
|
||||
当前的时间是:{time}
|
||||
你当前在 {subject} 环境中
|
||||
|
||||
对话示例:
|
||||
用户:小灵,今天的天气怎么样?
|
||||
小灵:让我查一下...
|
||||
(调用网络搜索工具)
|
||||
(调用 sendSingleMessage 工具)
|
||||
小灵:今天天气晴朗,温度在25°C左右,适合外出活动。
|
||||
(调用 endConversation 工具)
|
||||
|
||||
用户:帮我写一个Python函数来计算斐波那契数列
|
||||
小灵:好的,这是计算斐波那契数列的Python函数:
|
||||
(调用 sendCompositeMessage 工具发送代码)
|
||||
def fibonacci(n):
|
||||
if n <= 1:
|
||||
return n
|
||||
else:
|
||||
return fibonacci(n-1) + fibonacci(n-2)
|
||||
|
||||
# 示例使用
|
||||
print(fibonacci(10)) # 输出55
|
||||
(调用 endConversation 工具)
|
||||
|
||||
用户:你能识别这张图片吗?[图片链接]
|
||||
小灵:让我看看这张图片...
|
||||
(调用图像识别工具)
|
||||
(调用 sendSingleMessage 工具)
|
||||
小灵:这是一张猫咪的图片,看起来很可爱!
|
||||
(调用 endConversation 工具)
|
||||
|
||||
注意事项:
|
||||
1. 请勿重复发送相似内容
|
||||
2. 避免不必要的工具调用以节省资源
|
||||
3. 保护用户隐私,不泄露敏感信息
|
||||
4. 遵守法律法规,不传播违法内容
|
||||
5. **切记:只有通过调用发送消息工具,用户才能看到你的回复**
|
||||
6. **每次对话结束时都必须调用结束对话工具**
|
||||
```
|
||||
|
||||
### 编写建议
|
||||
|
||||
1. **明确角色定位**:清晰定义 AI 的身份和个性,让用户能够建立预期
|
||||
2. **设定行为边界**:规定 AI 应该和不应该做的事情,确保安全使用
|
||||
3. **强调工具调用机制**:明确说明只有通过调用发送消息工具才能让用户看到回复
|
||||
4. **强调结束对话**:每次对话都必须调用 endConversation 工具来结束
|
||||
5. **合理使用工具**:指导 AI 何时以及如何使用各种工具,避免滥用
|
||||
6. **优化交互体验**:确保对话自然流畅,避免重复和冗余
|
||||
7. **保护隐私安全**:确保敏感信息不会被泄露
|
||||
8. **提供具体示例**:通过对话示例展示预期的行为模式
|
||||
9. **使用占位符**:充分利用时间、环境和记忆占位符提供上下文感知
|
||||
|
||||
## 支持的模型
|
||||
|
||||
JChatGPT 默认配置为使用阿里云百炼平台的通义千问系列模型:
|
||||
- 聊天模型:`qwen-max`
|
||||
- 推理模型:`qwq-plus`
|
||||
- 视觉模型:`qwen-vl-plus`
|
||||
|
||||
当然,也可以配置为使用其他兼容 OpenAI API 的模型,如 GPT 系列模型。
|
||||
|
||||
## 工具系统
|
||||
|
||||
插件内置了丰富的工具供 AI 调用:
|
||||
|
||||
1. **WebSearch** - 使用 SearXNG 进行网络搜索
|
||||
2. **RunCode** - 在 glot.io 上执行多种编程语言代码
|
||||
3. **VisualAgent** - 图像识别和理解
|
||||
4. **ReasoningAgent** - 深度思考和推理
|
||||
5. **MemoryAppend/Replace** - 对话记忆管理
|
||||
6. **GroupManageAgent** - 群管理功能(如禁言)
|
||||
7. **SendSingleMessage/CompositeMessage** - 发送消息
|
||||
8. **SendVoiceMessage** - 发送语音消息
|
||||
9. **ImageEdit** - 图像编辑
|
||||
10. **WeatherService** - 天气查询
|
||||
|
||||
## 部署要求
|
||||
|
||||
- Java 11 或更高版本
|
||||
- Mirai Console 2.16.0 或更高版本
|
||||
- 可选:mirai-hibernate-plugin(用于历史消息上下文)
|
||||
- 相关 API Tokens(根据需要启用的功能配置)
|
||||
|
||||
## 备注
|
||||
|
||||
如果默认的openai api调用失败,可以换个镜像地址。
|
||||
|
||||
如果有必要,后续可以增加代理设置。
|
||||
- 如果默认的 API 调用失败,可以更换为其他兼容的 API 地址
|
||||
- 可根据需要配置代理设置
|
||||
- 某些工具需要额外的 API 密钥才能启用
|
||||
- 插件支持自定义系统提示词,可以通过修改 `SystemPrompt.md` 文件来实现
|
@@ -7,15 +7,22 @@ plugins {
|
||||
}
|
||||
|
||||
group = "top.jie65535.mirai"
|
||||
version = "1.5.0"
|
||||
version = "1.8.0"
|
||||
|
||||
mirai {
|
||||
jvmTarget = JavaVersion.VERSION_11
|
||||
noTestCore = true
|
||||
setupConsoleTestRuntime {
|
||||
// 移除 mirai-core 依赖
|
||||
classpath = classpath.filter {
|
||||
!it.nameWithoutExtension.startsWith("mirai-core-jvm")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
maven("https://maven.aliyun.com/repository/public")
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
val openaiClientVersion = "4.0.1"
|
||||
@@ -23,6 +30,7 @@ val ktorVersion = "3.0.3"
|
||||
val jLatexMathVersion = "1.0.7"
|
||||
val commonTextVersion = "1.13.0"
|
||||
val hibernateVersion = "2.9.0"
|
||||
val overflowVersion = "1.0.7"
|
||||
|
||||
dependencies {
|
||||
implementation("com.aallam.openai:openai-client:$openaiClientVersion")
|
||||
@@ -32,4 +40,6 @@ dependencies {
|
||||
|
||||
// 聊天记录插件
|
||||
compileOnly("xyz.cssxsh.mirai:mirai-hibernate-plugin:$hibernateVersion")
|
||||
|
||||
testConsoleRuntime("top.mrxiaom.mirai:overflow-core:$overflowVersion")
|
||||
}
|
@@ -1,17 +1,19 @@
|
||||
package top.jie65535.mirai
|
||||
|
||||
import com.aallam.openai.api.chat.ChatCompletionChunk
|
||||
import com.aallam.openai.api.chat.ChatCompletionRequest
|
||||
import com.aallam.openai.api.chat.ChatMessage
|
||||
import com.aallam.openai.api.chat.ChatRole
|
||||
import com.aallam.openai.api.chat.ToolCall
|
||||
import com.aallam.openai.api.http.Timeout
|
||||
import com.aallam.openai.api.model.ModelId
|
||||
import com.aallam.openai.client.Chat
|
||||
import com.aallam.openai.client.OpenAI
|
||||
import com.aallam.openai.client.OpenAIHost
|
||||
import io.ktor.util.collections.*
|
||||
import kotlinx.coroutines.Deferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import net.mamoe.mirai.console.command.CommandManager.INSTANCE.register
|
||||
import net.mamoe.mirai.console.command.CommandSender.Companion.toCommandSender
|
||||
import net.mamoe.mirai.console.permission.PermissionId
|
||||
@@ -26,50 +28,53 @@ 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.message.data.*
|
||||
import net.mamoe.mirai.message.data.MessageSource.Key.quote
|
||||
import net.mamoe.mirai.message.sourceIds
|
||||
import net.mamoe.mirai.message.data.Image.Key.queryUrl
|
||||
import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
|
||||
import net.mamoe.mirai.utils.info
|
||||
import top.jie65535.mirai.tools.*
|
||||
import xyz.cssxsh.mirai.hibernate.MiraiHibernateRecorder
|
||||
import xyz.cssxsh.mirai.hibernate.entry.MessageRecord
|
||||
import java.time.Instant
|
||||
import java.time.OffsetDateTime
|
||||
import java.time.ZoneOffset
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.regex.Pattern
|
||||
import kotlin.collections.*
|
||||
import kotlin.math.max
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
object JChatGPT : KotlinPlugin(
|
||||
JvmPluginDescription(
|
||||
id = "top.jie65535.mirai.JChatGPT",
|
||||
name = "J ChatGPT",
|
||||
version = "1.5.0",
|
||||
version = "1.8.0",
|
||||
) {
|
||||
author("jie65535")
|
||||
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
|
||||
}
|
||||
) {
|
||||
private var llm: Chat? = null
|
||||
|
||||
/**
|
||||
* 是否包含历史对话
|
||||
*/
|
||||
private var includeHistory: Boolean = false
|
||||
|
||||
/**
|
||||
* 聊天权限
|
||||
*/
|
||||
val chatPermission = PermissionId("JChatGPT", "Chat")
|
||||
|
||||
/**
|
||||
* 唤醒关键字
|
||||
*/
|
||||
private var keyword: Regex? = null
|
||||
|
||||
override fun onEnable() {
|
||||
// 注册聊天权限
|
||||
PermissionService.INSTANCE.register(chatPermission, "JChatGPT Chat Permission")
|
||||
PluginConfig.reload()
|
||||
PluginData.reload()
|
||||
|
||||
// 设置Token
|
||||
if (PluginConfig.openAiToken.isNotEmpty()) {
|
||||
updateOpenAiToken(PluginConfig.openAiToken)
|
||||
}
|
||||
LargeLanguageModels.reload()
|
||||
|
||||
// 注册插件命令
|
||||
PluginCommands.register()
|
||||
@@ -82,36 +87,25 @@ object JChatGPT : KotlinPlugin(
|
||||
false
|
||||
}
|
||||
|
||||
if (PluginConfig.callKeyword.isNotEmpty()) {
|
||||
keyword = Regex(PluginConfig.callKeyword)
|
||||
}
|
||||
|
||||
GlobalEventChannel.parentScope(this)
|
||||
.subscribeAlways<MessageEvent> { event -> onMessage(event) }
|
||||
|
||||
logger.info { "Plugin loaded" }
|
||||
}
|
||||
|
||||
fun updateOpenAiToken(token: String) {
|
||||
val timeout = PluginConfig.timeout.milliseconds
|
||||
llm = OpenAI(
|
||||
token,
|
||||
host = OpenAIHost(baseUrl = PluginConfig.openAiApi),
|
||||
timeout = Timeout(request = timeout, connect = timeout, socket = timeout),
|
||||
// logging = LoggingConfig(LogLevel.All)
|
||||
)
|
||||
reasoningAgent.llm = llm
|
||||
}
|
||||
|
||||
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
|
||||
.withZone(ZoneOffset.systemDefault())
|
||||
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
|
||||
|
||||
// private val userContext = ConcurrentMap<Long, MutableList<ChatMessage>>()
|
||||
private const val REPLAY_QUEUE_MAX = 10
|
||||
private val replyMap = ConcurrentMap<Int, MutableList<ChatMessage>>(REPLAY_QUEUE_MAX)
|
||||
private val replyQueue = mutableListOf<Int>()
|
||||
private val requestMap = ConcurrentSet<Long>()
|
||||
|
||||
private suspend fun onMessage(event: MessageEvent) {
|
||||
// 检查Token是否设置
|
||||
if (llm == null) return
|
||||
if (LargeLanguageModels.chat == null) return
|
||||
// 发送者是否有权限
|
||||
if (!event.toCommandSender().hasPermission(chatPermission)) {
|
||||
if (event is GroupMessageEvent) {
|
||||
@@ -132,39 +126,18 @@ object JChatGPT : KotlinPlugin(
|
||||
}
|
||||
}
|
||||
|
||||
// 是否@bot
|
||||
val isAtBot = event.message.contains(At(event.bot))
|
||||
// 是否包含引用消息
|
||||
val quote = event.message[QuoteReply]
|
||||
// 如果没有@bot或者引用消息则直接结束
|
||||
if (!isAtBot && quote == null)
|
||||
// 如果没有 @bot 或者 触发关键字 或者 回复bot的消息 则直接结束
|
||||
if (!event.message.contains(At(event.bot))
|
||||
&& keyword?.let { event.message.content.contains(it) } != true
|
||||
&& event.message[QuoteReply]?.source?.fromId != event.bot.id)
|
||||
return
|
||||
|
||||
// 如果有引用消息,则尝试从回复记录中找到对应消息
|
||||
var context: List<ChatMessage>? = if (quote != null) {
|
||||
replyMap[quote.source.ids[0]]
|
||||
} else null
|
||||
|
||||
// 如果没有At机器人同时上下文是空的,直接忽略
|
||||
if (!isAtBot && context == null) return
|
||||
|
||||
|
||||
if (context == null) {
|
||||
// 如果没有上下文但是引用了消息并且at了机器人,则用引用的消息内容作为上下文
|
||||
if (quote != null) {
|
||||
val msg = quote.source.originalMessage.plainText()
|
||||
if (msg.isNotEmpty()) {
|
||||
context = listOf(ChatMessage(ChatRole.User, msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
startChat(event, context)
|
||||
startChat(event)
|
||||
}
|
||||
|
||||
private fun getSystemPrompt(event: MessageEvent): String {
|
||||
val now = OffsetDateTime.now()
|
||||
val prompt = StringBuilder(PluginConfig.prompt)
|
||||
val prompt = StringBuilder(LargeLanguageModels.systemPrompt)
|
||||
fun replace(target: String, replacement: () -> String) {
|
||||
val i = prompt.indexOf(target)
|
||||
if (i != -1) {
|
||||
@@ -178,364 +151,554 @@ object JChatGPT : KotlinPlugin(
|
||||
|
||||
replace("{subject}") {
|
||||
if (event is GroupMessageEvent) {
|
||||
"\"${event.subject.name}\" 群聊中"
|
||||
"\"${event.subject.name}\" 群聊中,你在本群的名片是:${getNameCard(event.subject.botAsMember)}"
|
||||
} else {
|
||||
"私聊中"
|
||||
"与 \"${event.senderName}\" 私聊中"
|
||||
}
|
||||
}
|
||||
|
||||
// replace("{sender}") {
|
||||
// if (event is GroupMessageEvent) {
|
||||
// event.sender.specialTitle
|
||||
// val permissionName = when (event.sender.permission) {
|
||||
// MEMBER -> "普通群员"
|
||||
// ADMINISTRATOR -> "管理员"
|
||||
// OWNER -> "群主"
|
||||
// }
|
||||
// "\"${event.senderName}\" 身份:$permissionName"
|
||||
// } else {
|
||||
// "\"${event.senderName}\""
|
||||
// }
|
||||
// }
|
||||
|
||||
replace("{history}") {
|
||||
if (!includeHistory) {
|
||||
return@replace "暂无内容"
|
||||
}
|
||||
|
||||
// 一段时间内的消息
|
||||
val beforeTimestamp = now.minusMinutes(PluginConfig.historyWindowMin.toLong()).toEpochSecond().toInt()
|
||||
val nowTimestamp = now.toEpochSecond().toInt()
|
||||
// 最近这段时间的历史对话
|
||||
val history = MiraiHibernateRecorder[event.subject, beforeTimestamp, nowTimestamp]
|
||||
.take(PluginConfig.historyMessageLimit) // 只取最近的部分消息,避免上下文过长
|
||||
.sortedBy { it.time } // 按时间排序
|
||||
// 构造历史消息
|
||||
val historyText = StringBuilder()
|
||||
if (event is GroupMessageEvent) {
|
||||
for (record in history) {
|
||||
if (event.bot.id == record.fromId) {
|
||||
historyText.append("你")
|
||||
} else {
|
||||
val recordSender = event.subject[record.fromId]
|
||||
if (recordSender != null) {
|
||||
// 群活跃等级
|
||||
historyText.append(getNameCard(recordSender))
|
||||
} else {
|
||||
// 未知群员
|
||||
historyText.append("未知群员(").append(record.fromId).append(")")
|
||||
}
|
||||
}
|
||||
historyText
|
||||
.append(" ")
|
||||
// 发言时间
|
||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||
// 消息内容
|
||||
.append(" 说:").appendLine(record.toMessageChain().joinToString("") {
|
||||
when (it) {
|
||||
is At -> {
|
||||
it.getDisplay(event.subject)
|
||||
}
|
||||
|
||||
is ForwardMessage -> {
|
||||
it.title + "\n" + it.preview
|
||||
}
|
||||
|
||||
is QuoteReply -> {
|
||||
">" + it.source.originalMessage.contentToString().replace("\n", "\n> ") + "\n"
|
||||
}
|
||||
|
||||
else -> {
|
||||
it.contentToString()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
} else {
|
||||
// TODO 私聊
|
||||
}
|
||||
|
||||
historyText.toString()
|
||||
replace("{memory}") {
|
||||
val memoryText = PluginData.contactMemory[event.subject.id]
|
||||
if (memoryText.isNullOrEmpty()) {
|
||||
"暂无相关记忆"
|
||||
} else memoryText
|
||||
}
|
||||
|
||||
return prompt.toString()
|
||||
}
|
||||
|
||||
private suspend fun startChat(event: MessageEvent, context: List<ChatMessage>? = null) {
|
||||
val history = mutableListOf<ChatMessage>()
|
||||
if (!context.isNullOrEmpty()) {
|
||||
history.addAll(context)
|
||||
} else if (PluginConfig.prompt.isNotEmpty()) {
|
||||
// region - 历史消息相关 -
|
||||
|
||||
/**
|
||||
* 获取历史消息
|
||||
* @param event 消息事件
|
||||
* @return 如果未获取到则返回空字符串
|
||||
*/
|
||||
private fun getHistory(event: MessageEvent): String {
|
||||
if (!includeHistory) {
|
||||
return event.message.content
|
||||
}
|
||||
val now = OffsetDateTime.now()
|
||||
// 一段时间内的消息
|
||||
val beforeTimestamp = now.minusMinutes(PluginConfig.historyWindowMin.toLong()).toEpochSecond().toInt()
|
||||
return getAfterHistory(beforeTimestamp, event)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取指定时间后的历史消息
|
||||
* @param time Epoch时间戳
|
||||
* @param event 消息事件
|
||||
* @return 如果未获取到则返回空字符串
|
||||
*/
|
||||
private fun getAfterHistory(time: Int, event: MessageEvent): String {
|
||||
if (!includeHistory) {
|
||||
return ""
|
||||
}
|
||||
// 现在时间
|
||||
val nowTimestamp = OffsetDateTime.now().toEpochSecond().toInt()
|
||||
// 最近这段时间的历史对话
|
||||
val history = MiraiHibernateRecorder[event.subject, time, nowTimestamp]
|
||||
.take(PluginConfig.historyMessageLimit) // 只取最近的部分消息,避免上下文过长
|
||||
.sortedBy { it.time } // 按时间排序
|
||||
.toMutableList()
|
||||
|
||||
// 有一定概率最后一条消息没加入,这里检查然后补充一下
|
||||
val msgIds = event.message.ids.joinToString(",")
|
||||
if (!history.any { it.ids == msgIds }) {
|
||||
history.add(MessageRecord.fromSuccess(event.message.source, event.message))
|
||||
}
|
||||
|
||||
// 构造历史消息
|
||||
val historyText = StringBuilder()
|
||||
var lastId = 0L
|
||||
if (event is GroupMessageEvent) {
|
||||
for (record in history) {
|
||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||
appendGroupMessageRecord(historyText, record, event, lastId != record.fromId)
|
||||
lastId = record.fromId
|
||||
}
|
||||
} else {
|
||||
for (record in history) {
|
||||
// 同一人发言不要反复出现这人的名字,减少上下文
|
||||
appendMessageRecord(historyText, record, event, lastId != record.fromId)
|
||||
lastId = record.fromId
|
||||
}
|
||||
}
|
||||
|
||||
return historyText.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加群消息记录到历史上下文中
|
||||
* @param historyText 历史消息构造器
|
||||
* @param record 群消息记录
|
||||
* @param event 群消息事件
|
||||
*/
|
||||
fun appendGroupMessageRecord(
|
||||
historyText: StringBuilder,
|
||||
record: MessageRecord,
|
||||
event: GroupMessageEvent,
|
||||
showSender: Boolean,
|
||||
) {
|
||||
if (showSender) {
|
||||
if (event.bot.id == record.fromId) {
|
||||
historyText.append("**你** " + getNameCard(event.subject.botAsMember))
|
||||
} else {
|
||||
historyText.append(getNameCard(event.subject, record.fromId))
|
||||
}
|
||||
// 发言时间
|
||||
historyText.append(' ')
|
||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||
}
|
||||
|
||||
|
||||
val recordMessage = record.toMessageChain()
|
||||
recordMessage[QuoteReply.Key]?.let {
|
||||
historyText.append(" 引用 ${getNameCard(event.subject, it.source.fromId)} 说的\n > ")
|
||||
.appendLine(it.source.originalMessage.content.replace("\n", "\n > "))
|
||||
}
|
||||
|
||||
if (showSender) {
|
||||
// 消息内容
|
||||
historyText.append(" 说:")
|
||||
}
|
||||
|
||||
historyText.appendLine(record.toMessageChain().joinToString("") {
|
||||
when (it) {
|
||||
is At -> {
|
||||
it.getDisplay(event.subject)
|
||||
}
|
||||
|
||||
else -> singleMessageToText(it)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private fun getNameCard(group: Group, qq: Long): String {
|
||||
val member = group[qq]
|
||||
return if (member == null) {
|
||||
"未知群员($qq)"
|
||||
} else {
|
||||
getNameCard(member)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加消息记录到历史上下文中
|
||||
* @param historyText 历史消息构造器
|
||||
* @param record 消息记录
|
||||
* @param event 消息事件
|
||||
*/
|
||||
fun appendMessageRecord(
|
||||
historyText: StringBuilder,
|
||||
record: MessageRecord,
|
||||
event: MessageEvent,
|
||||
showSender: Boolean
|
||||
) {
|
||||
if (showSender) {
|
||||
if (event.bot.id == record.fromId) {
|
||||
historyText.append("**你** " + event.bot.nameCardOrNick)
|
||||
} else {
|
||||
historyText.append(event.senderName)
|
||||
}
|
||||
historyText
|
||||
.append(" ")
|
||||
// 发言时间
|
||||
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
|
||||
}
|
||||
val recordMessage = record.toMessageChain()
|
||||
recordMessage[QuoteReply.Key]?.let {
|
||||
historyText.append(" 引用\n > ")
|
||||
.appendLine(it.source.originalMessage
|
||||
.joinToString("", transform = ::singleMessageToText)
|
||||
.replace("\n", "\n > "))
|
||||
}
|
||||
if (showSender) {
|
||||
historyText.append(" 说:")
|
||||
}
|
||||
// 消息内容
|
||||
historyText.appendLine(
|
||||
record.toMessageChain().joinToString("", transform = ::singleMessageToText))
|
||||
}
|
||||
|
||||
private fun singleMessageToText(it: SingleMessage): String {
|
||||
return when (it) {
|
||||
is ForwardMessage -> {
|
||||
it.title + "\n " + it.preview
|
||||
}
|
||||
|
||||
// 图片格式化
|
||||
is Image -> {
|
||||
try {
|
||||
val imageUrl = runBlocking {
|
||||
it.queryUrl()
|
||||
}
|
||||
""
|
||||
} catch (e: Throwable) {
|
||||
logger.warning("图片地址获取失败", e)
|
||||
it.content
|
||||
}
|
||||
}
|
||||
|
||||
else -> it.content
|
||||
}
|
||||
}
|
||||
|
||||
// endregion - 历史消息相关 -
|
||||
|
||||
private val thinkRegex = Regex("<think>[\\s\\S]*?</think>")
|
||||
|
||||
private suspend fun startChat(event: MessageEvent) {
|
||||
if (!requestMap.add(event.subject.id)) {
|
||||
logger.warning("The current Contact is busy!")
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
val history = mutableListOf<ChatMessage>()
|
||||
|
||||
val prompt = getSystemPrompt(event)
|
||||
if (PluginConfig.logPrompt) {
|
||||
logger.info("Prompt: $prompt")
|
||||
}
|
||||
history.add(ChatMessage(ChatRole.System, prompt))
|
||||
}
|
||||
val msg = event.message.plainText()
|
||||
if (msg.isNotEmpty()) {
|
||||
history.add(ChatMessage(ChatRole.User, if (event is GroupMessageEvent) {
|
||||
"${getNameCard(event.sender)} 说:$msg"
|
||||
} else {
|
||||
msg
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
if (!requestMap.add(event.sender.id)) {
|
||||
event.subject.sendMessage(event.message.quote() + "再等等...")
|
||||
return
|
||||
}
|
||||
val historyText = getHistory(event)
|
||||
logger.info("History: $historyText")
|
||||
history.add(ChatMessage.User(historyText))
|
||||
|
||||
var done = true
|
||||
// 至少重试两次
|
||||
var retry = max(PluginConfig.retryMax, 2)
|
||||
|
||||
var done: Boolean
|
||||
// 至少循环3次
|
||||
var retry = max(PluginConfig.retryMax, 3)
|
||||
do {
|
||||
try {
|
||||
val reply = chatCompletion(history, retry > 1)
|
||||
history.add(reply)
|
||||
done = true
|
||||
val startedAt = OffsetDateTime.now().toEpochSecond().toInt()
|
||||
val responseFlow = chatCompletions(history)
|
||||
var responseMessageBuilder: StringBuilder? = null
|
||||
val responseToolCalls = mutableListOf<ToolCall.Function>()
|
||||
val toolCallTasks = mutableListOf<Deferred<ChatMessage>>()
|
||||
// 处理聊天流式响应
|
||||
responseFlow.collect { chunk ->
|
||||
val delta = chunk.choices[0].delta ?: return@collect
|
||||
|
||||
for (toolCall in reply.toolCalls.orEmpty()) {
|
||||
require(toolCall is ToolCall.Function) { "Tool call is not a function" }
|
||||
val functionResponse = toolCall.execute(event)
|
||||
history.add(
|
||||
// 处理内容更新
|
||||
if (delta.content != null) {
|
||||
if (responseMessageBuilder == null) {
|
||||
responseMessageBuilder = StringBuilder(delta.content)
|
||||
} else {
|
||||
responseMessageBuilder.append(delta.content)
|
||||
}
|
||||
}
|
||||
|
||||
// 处理工具调用更新
|
||||
val toolCalls = delta.toolCalls
|
||||
if (toolCalls != null) {
|
||||
for (toolCallChunk in toolCalls) {
|
||||
val index = toolCallChunk.index
|
||||
val toolId = toolCallChunk.id
|
||||
val function = toolCallChunk.function
|
||||
// 新的请求
|
||||
if (index >= responseToolCalls.size) {
|
||||
// 处理已完成的工具调用
|
||||
responseToolCalls.lastOrNull()?.let { toolCall ->
|
||||
toolCallTasks.add(async {
|
||||
val functionResponse = toolCall.execute(event)
|
||||
ChatMessage(
|
||||
role = ChatRole.Tool,
|
||||
toolCallId = toolCall.id,
|
||||
name = toolCall.function.name,
|
||||
content = functionResponse
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
// 加入新的工具调用
|
||||
if (toolId != null && function != null) {
|
||||
responseToolCalls.add(ToolCall.Function(toolId, function))
|
||||
}
|
||||
} else if (function != null) {
|
||||
// 拼接函数名字
|
||||
if (function.nameOrNull != null) {
|
||||
val currentTool = responseToolCalls[index]
|
||||
responseToolCalls[index] = currentTool.copy(
|
||||
function = currentTool.function.copy(
|
||||
nameOrNull = currentTool.function.nameOrNull.orEmpty() + function.name
|
||||
)
|
||||
)
|
||||
}
|
||||
// 拼接函数参数
|
||||
if (function.argumentsOrNull != null) {
|
||||
val currentTool = responseToolCalls[index]
|
||||
responseToolCalls[index] = currentTool.copy(
|
||||
function = currentTool.function.copy(
|
||||
argumentsOrNull = currentTool.function.argumentsOrNull.orEmpty() + function.arguments
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 移除思考内容
|
||||
val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim()
|
||||
logger.info("LLM Response: $responseContent")
|
||||
// 记录AI回答
|
||||
history.add(ChatMessage.Assistant(
|
||||
content = responseContent,
|
||||
toolCalls = responseToolCalls
|
||||
))
|
||||
|
||||
// 处理最后一个工具调用
|
||||
if (responseToolCalls.size > toolCallTasks.size) {
|
||||
val toolCallMessage = responseToolCalls.last().let { toolCall ->
|
||||
val functionResponse = toolCall.execute(event)
|
||||
ChatMessage(
|
||||
role = ChatRole.Tool,
|
||||
toolCallId = toolCall.id,
|
||||
name = toolCall.function.name,
|
||||
content = functionResponse
|
||||
)
|
||||
)
|
||||
done = false
|
||||
}
|
||||
if (toolCallTasks.isNotEmpty()) {
|
||||
// 等待之前的所有工具完成
|
||||
history.addAll(toolCallTasks.awaitAll())
|
||||
}
|
||||
// 将最后一个也加入对话历史中
|
||||
history.add(toolCallMessage)
|
||||
// 如果调用中包含结束对话工具则表示完成,反之则继续循环
|
||||
done = history.any { it.name == "endConversation" }
|
||||
} else {
|
||||
done = true
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
history.add(ChatMessage.User(
|
||||
buildString {
|
||||
appendLine("系统提示:本次运行最多还剩${retry-1}轮。")
|
||||
appendLine("如果要多次发言,可以一次性调用多次发言工具。")
|
||||
appendLine("如果没有什么要做的,可以提前结束。")
|
||||
appendLine("当前时间:" + dateTimeFormatter.format(OffsetDateTime.now()))
|
||||
|
||||
val newMessages = getAfterHistory(startedAt, event)
|
||||
if (newMessages.isNotEmpty()) {
|
||||
append("以下是上次运行至今的新消息\n\n$newMessages")
|
||||
}
|
||||
}
|
||||
))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (retry <= 1) {
|
||||
throw e
|
||||
} else {
|
||||
done = false
|
||||
logger.warning("调用llm时发生异常,重试中", e)
|
||||
event.subject.sendMessage(event.message.quote() + "出错了...正在重试...")
|
||||
event.subject.sendMessage("出错了...正在重试...")
|
||||
}
|
||||
}
|
||||
} while (!done && 0 <-- retry)
|
||||
|
||||
val content = history.last().content ?: "..."
|
||||
val replyMsg = event.subject.sendMessage(
|
||||
if (content.length < PluginConfig.messageMergeThreshold) {
|
||||
event.message.quote() + toMessage(event.subject, content)
|
||||
} else {
|
||||
// 消息内容太长则转为转发消息避免刷屏
|
||||
event.buildForwardMessage {
|
||||
event.bot says toMessage(event.subject, content)
|
||||
}
|
||||
|
||||
// 不再将历史对话记录加入其中
|
||||
// event.buildForwardMessage {
|
||||
// for (item in history) {
|
||||
// if (item.content.isNullOrEmpty())
|
||||
// continue
|
||||
// val temp = toMessage(event.subject, item.content!!)
|
||||
// when (item.role) {
|
||||
// Role.User -> event.sender says temp
|
||||
// Role.Assistant -> event.bot says temp
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// // 检查并移除超出转发消息上限的消息
|
||||
// var isOverflow = false
|
||||
// var count = 0
|
||||
// for (i in size - 1 downTo 0) {
|
||||
// if (count > 4900) {
|
||||
// isOverflow = true
|
||||
// // 删除早期上下文消息
|
||||
// removeAt(i)
|
||||
// } else {
|
||||
// for (text in this[i].messageChain.filterIsInstance<PlainText>()) {
|
||||
// count += text.content.length
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// if (count > 5000) {
|
||||
// removeAt(0)
|
||||
// }
|
||||
// if (isOverflow) {
|
||||
// // 如果溢出了,插入一条提示到最开始
|
||||
// add(
|
||||
// 0, ForwardMessage.Node(
|
||||
// senderId = event.bot.id,
|
||||
// time = this[0].time - 1,
|
||||
// senderName = event.bot.nameCardOrNick,
|
||||
// message = PlainText("更早的消息已隐藏,避免超出转发消息上限。")
|
||||
// )
|
||||
// )
|
||||
// }
|
||||
// }
|
||||
}
|
||||
)
|
||||
|
||||
// 将回复的消息和对话历史保存到队列
|
||||
if (replyMsg.sourceIds.isNotEmpty()) {
|
||||
val msgId = replyMsg.sourceIds[0]
|
||||
replyMap[msgId] = history
|
||||
replyQueue.add(msgId)
|
||||
}
|
||||
// 移除超出队列的对话
|
||||
if (replyQueue.size > REPLAY_QUEUE_MAX) {
|
||||
replyMap.remove(replyQueue.removeAt(0))
|
||||
}
|
||||
} while (!done && 0 < --retry)
|
||||
} catch (ex: Throwable) {
|
||||
logger.warning(ex)
|
||||
event.subject.sendMessage(event.message.quote() + "很抱歉,发生异常,请稍后重试")
|
||||
event.subject.sendMessage("很抱歉,发生异常,请稍后重试")
|
||||
} finally {
|
||||
requestMap.remove(event.sender.id)
|
||||
// 一段时间后才允许再次提问,防止高频对话
|
||||
launch {
|
||||
delay(1.seconds)
|
||||
requestMap.remove(event.subject.id)
|
||||
}
|
||||
}
|
||||
// catch (ex: OpenAITimeoutException) {
|
||||
// event.subject.sendMessage(event.message.quote() + "很抱歉,服务器没响应,请稍后重试")
|
||||
// }
|
||||
}
|
||||
|
||||
private val laTeXPattern = Pattern.compile(
|
||||
"\\\\\\((.+?)\\\\\\)|" + // 匹配行内公式 \(...\)
|
||||
"\\\\\\[(.+?)\\\\\\]|" + // 匹配独立公式 \[...\]
|
||||
"\\$\\$([^$]+?)\\$\\$|" + // 匹配独立公式 $$...$$
|
||||
"\\$\\s(.+?)\\s\\$|" + // 匹配行内公式 $...$
|
||||
"```latex\\s*([^`]+?)\\s*```" // 匹配 ```latex ... ```
|
||||
, Pattern.DOTALL
|
||||
)
|
||||
private val regexAtQq = Regex("""@(\d{5,12})""")
|
||||
|
||||
private val regexImage = Regex("""!\[(.*?)]\(([^\s"']+).*?\)""")
|
||||
|
||||
private data class MessageChunk(val range: IntRange, val content: Message)
|
||||
|
||||
/**
|
||||
* 将聊天内容转为聊天消息,如果聊天中包含LaTeX表达式,将会转为图片拼接到消息中。
|
||||
* 将聊天内容转为聊天消息
|
||||
*
|
||||
* @param contact 联系对象
|
||||
* @param content 文本内容
|
||||
* @return 构造的消息
|
||||
*/
|
||||
private suspend fun toMessage(contact: Contact, content: String): Message {
|
||||
suspend fun toMessage(contact: Contact, content: String): Message {
|
||||
return if (content.isEmpty()) {
|
||||
PlainText("...")
|
||||
} else if (content.length < 3) {
|
||||
PlainText(content)
|
||||
} else buildMessageChain {
|
||||
// 匹配LaTeX表达式
|
||||
val matcher = laTeXPattern.matcher(content)
|
||||
var index = 0
|
||||
while (matcher.find()) {
|
||||
for (i in 1..matcher.groupCount()) {
|
||||
if (matcher.group(i) == null) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
// 将所有匹配的LaTeX公式转为图片拼接到消息中
|
||||
val formula = matcher.group(i)
|
||||
val imageByteArray = LaTeXConverter.convertToImage(formula, "png")
|
||||
val resource = imageByteArray.toExternalResource("png")
|
||||
val image = contact.uploadImage(resource)
|
||||
|
||||
// 拼接公式前的文本
|
||||
append(content, index, matcher.start())
|
||||
// 插入图片
|
||||
append(image)
|
||||
// 移动索引
|
||||
index = matcher.end()
|
||||
} catch (ex: Throwable) {
|
||||
logger.warning("处理LaTeX表达式时异常", ex)
|
||||
}
|
||||
} else {
|
||||
val t = mutableListOf<MessageChunk>()
|
||||
// @某人
|
||||
regexAtQq.findAll(content).forEach {
|
||||
val qq = it.groups[1]?.value?.toLongOrNull()
|
||||
if (qq != null && contact is Group) {
|
||||
contact[qq]?.let { member -> t.add(MessageChunk(it.range, At(member))) }
|
||||
}
|
||||
}
|
||||
|
||||
// 图片
|
||||
regexImage.findAll(content).forEach {
|
||||
// val placeholder = it.groupValues[1]
|
||||
val url = it.groupValues[2]
|
||||
t.add(MessageChunk(
|
||||
it.range,
|
||||
Image(url)))
|
||||
}
|
||||
|
||||
// 构造消息链
|
||||
buildMessageChain {
|
||||
var index = 0
|
||||
for ((range, msg) in t.sortedBy { it.range.first }) {
|
||||
if (index < range.first) {
|
||||
append(content, index, range.first)
|
||||
}
|
||||
append(msg)
|
||||
index = range.last + 1
|
||||
}
|
||||
// 拼接后续消息
|
||||
if (index < content.length) {
|
||||
append(content, index, content.length)
|
||||
}
|
||||
}
|
||||
// 拼接后续消息
|
||||
append(content, index, content.length)
|
||||
}
|
||||
}
|
||||
|
||||
private val reasoningAgent = ReasoningAgent()
|
||||
|
||||
/**
|
||||
* 工具列表
|
||||
*/
|
||||
private val myTools = listOf(
|
||||
// 发送单条消息
|
||||
SendSingleMessageAgent(),
|
||||
|
||||
// 发送组合消息
|
||||
SendCompositeMessage(),
|
||||
|
||||
// 发送语音消息
|
||||
SendVoiceMessage(),
|
||||
|
||||
// 发送LaTeX表达式
|
||||
SendLaTeXExpression(),
|
||||
|
||||
// 结束循环
|
||||
StopLoopAgent(),
|
||||
|
||||
// 记忆代理
|
||||
MemoryAppend(),
|
||||
|
||||
// 记忆修改
|
||||
MemoryReplace(),
|
||||
|
||||
// 网页搜索
|
||||
WebSearch(),
|
||||
|
||||
// 访问网页
|
||||
VisitWeb(),
|
||||
|
||||
// 运行代码
|
||||
RunCode(),
|
||||
|
||||
// 推理代理
|
||||
reasoningAgent,
|
||||
ReasoningAgent(),
|
||||
|
||||
// 视觉代理
|
||||
VisualAgent(),
|
||||
|
||||
// 图像编辑模型
|
||||
ImageEdit(),
|
||||
|
||||
// 天气服务
|
||||
WeatherService(),
|
||||
|
||||
// IP所在地查询 暂时取消,几乎不会用到
|
||||
// IpAddressQuery(),
|
||||
|
||||
// Epic 免费游戏
|
||||
EpicFreeGame(),
|
||||
// EpicFreeGame(),
|
||||
|
||||
// 群管代理
|
||||
GroupManageAgent(),
|
||||
)
|
||||
|
||||
// private suspend fun chatCompletion(
|
||||
// chatMessages: List<ChatMessage>,
|
||||
// hasTools: Boolean = true
|
||||
// ): ChatMessage {
|
||||
// val llm = LargeLanguageModels.chat ?: throw NullPointerException("OpenAI Token 未设置,无法开始")
|
||||
// val availableTools = if (hasTools) {
|
||||
// myTools.filter { it.isEnabled }.map { it.tool }
|
||||
// } else null
|
||||
// val request = ChatCompletionRequest(
|
||||
// model = ModelId(PluginConfig.chatModel),
|
||||
// temperature = PluginConfig.chatTemperature,
|
||||
// messages = chatMessages,
|
||||
// tools = availableTools,
|
||||
// )
|
||||
// logger.info("API Requesting... Model=${PluginConfig.chatModel}")
|
||||
// val response = llm.chatCompletion(request)
|
||||
// val message = response.choices.first().message
|
||||
// logger.info("Response: $message ${response.usage}")
|
||||
// return message
|
||||
// }
|
||||
|
||||
private suspend fun chatCompletion(
|
||||
private fun chatCompletions(
|
||||
chatMessages: List<ChatMessage>,
|
||||
hasTools: Boolean = true
|
||||
): ChatMessage {
|
||||
val llm = this.llm ?: throw NullPointerException("OpenAI Token 未设置,无法开始")
|
||||
): Flow<ChatCompletionChunk> {
|
||||
val llm = LargeLanguageModels.chat ?: throw NullPointerException("OpenAI Token 未设置,无法开始")
|
||||
val availableTools = if (hasTools) {
|
||||
myTools.filter { it.isEnabled }.map { it.tool }
|
||||
} else null
|
||||
val request = ChatCompletionRequest(
|
||||
model = ModelId(PluginConfig.chatModel),
|
||||
temperature = PluginConfig.chatTemperature,
|
||||
messages = chatMessages,
|
||||
tools = availableTools,
|
||||
)
|
||||
logger.info("API Requesting... Model=${PluginConfig.chatModel}"
|
||||
// " Tools=${availableTools?.joinToString(prefix = "[", postfix = "]")}"
|
||||
)
|
||||
val response = llm.chatCompletion(request)
|
||||
val message = response.choices.first().message
|
||||
logger.info("Response: $message ${response.usage}")
|
||||
return message
|
||||
logger.info("API Requesting... Model=${PluginConfig.chatModel}")
|
||||
return llm.chatCompletions(request)
|
||||
}
|
||||
|
||||
private fun getNameCard(member: Member): String {
|
||||
val nameCard = StringBuilder()
|
||||
// 群活跃等级
|
||||
nameCard.append("【lv").append(member.active.temperature).append(" ")
|
||||
// 群头衔
|
||||
if (member.specialTitle.isNotEmpty()) {
|
||||
nameCard.append(member.specialTitle)
|
||||
} else {
|
||||
nameCard.append(
|
||||
when (member.permission) {
|
||||
OWNER -> "群主"
|
||||
ADMINISTRATOR -> "管理员"
|
||||
MEMBER -> member.temperatureTitle
|
||||
}
|
||||
)
|
||||
try {
|
||||
// 群头衔
|
||||
if (member.specialTitle.isNotEmpty()) {
|
||||
nameCard.append(member.specialTitle)
|
||||
} else {
|
||||
nameCard.append(
|
||||
when (member.permission) {
|
||||
OWNER -> "群主"
|
||||
ADMINISTRATOR -> "管理员"
|
||||
MEMBER -> member.temperatureTitle
|
||||
}
|
||||
)
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
logger.warning("获取群头衔失败", e)
|
||||
}
|
||||
// 群名片
|
||||
nameCard.append("】 ").append(member.nameCardOrNick)
|
||||
// .append(" (").append(recordSender.id).append(")")
|
||||
nameCard.append("】 ").append(member.nameCardOrNick).append("(").append(member.id).append(")")
|
||||
return nameCard.toString()
|
||||
}
|
||||
|
||||
private fun MessageChain.plainText() = this.filterIsInstance<PlainText>().joinToString().trim()
|
||||
|
||||
private suspend fun ToolCall.Function.execute(event: MessageEvent): String {
|
||||
val agent = myTools.find { it.tool.function.name == function.name }
|
||||
?: return "Function ${function.name} not found"
|
||||
// 提示正在执行函数
|
||||
val receipt = if (agent.loadingMessage.isNotEmpty()) {
|
||||
event.subject.sendMessage(event.message.quote() + agent.loadingMessage)
|
||||
val receipt = if (PluginConfig.showToolCallingMessage && agent.loadingMessage.isNotEmpty()) {
|
||||
event.subject.sendMessage(agent.loadingMessage)
|
||||
} else null
|
||||
// 提取参数
|
||||
val args = function.argumentsAsJsonOrNull()
|
||||
logger.info("Calling ${function.name}(${args})")
|
||||
// 执行函数
|
||||
val result = try {
|
||||
agent.execute(args)
|
||||
// 提取参数
|
||||
val args = function.argumentsAsJsonOrNull()
|
||||
logger.info("Calling ${function.name}(${args})")
|
||||
agent.execute(args, event)
|
||||
} catch (e: Throwable) {
|
||||
logger.error("Failed to call ${function.name}", e)
|
||||
"工具调用失败,请尝试自行回答用户,或如实告知。"
|
||||
"工具调用失败,请尝试自行回答用户,或如实告知。\n异常信息:${e.message}"
|
||||
}
|
||||
logger.info("Result=\"$result\"")
|
||||
// 过会撤回加载消息
|
||||
@@ -545,9 +708,11 @@ object JChatGPT : KotlinPlugin(
|
||||
try {
|
||||
receipt.recall()
|
||||
} catch (e: Throwable) {
|
||||
logger.error("消息撤回失败,调试信息:" +
|
||||
"source.internalIds=${receipt.source.internalIds.joinToString()} " +
|
||||
"source.ids= ${receipt.source.ids.joinToString()}", e)
|
||||
logger.error(
|
||||
"消息撤回失败,调试信息:" +
|
||||
"source.internalIds=${receipt.source.internalIds.joinToString()} " +
|
||||
"source.ids= ${receipt.source.ids.joinToString()}", e
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -23,7 +23,7 @@ object LaTeXConverter {
|
||||
g2.color = Color.white
|
||||
g2.fillRect(0, 0, icon.iconWidth, icon.iconHeight)
|
||||
val jl = JLabel()
|
||||
jl.setForeground(Color(0, 0, 0))
|
||||
jl.setForeground(Color.black)
|
||||
icon.paintIcon(jl, g2, 0, 0)
|
||||
val stream = ByteArrayOutputStream()
|
||||
ImageIO.write(image, format, stream)
|
||||
|
81
src/main/kotlin/LargeLanguageModels.kt
Normal file
81
src/main/kotlin/LargeLanguageModels.kt
Normal file
@@ -0,0 +1,81 @@
|
||||
package top.jie65535.mirai
|
||||
|
||||
import com.aallam.openai.api.http.Timeout
|
||||
import com.aallam.openai.client.Chat
|
||||
import com.aallam.openai.client.OpenAI
|
||||
import com.aallam.openai.client.OpenAIHost
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
object LargeLanguageModels {
|
||||
|
||||
|
||||
/**
|
||||
* 系统提示词
|
||||
*/
|
||||
var systemPrompt: String = "你是一个乐于助人的助手"
|
||||
private set
|
||||
|
||||
/**
|
||||
* 聊天助手
|
||||
*/
|
||||
var chat: Chat? = null
|
||||
|
||||
/**
|
||||
* 推理模型
|
||||
*/
|
||||
var reasoning: Chat? = null
|
||||
|
||||
/**
|
||||
* 视觉模型
|
||||
*/
|
||||
var visual: Chat? = null
|
||||
|
||||
fun reload() {
|
||||
// 载入超时时间
|
||||
val timeout = PluginConfig.timeout.milliseconds
|
||||
|
||||
// 初始化聊天模型
|
||||
if (PluginConfig.openAiApi.isNotBlank() && PluginConfig.openAiToken.isNotBlank()) {
|
||||
chat = OpenAI(
|
||||
token = PluginConfig.openAiToken,
|
||||
host = OpenAIHost(baseUrl = PluginConfig.openAiApi),
|
||||
timeout = Timeout(request = timeout, connect = timeout, socket = timeout)
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化推理模型
|
||||
if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) {
|
||||
reasoning = OpenAI(
|
||||
token = PluginConfig.reasoningModelToken,
|
||||
host = OpenAIHost(baseUrl = PluginConfig.reasoningModelApi),
|
||||
timeout = Timeout(request = timeout, connect = timeout, socket = timeout)
|
||||
)
|
||||
}
|
||||
|
||||
// 初始化视觉模型
|
||||
if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) {
|
||||
visual = OpenAI(
|
||||
token = PluginConfig.visualModelToken,
|
||||
host = OpenAIHost(baseUrl = PluginConfig.visualModelApi),
|
||||
timeout = Timeout(request = timeout, connect = timeout, socket = timeout)
|
||||
)
|
||||
}
|
||||
|
||||
// 载入提示词
|
||||
if (PluginConfig.promptFile.isNotEmpty()) {
|
||||
val file = JChatGPT.resolveConfigFile(PluginConfig.promptFile)
|
||||
systemPrompt = if (file.exists()) {
|
||||
file.readText()
|
||||
} else {
|
||||
// 迁移提示词
|
||||
file.writeText(PluginConfig.prompt)
|
||||
PluginConfig.prompt
|
||||
}
|
||||
|
||||
// 空提示词兜底
|
||||
if (systemPrompt.isEmpty()) {
|
||||
systemPrompt = "你是一个乐于助人的助手"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -18,7 +18,7 @@ object PluginCommands : CompositeCommand(
|
||||
suspend fun CommandSender.setToken(token: String) {
|
||||
PluginConfig.openAiToken = token
|
||||
PluginConfig.save()
|
||||
JChatGPT.updateOpenAiToken(token)
|
||||
LargeLanguageModels.reload()
|
||||
sendMessage("OK")
|
||||
}
|
||||
|
||||
@@ -45,7 +45,8 @@ object PluginCommands : CompositeCommand(
|
||||
@SubCommand
|
||||
suspend fun CommandSender.reload() {
|
||||
PluginConfig.reload()
|
||||
JChatGPT.updateOpenAiToken(PluginConfig.openAiToken)
|
||||
PluginData.reload()
|
||||
LargeLanguageModels.reload()
|
||||
sendMessage("OK")
|
||||
}
|
||||
}
|
@@ -6,7 +6,7 @@ import net.mamoe.mirai.console.data.value
|
||||
|
||||
object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("OpenAI API base url")
|
||||
val openAiApi: String by value("https://api.openai.com/v1/")
|
||||
val openAiApi: String by value("https://dashscope.aliyuncs.com/compatible-mode/v1/")
|
||||
|
||||
@ValueDescription("OpenAI API Token")
|
||||
var openAiToken: String by value("")
|
||||
@@ -14,11 +14,44 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("Chat模型")
|
||||
var chatModel: String by value("qwen-max")
|
||||
|
||||
@ValueDescription("Chat模型温度,默认为null")
|
||||
var chatTemperature: Double? by value(null)
|
||||
|
||||
@ValueDescription("推理模型API")
|
||||
var reasoningModelApi: String by value("https://dashscope.aliyuncs.com/compatible-mode/v1/")
|
||||
|
||||
@ValueDescription("推理模型Token")
|
||||
var reasoningModelToken: String by value("")
|
||||
|
||||
@ValueDescription("推理模型")
|
||||
var reasoningModel: String by value("qwq-plus")
|
||||
|
||||
@ValueDescription("Chat默认提示")
|
||||
var prompt: String by value("")
|
||||
@ValueDescription("视觉模型API")
|
||||
var visualModelApi: String by value("https://dashscope.aliyuncs.com/compatible-mode/v1/")
|
||||
|
||||
@ValueDescription("视觉模型Token")
|
||||
var visualModelToken: String by value("")
|
||||
|
||||
@ValueDescription("视觉模型")
|
||||
var visualModel: String by value("qwen-vl-plus")
|
||||
|
||||
@ValueDescription("百炼平台API KEY")
|
||||
val dashScopeApiKey: String by value("")
|
||||
|
||||
@ValueDescription("百炼平台图片编辑模型")
|
||||
val imageEditModel: String by value("qwen-image-edit")
|
||||
|
||||
@ValueDescription("百炼平台TTS模型")
|
||||
val ttsModel: String by value("qwen-tts")
|
||||
|
||||
@ValueDescription("Jina API Key")
|
||||
val jinaApiKey by value("")
|
||||
|
||||
@ValueDescription("SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回")
|
||||
val searXngUrl: String by value("")
|
||||
|
||||
@ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。")
|
||||
val glotToken: String by value("")
|
||||
|
||||
@ValueDescription("群管理是否自动拥有对话权限,默认是")
|
||||
val groupOpHasChatPermission: Boolean by value(true)
|
||||
@@ -26,17 +59,21 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("好友是否自动拥有对话权限,默认是")
|
||||
val friendHasChatPermission: Boolean by value(true)
|
||||
|
||||
@ValueDescription("群荣誉等级权限门槛,达到这个等级相当于自动拥有权限。")
|
||||
val temperaturePermission: Int by value(60)
|
||||
@ValueDescription("机器人是否可以禁言别人,默认禁止")
|
||||
val canMute: Boolean by value(false)
|
||||
|
||||
@ValueDescription("群荣誉等级权限门槛,达到这个等级相当于自动拥有对话权限。")
|
||||
val temperaturePermission: Int by value(50)
|
||||
|
||||
@ValueDescription("等待响应超时时间,单位毫秒,默认60秒")
|
||||
val timeout: Long by value(60000L)
|
||||
|
||||
@ValueDescription("SearXNG 搜索引擎地址,如 http://127.0.0.1:8080/search 必须启用允许json格式返回")
|
||||
val searXngUrl: String by value("")
|
||||
@Deprecated("使用外部文件而不是在配置文件内保存提示词")
|
||||
@ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改")
|
||||
var prompt: String by value("你是一个乐于助人的助手")
|
||||
|
||||
@ValueDescription("在线运行代码 glot.io 的 api token,在官网注册账号即可获取。")
|
||||
val glotToken: String by value("")
|
||||
@ValueDescription("系统提示词文件路径,相对于插件配置目录")
|
||||
val promptFile: String by value("SystemPrompt.md")
|
||||
|
||||
@ValueDescription("创建Prompt时取最近多少分钟内的消息")
|
||||
val historyWindowMin: Int by value(10)
|
||||
@@ -50,6 +87,15 @@ object PluginConfig : AutoSavePluginConfig("Config") {
|
||||
@ValueDescription("达到需要合并转发消息的阈值")
|
||||
val messageMergeThreshold by value(150)
|
||||
|
||||
@ValueDescription("最大重试次数,至少2次,最后一次请求不会带工具,非工具调用相当于正常回复")
|
||||
val retryMax: Int by value(3)
|
||||
@ValueDescription("最大循环次数,至少2次")
|
||||
val retryMax: Int by value(5)
|
||||
|
||||
@ValueDescription("关键字呼叫,支持正则表达式")
|
||||
val callKeyword by value("[小筱][林淋月玥]")
|
||||
|
||||
@ValueDescription("是否显示工具调用消息,默认是")
|
||||
val showToolCallingMessage by value(true)
|
||||
|
||||
@ValueDescription("是否启用记忆编辑功能,记忆存在data目录,提示词中需要加上{memory}来填充记忆,每个群都有独立记忆")
|
||||
val memoryEnabled by value(true)
|
||||
}
|
@@ -1,7 +1,36 @@
|
||||
package top.jie65535.mirai
|
||||
|
||||
import net.mamoe.mirai.console.data.AutoSavePluginData
|
||||
import net.mamoe.mirai.console.data.value
|
||||
|
||||
object PluginData : AutoSavePluginData("data") {
|
||||
/**
|
||||
* 联系人记忆
|
||||
*/
|
||||
val contactMemory by value(mutableMapOf<Long, String>())
|
||||
|
||||
/**
|
||||
* 添加对话记忆
|
||||
*/
|
||||
fun appendContactMemory(contactId: Long, newMemory: String) {
|
||||
val memory = contactMemory[contactId]
|
||||
if (memory.isNullOrEmpty()) {
|
||||
contactMemory[contactId] = newMemory
|
||||
} else {
|
||||
contactMemory[contactId] = "$memory\n$newMemory"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 替换对话记忆
|
||||
*/
|
||||
fun replaceContactMemory(contactId: Long, oldMemory: String, newMemory: String) {
|
||||
val memory = contactMemory[contactId]
|
||||
if (memory.isNullOrEmpty()) {
|
||||
contactMemory[contactId] = newMemory
|
||||
} else {
|
||||
contactMemory[contactId] = memory.replace(oldMemory, newMemory)
|
||||
.replace("\n\n", "\n")
|
||||
}
|
||||
}
|
||||
}
|
@@ -3,7 +3,12 @@ package top.jie65535.mirai.tools
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import io.ktor.client.*
|
||||
import io.ktor.client.engine.okhttp.*
|
||||
import io.ktor.client.plugins.HttpTimeout
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
|
||||
abstract class BaseAgent(
|
||||
val tool: Tool
|
||||
@@ -18,11 +23,33 @@ abstract class BaseAgent(
|
||||
*/
|
||||
open val loadingMessage: String = ""
|
||||
|
||||
/**
|
||||
* HTTP客户端
|
||||
*/
|
||||
protected val httpClient by lazy {
|
||||
HttpClient(OkHttp)
|
||||
HttpClient(OkHttp) {
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 120_000
|
||||
connectTimeoutMillis = 30_000
|
||||
socketTimeoutMillis = 120_000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
abstract suspend fun execute(args: JsonObject?): String
|
||||
/**
|
||||
* 协程作用域
|
||||
*/
|
||||
protected val scope by lazy {
|
||||
CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
}
|
||||
|
||||
open suspend fun execute(args: JsonObject?): String {
|
||||
return "OK"
|
||||
}
|
||||
|
||||
open suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
return execute(args)
|
||||
}
|
||||
|
||||
override fun toString(): String {
|
||||
return "${tool.function.name}: ${tool.function.description}"
|
||||
|
68
src/main/kotlin/tools/GroupManageAgent.kt
Normal file
68
src/main/kotlin/tools/GroupManageAgent.kt
Normal file
@@ -0,0 +1,68 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.long
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import net.mamoe.mirai.contact.MemberPermission
|
||||
import net.mamoe.mirai.event.events.GroupMessageEvent
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
class GroupManageAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "mute",
|
||||
description = "可用于禁言指定群成员,只有你是管理员且目标非管理或群主时有效,非必要不要轻易禁言别人,否则你可能会被禁用这个特权!",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("target") {
|
||||
put("type", "integer")
|
||||
put("description", "目标QQ号")
|
||||
}
|
||||
putJsonObject("durationM") {
|
||||
put("type", "integer")
|
||||
put("description", "禁言时长(分钟,目前暂时只支持1~10分钟,后续视情况增加上限)")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("target")
|
||||
add("durationM")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
override val isEnabled: Boolean
|
||||
get() = PluginConfig.canMute
|
||||
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val target = args.getValue("target").jsonPrimitive.long
|
||||
val duration = args.getValue("durationM").jsonPrimitive.int
|
||||
if (event !is GroupMessageEvent) {
|
||||
return "非群聊环境无法禁言"
|
||||
}
|
||||
if (event.group.botPermission == MemberPermission.MEMBER) {
|
||||
return "你并非管理,无法禁言他人"
|
||||
}
|
||||
val member = event.group[target]
|
||||
if (member == null) {
|
||||
return "未找到目标群成员"
|
||||
}
|
||||
|
||||
if (member.isMuted) {
|
||||
return "该目标已被禁言,还剩 " + member.muteTimeRemaining.seconds.toString() + " 解除。"
|
||||
}
|
||||
|
||||
// 禁言指定时长
|
||||
member.mute(duration.coerceIn(1, 10) * 60)
|
||||
return "已禁言目标"
|
||||
}
|
||||
}
|
111
src/main/kotlin/tools/ImageEdit.kt
Normal file
111
src/main/kotlin/tools/ImageEdit.kt
Normal file
@@ -0,0 +1,111 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import io.ktor.client.request.header
|
||||
import io.ktor.client.request.post
|
||||
import io.ktor.client.request.setBody
|
||||
import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.contentType
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.addJsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
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 top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
|
||||
class ImageEdit : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "imageEdit",
|
||||
description = "可通过调用图像编辑模型来修改图片。备注:该方法成本较高,非必要尽量不要调用。编辑图片前无需识别图片内容,图像编辑模型自己会理解图片内容!",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("image_url") {
|
||||
put("type", "string")
|
||||
put("description", "原始图片地址")
|
||||
}
|
||||
putJsonObject("prompt") {
|
||||
put("type", "string")
|
||||
put("description", "正向提示词,用来描述需要对图片进行修改的要求。")
|
||||
}
|
||||
// putJsonObject("negative_prompt") {
|
||||
// put("type", "string")
|
||||
// put("description", "反向提示词,用来描述不希望在画面中看到的内容,可以对画面进行限制。" +
|
||||
// "示例值:低分辨率、错误、最差质量、低质量、残缺、多余的手指、比例不良等。")
|
||||
// }
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("image_url")
|
||||
add("prompt")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
const val API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
}
|
||||
|
||||
override val isEnabled: Boolean
|
||||
get() = PluginConfig.dashScopeApiKey.isNotEmpty()
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "改图中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
requireNotNull(args)
|
||||
val imageUrl = args.getValue("image_url").jsonPrimitive.content
|
||||
val prompt = args.getValue("prompt").jsonPrimitive.content
|
||||
// val negativePrompt = args["negative_prompt"]?.jsonPrimitive?.content
|
||||
val response = httpClient.post(API_URL) {
|
||||
contentType(ContentType("application", "json"))
|
||||
header("Authorization", "Bearer " + PluginConfig.dashScopeApiKey)
|
||||
setBody(buildJsonObject {
|
||||
put("model", PluginConfig.imageEditModel)
|
||||
putJsonObject("input") {
|
||||
putJsonArray("messages") {
|
||||
addJsonObject {
|
||||
put("role", "user")
|
||||
putJsonArray("content") {
|
||||
addJsonObject {
|
||||
put("image", imageUrl)
|
||||
}
|
||||
addJsonObject {
|
||||
put("text", prompt)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// if (negativePrompt != null) {
|
||||
// putJsonObject("parameters") {
|
||||
// put("negative_prompt", negativePrompt)
|
||||
// }
|
||||
// }
|
||||
}.toString())
|
||||
}
|
||||
|
||||
val responseJson = response.bodyAsText()
|
||||
val responseObject = Json.parseToJsonElement(responseJson).jsonObject
|
||||
return try {
|
||||
val url = responseObject
|
||||
.getValue("output").jsonObject
|
||||
.getValue("choices").jsonArray[0].jsonObject
|
||||
.getValue("message").jsonObject
|
||||
.getValue("content").jsonArray[0].jsonObject
|
||||
.getValue("image").jsonPrimitive.content
|
||||
"图片已编辑完成,发送时请务必包含完整的url和查询参数,因为下载地址存在鉴权:"
|
||||
} catch (e: Throwable) {
|
||||
JChatGPT.logger.error("图像编辑结果解析异常", e)
|
||||
responseJson
|
||||
}
|
||||
}
|
||||
}
|
45
src/main/kotlin/tools/MemoryAppend.kt
Normal file
45
src/main/kotlin/tools/MemoryAppend.kt
Normal file
@@ -0,0 +1,45 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
import top.jie65535.mirai.PluginData
|
||||
|
||||
class MemoryAppend : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "memoryAppend",
|
||||
description = "新增记忆项",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("memory") {
|
||||
put("type", "string")
|
||||
put("description", "记忆项")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("memory")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
override val isEnabled: Boolean
|
||||
get() = PluginConfig.memoryEnabled
|
||||
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val contactId = event.subject.id
|
||||
val memoryText = args.getValue("memory").jsonPrimitive.content
|
||||
JChatGPT.logger.info("Remember ($contactId): \"$memoryText\"")
|
||||
PluginData.appendContactMemory(contactId, memoryText)
|
||||
return "OK"
|
||||
}
|
||||
}
|
51
src/main/kotlin/tools/MemoryReplace.kt
Normal file
51
src/main/kotlin/tools/MemoryReplace.kt
Normal file
@@ -0,0 +1,51 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
import top.jie65535.mirai.PluginData
|
||||
|
||||
class MemoryReplace : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "memoryReplace",
|
||||
description = "替换记忆项",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("oldMemory") {
|
||||
put("type", "string")
|
||||
put("description", "原记忆项")
|
||||
}
|
||||
putJsonObject("newMemory") {
|
||||
put("type", "string")
|
||||
put("description", "新记忆项")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("oldMemory")
|
||||
add("newMemory")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
override val isEnabled: Boolean
|
||||
get() = PluginConfig.memoryEnabled
|
||||
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val contactId = event.subject.id
|
||||
val oldMemoryText = args.getValue("oldMemory").jsonPrimitive.content
|
||||
val newMemoryText = args.getValue("newMemory").jsonPrimitive.content
|
||||
JChatGPT.logger.info("Replace memory ($contactId): \"$oldMemoryText\" -> \"$newMemoryText\"")
|
||||
PluginData.replaceContactMemory(contactId, oldMemoryText, newMemoryText)
|
||||
return "OK"
|
||||
}
|
||||
}
|
@@ -5,8 +5,8 @@ import com.aallam.openai.api.chat.ChatMessage
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import com.aallam.openai.api.model.ModelId
|
||||
import com.aallam.openai.client.Chat
|
||||
import kotlinx.serialization.json.*
|
||||
import top.jie65535.mirai.LargeLanguageModels
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
|
||||
class ReasoningAgent : BaseAgent(
|
||||
@@ -18,32 +18,30 @@ class ReasoningAgent : BaseAgent(
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("prompt") {
|
||||
put("type", "string")
|
||||
put("description", "用于调用推理模型的提示")
|
||||
put("description", "用于调用推理模型的提示词")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("question")
|
||||
add("prompt")
|
||||
}
|
||||
},
|
||||
)
|
||||
) {
|
||||
var llm: Chat? = null
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "深度思考中..."
|
||||
get() = "思考中..."
|
||||
|
||||
override val isEnabled: Boolean
|
||||
get() = llm != null
|
||||
get() = LargeLanguageModels.reasoning != null
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
requireNotNull(args)
|
||||
val llm = llm ?: return "未配置llm,无法进行推理。"
|
||||
val llm = LargeLanguageModels.reasoning ?: return "未配置llm,无法进行推理。"
|
||||
|
||||
val prompt = args.getValue("prompt").jsonPrimitive.content
|
||||
val answerContent = StringBuilder()
|
||||
llm.chatCompletions(ChatCompletionRequest(
|
||||
model = ModelId(PluginConfig.reasoningModel),
|
||||
messages = listOf(ChatMessage.Companion.User(prompt))
|
||||
messages = listOf(ChatMessage.User(prompt))
|
||||
)).collect {
|
||||
if (it.choices.isNotEmpty()) {
|
||||
val delta = it.choices[0].delta ?: return@collect
|
||||
|
@@ -11,7 +11,8 @@ import top.jie65535.mirai.PluginConfig
|
||||
class RunCode : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "runCode",
|
||||
description = "执行代码,请尽量避免需要运行时输入或可能导致死循环的代码!",
|
||||
description = "运行目标代码,请尽量避免需要运行时输入或可能导致死循环的代码!" +
|
||||
"注意,这些代码对用户不可见,如果用户要求展示代码,你应该直接发送相关代码而不是执行。",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
@@ -87,7 +88,7 @@ class RunCode : BaseAgent(
|
||||
get() = PluginConfig.glotToken.isNotEmpty()
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "执行代码中..."
|
||||
get() = "执行中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
requireNotNull(args)
|
||||
|
50
src/main/kotlin/tools/SendCompositeMessage.kt
Normal file
50
src/main/kotlin/tools/SendCompositeMessage.kt
Normal file
@@ -0,0 +1,50 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import net.mamoe.mirai.message.data.buildForwardMessage
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
import kotlin.collections.getValue
|
||||
|
||||
class SendCompositeMessage : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "sendCompositeMessage",
|
||||
description = "发送组合消息,适合发送较长消息而避免刷屏(不支持Markdown)",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("content") {
|
||||
put("type", "string")
|
||||
put("description", "消息内容")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("content")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val content = args.getValue("content").jsonPrimitive.content
|
||||
val msg = JChatGPT.toMessage(event.subject, content)
|
||||
event.subject.sendMessage(
|
||||
if (content.length > PluginConfig.messageMergeThreshold) {
|
||||
event.buildForwardMessage {
|
||||
event.bot says msg
|
||||
}
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
)
|
||||
return "OK"
|
||||
}
|
||||
}
|
46
src/main/kotlin/tools/SendLaTeXExpression.kt
Normal file
46
src/main/kotlin/tools/SendLaTeXExpression.kt
Normal file
@@ -0,0 +1,46 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import kotlinx.serialization.json.*
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.LaTeXConverter
|
||||
import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
|
||||
|
||||
class SendLaTeXExpression : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "sendLaTeXExpression",
|
||||
description = "发送LaTeX数学表达式,将其渲染为图片并发送",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("expression") {
|
||||
put("type", "string")
|
||||
put("description", "LaTeX数学表达式")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("expression")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val expression = args.getValue("expression").jsonPrimitive.content
|
||||
|
||||
try {
|
||||
// 将LaTeX表达式转换为图片
|
||||
val imageByteArray = LaTeXConverter.convertToImage(expression, "png")
|
||||
val resource = imageByteArray.toExternalResource("png")
|
||||
val image = event.subject.uploadImage(resource)
|
||||
|
||||
// 发送图片消息
|
||||
event.subject.sendMessage(image)
|
||||
|
||||
return "成功发送LaTeX表达式"
|
||||
} catch (ex: Throwable) {
|
||||
return "处理LaTeX表达式时发生异常: ${ex.message}"
|
||||
}
|
||||
}
|
||||
}
|
33
src/main/kotlin/tools/SendSingleMessageAgent.kt
Normal file
33
src/main/kotlin/tools/SendSingleMessageAgent.kt
Normal file
@@ -0,0 +1,33 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
import kotlinx.serialization.json.*
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
|
||||
class SendSingleMessageAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "sendSingleMessage",
|
||||
description = "发送一条消息,适合发送一行以内的短句(不支持Markdown)",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("content") {
|
||||
put("type", "string")
|
||||
put("description", "消息内容")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("content")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
val content = args.getValue("content").jsonPrimitive.content
|
||||
event.subject.sendMessage(JChatGPT.toMessage(event.subject, content))
|
||||
return "OK"
|
||||
}
|
||||
}
|
140
src/main/kotlin/tools/SendVoiceMessage.kt
Normal file
140
src/main/kotlin/tools/SendVoiceMessage.kt
Normal file
@@ -0,0 +1,140 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
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.*
|
||||
import kotlinx.serialization.json.*
|
||||
import net.mamoe.mirai.contact.AudioSupported
|
||||
import net.mamoe.mirai.event.events.MessageEvent
|
||||
import net.mamoe.mirai.utils.ExternalResource.Companion.toExternalResource
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.time.measureTime
|
||||
|
||||
/**
|
||||
* 发送语音消息,调用阿里TTS,需要系统中存在ffmpeg,因为要转换到QQ支持的amr格式。
|
||||
*/
|
||||
class SendVoiceMessage : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "sendVoiceMessage",
|
||||
description = "发送一条文本转语音消息。",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("content") {
|
||||
put("type", "string")
|
||||
put("description", "语音消息文本内容")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("content")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
const val API_URL = "https://dashscope.aliyuncs.com/api/v1/services/aigc/multimodal-generation/generation"
|
||||
}
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "录音中..."
|
||||
|
||||
override val isEnabled: Boolean
|
||||
get() = PluginConfig.dashScopeApiKey.isNotEmpty()
|
||||
|
||||
|
||||
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
|
||||
requireNotNull(args)
|
||||
if (event.subject !is AudioSupported) return "当前聊天环境不支持发送语音!"
|
||||
|
||||
val content = args.getValue("content").jsonPrimitive.content
|
||||
|
||||
// https://help.aliyun.com/zh/model-studio/qwen-tts
|
||||
val response = httpClient.post(API_URL) {
|
||||
contentType(ContentType("application", "json"))
|
||||
header("Authorization", "Bearer " + PluginConfig.dashScopeApiKey)
|
||||
setBody(buildJsonObject {
|
||||
put("model", PluginConfig.ttsModel)
|
||||
putJsonObject("input") {
|
||||
put("text", content)
|
||||
put("voice", "Chelsie") // Chelsie(女) Cherry(女) Ethan(男) Serena(女)
|
||||
}
|
||||
}.toString())
|
||||
}
|
||||
|
||||
val responseJson = response.bodyAsText()
|
||||
val responseObject = Json.parseToJsonElement(responseJson).jsonObject
|
||||
return try {
|
||||
val url = responseObject
|
||||
.getValue("output").jsonObject
|
||||
.getValue("audio").jsonObject
|
||||
.getValue("url").jsonPrimitive.content
|
||||
|
||||
val voiceFolder = JChatGPT.resolveDataFile("voice")
|
||||
voiceFolder.mkdir()
|
||||
val amrFile = File(voiceFolder, "${System.currentTimeMillis()}.amr")
|
||||
// 下载WAV并转到AMR
|
||||
downloadWav2Amr(url, amrFile.absolutePath)
|
||||
// 如果转换出来了则发送消息
|
||||
if (amrFile.exists()) {
|
||||
val audioMessage = amrFile.toExternalResource("amr").use {
|
||||
(event.subject as AudioSupported).uploadAudio(it)
|
||||
}
|
||||
event.subject.sendMessage(audioMessage)
|
||||
"OK"
|
||||
} else {
|
||||
"语音转换失败"
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
JChatGPT.logger.error("语音生成结果解析异常", e)
|
||||
responseJson
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 下载WAV并转换到AMR语音文件
|
||||
* @param url 下载地址
|
||||
* @param outputAmrPath 目标文件路径
|
||||
*/
|
||||
private suspend fun downloadWav2Amr(url: String, outputAmrPath: String) {
|
||||
val wavBytes: ByteArray
|
||||
val downloadDuration = measureTime {
|
||||
wavBytes = httpClient.get(url).bodyAsBytes()
|
||||
}
|
||||
JChatGPT.logger.info("下载语音文件耗时 $downloadDuration,文件大小 ${wavBytes.size} Bytes,开始转换为AMR...")
|
||||
|
||||
val convertDuration = measureTime {
|
||||
val ffmpeg = ProcessBuilder(
|
||||
"ffmpeg",
|
||||
"-f", "wav", // 指定输入格式
|
||||
"-i", "pipe:0", // 从标准输入读取
|
||||
"-ar", "8000",
|
||||
"-ac", "1",
|
||||
"-b:a", "12.2k",
|
||||
"-y", // 覆盖输出文件
|
||||
outputAmrPath // 输出到目标文件位置
|
||||
).start()
|
||||
ffmpeg.outputStream.use {
|
||||
it.write(wavBytes)
|
||||
}
|
||||
// 等待FFmpeg处理完成
|
||||
val completed = ffmpeg.waitFor(PluginConfig.timeout, TimeUnit.MILLISECONDS)
|
||||
|
||||
if (!completed) {
|
||||
ffmpeg.destroy()
|
||||
JChatGPT.logger.error("转换文件超时")
|
||||
}
|
||||
|
||||
if (ffmpeg.exitValue() != 0) {
|
||||
JChatGPT.logger.error("FFmpeg执行失败,退出代码:${ffmpeg.exitValue()}")
|
||||
}
|
||||
}
|
||||
|
||||
JChatGPT.logger.info("转换音频耗时 $convertDuration")
|
||||
}
|
||||
|
||||
}
|
12
src/main/kotlin/tools/StopLoopAgent.kt
Normal file
12
src/main/kotlin/tools/StopLoopAgent.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
import com.aallam.openai.api.chat.Tool
|
||||
import com.aallam.openai.api.core.Parameters
|
||||
|
||||
class StopLoopAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "endConversation",
|
||||
description = "结束本轮对话",
|
||||
parameters = Parameters.Empty
|
||||
)
|
||||
)
|
71
src/main/kotlin/tools/VisitWeb.kt
Normal file
71
src/main/kotlin/tools/VisitWeb.kt
Normal file
@@ -0,0 +1,71 @@
|
||||
package top.jie65535.mirai.tools
|
||||
|
||||
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 kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.serialization.json.*
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
|
||||
class VisitWeb : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "visit",
|
||||
description = "Visit webpage(s) and return the summary of the content.",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("url") {
|
||||
putJsonArray("type") {
|
||||
add("string")
|
||||
add("array")
|
||||
}
|
||||
putJsonObject("items") {
|
||||
put("type", "string")
|
||||
}
|
||||
put("minItems", 1)
|
||||
put("description", "The URL(s) of the webpage(s) to visit. Can be a single URL or an array of URLs.")
|
||||
}
|
||||
}
|
||||
|
||||
putJsonArray("required") {
|
||||
add("url")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
// Visit Tool (Using Jina Reader)
|
||||
const val JINA_READER_URL_PREFIX = "https://r.jina.ai/"
|
||||
}
|
||||
|
||||
override val isEnabled: Boolean
|
||||
get() = PluginConfig.jinaApiKey.isNotEmpty()
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "上网中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
requireNotNull(args)
|
||||
val urlJson = args.getValue("url")
|
||||
if (urlJson is JsonPrimitive) {
|
||||
return jinaReadPage(urlJson.content)
|
||||
} else if (urlJson is JsonArray) {
|
||||
return urlJson.map {
|
||||
scope.async { jinaReadPage(it.jsonPrimitive.content) }
|
||||
}.awaitAll().joinToString()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private suspend fun jinaReadPage(url: String): String {
|
||||
return try {
|
||||
httpClient.get(JINA_READER_URL_PREFIX + url) {
|
||||
header("Authorization", "Bearer ${PluginConfig.jinaApiKey}")
|
||||
}.bodyAsText()
|
||||
} catch (e: Throwable) {
|
||||
"Error fetching \"$url\": ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
76
src/main/kotlin/tools/VisualAgent.kt
Normal file
76
src/main/kotlin/tools/VisualAgent.kt
Normal file
@@ -0,0 +1,76 @@
|
||||
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.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 kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.add
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlinx.serialization.json.putJsonArray
|
||||
import kotlinx.serialization.json.putJsonObject
|
||||
import top.jie65535.mirai.LargeLanguageModels
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
|
||||
class VisualAgent : BaseAgent(
|
||||
tool = Tool.function(
|
||||
name = "imageRecognition",
|
||||
description = "可通过调用视觉模型来识别图片内容。备注:该方法成本较高,非必要尽量不要调用。",
|
||||
parameters = Parameters.buildJsonObject {
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("image_url") {
|
||||
put("type", "string")
|
||||
put("description", "图片地址")
|
||||
}
|
||||
putJsonObject("prompt") {
|
||||
put("type", "string")
|
||||
put("description", "用于调用视觉模型的提示词")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
add("image_url")
|
||||
add("prompt")
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
override val loadingMessage: String
|
||||
get() = "识别中..."
|
||||
|
||||
override val isEnabled: Boolean
|
||||
get() = LargeLanguageModels.visual != null
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
requireNotNull(args)
|
||||
val llm = LargeLanguageModels.visual ?: return "未配置llm,无法进行识别。"
|
||||
val imageUrl = args.getValue("image_url").jsonPrimitive.content
|
||||
val prompt = args.getValue("prompt").jsonPrimitive.content
|
||||
|
||||
val answerContent = StringBuilder()
|
||||
llm.chatCompletions(ChatCompletionRequest(
|
||||
model = ModelId(PluginConfig.visualModel),
|
||||
messages = listOf(
|
||||
ChatMessage.System("You are a helpful assistant."),
|
||||
ChatMessage.User(
|
||||
content = listOf(
|
||||
ImagePart(imageUrl),
|
||||
TextPart(prompt)
|
||||
)
|
||||
)
|
||||
)
|
||||
)).collect {
|
||||
if (it.choices.isNotEmpty()) {
|
||||
val delta = it.choices[0].delta ?: return@collect
|
||||
if (!delta.content.isNullOrEmpty()) {
|
||||
answerContent.append(delta.content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return answerContent.toString().ifEmpty { "识图异常,结果为空" }
|
||||
}
|
||||
}
|
@@ -34,7 +34,7 @@ class WeatherService : BaseAgent(
|
||||
)
|
||||
) {
|
||||
override val loadingMessage: String
|
||||
get() = "查询天气中..."
|
||||
get() = "观天中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
requireNotNull(args)
|
||||
|
@@ -5,8 +5,11 @@ import com.aallam.openai.api.core.Parameters
|
||||
import io.ktor.client.request.*
|
||||
import io.ktor.client.statement.*
|
||||
import io.ktor.http.*
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.serialization.json.*
|
||||
import org.apache.commons.text.StringEscapeUtils
|
||||
import top.jie65535.mirai.JChatGPT
|
||||
import top.jie65535.mirai.PluginConfig
|
||||
|
||||
class WebSearch : BaseAgent(
|
||||
@@ -18,35 +21,15 @@ class WebSearch : BaseAgent(
|
||||
put("type", "object")
|
||||
putJsonObject("properties") {
|
||||
putJsonObject("q") {
|
||||
put("type", "string")
|
||||
put("description", "查询内容关键字")
|
||||
}
|
||||
putJsonObject("categories") {
|
||||
put("type", "array")
|
||||
putJsonArray("type") {
|
||||
add("string")
|
||||
add("array")
|
||||
}
|
||||
putJsonObject("items") {
|
||||
put("type", "string")
|
||||
putJsonArray("enum") {
|
||||
add("general")
|
||||
add("images")
|
||||
add("videos")
|
||||
add("news")
|
||||
add("music")
|
||||
add("it")
|
||||
add("science")
|
||||
add("files")
|
||||
add("social_media")
|
||||
}
|
||||
}
|
||||
put("description", "可选择多项查询分类,通常情况下不传或用general即可。")
|
||||
}
|
||||
putJsonObject("time_range") {
|
||||
put("type", "string")
|
||||
putJsonArray("enum") {
|
||||
add("day")
|
||||
add("month")
|
||||
add("year")
|
||||
}
|
||||
put("description", "可选择获取最新消息,例如day表示只查询最近一天相关信息,以此类推。")
|
||||
put("minItems", 1)
|
||||
put("description", "查询关键字,可为单组关键字查询,也可并发多组同时查询。")
|
||||
}
|
||||
}
|
||||
putJsonArray("required") {
|
||||
@@ -62,65 +45,73 @@ class WebSearch : BaseAgent(
|
||||
get() = PluginConfig.searXngUrl.isNotEmpty()
|
||||
|
||||
override val loadingMessage: String
|
||||
get() = "联网搜索中..."
|
||||
get() = "搜索中..."
|
||||
|
||||
override suspend fun execute(args: JsonObject?): String {
|
||||
requireNotNull(args)
|
||||
val q = args.getValue("q").jsonPrimitive.content
|
||||
val categories = args["categories"]?.jsonArray
|
||||
val timeRange = args["time_range"]?.jsonPrimitive?.contentOrNull
|
||||
val response = httpClient.get(
|
||||
buildString {
|
||||
val q = args.getValue("q")
|
||||
if (q is JsonPrimitive) {
|
||||
return search(q.content)
|
||||
} else if (q is JsonArray) {
|
||||
return q.map {
|
||||
scope.async { search(it.jsonPrimitive.content) }
|
||||
}.awaitAll().joinToString()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
private suspend fun search(q: String): String {
|
||||
return try {
|
||||
val url = buildString {
|
||||
append(PluginConfig.searXngUrl)
|
||||
append("?q=")
|
||||
append(q.encodeURLParameter())
|
||||
append("&format=json")
|
||||
if (categories != null) {
|
||||
append("&")
|
||||
append(categories.joinToString { it.jsonPrimitive.content })
|
||||
}
|
||||
if (timeRange != null) {
|
||||
append("&")
|
||||
append(timeRange)
|
||||
}
|
||||
}
|
||||
)
|
||||
val body = response.bodyAsText()
|
||||
val responseJsonElement = Json.parseToJsonElement(body)
|
||||
val filteredResponse = buildJsonObject {
|
||||
val root = responseJsonElement.jsonObject
|
||||
// 查询内容原样转发
|
||||
root["query"]?.let { put("query", it) }
|
||||
|
||||
// 过滤搜索结果
|
||||
val results = root["results"]?.jsonArray
|
||||
if (results != null) {
|
||||
val filteredResults = results
|
||||
.filter {
|
||||
// 去掉所有内容为空的结果
|
||||
!it.jsonObject.getValue("content").jsonPrimitive.contentOrNull.isNullOrEmpty()
|
||||
}.sortedByDescending {
|
||||
it.jsonObject.getValue("score").jsonPrimitive.double
|
||||
}.take(5) // 只取得分最高的前5条结果
|
||||
.map {
|
||||
// 移除掉我不想要的字段
|
||||
val item = it.jsonObject.toMutableMap()
|
||||
item.remove("engine")
|
||||
item.remove("parsed_url")
|
||||
item.remove("template")
|
||||
item.remove("engines")
|
||||
item.remove("positions")
|
||||
item.remove("metadata")
|
||||
item.remove("thumbnail")
|
||||
JsonObject(item)
|
||||
}
|
||||
put("results", JsonArray(filteredResults))
|
||||
}
|
||||
|
||||
// 答案和信息盒子原样转发
|
||||
root["answers"]?.let { put("answers", it) }
|
||||
root["infoboxes"]?.let { put("infoboxes", it) }
|
||||
}.toString()
|
||||
return StringEscapeUtils.unescapeJava(filteredResponse)
|
||||
val response = httpClient.get(url)
|
||||
JChatGPT.logger.info("Request: $url")
|
||||
val body = response.bodyAsText()
|
||||
JChatGPT.logger.debug("Response: $body")
|
||||
val responseJsonElement = Json.parseToJsonElement(body)
|
||||
val filteredResponse = buildJsonObject {
|
||||
val root = responseJsonElement.jsonObject
|
||||
// 查询内容原样转发
|
||||
root["query"]?.let { put("query", it) }
|
||||
|
||||
// 过滤搜索结果
|
||||
val results = root["results"]?.jsonArray
|
||||
if (results != null) {
|
||||
val filteredResults = results
|
||||
.filter {
|
||||
// 去掉所有内容为空的结果
|
||||
!it.jsonObject.getValue("content").jsonPrimitive.contentOrNull.isNullOrEmpty()
|
||||
}.sortedByDescending {
|
||||
it.jsonObject.getValue("score").jsonPrimitive.double
|
||||
}.take(5) // 只取得分最高的前5条结果
|
||||
.map {
|
||||
// 移除掉我不想要的字段
|
||||
val item = it.jsonObject.toMutableMap()
|
||||
item.remove("engine")
|
||||
item.remove("parsed_url")
|
||||
item.remove("template")
|
||||
item.remove("engines")
|
||||
item.remove("positions")
|
||||
item.remove("metadata")
|
||||
item.remove("thumbnail")
|
||||
JsonObject(item)
|
||||
}
|
||||
put("results", JsonArray(filteredResults))
|
||||
}
|
||||
|
||||
// 答案和信息盒子原样转发
|
||||
root["answers"]?.let { put("answers", it) }
|
||||
root["infoboxes"]?.let { put("infoboxes", it) }
|
||||
}.toString()
|
||||
|
||||
StringEscapeUtils.unescapeJava(filteredResponse)
|
||||
} catch (e: Throwable) {
|
||||
"Failed to search \"$q\": ${e.message}"
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user