diff --git a/src/main/java/dev/marcospaulo/canalhandia/Ai.java b/src/main/java/dev/marcospaulo/canalhandia/Ai.java index 8e30d5f..4857860 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Ai.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Ai.java @@ -398,7 +398,14 @@ final class Ai { } return; } - String clean = AiText.sanitise(answer, settings.aiMaxAnswer()); + java.util.List segments = AiText.segments(answer, settings.aiMaxAnswer(), settings.aiMaxMessages()); + if (segments.isEmpty()) { + if (asker != null) { + Msg.error(asker, "Não consegui resposta agora. Tente de novo em instantes."); + } + return; + } + String clean = String.join(" ", segments); // Only remember if the asker is still online: a PlayerQuitEvent forgets // the player's history (carry-forward #6), and re-adding here after the // quit would resurrect it. lastAnswer stays regardless, so /ia corrigir @@ -410,13 +417,22 @@ final class Ai { if (isPrivate || !settings.aiPublic()) { if (asker != null) { - asker.sendMessage(style(clean, question, settings, Platform.isBedrock(asker))); + boolean bedrock = Platform.isBedrock(asker); + for (int i = 0; i < segments.size(); i++) { + asker.sendMessage(style(segments.get(i), question, settings, bedrock, i == 0)); + } } return; } // Built per platform: Bedrock renders neither hover nor click, so it - // gets the plain line instead of silently losing the interaction. - plugin.broadcastPerPlatform(bedrock -> style(clean, question, settings, bedrock)); + // gets the plain line instead of silently losing the interaction. Each + // segment is its own broadcast — a list sent as five one-line messages + // reads as a list; sent as one flattened line it reads as noise. + for (int i = 0; i < segments.size(); i++) { + String segment = segments.get(i); + boolean first = i == 0; + plugin.broadcastPerPlatform(bedrock -> style(segment, question, settings, bedrock, first)); + } plugin.openAiReactions(askerId); } @@ -431,8 +447,14 @@ final class Ai { *

Bedrock gets the same text with no hover and no click, because it * renders neither; the styling is decoration and its absence costs nothing. * {@code ia.estilo-rico: false} forces the plain form everywhere. + * + *

