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

Reply via email to