Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
401 changes: 4 additions & 397 deletions components/MessageView.tsx

Large diffs are not rendered by default.

67 changes: 67 additions & 0 deletions components/SafeMarkdownBody.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"use client";

import { useState, type ComponentProps } from "react";
import { MarkdownBody } from "./MarkdownBody";
import { useI18n } from "@/hooks/useI18n";

// Messages larger than this skip markdown rendering entirely. react-markdown +
// KaTeX + syntax highlighting on multi-hundred-KB payloads (e.g. pasted HAR or
// log dumps) freezes the browser main thread.
const MAX_MARKDOWN_CHARS = 100_000;

function formatMessageBytes(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)} MB`;
if (n >= 1_000) return `${Math.round(n / 1_000)} KB`;
return `${n} B`;
}

/**
* MarkdownBody with an oversized-content guard: huge messages render as a
* click-to-reveal plain-text <pre> instead of running the markdown pipeline.
*/
export function SafeMarkdownBody({ children, className, ...props }: ComponentProps<typeof MarkdownBody>) {
const { t } = useI18n();
const [showRaw, setShowRaw] = useState(false);

if (children.length <= MAX_MARKDOWN_CHARS) {
return <MarkdownBody className={className} {...props}>{children}</MarkdownBody>;
}
if (!showRaw) {
return (
<button
onClick={() => setShowRaw(true)}
style={{
display: "block",
width: "100%",
margin: "4px 0",
padding: "7px 10px",
border: "1px solid var(--border)",
borderRadius: 6,
background: "var(--bg-panel)",
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 12,
textAlign: "left",
}}
>
⚠ {t("i18n.largeMessageReveal", { size: formatMessageBytes(children.length) })}
</button>
);
}
return (
<div className={className} style={{ maxHeight: 420, overflow: "auto", fontSize: "calc(12px + var(--chat-font-size-offset, 0px))", lineHeight: 1.5 }}>
<pre
style={{
margin: 0,
padding: "8px 10px",
whiteSpace: "pre-wrap",
wordBreak: "break-word",
fontFamily: "var(--font-mono)",
color: "var(--text-muted)",
}}
>
{children}
</pre>
</div>
);
}
83 changes: 83 additions & 0 deletions components/UserMessageView.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { createJiti } from "jiti";

const jiti = createJiti(import.meta.url, {
jsx: { runtime: "automatic" },
tsconfigPaths: true,
});
const { MessageView } = await jiti.import("./MessageView.tsx");
const { I18nProvider } = await jiti.import("@/hooks/useI18n");
const { getLocalePlugin } = await jiti.import("../lib/i18n/registry.ts");

function renderUser(content, props = {}) {
const message = { role: "user", content };
const before = JSON.stringify(message);
const html = renderToStaticMarkup(React.createElement(
I18nProvider,
null,
React.createElement(MessageView, { message, ...props }),
));
assert.equal(JSON.stringify(message), before, "display must not mutate the message");
return html;
}

test("keeps rendered user messages as the default and adds Source after existing actions", () => {
const html = renderUser("**Hello**\n$x + y$", {
entryId: "user-entry",
onFork() {},
onEditContent() {},
});
assert.match(html, /<strong>Hello<\/strong>/);
assert.match(html, /class="katex"/);
assert.doesNotMatch(html, /class="user-message-source"/);
const titles = [...html.matchAll(/<button[^>]*title="([^"]+)"/g)].map((match) => match[1]);
const messages = getLocalePlugin("en").messages;
assert.deepEqual(titles, [messages["i18n.copyMessage"], messages["i18n.editFromHereTitle"], messages["i18n.newSessionTitle"], "Source"]);
assert.match(html, /<button[^>]*type="button"[^>]*title="Source"[^>]*aria-label="Source"/);
assert.match(html, /aria-hidden="true" focusable="false"><polyline points="7 6 1 12 7 18"/);
});

test("offers Source for HTML that the existing rendered view hides (#1407)", () => {
const text = '<TableOperator v-model="columns" :tableId="TABLE_ID_CONST.BUSINESS.${basic.businessModule.toUpperCase()}.Demo" :refresh="queryData" /> 后文';
const html = renderUser(text);
assert.match(html, /后文/);
assert.doesNotMatch(html, /TableOperator|BUSINESS/);
assert.match(html, /aria-label="Source"/);
});

test("does not offer a text display switch for empty or image-only messages", () => {
for (const content of ["", [{ type: "image", data: "YWJj", mimeType: "image/png" }]]) {
assert.doesNotMatch(renderUser(content), /aria-label="Source"/);
}
});

test("keeps attachments and compact skill display with its Source action", () => {
const skill = '<skill name="review" location="/skills/review/SKILL.md">\nReferences are relative to /skills/review.\n\nReview **these files**.\n</skill>\n\nsrc/main.ts';
const html = renderUser([
{ type: "text", text: skill },
{ type: "image", data: "YWJj", mimeType: "image/png" },
]);
assert.match(html, /src="data:image\/png;base64,YWJj"/);
assert.match(html, /\/skill:review/);
assert.match(html, /aria-expanded="false"/);
assert.match(html, /aria-label="Source"/);
assert.doesNotMatch(html, /Review <strong>/);
});

test("keeps the large-message guard and offers direct access to Source", () => {
const html = renderUser("<div>" + "x".repeat(100_001) + "</div>");
assert.match(html, /markdown rendering is disabled/);
assert.match(html, /aria-label="Source"/);
assert.doesNotMatch(html, /x{100}/);
});

test("labels the target view consistently in all built-in languages", () => {
for (const [locale, source, render] of [["en", "Source", "Render"], ["zh-CN", "原文", "渲染"], ["zh-TW", "原文", "渲染"]]) {
const messages = getLocalePlugin(locale).messages;
assert.equal(messages["chat.messageSource"], source);
assert.equal(messages["chat.renderMessage"], render);
}
});
Loading
Loading