Fix: handle unpaired <> in messages (#17983)

This commit is contained in:
Lynn
2026-08-07 16:10:30 +08:00
committed by GitHub
parent c7d78db9b4
commit 4044d3bc5d
4 changed files with 92 additions and 18 deletions

View File

@@ -21,11 +21,13 @@ import { useFetchDocumentThumbnailsByIds } from '@/hooks/use-document-request';
import { useLoadingPause } from '@/hooks/use-loading-pause';
import {
currentReg,
escapeUnmatchedAngleBrackets,
parseCitationIndex,
preprocessLaTeX,
replaceRetrievingToSection,
replaceTextByOldReg,
replaceThinkToSection,
unescapeAngleBrackets,
} from '@/utils/chat';
import classNames from 'classnames';
import { omit } from 'lodash';
@@ -65,7 +67,11 @@ const MarkdownContent = ({
const { setDocumentIds, data: fileThumbnails } =
useFetchDocumentThumbnailsByIds();
const contentWithCursor = useMemo(() => {
let text = DOMPurify.sanitize(content, {
// Escape standalone < and > outside matched <...> tags
// so DOMPurify doesn't strip them as HTML.
const safeContent = escapeUnmatchedAngleBrackets(content);
let text = DOMPurify.sanitize(safeContent, {
ADD_TAGS: ['think', 'section', 'details', 'summary', 'retrieving'],
ADD_ATTR: ['class'],
});
@@ -78,11 +84,13 @@ const MarkdownContent = ({
const thinkSummary = loading
? `${t('chat.thinking')}...`
: t('chat.thought');
return pipe(
(value: string) => replaceThinkToSection(value, thinkSummary),
replaceRetrievingToSection,
preprocessLaTeX,
)(nextText);
return unescapeAngleBrackets(
pipe(
(value: string) => replaceThinkToSection(value, thinkSummary),
replaceRetrievingToSection,
preprocessLaTeX,
)(nextText),
);
}, [content, loading, t]);
useEffect(() => {

View File

@@ -19,11 +19,13 @@ import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for
import {
currentReg,
escapeUnmatchedAngleBrackets,
parseCitationIndex,
preprocessLaTeX,
replaceRetrievingToSection,
replaceTextByOldReg,
replaceThinkToSection,
unescapeAngleBrackets,
} from '@/utils/chat';
import { citationMarkerReg } from '@/utils/citation-utils';
import { getDirAttribute } from '@/utils/text-direction';
@@ -172,7 +174,11 @@ function MarkdownContent({
const { setDocumentIds, data: fileThumbnails } =
useFetchDocumentThumbnailsByIds();
const contentWithCursor = useMemo(() => {
let text = DOMPurify.sanitize(content, {
// Escape standalone < and > outside matched <...> tags
// so DOMPurify doesn't strip them as HTML.
const safeContent = escapeUnmatchedAngleBrackets(content);
let text = DOMPurify.sanitize(safeContent, {
ADD_TAGS: ['think', 'section', 'details', 'summary', 'retrieving'],
ADD_ATTR: ['class'],
});
@@ -184,11 +190,13 @@ function MarkdownContent({
const thinkSummary = loading
? `${t('chat.thinking')}...`
: t('chat.thought');
return pipe(
(value: string) => replaceThinkToSection(value, thinkSummary),
replaceRetrievingToSection,
preprocessLaTeX,
)(nextText);
return unescapeAngleBrackets(
pipe(
(value: string) => replaceThinkToSection(value, thinkSummary),
replaceRetrievingToSection,
preprocessLaTeX,
)(nextText),
);
}, [content, loading, t]);
useEffect(() => {

View File

@@ -17,11 +17,13 @@ import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for
import {
currentReg,
escapeUnmatchedAngleBrackets,
parseCitationIndex,
preprocessLaTeX,
replaceRetrievingToSection,
replaceTextByOldReg,
replaceThinkToSection,
unescapeAngleBrackets,
} from '@/utils/chat';
import { citationMarkerReg } from '@/utils/citation-utils';
import { getDirAttribute } from '@/utils/text-direction';
@@ -66,7 +68,11 @@ const MarkdownContent = ({
const { setDocumentIds, data: fileThumbnails } =
useFetchDocumentThumbnailsByIds();
const contentWithCursor = useMemo(() => {
let text = DOMPurify.sanitize(content, {
// Escape standalone < and > outside matched <...> tags
// so DOMPurify doesn't strip them as HTML.
const safeContent = escapeUnmatchedAngleBrackets(content);
let text = DOMPurify.sanitize(safeContent, {
ADD_TAGS: ['think', 'section', 'details', 'summary', 'retrieving'],
ADD_ATTR: ['class'],
});
@@ -75,11 +81,13 @@ const MarkdownContent = ({
text = t('chat.searching');
}
const nextText = replaceTextByOldReg(text);
return pipe(
replaceThinkToSection,
replaceRetrievingToSection,
preprocessLaTeX,
)(nextText);
return unescapeAngleBrackets(
pipe(
replaceThinkToSection,
replaceRetrievingToSection,
preprocessLaTeX,
)(nextText),
);
}, [content, t]);
useEffect(() => {

View File

@@ -103,6 +103,56 @@ export function replaceRetrievingToSection(text: string = '') {
return result;
}
// Placeholder markers used internally to protect standalone < and > from
// DOMPurify stripping. These Unicode symbols (U+27E8/U+27E9) are extremely
// unlikely to appear in normal user input.
const LT_MARKER = '\u27E8LT\u27E9';
const GT_MARKER = '\u27E8GT\u27E9';
/**
* Escape standalone < and > that are NOT part of a matched <...> pair,
* so that DOMPurify won't strip them as HTML tags.
* Only brackets inside text segments (outside complete tags) are escaped;
* matched <...> tags are left intact for DOMPurify to handle.
*/
export function escapeUnmatchedAngleBrackets(content: string): string {
if (!content) return content;
const segments: string[] = [];
const tags: string[] = [];
let lastIndex = 0;
const regex = /<[^>]*>/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
segments.push(content.slice(lastIndex, match.index));
tags.push(match[0]);
lastIndex = regex.lastIndex;
}
segments.push(content.slice(lastIndex));
const escapedSegments = segments.map((seg) =>
seg.replace(/</g, LT_MARKER).replace(/>/g, GT_MARKER),
);
return escapedSegments
.map((seg, i) => (i < tags.length ? seg + tags[i] : seg))
.join('');
}
/**
* Restore escaped angle bracket markers back to HTML entities (&lt;/&gt;).
* Must be called *after* preprocessLaTeX (which would otherwise convert
* &lt;/&gt; back to raw <, >).
*/
export function unescapeAngleBrackets(content: string): string {
if (!content) return content;
return content
.replace(new RegExp(LT_MARKER, 'g'), '&lt;')
.replace(new RegExp(GT_MARKER, 'g'), '&gt;');
}
export function setInitialChatVariableEnabledFieldValue(
field: ChatVariableEnabledField,
) {