diff --git a/Partner-External-Modules/Partner-Onebot-Adapter/src/main/java/work/slhaf/partner/external/onebot/gateway/OnebotGateway.java b/Partner-External-Modules/Partner-Onebot-Adapter/src/main/java/work/slhaf/partner/external/onebot/gateway/OnebotGateway.java index e8ddbbf1..aa2a7cb1 100644 --- a/Partner-External-Modules/Partner-Onebot-Adapter/src/main/java/work/slhaf/partner/external/onebot/gateway/OnebotGateway.java +++ b/Partner-External-Modules/Partner-Onebot-Adapter/src/main/java/work/slhaf/partner/external/onebot/gateway/OnebotGateway.java @@ -9,7 +9,8 @@ import org.java_websocket.server.WebSocketServer; import org.jetbrains.annotations.NotNull; import work.slhaf.partner.external.onebot.v11.*; import work.slhaf.partner.framework.agent.interaction.AgentGateway; -import work.slhaf.partner.framework.agent.interaction.data.*; +import work.slhaf.partner.framework.agent.interaction.data.InputData; +import work.slhaf.partner.framework.agent.interaction.data.InteractionEvent; import work.slhaf.partner.runtime.PartnerRunningFlowContext; import java.net.InetSocketAddress; @@ -25,6 +26,7 @@ public class OnebotGateway extends WebSocketServer implements AgentGateway() + + fun accept(event: InteractionEvent) { + when (event) { + is ReplyEvent -> acceptReply(event) + is ModuleEvent -> { + flush(event.target) + sendStandalone(event.target, event.data.content) + } + + is SystemEvent -> { + flush(event.target) + sendStandalone(event.target, formatSystemEvent(event)) + } + } + } + + fun flush(target: String) { + val buffer = buffers[target] ?: return + val content = buffer.toString().trim() + buffer.clear() + if (content.isNotBlank()) { + sendSegments(target, content) + } + } + + override fun close() { + buffers.keys.toList().forEach(::flush) + } + + private fun acceptReply(event: ReplyEvent) { + val content = event.content + if (content.isNotBlank()) { + val buffer = buffers.computeIfAbsent(event.target) { StringBuilder() } + when (event.mode) { + ReplyEvent.ContentMode.APPEND -> buffer.append(content) + ReplyEvent.ContentMode.REPLACE -> { + buffer.clear() + buffer.append(content) + } + } + flushCompletedBlocks(event.target, buffer) + } + + if (event.status == InteractionEvent.EventStatus.DONE || event.status == InteractionEvent.EventStatus.ERROR) { + flush(event.target) + } + } + + private fun flushCompletedBlocks(target: String, buffer: StringBuilder) { + while (buffer.isNotEmpty()) { + if (hasUnclosedCodeFence(buffer)) { + return + } + + val completedBlockEnd = findCompletedBlockEnd(buffer) + if (completedBlockEnd > 0) { + val block = buffer.substring(0, completedBlockEnd).trim() + buffer.delete(0, completedBlockEnd) + trimLeadingBlankLines(buffer) + if (block.isNotBlank()) { + sendSegments(target, block) + } + continue + } + + if (buffer.length >= softFlushChars) { + val splitAt = findNaturalSplit(buffer, maxMessageChars.coerceAtMost(buffer.length)) + if (splitAt != null && splitAt > 0) { + val block = buffer.substring(0, splitAt).trim() + buffer.delete(0, splitAt) + trimLeadingBlankLines(buffer) + if (block.isNotBlank()) { + sendSegments(target, block) + } + continue + } + } + + return + } + } + + private fun findCompletedBlockEnd(buffer: CharSequence): Int { + val text = buffer.toString() + val blankLine = findFirstBlankLine(text) + if (blankLine <= 0) { + return -1 + } + + val candidate = text.substring(0, blankLine).trim() + if (candidate.isBlank()) { + return blankLine + } + + val nextBlockStart = skipBlankLines(text, blankLine) + if (isHeadingOnly(candidate) && nextBlockStart < text.length) { + val nextBlankLine = findFirstBlankLine(text, nextBlockStart) + if (nextBlankLine > 0) { + return nextBlankLine + } + return -1 + } + + return blankLine + } + + private fun sendSegments(target: String, content: String) { + splitForOneBot(content).forEach { segment -> + actionExecutor.sendMessage(target, segment) + } + } + + private fun sendStandalone(target: String, content: String) { + if (content.isBlank()) { + return + } + sendSegments(target, content.trim()) + } + + private fun splitForOneBot(content: String): List { + val result = mutableListOf() + val buffer = StringBuilder(content.trim()) + + while (buffer.length > maxMessageChars) { + val splitAt = findNaturalSplit(buffer, maxMessageChars) + ?.takeIf { it > 0 } + ?: maxMessageChars + val segment = buffer.substring(0, splitAt).trim() + if (segment.isNotBlank()) { + result.add(segment) + } + buffer.delete(0, splitAt) + trimLeadingBlankLines(buffer) + } + + val rest = buffer.toString().trim() + if (rest.isNotBlank()) { + result.add(rest) + } + return result + } + + private fun findNaturalSplit(text: CharSequence, preferredMax: Int): Int? { + val max = preferredMax.coerceAtMost(text.length) + if (max <= 0) { + return null + } + + val searchStart = (max * 0.45).toInt().coerceAtLeast(1) + val priorities = listOf( + "\n\n", "\r\n\r\n", + "。", "!", "?", "!", "?", + ";", ";", + ":", ":", + ",", ",", + "\n", + " " + ) + + for (delimiter in priorities) { + val idx = lastIndexOf(text, delimiter, max, searchStart) + if (idx >= 0) { + return idx + delimiter.length + } + } + return null + } + + private fun lastIndexOf(text: CharSequence, delimiter: String, endExclusive: Int, startInclusive: Int): Int { + var i = endExclusive - delimiter.length + while (i >= startInclusive) { + var matched = true + for (j in delimiter.indices) { + if (text[i + j] != delimiter[j]) { + matched = false + break + } + } + if (matched) { + return i + } + i-- + } + return -1 + } + + private fun hasUnclosedCodeFence(text: CharSequence): Boolean { + var count = 0 + var index = 0 + val value = text.toString() + while (true) { + val found = value.indexOf("```", index) + if (found < 0) { + break + } + count++ + index = found + 3 + } + return count % 2 != 0 + } + + private fun findFirstBlankLine(text: String, startIndex: Int = 0): Int { + val lf = text.indexOf("\n\n", startIndex) + val crlf = text.indexOf("\r\n\r\n", startIndex) + return when { + lf < 0 -> crlf.takeIf { it >= 0 }?.plus(4) ?: -1 + crlf < 0 -> lf + 2 + lf < crlf -> lf + 2 + else -> crlf + 4 + } + } + + private fun skipBlankLines(text: String, index: Int): Int { + var i = index + while (i < text.length && (text[i] == '\n' || text[i] == '\r')) { + i++ + } + return i + } + + private fun trimLeadingBlankLines(buffer: StringBuilder) { + while (buffer.isNotEmpty() && (buffer.first() == '\n' || buffer.first() == '\r')) { + buffer.deleteCharAt(0) + } + } + + private fun isHeadingOnly(text: String): Boolean { + val lines = text.lines().filter { it.isNotBlank() } + return lines.size == 1 && lines.first().trimStart().startsWith("#") + } + + private fun formatSystemEvent(event: SystemEvent): String { + return buildString { + append(event.title) + if (event.content.isNotBlank()) { + append('\n') + append(event.content) + } + } + } +}