Compare commits

...

12 Commits

Author SHA1 Message Date
099625c2f2 Separate LaTeX parsing into independent tool for LLM calling 2025-09-11 19:15:33 +08:00
ffa7f78c25 Update README by Qwen 2025-09-10 11:12:30 +08:00
2c40ef1b16 Add Memory switch 2025-09-10 10:53:34 +08:00
e083b7aff9 Adjust the chat history concatenation format 2025-09-05 23:45:21 +08:00
e2caba6a10 Add reply message call 2025-08-31 21:04:57 +08:00
af17f1e698 Update version to 1.8.0
Add SendVoiceMessage tool
Move prompt to SystemPrompt.md file
Add tool calling message switch config
2025-08-31 15:57:16 +08:00
e79bcd9983 Update tool list
Disable EPIC Free game tool
Fix the issue where invalid JSON parameters caused exceptions
2025-08-21 13:57:03 +08:00
b6aa638b1a Add DashScope API Config 2025-08-21 13:55:21 +08:00
bc2eb437e6 Update VisualAgent tool name to imageRecognition 2025-08-21 13:55:08 +08:00
b534606bb0 Update runCode tool description 2025-08-21 13:54:46 +08:00
867d9ad56f Update http client timeout 2025-08-21 13:54:20 +08:00
92a6879cc1 Add imageEdit tool 2025-08-21 13:54:02 +08:00
18 changed files with 800 additions and 152 deletions

252
README.md
View File

