This is an automated email from the ASF dual-hosted git repository.
lizhimins pushed a commit to branch rocketmq-studio
in repository https://gitbox.apache.org/repos/asf/rocketmq-dashboard.git
The following commit(s) were added to refs/heads/rocketmq-studio by this push:
new 816abce63 fix(ai): truncate conversation titles on code point
boundaries (#5213)
816abce63 is described below
commit 816abce63c0aed7444d2772836482dc56b31648c
Author: 健康宝 <[email protected]>
AuthorDate: Fri Oct 9 11:55:12 2026 +0800
fix(ai): truncate conversation titles on code point boundaries (#5213)
`deriveTitle` and `capTitle` cut on UTF-16 units, so a title ending in a
supplementary character
stored a lone high surrogate, which the utf8mb4 `rmq_ai_conversation.title`
column cannot represent;
`VARCHAR(512)` also counts characters, not UTF-16 units, so the cap did not
match the column either.
Both now go through the existing `TextBounds.truncate`, which cuts on code
point boundaries, and the
two new cases pin the whole-or-drop behaviour for the derived title and for
the explicit-rename cap.
Known follow-up: one comment in
`capTitleShouldCutOnCodePointBoundariesTest` attributes the old
behaviour to splitting the last emoji, which its fixture does not actually
do - 32 x (15 y + 1 emoji)
is 512 code points but 544 UTF-16 units, so the old cut stopped after 30
groups plus two y's. The
assertion itself is correct. `maintainerCanModify` is off on this PR, so
the comment is corrected in
a separate pull request.
---
.../ops/ai/conversation/AiConversationService.java | 17 ++++++-------
.../ai/conversation/AiConversationServiceTest.java | 29 ++++++++++++++++++++++
2 files changed, 37 insertions(+), 9 deletions(-)
diff --git
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationService.java
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationService.java
index 234d11e3f..24175f5c0 100644
---
a/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationService.java
+++
b/server/src/main/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationService.java
@@ -21,6 +21,7 @@ import lombok.extern.slf4j.Slf4j;
import org.apache.rocketmq.studio.auth.AuthenticatedUserContext;
import org.apache.rocketmq.studio.common.domain.PageResult;
import org.apache.rocketmq.studio.common.exception.BusinessException;
+import org.apache.rocketmq.studio.common.util.TextBounds;
import org.apache.rocketmq.studio.ops.ai.LlmConfigService;
import org.apache.rocketmq.studio.ops.ai.LlmConfigVO;
import org.apache.rocketmq.studio.ops.ai.conversation.agent.RmqctlWorkspace;
@@ -93,10 +94,10 @@ public class AiConversationService implements
ApplicationRunner {
*/
static final String DEFAULT_TITLE = "\u65B0\u4F1A\u8BDD";
- /** Characters of the first message a derived title keeps. */
+ /** Code points of the first message a derived title keeps. */
static final int TITLE_MAX_CHARS = 40;
- /** {@code rmq_ai_conversation.title} is VARCHAR(512); an explicit rename
is capped at that. */
+ /** {@code rmq_ai_conversation.title} is VARCHAR(512) characters; an
explicit rename is capped at that. */
static final int TITLE_COLUMN_MAX_CHARS = 512;
static final String DEFAULT_MODE = "chat";
@@ -343,7 +344,8 @@ public class AiConversationService implements
ApplicationRunner {
/**
* The title of a conversation, derived from its first user message rather
than generated by a model:
- * newlines folded to spaces, then the first {@value #TITLE_MAX_CHARS}
characters. An extra model
+ * newlines folded to spaces, then the first {@value #TITLE_MAX_CHARS}
code points — the cut never
+ * lands inside a supplementary character, which is also how MySQL counts
a VARCHAR. An extra model
* call per conversation would cost latency and a failure mode for
something a rule does well enough.
*/
public static String deriveTitle(String message) {
@@ -351,18 +353,15 @@ public class AiConversationService implements
ApplicationRunner {
return DEFAULT_TITLE;
}
String folded = message.strip().replaceAll("\\s+", " ");
- if (folded.length() <= TITLE_MAX_CHARS) {
- return folded;
- }
- return folded.substring(0, TITLE_MAX_CHARS);
+ return TextBounds.truncate(folded, TITLE_MAX_CHARS);
}
- /** Caps an explicitly supplied title at the column width. */
+ /** Caps an explicitly supplied title at the column width, on code point
boundaries. */
static String capTitle(String title) {
if (title == null) {
return null;
}
- return title.length() <= TITLE_COLUMN_MAX_CHARS ? title :
title.substring(0, TITLE_COLUMN_MAX_CHARS);
+ return TextBounds.truncate(title, TITLE_COLUMN_MAX_CHARS);
}
static String normalizeMode(String mode) {
diff --git
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationServiceTest.java
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationServiceTest.java
index 727d8651d..61d51ee21 100644
---
a/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationServiceTest.java
+++
b/server/src/test/java/org/apache/rocketmq/studio/ops/ai/conversation/AiConversationServiceTest.java
@@ -226,6 +226,35 @@ class AiConversationServiceTest {
assertThat(AiConversationService.capTitle("y".repeat(600))).hasSize(512);
}
+ @Test
+ void deriveTitleShouldCutOnCodePointBoundariesTest() {
+ // The emoji below is one supplementary character carried as two
UTF-16 units. At the
+ // budget the old UTF-16 cut kept only its high surrogate, corrupting
the stored title; it
+ // must now stay whole or drop whole.
+ String emoji = String.valueOf(Character.toChars(0x1F600));
+ assertThat(AiConversationService.deriveTitle("x".repeat(39) + emoji))
+ .isEqualTo("x".repeat(39) + emoji);
+ assertThat(AiConversationService.deriveTitle("x".repeat(40) + emoji))
+ .isEqualTo("x".repeat(40));
+ assertThat(AiConversationService.deriveTitle("x".repeat(39) + emoji +
"tail"))
+ .isEqualTo("x".repeat(39) + emoji);
+ }
+
+ @Test
+ void capTitleShouldCutOnCodePointBoundariesTest() {
+ String emoji = String.valueOf(Character.toChars(0x1F600));
+ // 512 code points of emoji-bearing text; the old 512-UTF-16-unit cut
split the last emoji.
+ String mixed = ("y".repeat(15) + emoji).repeat(32);
+ assertThat(AiConversationService.capTitle(mixed + emoji +
"z".repeat(600)))
+ .isEqualTo(mixed);
+ // 511 ASCII characters plus one emoji is exactly the 512-code-point
budget; appending one
+ // more emoji must drop it whole instead of leaving half a surrogate
pair behind.
+ assertThat(AiConversationService.capTitle("y".repeat(511) + emoji +
emoji))
+ .isEqualTo("y".repeat(511) + emoji);
+ assertThat(AiConversationService.capTitle(null)).isNull();
+ assertThat(AiConversationService.capTitle("short")).isEqualTo("short");
+ }
+
// --- timeline
--------------------------------------------------------------
@Test