mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-10-26 05:06:59 +00:00
fix(frontend): bound notification bodies by code points (#7474)
Both chat pages capped the "conversation finished" notification body with String#length / substring, which count UTF-16 code units: a cut inside an astral character's surrogate pair rendered U+FFFD, and a body that fits 200 code points was shortened early. Extract notificationBody() so both pages share the code-point bound and the "Conversation finished" fallback, and add boundary regressions. Co-authored-by: 一片一片 <192845873+gdut4140@users.noreply.github.com>
This commit is contained in:
parent
db7f919118
commit
6213fee192
@ -67,6 +67,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { modelTaskInput } from "@/core/models/control";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useModelControl } from "@/core/models/use-model-control";
|
||||
import { notificationBody } from "@/core/notification/body";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useThreadScheduledTaskEvents } from "@/core/scheduled-tasks/events";
|
||||
import { useLocalSettings, useThreadSettings } from "@/core/settings";
|
||||
@ -221,18 +222,12 @@ export default function AgentChatPage() {
|
||||
// it is open: it has been read.
|
||||
markThreadRead();
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages[state.messages.length - 1];
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
showNotification(state.title, {
|
||||
body: notificationBody(
|
||||
lastMessage ? textOfMessage(lastMessage) : null,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
@ -62,6 +62,7 @@ import { isHiddenFromUIMessage } from "@/core/messages/utils";
|
||||
import { modelTaskInput } from "@/core/models/control";
|
||||
import { useModels } from "@/core/models/hooks";
|
||||
import { useModelControl } from "@/core/models/use-model-control";
|
||||
import { notificationBody } from "@/core/notification/body";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useProject } from "@/core/projects";
|
||||
import { useThreadScheduledTaskEvents } from "@/core/scheduled-tasks/events";
|
||||
@ -231,18 +232,12 @@ export default function ChatPage() {
|
||||
// it is open: it has been read.
|
||||
markThreadRead();
|
||||
if (document.hidden || !document.hasFocus()) {
|
||||
let body = "Conversation finished";
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (lastMessage) {
|
||||
const textContent = textOfMessage(lastMessage);
|
||||
if (textContent) {
|
||||
body =
|
||||
textContent.length > 200
|
||||
? textContent.substring(0, 200) + "..."
|
||||
: textContent;
|
||||
}
|
||||
}
|
||||
showNotification(state.title, { body });
|
||||
showNotification(state.title, {
|
||||
body: notificationBody(
|
||||
lastMessage ? textOfMessage(lastMessage) : null,
|
||||
),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
27
frontend/src/core/notification/body.ts
Normal file
27
frontend/src/core/notification/body.ts
Normal file
@ -0,0 +1,27 @@
|
||||
/** Longest notification body, counted in code points. */
|
||||
export const MAX_NOTIFICATION_BODY_CODE_POINTS = 200;
|
||||
|
||||
/** Ellipsis appended to a shortened notification body. */
|
||||
const ELLIPSIS = "...";
|
||||
|
||||
/**
|
||||
* Body for a "finished in the background" notification.
|
||||
*
|
||||
* The text is bounded by code points rather than UTF-16 code units: a
|
||||
* `String.prototype.length` / `substring` cut can land between the two halves
|
||||
* of an astral character (an emoji) and leave a lone surrogate, which the
|
||||
* notification renders as U+FFFD. Summaries that fit the budget are returned
|
||||
* whole even when they contain astral characters.
|
||||
*/
|
||||
export function notificationBody(
|
||||
text: string | null | undefined,
|
||||
fallback = "Conversation finished",
|
||||
): string {
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
const characters = Array.from(text);
|
||||
return characters.length > MAX_NOTIFICATION_BODY_CODE_POINTS
|
||||
? `${characters.slice(0, MAX_NOTIFICATION_BODY_CODE_POINTS).join("")}${ELLIPSIS}`
|
||||
: text;
|
||||
}
|
||||
36
frontend/tests/unit/core/notification/body.test.ts
Normal file
36
frontend/tests/unit/core/notification/body.test.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { expect, test } from "@rstest/core";
|
||||
|
||||
import {
|
||||
MAX_NOTIFICATION_BODY_CODE_POINTS,
|
||||
notificationBody,
|
||||
} from "@/core/notification/body";
|
||||
|
||||
const LONE_SURROGATE =
|
||||
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
||||
|
||||
test("quotes the last message when it fits", () => {
|
||||
expect(notificationBody("Done in 3 steps.")).toBe("Done in 3 steps.");
|
||||
});
|
||||
|
||||
test("falls back when there is no text to quote", () => {
|
||||
expect(notificationBody(null)).toBe("Conversation finished");
|
||||
expect(notificationBody(undefined)).toBe("Conversation finished");
|
||||
expect(notificationBody("")).toBe("Conversation finished");
|
||||
expect(notificationBody("", "Custom fallback")).toBe("Custom fallback");
|
||||
});
|
||||
|
||||
test("a body that fits the code-point budget keeps its trailing emoji", () => {
|
||||
// 200 code points but 201 UTF-16 units: UTF-16 counting would cut this.
|
||||
const text = `${"a".repeat(MAX_NOTIFICATION_BODY_CODE_POINTS - 1)}😀`;
|
||||
expect(notificationBody(text)).toBe(text);
|
||||
});
|
||||
|
||||
test("a truncated body never ends in half an emoji", () => {
|
||||
// 201 code points; the old UTF-16 cut landed inside the surrogate pair.
|
||||
const text = `${"a".repeat(MAX_NOTIFICATION_BODY_CODE_POINTS - 1)}😀b`;
|
||||
const body = notificationBody(text);
|
||||
expect(body).toBe(
|
||||
`${"a".repeat(MAX_NOTIFICATION_BODY_CODE_POINTS - 1)}😀...`,
|
||||
);
|
||||
expect(body).not.toMatch(LONE_SURROGATE);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user