JChatGPT/src/main/kotlin/JChatGPT.kt

791 lines
30 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package top.jie65535.mirai
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.model.ModelId
import io.ktor.util.collections.*
import kotlinx.coroutines.delay
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
import net.mamoe.mirai.console.permission.PermissionService
import net.mamoe.mirai.console.permission.PermissionService.Companion.hasPermission
import net.mamoe.mirai.console.plugin.jvm.JvmPluginDescription
import net.mamoe.mirai.console.plugin.jvm.KotlinPlugin
import net.mamoe.mirai.contact.*
import net.mamoe.mirai.contact.MemberPermission.*
import net.mamoe.mirai.event.GlobalEventChannel
import net.mamoe.mirai.event.events.FriendMessageEvent
import net.mamoe.mirai.event.events.GroupMessageEvent
import net.mamoe.mirai.event.events.MessageEvent
import net.mamoe.mirai.message.data.*
import net.mamoe.mirai.message.data.Image.Key.queryUrl
import net.mamoe.mirai.message.data.MessageSource.Key.quote
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 kotlin.collections.*
import kotlin.math.max
import kotlin.time.Duration.Companion.seconds
object JChatGPT : KotlinPlugin(
JvmPluginDescription(
id = "top.jie65535.mirai.JChatGPT",
name = "J ChatGPT",
version = "1.7.0",
) {
author("jie65535")
// dependsOn("xyz.cssxsh.mirai.plugin.mirai-hibernate-plugin", true)
}
) {
/**
* 是否包含历史对话
*/
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()
// 设置Token
LargeLanguageModels.reload()
// 注册插件命令
PluginCommands.register()
// 检查消息记录插件是否存在
includeHistory = try {
MiraiHibernateRecorder
true
} catch (_: Throwable) {
false
}
if (PluginConfig.callKeyword.isNotEmpty()) {
keyword = Regex(PluginConfig.callKeyword)
}
GlobalEventChannel.parentScope(this)
.subscribeAlways<MessageEvent> { event -> onMessage(event) }
logger.info { "Plugin loaded" }
}
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
.withZone(ZoneOffset.systemDefault())
private val dateTimeFormatter = DateTimeFormatter.ofPattern("yyyy年MM月dd E HH:mm:ss")
private val requestMap = ConcurrentSet<Long>()
private suspend fun onMessage(event: MessageEvent) {
// 检查Token是否设置
if (LargeLanguageModels.chat == null) return
// 发送者是否有权限
if (!event.toCommandSender().hasPermission(chatPermission)) {
if (event is GroupMessageEvent) {
if (PluginConfig.groupOpHasChatPermission && event.sender.isOperator()) {
// 允许管理员使用
} else if (event.sender.active.temperature >= PluginConfig.temperaturePermission) {
// 允许活跃度达标成员使用
} else {
// 其它情况阻止使用
return
}
}
if (event is FriendMessageEvent) {
if (!PluginConfig.friendHasChatPermission) {
return
}
// TODO 检查好友上下文
}
}
// 如果没有@bot或者触发关键字则直接结束
if (!event.message.contains(At(event.bot))
&& keyword?.let { event.message.content.contains(it) } != true)
return
startChat(event)
}
private fun getSystemPrompt(event: MessageEvent): String {
val now = OffsetDateTime.now()
val prompt = StringBuilder(PluginConfig.prompt)
fun replace(target: String, replacement: () -> String) {
val i = prompt.indexOf(target)
if (i != -1) {
prompt.replace(i, i + target.length, replacement())
}
}
replace("{time}") {
dateTimeFormatter.format(now)
}
replace("{subject}") {
if (event is GroupMessageEvent) {
"\"${event.subject.name}\" 群聊中,你在本群的名片是:${getNameCard(event.subject.botAsMember)}"
} else {
"\"${event.senderName}\" 私聊中"
}
}
return prompt.toString()
}
// 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 } // 按时间排序
// 构造历史消息
val historyText = StringBuilder()
if (event is GroupMessageEvent) {
for (record in history) {
appendGroupMessageRecord(historyText, record, event)
}
} else {
for (record in history) {
appendMessageRecord(historyText, record, event)
}
}
return historyText.toString()
}
/**
* 添加群消息记录到历史上下文中
* @param historyText 历史消息构造器
* @param record 群消息记录
* @param event 群消息事件
*/
fun appendGroupMessageRecord(
historyText: StringBuilder,
record: MessageRecord,
event: GroupMessageEvent
) {
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 > "))
}
// 消息内容
historyText.append(" 说:").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
) {
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 > "))
}
// 消息内容
historyText.append(" 说:").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()
}
"![图片]($imageUrl)"
} 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.sender.id)) {
event.subject.sendMessage("再等等...")
return
}
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 historyText = getHistory(event)
logger.info("History: $historyText")
history.add(ChatMessage.User(historyText))
try {
var done: Boolean
// 至少循环3次
var retry = max(PluginConfig.retryMax, 3)
do {
try {
val startedAt = OffsetDateTime.now().toEpochSecond().toInt()
val response = chatCompletion(history)
// 移除思考内容
val responseContent = response.content?.replace(thinkRegex, "")?.trim()
history.add(ChatMessage.Assistant(
content = responseContent,
name = response.name,
toolCalls = response.toolCalls
))
if (response.toolCalls.isNullOrEmpty()) {
done = true
} else {
done = false
// 处理函数调用
for (toolCall in response.toolCalls) {
require(toolCall is ToolCall.Function) { "Tool call is not a function" }
val functionResponse = toolCall.execute(event)
history.add(
ChatMessage(
role = ChatRole.Tool,
toolCallId = toolCall.id,
name = toolCall.function.name,
content = functionResponse
)
)
if (toolCall.function.name == "endConversation") {
done = true
}
}
}
if (!done) {
history.add(ChatMessage.User(
buildString {
append("系统提示:本次运行还剩${retry-1}")
// if (response.toolCalls.isNullOrEmpty()) {
// append("\n在上一轮对话中未检测到调用任何工具请检查工具调用语法是否正确")
// append("\n如果你确实不需要调用其它工具比如发送消息请调用`endConversation`来结束对话。")
// }
val newMessages = getAfterHistory(startedAt, event)
if (newMessages.isNotEmpty()) {
append("\n以下是上次运行至今的新消息\n\n$newMessages")
}
}
))
}
} catch (e: Exception) {
if (retry <= 1) {
throw e
} else {
done = false
logger.warning("调用llm时发生异常重试中", e)
event.subject.sendMessage(event.message.quote() + "出错了...正在重试...")
}
}
} while (!done && 0 < --retry)
} catch (ex: Throwable) {
logger.warning(ex)
event.subject.sendMessage(event.message.quote() + "很抱歉,发生异常,请稍后重试")
} finally {
// 一段时间后才允许再次提问,防止高频对话
launch {
delay(5.seconds)
requestMap.remove(event.sender.id)
}
}
}
// private suspend fun startChat(event: MessageEvent) {
// if (!requestMap.add(event.sender.id)) {
// // CD中不再引用消息否则可能导致和机器人无限循环对话
// event.subject.sendMessage("再等等...")
// return
// }
//
// 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 historyText = getHistory(event)
// logger.info("History: $historyText")
// history.add(ChatMessage.User(historyText))
//
// try {
// var done = true
// // 至少重试两次
// var retry = max(PluginConfig.retryMax, 3)
// val finalToolCalls = mutableMapOf<Int, ToolCall.Function>()
// val contentBuilder = StringBuilder()
// do {
// finalToolCalls.clear()
// contentBuilder.setLength(0)
//
// try {
// var sent = false
// // 流式处理响应
// withTimeout(PluginConfig.timeout) {
// chatCompletions(history, retry > 1).collect { chunk ->
// val delta = chunk.choices[0].delta
// if (delta == null) return@collect
//
// // 处理工具调用
// val toolCalls = delta.toolCalls
// if (toolCalls != null) {
// for (toolCall in toolCalls) {
// val index = toolCall.index
// val toolId = toolCall.id
// val function = toolCall.function
// // 取出未完成的函数调用
// val incompleteCall = finalToolCalls[index]
// // 如果是新的函数调用,保存起来
// if (incompleteCall == null && toolId != null && function != null) {
// // 添加函数调用
// finalToolCalls[index] = ToolCall.Function(toolId, function)
// } else if (incompleteCall != null && function != null && function.argumentsOrNull != null) {
// // 更新参数内容
// finalToolCalls[index] = incompleteCall.copy(
// function = incompleteCall.function.copy(
// argumentsOrNull = incompleteCall.function.arguments + function.arguments
// )
// )
// }
// }
// }
//
// // 处理响应内容
// val contentChunk = delta.content
// // 避免连续发送多次,只拆分第一次进行发送
// if (contentChunk != null && !sent) {
// // 填入内容
// contentBuilder.append(contentChunk)
// sent = parseStreamingContent(contentBuilder, event)
// }
// }
// }
//
// val lastBlock = contentBuilder.toString().trim()
// if (lastBlock.isNotEmpty()) {
// event.subject.sendMessage(
// if (lastBlock.length > PluginConfig.messageMergeThreshold) {
// event.buildForwardMessage {
// event.bot says toMessage(event.subject, lastBlock)
// }
// } else {
// toMessage(event.subject, lastBlock)
// }
// )
// }
//
// if (finalToolCalls.isNotEmpty()) {
// val toolCalls = finalToolCalls.values.toList()
// history.add(ChatMessage.Assistant(toolCalls = toolCalls))
// for (toolCall in toolCalls) {
// val functionResponse = toolCall.execute(event)
// history.add(
// ChatMessage(
// role = ChatRole.Tool,
// toolCallId = toolCall.id,
// name = toolCall.function.name,
// content = functionResponse
// )
// )
// done = false
// }
// } else {
// done = true
// }
// } catch (e: Exception) {
// if (retry <= 1) {
// throw e
// } else {
// done = false
// logger.warning("调用llm时发生异常重试中", e)
// event.subject.sendMessage(event.message.quote() + "出错了...正在重试...")
// }
// }
// } while (!done && 0 < --retry)
// } catch (ex: Throwable) {
// logger.warning(ex)
// event.subject.sendMessage(event.message.quote() + "很抱歉,发生异常,请稍后重试")
// } finally {
// // 一段时间后才允许再次提问,防止高频对话
// launch {
// delay(10.seconds)
// requestMap.remove(event.sender.id)
// }
// }
// }
// /**
// * 解析流消息
// */
// private fun parseStreamingContent(contentBuilder: StringBuilder, event: MessageEvent): Boolean {
// // 处理推理内容
// val thinkBeginAt = contentBuilder.indexOf("<think")
// if (thinkBeginAt >= 0) {
// val thinkEndAt = contentBuilder.indexOf("</think>")
// if (thinkEndAt > 0) {
// // 去除思考内容
// contentBuilder.delete(thinkBeginAt, thinkEndAt + "</think>".length)
// }
// // 跳过本轮处理
// return false
// }
//
// // 处理代码块
// val codeBlockBeginAt = contentBuilder.indexOf("```")
// if (codeBlockBeginAt >= 0) {
// val codeBlockEndAt = contentBuilder.indexOf("```", codeBlockBeginAt + 3)
// if (codeBlockEndAt >= 0) {
// val codeBlockContentBegin = contentBuilder.indexOf("\n", codeBlockBeginAt + 3)
// if (codeBlockContentBegin in codeBlockBeginAt..codeBlockEndAt) {
// val codeBlockContent = contentBuilder.substring(codeBlockContentBegin, codeBlockEndAt).trim()
// contentBuilder.delete(codeBlockBeginAt, codeBlockEndAt + 3)
// launch {
// // 发送代码块内容
// event.subject.sendMessage(
// if (codeBlockContent.length < PluginConfig.messageMergeThreshold) {
// toMessage(event.subject, codeBlockContent)
// } else {
// // 消息内容太长则转为转发消息避免刷屏
// event.buildForwardMessage {
// event.bot says toMessage(event.subject, codeBlockContent)
// }
// }
// )
// }
// }
// }
// // 跳过本轮处理
// return true
// }
//
// // 徒手trimStart
// var contentBeginAt = 0
// while (contentBeginAt < contentBuilder.length) {
// if (contentBuilder[contentBeginAt].isWhitespace()) {
// contentBeginAt++
// } else {
// break
// }
// }
//
// // 对空行进行分割输出
// val emptyLineAt = contentBuilder.indexOf("\n\n", contentBeginAt)
// if (emptyLineAt > 0) {
// val lineContent = contentBuilder.substring(contentBeginAt, emptyLineAt)
// contentBuilder.delete(0, emptyLineAt + 2)
// launch {
// // 发送消息内容
// event.subject.sendMessage(toMessage(event.subject, lineContent))
// }
// return true
// }
// return false
// }
private val regexAtQq = Regex("@(\\d+)")
private val regexLaTeX = Regex(
"\\\\\\((.+?)\\\\\\)|" + // 匹配行内公式 \(...\)
"\\\\\\[(.+?)\\\\\\]|" + // 匹配独立公式 \[...\]
"\\$\\s(.+?)\\s\\$|" // 匹配行内公式 $...$
)
private data class MessageChunk(val range: IntRange, val content: Message)
/**
* 将聊天内容转为聊天消息如果聊天中包含LaTeX表达式将会转为图片拼接到消息中。
*
* @param contact 联系对象
* @param content 文本内容
* @return 构造的消息
*/
suspend fun toMessage(contact: Contact, content: String): Message {
return if (content.isEmpty()) {
PlainText("...")
} else if (content.length < 3) {
PlainText(content)
} 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))) }
}
}
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)
}
append(msg)
index = range.endInclusive + 1
}
// 拼接后续消息
if (index < content.length) {
append(content, index, content.length)
}
}
}
}
/**
* 工具列表
*/
private val myTools = listOf(
// 发送单条消息
SendSingleMessageAgent(),
// 发送组合消息
SendCompositeMessage(),
// 结束循环
StopLoopAgent(),
// 网页搜索
WebSearch(),
// 访问网页
VisitWeb(),
// 运行代码
RunCode(),
// 推理代理
ReasoningAgent(),
// 视觉代理
VisualAgent(),
// 天气服务
WeatherService(),
// Epic 免费游戏
EpicFreeGame(),
)
// private fun chatCompletions(
// chatMessages: List<ChatMessage>,
// hasTools: Boolean = true
// ): Flow<ChatCompletionChunk> {
// val llm = this.llm ?: throw NullPointerException("OpenAI Token 未设置,无法开始")
// val availableTools = if (hasTools) {
// myTools.filter { it.isEnabled }.map { it.tool }
// } else null
// val request = ChatCompletionRequest(
// model = ModelId(PluginConfig.chatModel),
// messages = chatMessages,
// tools = availableTools,
// )
// logger.info("API Requesting... Model=${PluginConfig.chatModel}")
// return llm.chatCompletions(request)
// }
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),
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 fun getNameCard(member: Member): String {
val nameCard = StringBuilder()
// 群活跃等级
nameCard.append("【lv").append(member.active.temperature).append(" ")
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(member.id).append(")")
return nameCard.toString()
}
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(agent.loadingMessage)
} else null
// 提取参数
val args = function.argumentsAsJsonOrNull()
logger.info("Calling ${function.name}(${args})")
// 执行函数
val result = try {
agent.execute(args, event)
} catch (e: Throwable) {
logger.error("Failed to call ${function.name}", e)
"工具调用失败,请尝试自行回答用户,或如实告知。"
}
logger.info("Result=\"$result\"")
// 过会撤回加载消息
if (receipt != null) {
launch {
delay(3.seconds)
try {
receipt.recall()
} catch (e: Throwable) {
logger.error(
"消息撤回失败,调试信息:" +
"source.internalIds=${receipt.source.internalIds.joinToString()} " +
"source.ids= ${receipt.source.ids.joinToString()}", e
)
}
}
}
return result
}
}