A long or list-shaped answer arrives as several segments ({@link + * AiText#segments}); the first carries the full {@code [IA]} tag, the rest + * carry a plain grey continuation mark instead of repeating the tag on + * every line, so a five-item list reads as one grouped answer rather than + * five separate IA replies. */ - private Component style(String answer, String question, Settings settings, boolean bedrock) { + private Component style(String answer, String question, Settings settings, boolean bedrock, boolean firstLine) { Component body = Component.text(answer, NamedTextColor.WHITE) .decoration(TextDecoration.BOLD, false); if (!bedrock && settings.aiFancy()) { @@ -449,7 +471,10 @@ final class Ai { NamedTextColor.DARK_GRAY)))) .clickEvent(net.kyori.adventure.text.event.ClickEvent.suggestCommand("/ia ")); } - return Msg.tag("IA", NamedTextColor.LIGHT_PURPLE).append(body); + Component prefix = firstLine + ? Msg.tag("IA", NamedTextColor.LIGHT_PURPLE) + : Component.text(" » ", NamedTextColor.DARK_GRAY); + return prefix.append(body); } // --- spontaneous lines -------------------------------------------------- diff --git a/src/main/java/dev/marcospaulo/canalhandia/AiText.java b/src/main/java/dev/marcospaulo/canalhandia/AiText.java index e286215..4c37534 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/AiText.java +++ b/src/main/java/dev/marcospaulo/canalhandia/AiText.java @@ -1,5 +1,9 @@ package dev.marcospaulo.canalhandia; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; import java.util.regex.Pattern; /** @@ -83,6 +87,179 @@ final class AiText { return text; } + /** + * Line-wrap width used inside {@link #segments}, in characters. + * + *

Minecraft imposes no real limit here: the 256-character cap is on + * what a player types, not on chat components the server sends, + * and the underlying packet allows far more than any answer needs. This + * number instead picks how much text belongs in one visual chat line — + * a list with five items reads as five lines, not one paragraph, and a + * long explanation reads as a few short lines instead of one wall wrapped + * by the client at whatever width the player's window happens to be. + */ + private static final int LINE_WIDTH = 200; + + private static final Pattern SENTENCE = Pattern.compile("[^.!?]+[.!?]*\\s*"); + + /** + * Splits a model answer into the separate chat messages it should be sent + * as, instead of one flattened line. + * + *

Unlike {@link #sanitise}, this keeps the model's own line breaks — + * that is what turns a numbered list or a set of short points back into + * one message per item. Each resulting line is then colour/markdown/emoji + * cleaned exactly like {@code sanitise} does, and re-wrapped at + * {@link #LINE_WIDTH} if it is still too long to read as one message. + * + *

{@code totalMax} caps the combined length exactly like + * {@code sanitise}'s {@code max} does today (protects the token/spam + * budget); {@code maxMessages} caps how many separate chat lines go out + * (protects against a runaway list flooding chat) — anything past that + * cap is folded into the last line and ellipsised. + * + * @return never null; empty list only for a null/blank/all-noise answer + */ + static List segments(String raw, int totalMax, int maxMessages) { + if (raw == null || raw.isBlank()) { + return List.of(); + } + String cleaned = raw + .replaceAll("§[0-9A-Za-z]", " ") + .replace('§', ' ') + .replace("\r\n", "\n") + .replace('\r', '\n') + .replaceAll("(?s)\\*{1,3}(?!\\s)(.+?)(? Math.max(totalMax, 1) * Math.max(maxMessages, 1)) { + cleaned = truncate(cleaned, Math.max(totalMax, 1) * Math.max(maxMessages, 1)); + } + + List lines = new ArrayList<>(); + for (String line : cleaned.split("\\n+")) { + String trimmed = line.trim(); + if (!trimmed.isEmpty()) { + lines.add(trimmed); + } + } + if (lines.isEmpty()) { + return List.of(); + } + + List wrapped = new ArrayList<>(); + for (String line : lines) { + wrapped.addAll(wrap(line, LINE_WIDTH)); + } + + int cap = Math.max(1, maxMessages); + if (wrapped.size() <= cap) { + return wrapped; + } + List capped = new ArrayList<>(wrapped.subList(0, cap - 1)); + StringBuilder rest = new StringBuilder(); + for (int i = cap - 1; i < wrapped.size(); i++) { + if (!rest.isEmpty()) { + rest.append(' '); + } + rest.append(wrapped.get(i)); + } + capped.add(truncate(rest.toString(), Math.max(totalMax, LINE_WIDTH))); + return capped; + } + + /** Breaks one line into sentence-sized chunks of at most {@code width} chars. */ + private static List wrap(String line, int width) { + if (line.length() <= width) { + return List.of(line); + } + List out = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + Matcher m = SENTENCE.matcher(line); + while (m.find()) { + String sentence = m.group().trim(); + if (sentence.isEmpty()) { + continue; + } + if (sentence.length() > width) { + if (!current.isEmpty()) { + out.add(current.toString()); + current.setLength(0); + } + out.addAll(wrapByWord(sentence, width)); + continue; + } + if (!current.isEmpty() && current.length() + 1 + sentence.length() > width) { + out.add(current.toString()); + current.setLength(0); + } + if (!current.isEmpty()) { + current.append(' '); + } + current.append(sentence); + } + if (!current.isEmpty()) { + out.add(current.toString()); + } + return out.isEmpty() ? List.of(line) : out; + } + + /** Last-resort wrap for a single sentence with no punctuation to break on. */ + private static List wrapByWord(String text, int width) { + List out = new ArrayList<>(); + StringBuilder current = new StringBuilder(); + for (String word : text.split("\\s+")) { + // A "word" longer than the whole width (no spaces at all — never + // seen from the model, but not impossible from pasted junk) has + // nothing left to break on but the character boundary itself. + if (word.length() > width) { + if (!current.isEmpty()) { + out.add(current.toString()); + current.setLength(0); + } + for (int i = 0; i < word.length(); i += width) { + out.add(word.substring(i, Math.min(i + width, word.length()))); + } + continue; + } + if (!current.isEmpty() && current.length() + 1 + word.length() > width) { + out.add(current.toString()); + current.setLength(0); + } + if (!current.isEmpty()) { + current.append(' '); + } + current.append(word); + } + if (!current.isEmpty()) { + out.add(current.toString()); + } + return out; + } + + /** + * Surrogate-safe truncation shared by {@link #sanitise} and + * {@link #segments}: backing off one char when the cut lands on a high + * surrogate avoids leaving an orphan half that renders as a replacement + * box. + */ + private static String truncate(String text, int max) { + if (text.length() <= max) { + return text; + } + int cut = max; + if (cut > 0 && Character.isHighSurrogate(text.charAt(cut - 1))) { + cut--; + } + return text.substring(0, cut).trim() + "…"; + } + /** Shortens text for a log line. */ static String forLog(String text) { return text.length() > 300 ? text.substring(0, 300) + "…" : text; diff --git a/src/main/java/dev/marcospaulo/canalhandia/Settings.java b/src/main/java/dev/marcospaulo/canalhandia/Settings.java index 7d2e217..0661cf2 100644 --- a/src/main/java/dev/marcospaulo/canalhandia/Settings.java +++ b/src/main/java/dev/marcospaulo/canalhandia/Settings.java @@ -297,6 +297,18 @@ final class Settings { return Math.max(64, plugin.getConfig().getInt("ia.max-caracteres", 500)); } + /** + * How many separate chat messages a single answer may be split into. + * Minecraft has no meaningful per-message character limit for text the + * server sends (that 256-char cap is only on what a player can type), but + * a list or a long paragraph dumped into one chat line loses its + * structure. This bounds how many lines {@link Ai} will break an answer + * into instead — a hard cap so a runaway list can't flood chat. + */ + int aiMaxMessages() { + return Math.max(1, Math.min(8, plugin.getConfig().getInt("ia.max-mensagens", 4))); + } + /** Whether the question and answer go to everyone or only to the asker. */ boolean aiPublic() { return plugin.getConfig().getBoolean("ia.publico", true); diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 562dd88..52a2983 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -187,6 +187,14 @@ ia: max-tokens: 1200 max-caracteres: 500 + # O Minecraft não limita o tamanho de uma mensagem que o SERVIDOR manda (o + # limite de 256 caracteres é só no que um JOGADOR digita). O problema de + # despejar uma lista inteira numa linha só é de leitura, não do jogo: vira + # um bloco de texto em vez de itens separados. Por isso a resposta é + # dividida em até N mensagens de chat — uma lista de 5 itens vira 5 linhas. + # max-caracteres continua sendo o teto total somado entre todas elas. + max-mensagens: 4 + # Tamanho máximo da pergunta, em caracteres. max-pergunta: 300 diff --git a/src/test/java/dev/marcospaulo/canalhandia/AiTextSegmentsTest.java b/src/test/java/dev/marcospaulo/canalhandia/AiTextSegmentsTest.java new file mode 100644 index 0000000..a6b4182 --- /dev/null +++ b/src/test/java/dev/marcospaulo/canalhandia/AiTextSegmentsTest.java @@ -0,0 +1,92 @@ +package dev.marcospaulo.canalhandia; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; + +class AiTextSegmentsTest { + + @Test + void shortAnswerIsOneSegment() { + assertEquals(List.of("Sim, dá para plantar cacto na areia."), + AiText.segments("Sim, dá para plantar cacto na areia.", 500, 4)); + } + + @Test + void nullOrBlankIsEmpty() { + assertEquals(List.of(), AiText.segments(null, 500, 4)); + assertEquals(List.of(), AiText.segments(" ", 500, 4)); + } + + @Test + void newlinesBecomeSeparateSegmentsInsteadOfBeingFlattened() { + // AiText.sanitise collapses \n to a space; segments must not, because + // this is exactly what turns a model-produced list into one message + // per item instead of one wall of text. + List out = AiText.segments("1. minere ferro\n2. faça uma picareta\n3. vá para a caverna", + 500, 4); + assertEquals(List.of("1. minere ferro", "2. faça uma picareta", "3. vá para a caverna"), out); + } + + @Test + void blankLinesBetweenParagraphsDoNotProduceEmptySegments() { + List out = AiText.segments("primeira parte\n\n\nsegunda parte", 500, 4); + assertEquals(List.of("primeira parte", "segunda parte"), out); + } + + @Test + void aLineLongerThanTheWidthIsWrappedBySentence() { + String longLine = "Esta é a primeira frase bem grande para forçar a quebra de linha no teste. " + + "E esta é a segunda frase, também grande, para garantir que os limites funcionam direito. " + + "E aqui vai uma terceira frase só para garantir que passamos dos duzentos caracteres."; + assertTrue(longLine.length() > 200, "fixture too short: " + longLine.length()); + List out = AiText.segments(longLine, 500, 4); + assertTrue(out.size() >= 2, "expected the long line to wrap into multiple segments, got: " + out); + for (String segment : out) { + assertTrue(segment.length() <= 200, "segment too long: " + segment); + } + } + + @Test + void moreLinesThanMaxMessagesAreFoldedIntoTheLast() { + List out = AiText.segments("um\ndois\ntrês\nquatro\ncinco\nseis", 500, 3); + assertEquals(3, out.size()); + assertEquals("um", out.get(0)); + assertEquals("dois", out.get(1)); + assertTrue(out.get(2).contains("três") && out.get(2).contains("seis"), + "expected overflow lines merged into the last segment: " + out.get(2)); + } + + @Test + void eachSegmentIsCleanedLikeSanitise() { + List out = AiText.segments("§cvermelho\n**negrito**\n`codigo`", 500, 4); + assertEquals(List.of("vermelho", "negrito", "codigo"), out); + } + + @Test + void leadingSlashIsStrippedOnlyOnce() { + List out = AiText.segments("/kill isso não é um comando de verdade", 500, 4); + assertEquals(List.of("kill isso não é um comando de verdade"), out); + } + + @Test + void totalBudgetStillCapsAVeryLongAnswer() { + String huge = "palavra ".repeat(400); // way over any reasonable total budget + // totalMax * maxMessages (750) clears the 200-char line-wrap width, so + // the truncated text still wraps into more lines than fit, and the + // overflow gets folded into the last of the 3 allowed segments. + List out = AiText.segments(huge, 250, 3); + assertEquals(3, out.size()); + int total = out.stream().mapToInt(String::length).sum(); + assertTrue(total <= 250 * 3 + 20, "segments should stay close to the total budget, got total=" + total); + } + + @Test + void singleSegmentHardWrapsAWordSaladLineWithNoPunctuation() { + String noPunctuation = "palavra".repeat(60); // 420 chars, no spaces or sentence breaks + List out = AiText.segments(noPunctuation, 1000, 4); + assertTrue(out.size() >= 2, "expected a hard wrap fallback, got: " + out); + } +}