@@ -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` 文件来实现

View File

@@ -7,15 +7,22 @@ plugins {
}
group = "top.jie65535.mirai"
version = "1.7.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")
}

View File

@@ -46,7 +46,7 @@ object JChatGPT : KotlinPlugin(
JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT",
version = "1.7.0",
version = "1.8.0",
) {
author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
@@ -57,8 +57,14 @@ object JChatGPT : KotlinPlugin(
*/
private var includeHistory: Boolean = false
/**
* 聊天权限
*/
val chatPermission = PermissionId("JChatGPT", "Chat")
/**
* 唤醒关键字
*/
private var keyword: Regex? = null
override fun onEnable() {
@@ -120,9 +126,10 @@ object JChatGPT : KotlinPlugin(
}
}
// 如果没有@bot或者触发关键字则直接结束
// 如果没有 @bot 或者 触发关键字 或者 回复bot的消息 则直接结束
if (!event.message.contains(At(event.bot))
&& keyword?.let { event.message.content.contains(it) } != true)
&& keyword?.let { event.message.content.contains(it) } != true
&& event.message[QuoteReply]?.source?.fromId != event.bot.id)
return
startChat(event)
@@ -130,7 +137,7 @@ object JChatGPT : KotlinPlugin(
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) {
@@ -193,15 +200,28 @@ object JChatGPT : KotlinPlugin(
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)
// 同一人发言不要反复出现这人的名字,减少上下文
appendGroupMessageRecord(historyText, record, event, lastId != record.fromId)
lastId = record.fromId
}
} else {
for (record in history) {
appendMessageRecord(historyText, record, event)
// 同一人发言不要反复出现这人的名字,减少上下文
appendMessageRecord(historyText, record, event, lastId != record.fromId)
lastId = record.fromId
}
}
@@ -217,23 +237,33 @@ object JChatGPT : KotlinPlugin(
fun appendGroupMessageRecord(
historyText: StringBuilder,
record: MessageRecord,
event: GroupMessageEvent
event: GroupMessageEvent,
showSender: Boolean,
) {
if (event.bot.id == record.fromId) {
historyText.append("**你** " + getNameCard(event.subject.botAsMember))
} else {
historyText.append(getNameCard(event.subject, record.fromId))
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())))
}
// 发言时间
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(" 说:").appendLine(record.toMessageChain().joinToString("") {
historyText.append(" 说:")
}
historyText.appendLine(record.toMessageChain().joinToString("") {
when (it) {
is At -> {
it.getDisplay(event.subject)
@@ -262,17 +292,20 @@ object JChatGPT : KotlinPlugin(
fun appendMessageRecord(
historyText: StringBuilder,
record: MessageRecord,
event: MessageEvent
event: MessageEvent,
showSender: Boolean
) {
if (event.bot.id == record.fromId) {
historyText.append("**你** " + event.bot.nameCardOrNick)
} else {
historyText.append(event.senderName)
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())))
}
historyText
.append(" ")
// 发言时间
.append(timeFormatter.format(Instant.ofEpochSecond(record.time.toLong())))
val recordMessage = record.toMessageChain()
recordMessage[QuoteReply.Key]?.let {
historyText.append(" 引用\n > ")
@@ -280,8 +313,11 @@ object JChatGPT : KotlinPlugin(
.joinToString("", transform = ::singleMessageToText)
.replace("\n", "\n > "))
}
if (showSender) {
historyText.append(" 说:")
}
// 消息内容
historyText.append(" 说:").appendLine(
historyText.appendLine(
record.toMessageChain().joinToString("", transform = ::singleMessageToText))
}
@@ -297,7 +333,7 @@ object JChatGPT : KotlinPlugin(
val imageUrl = runBlocking {
it.queryUrl()
}
"![图片]($imageUrl)"
"![${if (it.isEmoji) "表情包" else "图片"}]($imageUrl)"
} catch (e: Throwable) {
logger.warning("图片地址获取失败", e)
it.content
@@ -320,13 +356,13 @@ object JChatGPT : KotlinPlugin(
try {
val history = mutableListOf<ChatMessage>()
if (PluginConfig.prompt.isNotEmpty()) {
val prompt = getSystemPrompt(event)
if (PluginConfig.logPrompt) {
logger.info("Prompt: $prompt")
}
history.add(ChatMessage(ChatRole.System, prompt))
val prompt = getSystemPrompt(event)
if (PluginConfig.logPrompt) {
logger.info("Prompt: $prompt")
}
history.add(ChatMessage(ChatRole.System, prompt))
val historyText = getHistory(event)
logger.info("History: $historyText")
history.add(ChatMessage.User(historyText))
@@ -344,8 +380,7 @@ object JChatGPT : KotlinPlugin(
val toolCallTasks = mutableListOf<Deferred<ChatMessage>>()
// 处理聊天流式响应
responseFlow.collect { chunk ->
val delta = chunk.choices[0].delta
if (delta == null) return@collect
val delta = chunk.choices[0].delta ?: return@collect
// 处理内容更新
if (delta.content != null) {
@@ -408,6 +443,7 @@ object JChatGPT : KotlinPlugin(
// 移除思考内容
val responseContent = responseMessageBuilder?.replace(thinkRegex, "")?.trim()
logger.info("LLM Response: $responseContent")
// 记录AI回答
history.add(ChatMessage.Assistant(
content = responseContent,
@@ -440,11 +476,14 @@ object JChatGPT : KotlinPlugin(
if (!done) {
history.add(ChatMessage.User(
buildString {
append("系统提示:本次运行还剩${retry-1}")
appendLine("系统提示:本次运行最多还剩${retry-1}")
appendLine("如果要多次发言,可以一次性调用多次发言工具。")
appendLine("如果没有什么要做的,可以提前结束。")
appendLine("当前时间:" + dateTimeFormatter.format(OffsetDateTime.now()))
val newMessages = getAfterHistory(startedAt, event)
if (newMessages.isNotEmpty()) {
append("\n以下是上次运行至今的新消息\n\n$newMessages")
append("以下是上次运行至今的新消息\n\n$newMessages")
}
}
))
@@ -473,18 +512,12 @@ object JChatGPT : KotlinPlugin(
private val regexAtQq = Regex("""@(\d{5,12})""")
private val regexLaTeX = Regex(
"""\\\((.+?)\\\)|""" + // 匹配行内公式 \(...\)
"""\\\[(.+?)\\]|""" + // 匹配独立公式 \[...\]
"""\$(.+?)\$""" // 匹配行内公式 $...$
)
private val regexImage = Regex("""!\[(.*?)]\(([^\s"']+).*?\)""")
private data class MessageChunk(val range: IntRange, val content: Message)
/**
* 将聊天内容转为聊天消息如果聊天中包含LaTeX表达式将会转为图片拼接到消息中。
* 将聊天内容转为聊天消息
*
* @param contact 联系对象
* @param content 文本内容
@@ -514,33 +547,15 @@ object JChatGPT : KotlinPlugin(
Image(url)))
}
// LeTeX渲染
regexLaTeX.findAll(content).forEach {
it.groups.forEach { group ->
if (group == null || group.value.isEmpty()) return@forEach
try {
// 将所有匹配的LaTeX公式转为图片拼接到消息中
val formula = group.value
val imageByteArray = LaTeXConverter.convertToImage(formula, "png")
val resource = imageByteArray.toExternalResource("png")
val image = contact.uploadImage(resource)
t.add(MessageChunk(group.range, image))
} catch (ex: Throwable) {
logger.warning("处理LaTeX表达式时异常", ex)
}
}
}
// 构造消息链
buildMessageChain {
var index = 0
for ((range, msg) in t.sortedBy { it.range.start }) {
if (index < range.start) {
append(content, index, range.start)
for ((range, msg) in t.sortedBy { it.range.first }) {
if (index < range.first) {
append(content, index, range.first)
}
append(msg)
index = range.endInclusive + 1
index = range.last + 1
}
// 拼接后续消息
if (index < content.length) {
@@ -560,15 +575,21 @@ object JChatGPT : KotlinPlugin(
// 发送组合消息
SendCompositeMessage(),
// 发送语音消息
SendVoiceMessage(),
// 发送LaTeX表达式
SendLaTeXExpression(),
// 结束循环
StopLoopAgent(),
// 记忆代理
MemoryAppend(),
// 记忆修改
MemoryReplace(),
// 结束循环
StopLoopAgent(),
// 网页搜索
WebSearch(),
@@ -584,11 +605,14 @@ object JChatGPT : KotlinPlugin(
// 视觉代理
VisualAgent(),
// 图像编辑模型
ImageEdit(),
// 天气服务
WeatherService(),
// Epic 免费游戏
EpicFreeGame(),
// EpicFreeGame(),
// 群管代理
GroupManageAgent(),
@@ -663,18 +687,18 @@ object JChatGPT : KotlinPlugin(
val agent = myTools.find { it.tool.function.name == function.name }
?: return "Function ${function.name} not found"
// 提示正在执行函数
val receipt = if (agent.loadingMessage.isNotEmpty()) {
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 {
// 提取参数
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\"")
// 过会撤回加载消息

View File

@@ -7,12 +7,34 @@ 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,
@@ -21,6 +43,7 @@ object LargeLanguageModels {
)
}
// 初始化推理模型
if (PluginConfig.reasoningModelApi.isNotBlank() && PluginConfig.reasoningModelToken.isNotBlank()) {
reasoning = OpenAI(
token = PluginConfig.reasoningModelToken,
@@ -29,6 +52,7 @@ object LargeLanguageModels {
)
}
// 初始化视觉模型
if (PluginConfig.visualModelApi.isNotBlank() && PluginConfig.visualModelToken.isNotBlank()) {
visual = OpenAI(
token = PluginConfig.visualModelToken,
@@ -36,5 +60,22 @@ object LargeLanguageModels {
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 = "你是一个乐于助人的助手"
}
}
}
}

View File

@@ -35,6 +35,15 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@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("")
@@ -59,9 +68,13 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("等待响应超时时间单位毫秒默认60秒")
val timeout: Long by value(60000L)
@ValueDescription("系统提示词")
@Deprecated("使用外部文件而不是在配置文件内保存提示词")
@ValueDescription("系统提示词,该字段已弃用,使用提示词文件而不是在这里修改")
var prompt: String by value("你是一个乐于助人的助手")
@ValueDescription("系统提示词文件路径,相对于插件配置目录")
val promptFile: String by value("SystemPrompt.md")
@ValueDescription("创建Prompt时取最近多少分钟内的消息")
val historyWindowMin: Int by value(10)
@@ -79,4 +92,10 @@ object PluginConfig : AutoSavePluginConfig("Config") {
@ValueDescription("关键字呼叫,支持正则表达式")
val callKeyword by value("[小筱][林淋月玥]")
@ValueDescription("是否显示工具调用消息,默认是")
val showToolCallingMessage by value(true)
@ValueDescription("是否启用记忆编辑功能记忆存在data目录提示词中需要加上{memory}来填充记忆,每个群都有独立记忆")
val memoryEnabled by value(true)
}

View File

@@ -30,6 +30,7 @@ object PluginData : AutoSavePluginData("data") {
contactMemory[contactId] = newMemory
} else {
contactMemory[contactId] = memory.replace(oldMemory, newMemory)
.replace("\n\n", "\n")
}
}
}

View File

@@ -29,9 +29,9 @@ abstract class BaseAgent(
protected val httpClient by lazy {
HttpClient(OkHttp) {
install(HttpTimeout) {
requestTimeoutMillis = 60000
connectTimeoutMillis = 5000
socketTimeoutMillis = 15000
requestTimeoutMillis = 120_000
connectTimeoutMillis = 30_000
socketTimeoutMillis = 120_000
}
}
}

View 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和查询参数因为下载地址存在鉴权![图片]($url)"
} catch (e: Throwable) {
JChatGPT.logger.error("图像编辑结果解析异常", e)
responseJson
}
}
}

View File

@@ -10,6 +10,7 @@ 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(
@@ -30,6 +31,9 @@ class MemoryAppend : BaseAgent(
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.memoryEnabled
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val contactId = event.subject.id

View File

@@ -10,13 +10,14 @@ 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.Companion.function(
tool = Tool.function(
name = "memoryReplace",
description = "替换记忆项",
parameters = Parameters.Companion.buildJsonObject {
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
putJsonObject("oldMemory") {
@@ -35,6 +36,9 @@ class MemoryReplace : BaseAgent(
}
)
) {
override val isEnabled: Boolean
get() = PluginConfig.memoryEnabled
override suspend fun execute(args: JsonObject?, event: MessageEvent): String {
requireNotNull(args)
val contactId = event.subject.id

View File

@@ -28,7 +28,7 @@ class ReasoningAgent : BaseAgent(
)
) {
override val loadingMessage: String
get() = "深度思考中..."
get() = "思考中..."
override val isEnabled: Boolean
get() = LargeLanguageModels.reasoning != null

View File

@@ -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)

View 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}"
}
}
}

View 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")
}
}

View File

@@ -44,7 +44,7 @@ class VisitWeb : BaseAgent(
get() = PluginConfig.jinaApiKey.isNotEmpty()
override val loadingMessage: String
get() = "访问网页中..."
get() = "上网中..."
override suspend fun execute(args: JsonObject?): String {
requireNotNull(args)

View File

@@ -18,8 +18,8 @@ import top.jie65535.mirai.PluginConfig
class VisualAgent : BaseAgent(
tool = Tool.function(
name = "visualAgent",
description = "可通过调用视觉模型识别图片。",
name = "imageRecognition",
description = "可通过调用视觉模型识别图片内容。备注:该方法成本较高,非必要尽量不要调用",
parameters = Parameters.buildJsonObject {
put("type", "object")
putJsonObject("properties") {
@@ -40,7 +40,7 @@ class VisualAgent : BaseAgent(
)
) {
override val loadingMessage: String
get() = "图片识别中..."
get() = "识别中..."
override val isEnabled: Boolean
get() = LargeLanguageModels.visual != null

View File

@@ -34,7 +34,7 @@ class WeatherService : BaseAgent(
)
) {
override val loadingMessage: String
get() = "查询天气中..."
get() = "观天中..."
override suspend fun execute(args: JsonObject?): String {
requireNotNull(args)

View File

@@ -5,6 +5,8 @@ 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
@@ -19,8 +21,15 @@ class WebSearch : BaseAgent(
put("type", "object")
putJsonObject("properties") {
putJsonObject("q") {
put("type", "string")
put("description", "查询内容关键字")
putJsonArray("type") {
add("string")
add("array")
}
putJsonObject("items") {
put("type", "string")
}
put("minItems", 1)
put("description", "查询关键字,可为单组关键字查询,也可并发多组同时查询。")
}
}
putJsonArray("required") {
@@ -36,57 +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 url = buildString {
append(PluginConfig.searXngUrl)
append("?q=")
append(q.encodeURLParameter())
append("&format=json")
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 ""
}
val response = httpClient.get(url)
JChatGPT.logger.info("Request: $url")
val body = response.bodyAsText()
JChatGPT.logger.info("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))
private suspend fun search(q: String): String {
return try {
val url = buildString {
append(PluginConfig.searXngUrl)
append("?q=")
append(q.encodeURLParameter())
append("&format=json")
}
// 答案和信息盒子原样转发
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}"
}
}
}