Compare commits

...

2 Commits

Author SHA1 Message Date
Admire f68bcb771c fix(frontend): guard message copy clipboard access (#3211)
* fix(frontend): guard message copy clipboard access

* fix(frontend): reuse clipboard guard across copy actions
2026-05-26 09:37:51 +08:00
AochenShen99 11dd5b0683 fix(frontend): strip unclosed <think> tags from streaming AI content (#3218)
* fix(frontend): strip unclosed <think> tags from streaming AI content

During streaming, an opening <think> tag may arrive in one chunk
while the matching </think> arrives in a later chunk. The existing
splitInlineReasoning regex only matched fully closed pairs, so the
mid-flight reasoning was left in message.content and rendered into
the chat bubble via the markdown pipeline's rehypeRaw plugin until
the closing tag landed.

Extend splitInlineReasoning with a second pass: after stripping every
closed <think>...</think> pair, route any remaining content from a
lone opener to the reasoning slot and leave only the preceding
preamble in content. Closed-tag behavior is unchanged.

Covers every provider whose stream emits reasoning inline as <think>
tags (MiniMax streaming path, MindIE, PatchedChatOpenAI, and any
gateway-served DeepSeek/OpenAI-compatible model).

* style(frontend): apply prettier formatting to streaming reasoning tests

* fix(frontend): skip <think> split for literal think tags in inline code

Treats a `<think>` opener immediately preceded by a backtick as part of
markdown inline code rather than a streaming reasoning marker. Prevents
permanent content truncation when an AI message documents the `<think>`
tag literally (e.g. ``Use `<think>` markers``), where the streaming-safe
fallback would otherwise route the rest of the answer into the reasoning
panel because no `</think>` ever arrives.

Adds regression tests for both the post-stream and mid-stream cases.
2026-05-26 09:35:07 +08:00
8 changed files with 360 additions and 32 deletions
@@ -1,6 +1,7 @@
"use client";
import { Button } from "@/components/ui/button";
import { writeTextToClipboard } from "@/core/clipboard";
import { cn } from "@/lib/utils";
import { CheckIcon, CopyIcon } from "lucide-react";
import {
@@ -146,20 +147,20 @@ export const CodeBlockCopyButton = ({
const [isCopied, setIsCopied] = useState(false);
const { code } = useContext(CodeBlockContext);
const copyToClipboard = async () => {
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
onError?.(new Error("Clipboard API not available"));
return;
}
const copyToClipboard = () => {
void (async () => {
const didCopy = await writeTextToClipboard(code);
if (!didCopy) {
onError?.(new Error("Clipboard API not available"));
return;
}
try {
await navigator.clipboard.writeText(code);
setIsCopied(true);
onCopy?.();
setTimeout(() => setIsCopied(false), timeout);
} catch (error) {
})().catch((error) => {
onError?.(error as Error);
}
});
};
const Icon = isCopied ? CheckIcon : CopyIcon;
@@ -38,6 +38,7 @@ import {
HTML_PREVIEW_SCROLL_MESSAGE_SOURCE,
} from "@/core/artifacts/preview";
import { urlOfArtifact } from "@/core/artifacts/utils";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import { findToolCallResult } from "@/core/messages/utils";
import { installSkill } from "@/core/skills/api";
@@ -237,14 +238,20 @@ export function ArtifactFileDetail({
icon={CopyIcon}
label={t.clipboard.copyToClipboard}
disabled={!content}
onClick={async () => {
try {
await navigator.clipboard.writeText(visibleContent ?? "");
onClick={() => {
void (async () => {
const didCopy = await writeTextToClipboard(
visibleContent ?? "",
);
if (!didCopy) {
toast.error(t.clipboard.failedToCopyToClipboard);
return;
}
toast.success(t.clipboard.copiedToClipboard);
} catch (error) {
toast.error("Failed to copy to clipboard");
console.error(error);
}
})().catch(() => {
toast.error(t.clipboard.failedToCopyToClipboard);
});
}}
tooltip={t.clipboard.copyToClipboard}
/>
@@ -1,7 +1,9 @@
import { CheckIcon, CopyIcon } from "lucide-react";
import { useCallback, useState, type ComponentProps } from "react";
import { toast } from "sonner";
import { Button } from "@/components/ui/button";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import { Tooltip } from "./tooltip";
@@ -15,10 +17,19 @@ export function CopyButton({
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(() => {
void navigator.clipboard.writeText(clipboardData);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}, [clipboardData]);
void (async () => {
const didCopy = await writeTextToClipboard(clipboardData);
if (!didCopy) {
toast.error(t.clipboard.failedToCopyToClipboard);
return;
}
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})().catch(() => {
toast.error(t.clipboard.failedToCopyToClipboard);
});
}, [clipboardData, t.clipboard.failedToCopyToClipboard]);
return (
<Tooltip content={t.clipboard.copyToClipboard}>
<Button
@@ -43,6 +43,7 @@ import {
SidebarMenuItem,
} from "@/components/ui/sidebar";
import { getAPIClient } from "@/core/api";
import { writeTextToClipboard } from "@/core/clipboard";
import { useI18n } from "@/core/i18n/hooks";
import {
exportThreadAsJSON,
@@ -126,7 +127,12 @@ export function RecentChatList() {
const baseUrl = isLocalhost ? VERCEL_URL : window.location.origin;
const shareUrl = `${baseUrl}${pathOfThread(thread)}`;
try {
await navigator.clipboard.writeText(shareUrl);
const didCopy = await writeTextToClipboard(shareUrl);
if (!didCopy) {
toast.error(t.clipboard.failedToCopyToClipboard);
return;
}
toast.success(t.clipboard.linkCopied);
} catch {
toast.error(t.clipboard.failedToCopyToClipboard);
+31
View File
@@ -0,0 +1,31 @@
export async function writeTextToClipboard(text: string): Promise<boolean> {
try {
const clipboard = globalThis.navigator?.clipboard;
if (clipboard?.writeText) {
await clipboard.writeText(text);
return true;
}
const document = globalThis.document;
if (!document?.body?.appendChild || !document.execCommand) {
return false;
}
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "");
textarea.style.position = "fixed";
textarea.style.top = "-9999px";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
try {
return document.execCommand("copy");
} finally {
textarea.remove();
}
} catch {
return false;
}
}
+30 -10
View File
@@ -266,22 +266,42 @@ export function extractTextFromMessage(message: Message) {
return "";
}
const THINK_OPEN_TAG = "<think>";
const THINK_TAG_RE = /<think>\s*([\s\S]*?)\s*<\/think>/g;
function splitInlineReasoning(content: string) {
const reasoningParts: string[] = [];
const cleaned = content
.replace(THINK_TAG_RE, (_, reasoning: string) => {
const normalized = reasoning.trim();
if (normalized) {
reasoningParts.push(normalized);
}
return "";
})
.trim();
// First pass: strip every fully closed `<think>...</think>` pair and
// collect its body as reasoning.
let cleaned = content.replace(THINK_TAG_RE, (_, reasoning: string) => {
const normalized = reasoning.trim();
if (normalized) {
reasoningParts.push(normalized);
}
return "";
});
// Streaming-safe pass: a `<think>` opener whose `</think>` has not arrived
// yet means the rest of the chunk is reasoning in flight. Route it into the
// reasoning slot instead of letting it render as message content (the
// raw-HTML markdown pipeline would otherwise paint the inner text on
// screen until the closing tag lands).
//
// Skip when the opener sits right after a backtick — that is the model
// talking about `<think>` literally inside markdown inline code, not
// actually streaming reasoning.
const openTagIndex = cleaned.indexOf(THINK_OPEN_TAG);
if (openTagIndex !== -1 && cleaned[openTagIndex - 1] !== "`") {
const tail = cleaned.slice(openTagIndex + THINK_OPEN_TAG.length).trim();
if (tail) {
reasoningParts.push(tail);
}
cleaned = cleaned.slice(0, openTagIndex);
}
return {
content: cleaned,
content: cleaned.trim(),
reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : null,
};
}
+146
View File
@@ -0,0 +1,146 @@
import { afterEach, expect, test, vi } from "vitest";
import { writeTextToClipboard } from "@/core/clipboard";
const originalNavigator = globalThis.navigator;
const hadOriginalNavigator = "navigator" in globalThis;
const originalDocument = globalThis.document;
const hadOriginalDocument = "document" in globalThis;
afterEach(() => {
vi.restoreAllMocks();
if (!hadOriginalNavigator) {
Reflect.deleteProperty(globalThis, "navigator");
} else {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: originalNavigator,
});
}
if (!hadOriginalDocument) {
Reflect.deleteProperty(globalThis, "document");
} else {
Object.defineProperty(globalThis, "document", {
configurable: true,
value: originalDocument,
});
}
});
test("writes text with the Clipboard API when available", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
writeText,
},
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith("hello");
});
test("returns false when Clipboard API is unavailable", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
});
test("falls back to execCommand when Clipboard API is unavailable", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
const appendChild = vi.fn();
const execCommand = vi.fn().mockReturnValue(true);
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild,
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand,
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(true);
expect(textarea.value).toBe("hello");
expect(appendChild).toHaveBeenCalledWith(textarea);
expect(textarea.select).toHaveBeenCalled();
expect(execCommand).toHaveBeenCalledWith("copy");
expect(textarea.remove).toHaveBeenCalled();
});
test("returns false when execCommand fallback fails", async () => {
const textarea = {
remove: vi.fn(),
select: vi.fn(),
setAttribute: vi.fn(),
style: {},
value: "",
};
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {},
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: {
body: {
appendChild: vi.fn(),
},
createElement: vi.fn().mockReturnValue(textarea),
execCommand: vi.fn().mockReturnValue(false),
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
expect(textarea.remove).toHaveBeenCalled();
});
test("returns false when navigator is unavailable", async () => {
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: undefined,
});
Object.defineProperty(globalThis, "document", {
configurable: true,
value: undefined,
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
});
test("returns false when Clipboard API rejects", async () => {
const writeText = vi.fn().mockRejectedValue(new Error("denied"));
Object.defineProperty(globalThis, "navigator", {
configurable: true,
value: {
clipboard: {
writeText,
},
},
});
await expect(writeTextToClipboard("hello")).resolves.toBe(false);
});
+107 -1
View File
@@ -1,14 +1,26 @@
import type { Message } from "@langchain/langgraph-sdk";
import { expect, test } from "vitest";
import { describe, expect, test } from "vitest";
import {
extractContentFromMessage,
extractReasoningContentFromMessage,
getAssistantTurnCopyData,
getAssistantTurnUsageMessages,
getMessageGroups,
getStreamingMessageLookup,
hasContent,
hasReasoning,
isAssistantMessageGroupStreaming,
} from "@/core/messages/utils";
function aiMessage(content: string): Message {
return {
id: "ai-1",
type: "ai",
content,
} as Message;
}
test("aggregates token usage messages once per assistant turn", () => {
const messages = [
{
@@ -67,6 +79,100 @@ test("aggregates token usage messages once per assistant turn", () => {
).toEqual([null, null, ["ai-1", "ai-2"], null, ["ai-3"]]);
});
describe("inline <think> tag splitting", () => {
test("strips a fully closed <think> block from AI content", () => {
const message = aiMessage("<think>internal reasoning</think>final answer");
expect(extractContentFromMessage(message)).toBe("final answer");
expect(extractReasoningContentFromMessage(message)).toBe(
"internal reasoning",
);
});
test("strips multiple closed <think> blocks and joins their reasoning", () => {
const message = aiMessage(
"<think>step one</think>between<think>step two</think>after",
);
expect(extractContentFromMessage(message)).toBe("betweenafter");
expect(extractReasoningContentFromMessage(message)).toBe(
"step one\n\nstep two",
);
});
test("during streaming, an unclosed <think> tag does not leak its tail into content", () => {
// Simulates accumulated content mid-stream, before </think> arrives.
const message = aiMessage(
"<think>I need to analyze the user's question step by",
);
expect(extractContentFromMessage(message)).toBe("");
expect(extractContentFromMessage(message)).not.toContain("<think>");
expect(extractReasoningContentFromMessage(message)).toBe(
"I need to analyze the user's question step by",
);
});
test("preamble before an unclosed <think> stays in content", () => {
const message = aiMessage(
"Here is part of the answer.<think>but wait, let me reconsider",
);
expect(extractContentFromMessage(message)).toBe(
"Here is part of the answer.",
);
expect(extractReasoningContentFromMessage(message)).toBe(
"but wait, let me reconsider",
);
});
test("closed <think> followed by a trailing unclosed <think> merges both into reasoning", () => {
const message = aiMessage(
"<think>first step</think>partial answer<think>second step still streaming",
);
expect(extractContentFromMessage(message)).toBe("partial answer");
expect(extractReasoningContentFromMessage(message)).toBe(
"first step\n\nsecond step still streaming",
);
});
test("hasReasoning recognises an unclosed <think> tag mid-stream", () => {
expect(hasReasoning(aiMessage("<think>thinking in progress"))).toBe(true);
});
test("hasContent excludes an unclosed <think> tail when no preamble exists", () => {
expect(hasContent(aiMessage("<think>thinking in progress"))).toBe(false);
});
test("hasContent stays true when preamble precedes an unclosed <think>", () => {
expect(hasContent(aiMessage("preamble<think>still thinking"))).toBe(true);
});
test("a lone <think> open tag with no body yields no reasoning and no content", () => {
const message = aiMessage("<think>");
expect(extractContentFromMessage(message)).toBe("");
expect(extractReasoningContentFromMessage(message)).toBeNull();
expect(hasReasoning(message)).toBe(false);
});
test("a literal <think> inside markdown inline code is not treated as reasoning", () => {
const message = aiMessage(
"Use `<think>` markers to delimit reasoning sections.",
);
expect(extractContentFromMessage(message)).toBe(
"Use `<think>` markers to delimit reasoning sections.",
);
expect(extractReasoningContentFromMessage(message)).toBeNull();
expect(hasReasoning(message)).toBe(false);
});
test("a backtick-prefixed <think> mid-stream is not split into reasoning", () => {
// Simulates the moment the model has emitted the opening backtick and
// `<think>` for a literal documentation reference, before the closing
// backtick arrives. The pre-fix behaviour would have permanently
// truncated the content here.
const message = aiMessage("Documentation: `<think>");
expect(extractContentFromMessage(message)).toBe("Documentation: `<think>");
expect(extractReasoningContentFromMessage(message)).toBeNull();
});
});
test("hides internal todo reminder messages from message groups", () => {
const messages = [
{