From af17f1e698af27ef0b607d21eb68db0192d41452 Mon Sep 17 00:00:00 2001 From: jie65535 Date: Sun, 31 Aug 2025 15:57:16 +0800 Subject: [PATCH] Update version to 1.8.0 Add SendVoiceMessage tool Move prompt to SystemPrompt.md file Add tool calling message switch config --- build.gradle.kts | 14 ++- src/main/kotlin/JChatGPT.kt | 29 +++-- src/main/kotlin/LargeLanguageModels.kt | 41 +++++++ src/main/kotlin/PluginConfig.kt | 10 ++ src/main/kotlin/PluginData.kt | 1 + src/main/kotlin/tools/ImageEdit.kt | 31 +++-- src/main/kotlin/tools/ReasoningAgent.kt | 2 +- src/main/kotlin/tools/RunCode.kt | 2 +- src/main/kotlin/tools/SendVoiceMessage.kt | 140 ++++++++++++++++++++++ src/main/kotlin/tools/VisitWeb.kt | 2 +- src/main/kotlin/tools/VisualAgent.kt | 2 +- src/main/kotlin/tools/WeatherService.kt | 2 +- src/main/kotlin/tools/WebSearch.kt | 119 ++++++++++-------- 13 files changed, 315 insertions(+), 80 deletions(-) create mode 100644 src/main/kotlin/tools/SendVoiceMessage.kt diff --git a/build.gradle.kts b/build.gradle.kts index d4a6cf4..b2500f4 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") } \ No newline at end of file diff --git a/src/main/kotlin/JChatGPT.kt b/src/main/kotlin/JChatGPT.kt index 7d2966c..3b26f7a 100644 --- a/src/main/kotlin/JChatGPT.kt +++ b/src/main/kotlin/JChatGPT.kt @@ -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() { @@ -130,7 +136,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) { @@ -297,7 +303,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 +326,13 @@ object JChatGPT : KotlinPlugin( try { val history = mutableListOf() - 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)) @@ -563,6 +569,9 @@ object JChatGPT : KotlinPlugin( // 发送组合消息 SendCompositeMessage(), + // 发送语音消息 + SendVoiceMessage(), + // 结束循环 StopLoopAgent(), @@ -669,7 +678,7 @@ 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 // 执行函数 diff --git a/src/main/kotlin/LargeLanguageModels.kt b/src/main/kotlin/LargeLanguageModels.kt index 0712d85..378b3a3 100644 --- a/src/main/kotlin/LargeLanguageModels.kt +++ b/src/main/kotlin/LargeLanguageModels.kt @@ -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 = "你是一个乐于助人的助手" + } + } } } \ No newline at end of file diff --git a/src/main/kotlin/PluginConfig.kt b/src/main/kotlin/PluginConfig.kt index 815fbb3..11c8050 100644 --- a/src/main/kotlin/PluginConfig.kt +++ b/src/main/kotlin/PluginConfig.kt @@ -41,6 +41,9 @@ object PluginConfig : AutoSavePluginConfig("Config") { @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("") @@ -65,9 +68,13 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("等待响应超时时间,单位毫秒,默认60秒") val timeout: Long by value(60000L) + @Deprecated("使用外部文件而不是在配置文件内保存提示词") @ValueDescription("系统提示词") var prompt: String by value("你是一个乐于助人的助手") + @ValueDescription("系统提示词文件路径,相对于插件配置目录") + val promptFile: String by value("SystemPrompt.md") + @ValueDescription("创建Prompt时取最近多少分钟内的消息") val historyWindowMin: Int by value(10) @@ -85,4 +92,7 @@ object PluginConfig : AutoSavePluginConfig("Config") { @ValueDescription("关键字呼叫,支持正则表达式") val callKeyword by value("[小筱][林淋月玥]") + + @ValueDescription("是否显示工具调用消息,默认是") + val showToolCallingMessage by value(true) } \ No newline at end of file diff --git a/src/main/kotlin/PluginData.kt b/src/main/kotlin/PluginData.kt index ec526ad..38ce9ea 100644 --- a/src/main/kotlin/PluginData.kt +++ b/src/main/kotlin/PluginData.kt @@ -30,6 +30,7 @@ object PluginData : AutoSavePluginData("data") { contactMemory[contactId] = newMemory } else { contactMemory[contactId] = memory.replace(oldMemory, newMemory) + .replace("\n\n", "\n") } } } \ No newline at end of file diff --git a/src/main/kotlin/tools/ImageEdit.kt b/src/main/kotlin/tools/ImageEdit.kt index 12cf3d8..e788eb4 100644 --- a/src/main/kotlin/tools/ImageEdit.kt +++ b/src/main/kotlin/tools/ImageEdit.kt @@ -2,7 +2,6 @@ package top.jie65535.mirai.tools import com.aallam.openai.api.chat.Tool import com.aallam.openai.api.core.Parameters -import io.ktor.client.call.body import io.ktor.client.request.header import io.ktor.client.request.post import io.ktor.client.request.setBody @@ -38,11 +37,11 @@ class ImageEdit : BaseAgent( put("type", "string") put("description", "正向提示词,用来描述需要对图片进行修改的要求。") } - putJsonObject("negative_prompt") { - put("type", "string") - put("description", "反向提示词,用来描述不希望在画面中看到的内容,可以对画面进行限制。" + - "示例值:低分辨率、错误、最差质量、低质量、残缺、多余的手指、比例不良等。") - } +// putJsonObject("negative_prompt") { +// put("type", "string") +// put("description", "反向提示词,用来描述不希望在画面中看到的内容,可以对画面进行限制。" + +// "示例值:低分辨率、错误、最差质量、低质量、残缺、多余的手指、比例不良等。") +// } } putJsonArray("required") { add("image_url") @@ -59,13 +58,13 @@ class ImageEdit : BaseAgent( get() = PluginConfig.dashScopeApiKey.isNotEmpty() override val loadingMessage: String - get() = "图片编辑中..." + 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 negativePrompt = args["negative_prompt"]?.jsonPrimitive?.content val response = httpClient.post(API_URL) { contentType(ContentType("application", "json")) header("Authorization", "Bearer " + PluginConfig.dashScopeApiKey) @@ -86,11 +85,11 @@ class ImageEdit : BaseAgent( } } } - if (negativePrompt != null) { - putJsonObject("parameters") { - put("negative_prompt", negativePrompt) - } - } +// if (negativePrompt != null) { +// putJsonObject("parameters") { +// put("negative_prompt", negativePrompt) +// } +// } }.toString()) } @@ -103,10 +102,10 @@ class ImageEdit : BaseAgent( .getValue("message").jsonObject .getValue("content").jsonArray[0].jsonObject .getValue("image").jsonPrimitive.content - "图片已编辑完成,发送时请务必包含完整的url和查询参数,因为下载地址存在鉴权。图片地址:$url" - } catch (e: Exception) { + "图片已编辑完成,发送时请务必包含完整的url和查询参数,因为下载地址存在鉴权:![图片]($url)" + } catch (e: Throwable) { JChatGPT.logger.error("图像编辑结果解析异常", e) - responseObject.toString() + responseJson } } } \ No newline at end of file diff --git a/src/main/kotlin/tools/ReasoningAgent.kt b/src/main/kotlin/tools/ReasoningAgent.kt index 850dd8b..6fa5513 100644 --- a/src/main/kotlin/tools/ReasoningAgent.kt +++ b/src/main/kotlin/tools/ReasoningAgent.kt @@ -28,7 +28,7 @@ class ReasoningAgent : BaseAgent( ) ) { override val loadingMessage: String - get() = "深度思考中..." + get() = "思考中..." override val isEnabled: Boolean get() = LargeLanguageModels.reasoning != null diff --git a/src/main/kotlin/tools/RunCode.kt b/src/main/kotlin/tools/RunCode.kt index 1d7bf9b..6d03cf2 100644 --- a/src/main/kotlin/tools/RunCode.kt +++ b/src/main/kotlin/tools/RunCode.kt @@ -88,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) diff --git a/src/main/kotlin/tools/SendVoiceMessage.kt b/src/main/kotlin/tools/SendVoiceMessage.kt new file mode 100644 index 0000000..b60bdcc --- /dev/null +++ b/src/main/kotlin/tools/SendVoiceMessage.kt @@ -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") + } + +} \ No newline at end of file diff --git a/src/main/kotlin/tools/VisitWeb.kt b/src/main/kotlin/tools/VisitWeb.kt index e7b82c3..9b6890c 100644 --- a/src/main/kotlin/tools/VisitWeb.kt +++ b/src/main/kotlin/tools/VisitWeb.kt @@ -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) diff --git a/src/main/kotlin/tools/VisualAgent.kt b/src/main/kotlin/tools/VisualAgent.kt index a5c8116..31f89b4 100644 --- a/src/main/kotlin/tools/VisualAgent.kt +++ b/src/main/kotlin/tools/VisualAgent.kt @@ -40,7 +40,7 @@ class VisualAgent : BaseAgent( ) ) { override val loadingMessage: String - get() = "图片识别中..." + get() = "识别中..." override val isEnabled: Boolean get() = LargeLanguageModels.visual != null diff --git a/src/main/kotlin/tools/WeatherService.kt b/src/main/kotlin/tools/WeatherService.kt index 8cd905a..e291ce0 100644 --- a/src/main/kotlin/tools/WeatherService.kt +++ b/src/main/kotlin/tools/WeatherService.kt @@ -34,7 +34,7 @@ class WeatherService : BaseAgent( ) ) { override val loadingMessage: String - get() = "查询天气中..." + get() = "观天中..." override suspend fun execute(args: JsonObject?): String { requireNotNull(args) diff --git a/src/main/kotlin/tools/WebSearch.kt b/src/main/kotlin/tools/WebSearch.kt index 246944e..998e4ae 100644 --- a/src/main/kotlin/tools/WebSearch.kt +++ b/src/main/kotlin/tools/WebSearch.kt @@ -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}" + } } } \ No newline at end of file