mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-08-01 10:56:02 +00:00
fix(frontend): improve chat math rendering (#3557)
* fix(frontend): improve chat math rendering * fix(frontend): refine math rendering stability * fix(frontend): address review feedback on math preprocessing - Fix escaped-backslash blind spot: consume \\\\ as a unit so \\( and \\[ are not mis-interpreted as math delimiters when the backslash itself is escaped. - Thread inInlineCode state across lines so multi-line backtick code spans are protected from delimiter conversion. - Remove double math normalization: preprocessStreamdownMarkdown now only handles Mermaid; math normalization lives solely in ClipboardSafeStreamdown, preventing non-idempotent double passes. - Wire parseIncompleteMarkdown to isLoading in MarkdownContent so Streamdown's streaming-incomplete-math handling is actually active during streaming. * fix(frontend): address streamdown math review issues * refactor(frontend): move streamdown preprocessing out of ai elements
This commit is contained in:
parent
debb0fd161
commit
4a8f94eb28
@ -1,5 +1,5 @@
|
|||||||
import "@/styles/globals.css";
|
|
||||||
import "katex/dist/katex.min.css";
|
import "katex/dist/katex.min.css";
|
||||||
|
import "@/styles/globals.css";
|
||||||
|
|
||||||
import { type Metadata } from "next";
|
import { type Metadata } from "next";
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { Component, useMemo, type ComponentProps, type ReactNode } from "react";
|
import { Component, type ComponentProps, type ReactNode } from "react";
|
||||||
import { Streamdown } from "streamdown";
|
import { Streamdown } from "streamdown";
|
||||||
|
|
||||||
import { installClipboardFallback } from "@/core/clipboard";
|
import { installClipboardFallback } from "@/core/clipboard";
|
||||||
import { capMarkdownNesting } from "@/core/streamdown/preprocess";
|
|
||||||
|
|
||||||
export type ClipboardSafeStreamdownProps = ComponentProps<typeof Streamdown>;
|
export type ClipboardSafeStreamdownProps = ComponentProps<typeof Streamdown>;
|
||||||
|
|
||||||
@ -58,19 +57,9 @@ export function ClipboardSafeStreamdown({
|
|||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: ClipboardSafeStreamdownProps) {
|
}: ClipboardSafeStreamdownProps) {
|
||||||
// Fast path for the dominant pathological inputs (deep ">" chains and deeply
|
|
||||||
// nested lists both blow up marked's recursive tokenizers) so the error
|
|
||||||
// boundary below rarely has to absorb a stack overflow — and never has to
|
|
||||||
// face the heap exhaustion the same lists cause on larger stacks, which it
|
|
||||||
// cannot catch.
|
|
||||||
const safeChildren = useMemo(
|
|
||||||
() =>
|
|
||||||
typeof children === "string" ? capMarkdownNesting(children) : children,
|
|
||||||
[children],
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<StreamdownFallbackBoundary raw={children}>
|
<StreamdownFallbackBoundary raw={children}>
|
||||||
<Streamdown {...props}>{safeChildren}</Streamdown>
|
<Streamdown {...props}>{children}</Streamdown>
|
||||||
</StreamdownFallbackBoundary>
|
</StreamdownFallbackBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,7 +19,6 @@ import {
|
|||||||
ArtifactHeader,
|
ArtifactHeader,
|
||||||
ArtifactTitle,
|
ArtifactTitle,
|
||||||
} from "@/components/ai-elements/artifact";
|
} from "@/components/ai-elements/artifact";
|
||||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Select, SelectItem } from "@/components/ui/select";
|
import { Select, SelectItem } from "@/components/ui/select";
|
||||||
import {
|
import {
|
||||||
@ -44,6 +43,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
|||||||
import { findToolCallResult } from "@/core/messages/utils";
|
import { findToolCallResult } from "@/core/messages/utils";
|
||||||
import { installSkill } from "@/core/skills/api";
|
import { installSkill } from "@/core/skills/api";
|
||||||
import { streamdownPlugins } from "@/core/streamdown";
|
import { streamdownPlugins } from "@/core/streamdown";
|
||||||
|
import { SafeStreamdown } from "@/core/streamdown/components";
|
||||||
import {
|
import {
|
||||||
canBrowserPreviewFile,
|
canBrowserPreviewFile,
|
||||||
checkCodeFile,
|
checkCodeFile,
|
||||||
@ -462,13 +462,13 @@ export function ArtifactFilePreview({
|
|||||||
if (language === "markdown") {
|
if (language === "markdown") {
|
||||||
return (
|
return (
|
||||||
<div className="size-full px-4">
|
<div className="size-full px-4">
|
||||||
<ClipboardSafeStreamdown
|
<SafeStreamdown
|
||||||
className="size-full"
|
className="size-full"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
components={{ a: ArtifactLink }}
|
components={{ a: ArtifactLink }}
|
||||||
>
|
>
|
||||||
{content ?? ""}
|
{content ?? ""}
|
||||||
</ClipboardSafeStreamdown>
|
</SafeStreamdown>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,12 @@
|
|||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import type { AnchorHTMLAttributes } from "react";
|
import type { AnchorHTMLAttributes } from "react";
|
||||||
|
|
||||||
import {
|
import { type ClipboardSafeStreamdownProps } from "@/components/ai-elements/streamdown";
|
||||||
MessageResponse,
|
|
||||||
type MessageResponseProps,
|
|
||||||
} from "@/components/ai-elements/message";
|
|
||||||
import {
|
import {
|
||||||
preprocessStreamdownMarkdown,
|
preprocessStreamdownMarkdown,
|
||||||
streamdownPlugins,
|
streamdownPluginsWithoutRawHtml,
|
||||||
} from "@/core/streamdown";
|
} from "@/core/streamdown";
|
||||||
|
import { SafeMessageResponse } from "@/core/streamdown/components";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { CitationLink } from "../citations/citation-link";
|
import { CitationLink } from "../citations/citation-link";
|
||||||
@ -22,24 +20,30 @@ function isExternalUrl(href: string | undefined): boolean {
|
|||||||
export type MarkdownContentProps = {
|
export type MarkdownContentProps = {
|
||||||
content: string;
|
content: string;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
rehypePlugins: MessageResponseProps["rehypePlugins"];
|
rehypePlugins?: ClipboardSafeStreamdownProps["rehypePlugins"];
|
||||||
className?: string;
|
className?: string;
|
||||||
remarkPlugins?: MessageResponseProps["remarkPlugins"];
|
remarkPlugins?: ClipboardSafeStreamdownProps["remarkPlugins"];
|
||||||
components?: MessageResponseProps["components"];
|
components?: ClipboardSafeStreamdownProps["components"];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Renders markdown content. */
|
/** Renders markdown content. */
|
||||||
export function MarkdownContent({
|
export function MarkdownContent({
|
||||||
content,
|
content,
|
||||||
|
isLoading,
|
||||||
rehypePlugins,
|
rehypePlugins,
|
||||||
className,
|
className,
|
||||||
remarkPlugins = streamdownPlugins.remarkPlugins,
|
remarkPlugins = streamdownPluginsWithoutRawHtml.remarkPlugins,
|
||||||
components: componentsFromProps,
|
components: componentsFromProps,
|
||||||
}: MarkdownContentProps) {
|
}: MarkdownContentProps) {
|
||||||
const normalizedContent = useMemo(
|
const normalizedContent = useMemo(
|
||||||
() => preprocessStreamdownMarkdown(content),
|
() => preprocessStreamdownMarkdown(content),
|
||||||
[content],
|
[content],
|
||||||
);
|
);
|
||||||
|
const effectiveRehypePlugins = useMemo(() => {
|
||||||
|
const base = streamdownPluginsWithoutRawHtml.rehypePlugins ?? [];
|
||||||
|
const extra = rehypePlugins ?? [];
|
||||||
|
return [...base, ...extra] as ClipboardSafeStreamdownProps["rehypePlugins"];
|
||||||
|
}, [rehypePlugins]);
|
||||||
const components = useMemo(() => {
|
const components = useMemo(() => {
|
||||||
return {
|
return {
|
||||||
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
a: (props: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||||
@ -71,13 +75,14 @@ export function MarkdownContent({
|
|||||||
if (!content) return null;
|
if (!content) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MessageResponse
|
<SafeMessageResponse
|
||||||
className={className}
|
className={className}
|
||||||
remarkPlugins={remarkPlugins}
|
remarkPlugins={remarkPlugins}
|
||||||
rehypePlugins={rehypePlugins}
|
rehypePlugins={effectiveRehypePlugins}
|
||||||
components={components}
|
components={components}
|
||||||
|
parseIncompleteMarkdown={isLoading}
|
||||||
>
|
>
|
||||||
{normalizedContent}
|
{normalizedContent}
|
||||||
</MessageResponse>
|
</SafeMessageResponse>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import {
|
|||||||
type AnchorHTMLAttributes,
|
type AnchorHTMLAttributes,
|
||||||
type ImgHTMLAttributes,
|
type ImgHTMLAttributes,
|
||||||
} from "react";
|
} from "react";
|
||||||
import rehypeKatex from "rehype-katex";
|
|
||||||
|
|
||||||
import { Loader } from "@/components/ai-elements/loader";
|
import { Loader } from "@/components/ai-elements/loader";
|
||||||
import {
|
import {
|
||||||
@ -24,7 +23,6 @@ import {
|
|||||||
} from "@/components/ai-elements/message";
|
} from "@/components/ai-elements/message";
|
||||||
import {
|
import {
|
||||||
Reasoning,
|
Reasoning,
|
||||||
ReasoningContent,
|
|
||||||
ReasoningTrigger,
|
ReasoningTrigger,
|
||||||
} from "@/components/ai-elements/reasoning";
|
} from "@/components/ai-elements/reasoning";
|
||||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||||
@ -44,6 +42,7 @@ import {
|
|||||||
type FileInMessage,
|
type FileInMessage,
|
||||||
} from "@/core/messages/utils";
|
} from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
|
import { SafeReasoningContent } from "@/core/streamdown/components";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
import { CopyButton } from "../copy-button";
|
import { CopyButton } from "../copy-button";
|
||||||
@ -347,7 +346,7 @@ function MessageContent_({
|
|||||||
onTurnDurationChange={handleDurationChange}
|
onTurnDurationChange={handleDurationChange}
|
||||||
>
|
>
|
||||||
<ReasoningTrigger />
|
<ReasoningTrigger />
|
||||||
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
<SafeReasoningContent>{reasoningContent}</SafeReasoningContent>
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
</AIElementMessageContent>
|
</AIElementMessageContent>
|
||||||
);
|
);
|
||||||
@ -390,14 +389,14 @@ function MessageContent_({
|
|||||||
>
|
>
|
||||||
<ReasoningTrigger hasContent={!!reasoningContent} />
|
<ReasoningTrigger hasContent={!!reasoningContent} />
|
||||||
{reasoningContent && (
|
{reasoningContent && (
|
||||||
<ReasoningContent>{reasoningContent}</ReasoningContent>
|
<SafeReasoningContent>{reasoningContent}</SafeReasoningContent>
|
||||||
)}
|
)}
|
||||||
</Reasoning>
|
</Reasoning>
|
||||||
)}
|
)}
|
||||||
<MarkdownContent
|
<MarkdownContent
|
||||||
content={contentToDisplay}
|
content={contentToDisplay}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
rehypePlugins={[...rehypePlugins, [rehypeKatex, { output: "html" }]]}
|
rehypePlugins={rehypePlugins}
|
||||||
className="my-3"
|
className="my-3"
|
||||||
components={components}
|
components={components}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -13,13 +13,13 @@ import {
|
|||||||
ChainOfThoughtStep,
|
ChainOfThoughtStep,
|
||||||
} from "@/components/ai-elements/chain-of-thought";
|
} from "@/components/ai-elements/chain-of-thought";
|
||||||
import { Shimmer } from "@/components/ai-elements/shimmer";
|
import { Shimmer } from "@/components/ai-elements/shimmer";
|
||||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ShineBorder } from "@/components/ui/shine-border";
|
import { ShineBorder } from "@/components/ui/shine-border";
|
||||||
import { useI18n } from "@/core/i18n/hooks";
|
import { useI18n } from "@/core/i18n/hooks";
|
||||||
import { hasToolCalls } from "@/core/messages/utils";
|
import { hasToolCalls } from "@/core/messages/utils";
|
||||||
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
|
||||||
import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
|
import { streamdownPluginsWithWordAnimation } from "@/core/streamdown";
|
||||||
|
import { SafeStreamdown } from "@/core/streamdown/components";
|
||||||
import { useSubtask } from "@/core/tasks/context";
|
import { useSubtask } from "@/core/tasks/context";
|
||||||
import { explainLastToolCall } from "@/core/tools/utils";
|
import { explainLastToolCall } from "@/core/tools/utils";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@ -126,12 +126,12 @@ export function SubtaskCard({
|
|||||||
{task.prompt && (
|
{task.prompt && (
|
||||||
<ChainOfThoughtStep
|
<ChainOfThoughtStep
|
||||||
label={
|
label={
|
||||||
<ClipboardSafeStreamdown
|
<SafeStreamdown
|
||||||
{...streamdownPluginsWithWordAnimation}
|
{...streamdownPluginsWithWordAnimation}
|
||||||
components={{ a: CitationLink }}
|
components={{ a: CitationLink }}
|
||||||
>
|
>
|
||||||
{task.prompt}
|
{task.prompt}
|
||||||
</ClipboardSafeStreamdown>
|
</SafeStreamdown>
|
||||||
}
|
}
|
||||||
></ChainOfThoughtStep>
|
></ChainOfThoughtStep>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
import { SafeStreamdown } from "@/core/streamdown/components";
|
||||||
|
|
||||||
import { aboutMarkdown } from "./about-content";
|
import { aboutMarkdown } from "./about-content";
|
||||||
|
|
||||||
export function AboutSettingsPage() {
|
export function AboutSettingsPage() {
|
||||||
return <ClipboardSafeStreamdown>{aboutMarkdown}</ClipboardSafeStreamdown>;
|
return <SafeStreamdown>{aboutMarkdown}</SafeStreamdown>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,6 @@ import Link from "next/link";
|
|||||||
import { useDeferredValue, useId, useRef, useState } from "react";
|
import { useDeferredValue, useId, useRef, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
|
||||||
import { ClipboardSafeStreamdown } from "@/components/ai-elements/streamdown";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@ -39,6 +38,7 @@ import type {
|
|||||||
MemoryFactPatchInput,
|
MemoryFactPatchInput,
|
||||||
UserMemory,
|
UserMemory,
|
||||||
} from "@/core/memory/types";
|
} from "@/core/memory/types";
|
||||||
|
import { SafeStreamdown } from "@/core/streamdown/components";
|
||||||
import { streamdownPlugins } from "@/core/streamdown/plugins";
|
import { streamdownPlugins } from "@/core/streamdown/plugins";
|
||||||
import { pathOfThread } from "@/core/threads/utils";
|
import { pathOfThread } from "@/core/threads/utils";
|
||||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||||
@ -639,12 +639,12 @@ export function MemorySettingsPage() {
|
|||||||
<div className="text-muted-foreground mb-4 text-sm">
|
<div className="text-muted-foreground mb-4 text-sm">
|
||||||
{summaryReadOnly}
|
{summaryReadOnly}
|
||||||
</div>
|
</div>
|
||||||
<ClipboardSafeStreamdown
|
<SafeStreamdown
|
||||||
className="size-full min-w-0 [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
className="size-full min-w-0 [overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0"
|
||||||
{...streamdownPlugins}
|
{...streamdownPlugins}
|
||||||
>
|
>
|
||||||
{summariesToMarkdown(memory, filteredSectionGroups, t)}
|
{summariesToMarkdown(memory, filteredSectionGroups, t)}
|
||||||
</ClipboardSafeStreamdown>
|
</SafeStreamdown>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|||||||
48
frontend/src/core/streamdown/components.tsx
Normal file
48
frontend/src/core/streamdown/components.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
MessageResponse,
|
||||||
|
type MessageResponseProps,
|
||||||
|
} from "@/components/ai-elements/message";
|
||||||
|
import {
|
||||||
|
ReasoningContent,
|
||||||
|
type ReasoningContentProps,
|
||||||
|
} from "@/components/ai-elements/reasoning";
|
||||||
|
import {
|
||||||
|
ClipboardSafeStreamdown,
|
||||||
|
type ClipboardSafeStreamdownProps,
|
||||||
|
} from "@/components/ai-elements/streamdown";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useSafeStreamdownChildren,
|
||||||
|
useSafeStreamdownMarkdown,
|
||||||
|
} from "./safe-children";
|
||||||
|
|
||||||
|
export function SafeStreamdown({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ClipboardSafeStreamdownProps) {
|
||||||
|
const safeChildren = useSafeStreamdownChildren(children);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ClipboardSafeStreamdown {...props}>{safeChildren}</ClipboardSafeStreamdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SafeMessageResponse({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: MessageResponseProps) {
|
||||||
|
const safeChildren = useSafeStreamdownChildren(children);
|
||||||
|
|
||||||
|
return <MessageResponse {...props}>{safeChildren}</MessageResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SafeReasoningContent({
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ReasoningContentProps) {
|
||||||
|
const safeChildren = useSafeStreamdownMarkdown(children);
|
||||||
|
|
||||||
|
return <ReasoningContent {...props}>{safeChildren}</ReasoningContent>;
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
export * from "./components";
|
||||||
export * from "./mermaid";
|
export * from "./mermaid";
|
||||||
export * from "./preprocess";
|
export * from "./preprocess";
|
||||||
export * from "./plugins";
|
export * from "./plugins";
|
||||||
|
export * from "./safe-children";
|
||||||
|
|||||||
@ -6,6 +6,12 @@ import type { StreamdownProps } from "streamdown";
|
|||||||
|
|
||||||
import { rehypeSplitWordsIntoSpans } from "../rehype";
|
import { rehypeSplitWordsIntoSpans } from "../rehype";
|
||||||
|
|
||||||
|
const katexOptions = {
|
||||||
|
output: "html",
|
||||||
|
throwOnError: false,
|
||||||
|
strict: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
export const streamdownPlugins = {
|
export const streamdownPlugins = {
|
||||||
remarkPlugins: [
|
remarkPlugins: [
|
||||||
remarkGfm,
|
remarkGfm,
|
||||||
@ -13,7 +19,7 @@ export const streamdownPlugins = {
|
|||||||
] as StreamdownProps["remarkPlugins"],
|
] as StreamdownProps["remarkPlugins"],
|
||||||
rehypePlugins: [
|
rehypePlugins: [
|
||||||
rehypeRaw,
|
rehypeRaw,
|
||||||
[rehypeKatex, { output: "html" }],
|
[rehypeKatex, katexOptions],
|
||||||
] as StreamdownProps["rehypePlugins"],
|
] as StreamdownProps["rehypePlugins"],
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -23,16 +29,18 @@ export const streamdownPluginsWithWordAnimation = {
|
|||||||
[remarkMath, { singleDollarTextMath: true }],
|
[remarkMath, { singleDollarTextMath: true }],
|
||||||
] as StreamdownProps["remarkPlugins"],
|
] as StreamdownProps["remarkPlugins"],
|
||||||
rehypePlugins: [
|
rehypePlugins: [
|
||||||
[rehypeKatex, { output: "html" }],
|
[rehypeKatex, katexOptions],
|
||||||
rehypeSplitWordsIntoSpans,
|
rehypeSplitWordsIntoSpans,
|
||||||
] as StreamdownProps["rehypePlugins"],
|
] as StreamdownProps["rehypePlugins"],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Plugins for reasoning/thinking content — derived from streamdownPlugins but without rehypeRaw,
|
export const streamdownPluginsWithoutRawHtml = {
|
||||||
// to prevent LLM-hallucinated HTML tags (e.g. <simd>) from being rendered as DOM elements.
|
|
||||||
export const reasoningPlugins = {
|
|
||||||
remarkPlugins: streamdownPlugins.remarkPlugins,
|
remarkPlugins: streamdownPlugins.remarkPlugins,
|
||||||
rehypePlugins: streamdownPlugins.rehypePlugins?.filter(
|
rehypePlugins: streamdownPlugins.rehypePlugins?.filter(
|
||||||
(p) => p !== rehypeRaw,
|
(p) => p !== rehypeRaw,
|
||||||
) as StreamdownProps["rehypePlugins"],
|
) as StreamdownProps["rehypePlugins"],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Plugins for reasoning/thinking content — derived from streamdownPlugins but without rehypeRaw,
|
||||||
|
// to prevent LLM-hallucinated HTML tags (e.g. <simd>) from being rendered as DOM elements.
|
||||||
|
export const reasoningPlugins = streamdownPluginsWithoutRawHtml;
|
||||||
|
|||||||
@ -100,6 +100,223 @@ export function capMarkdownNesting(markdown: string): string {
|
|||||||
return capListNesting(capBlockquoteNesting(markdown));
|
return capListNesting(capBlockquoteNesting(markdown));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type MathDelimiter = {
|
||||||
|
close: "\\)" | "\\]";
|
||||||
|
replacement: "$" | "$$";
|
||||||
|
};
|
||||||
|
|
||||||
|
type DelimiterState = {
|
||||||
|
openBlock: MathDelimiter | null;
|
||||||
|
inlineCodeDelimiterLength: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function consumeBacktickRun(line: string, index: number): number {
|
||||||
|
let runLength = 0;
|
||||||
|
while (line[index + runLength] === "`") {
|
||||||
|
runLength += 1;
|
||||||
|
}
|
||||||
|
return runLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
function convertLatexDelimitersInLine(
|
||||||
|
line: string,
|
||||||
|
state: DelimiterState,
|
||||||
|
): { line: string; state: DelimiterState } {
|
||||||
|
let result = "";
|
||||||
|
let i = 0;
|
||||||
|
let inlineCodeDelimiterLength = state.inlineCodeDelimiterLength;
|
||||||
|
let currentBlock = state.openBlock;
|
||||||
|
|
||||||
|
while (i < line.length) {
|
||||||
|
if (line[i] === "`") {
|
||||||
|
const runLength = consumeBacktickRun(line, i);
|
||||||
|
result += line.slice(i, i + runLength);
|
||||||
|
if (!currentBlock) {
|
||||||
|
if (inlineCodeDelimiterLength === null) {
|
||||||
|
inlineCodeDelimiterLength = runLength;
|
||||||
|
} else if (runLength === inlineCodeDelimiterLength) {
|
||||||
|
inlineCodeDelimiterLength = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i += runLength;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const two = line.slice(i, i + 2);
|
||||||
|
const inInlineCode = inlineCodeDelimiterLength !== null;
|
||||||
|
|
||||||
|
// Consume escaped backslash as a unit — `\\` is never part of a math
|
||||||
|
// delimiter, so skip past both characters to avoid the second `\` being
|
||||||
|
// mis-paired with a following `(` or `[`.
|
||||||
|
if (two === "\\\\" && !inInlineCode) {
|
||||||
|
result += two;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close an open math block
|
||||||
|
if (!inInlineCode && currentBlock?.close === two) {
|
||||||
|
result += currentBlock.replacement;
|
||||||
|
currentBlock = null;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open a new math block
|
||||||
|
if (!inInlineCode && !currentBlock && (two === "\\(" || two === "\\[")) {
|
||||||
|
const isDisplay = two === "\\[";
|
||||||
|
currentBlock = {
|
||||||
|
close: isDisplay ? "\\]" : "\\)",
|
||||||
|
replacement: isDisplay ? "$$" : "$",
|
||||||
|
};
|
||||||
|
result += currentBlock.replacement;
|
||||||
|
i += 2;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
result += line[i];
|
||||||
|
i += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
line: result,
|
||||||
|
state: { openBlock: currentBlock, inlineCodeDelimiterLength },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize common LLM LaTeX delimiters for remark-math.
|
||||||
|
*
|
||||||
|
* remark-math recognizes `$...$` and `$$...$$`, but many models output
|
||||||
|
* `\(...\)` and `\[...\]`. Convert those delimiters outside fenced/indented
|
||||||
|
* code so KaTeX can render equations without corrupting code blocks. The
|
||||||
|
* conversion is stateful across lines, because display math normally spans
|
||||||
|
* several lines:
|
||||||
|
*
|
||||||
|
* \[
|
||||||
|
* ...
|
||||||
|
* \]
|
||||||
|
*/
|
||||||
|
export function normalizeLatexMathDelimiters(markdown: string): string {
|
||||||
|
if (!/[\\][([\])]/.test(markdown)) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
let insideFence = false;
|
||||||
|
let mathState: DelimiterState = {
|
||||||
|
openBlock: null,
|
||||||
|
inlineCodeDelimiterLength: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
return markdown
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => {
|
||||||
|
if (CODE_FENCE_RE.test(line) && !mathState.openBlock) {
|
||||||
|
insideFence = !insideFence;
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
insideFence ||
|
||||||
|
(INDENTED_CODE_RE.test(line) && !mathState.openBlock)
|
||||||
|
) {
|
||||||
|
return line;
|
||||||
|
}
|
||||||
|
const converted = convertLatexDelimitersInLine(line, mathState);
|
||||||
|
mathState = converted.state;
|
||||||
|
return converted.line;
|
||||||
|
})
|
||||||
|
.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnescapedTexComment(line: string): boolean {
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
if (line[i] !== "%") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let backslashCount = 0;
|
||||||
|
for (let j = i - 1; j >= 0 && line[j] === "\\"; j--) {
|
||||||
|
backslashCount += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backslashCount % 2 === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenDisplayMathBody(lines: string[]): string[] {
|
||||||
|
if (lines.some(hasUnescapedTexComment)) {
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [lines.map((line) => line.trim()).join(" ")];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep complete display-math blocks atomic for Streamdown.
|
||||||
|
*
|
||||||
|
* Streamdown first splits Markdown into render blocks with marked, then runs
|
||||||
|
* react-markdown on each block. Multi-line `$$ ... $$` can be split before
|
||||||
|
* remark-math sees the matching delimiters, especially in long numbered
|
||||||
|
* responses. Compacting the content between the opening and closing `$$`
|
||||||
|
* preserves the LaTeX semantics (visual line breaks still come from `\\`,
|
||||||
|
* `aligned`, `matrix`, `cases`, etc.) while keeping the display-math block
|
||||||
|
* atomic for Streamdown's splitter.
|
||||||
|
*/
|
||||||
|
export function compactDisplayMathBlocks(markdown: string): string {
|
||||||
|
if (!markdown.includes("$$")) {
|
||||||
|
return markdown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const output: string[] = [];
|
||||||
|
let insideFence = false;
|
||||||
|
let mathLines: string[] | null = null;
|
||||||
|
|
||||||
|
for (const line of markdown.split("\n")) {
|
||||||
|
if (CODE_FENCE_RE.test(line) && mathLines === null) {
|
||||||
|
insideFence = !insideFence;
|
||||||
|
output.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (insideFence || (INDENTED_CODE_RE.test(line) && mathLines === null)) {
|
||||||
|
output.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.trim() === "$$") {
|
||||||
|
if (mathLines === null) {
|
||||||
|
mathLines = [];
|
||||||
|
} else {
|
||||||
|
const flattenedMathLines = flattenDisplayMathBody(mathLines);
|
||||||
|
output.push("$$", ...flattenedMathLines, "$$");
|
||||||
|
mathLines = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mathLines !== null) {
|
||||||
|
mathLines.push(line);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
output.push(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mathLines !== null) {
|
||||||
|
output.push("$$", ...mathLines);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeStreamdownMathMarkdown(markdown: string): string {
|
||||||
|
return compactDisplayMathBlocks(normalizeLatexMathDelimiters(markdown));
|
||||||
|
}
|
||||||
|
|
||||||
export function preprocessStreamdownMarkdown(markdown: string): string {
|
export function preprocessStreamdownMarkdown(markdown: string): string {
|
||||||
if (!MERMAID_BLOCK_HINT_RE.test(markdown) || !markdown.includes("-.->")) {
|
if (!MERMAID_BLOCK_HINT_RE.test(markdown) || !markdown.includes("-.->")) {
|
||||||
return markdown;
|
return markdown;
|
||||||
|
|||||||
34
frontend/src/core/streamdown/safe-children.ts
Normal file
34
frontend/src/core/streamdown/safe-children.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { useMemo } from "react";
|
||||||
|
import type { ComponentProps } from "react";
|
||||||
|
import { type Streamdown } from "streamdown";
|
||||||
|
|
||||||
|
import {
|
||||||
|
capMarkdownNesting,
|
||||||
|
normalizeStreamdownMathMarkdown,
|
||||||
|
} from "./preprocess";
|
||||||
|
|
||||||
|
type StreamdownChildren = ComponentProps<typeof Streamdown>["children"];
|
||||||
|
|
||||||
|
export function getSafeStreamdownMarkdown(markdown: string): string {
|
||||||
|
return normalizeStreamdownMathMarkdown(capMarkdownNesting(markdown));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSafeStreamdownChildren(
|
||||||
|
children: StreamdownChildren,
|
||||||
|
): StreamdownChildren {
|
||||||
|
if (typeof children !== "string") {
|
||||||
|
return children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return getSafeStreamdownMarkdown(children);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSafeStreamdownChildren(
|
||||||
|
children: StreamdownChildren,
|
||||||
|
): StreamdownChildren {
|
||||||
|
return useMemo(() => getSafeStreamdownChildren(children), [children]);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSafeStreamdownMarkdown(markdown: string): string {
|
||||||
|
return useMemo(() => getSafeStreamdownMarkdown(markdown), [markdown]);
|
||||||
|
}
|
||||||
@ -382,6 +382,41 @@
|
|||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
text-fill-color: transparent;
|
text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* KaTeX rendering polish for chat messages.
|
||||||
|
Keep this narrowly scoped to KaTeX classes so normal Markdown text is not
|
||||||
|
affected. Display equations can be wider than the chat column; horizontal
|
||||||
|
scrolling is safer than wrapping/corrupting matrices, aligned equations, or
|
||||||
|
long PGD/DDPM expressions. */
|
||||||
|
.katex {
|
||||||
|
font-size: 1.02em;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-rendering: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.katex-display {
|
||||||
|
max-width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 0.35rem 0.125rem 0.45rem;
|
||||||
|
margin: 0.85rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.katex-display > .katex {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: initial;
|
||||||
|
}
|
||||||
|
|
||||||
|
.katex-display::-webkit-scrollbar {
|
||||||
|
height: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.katex-display::-webkit-scrollbar-thumb {
|
||||||
|
background: color-mix(in oklch, var(--muted-foreground) 35%, transparent);
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import {
|
|||||||
capBlockquoteNesting,
|
capBlockquoteNesting,
|
||||||
capListNesting,
|
capListNesting,
|
||||||
capMarkdownNesting,
|
capMarkdownNesting,
|
||||||
|
compactDisplayMathBlocks,
|
||||||
|
normalizeStreamdownMathMarkdown,
|
||||||
preprocessStreamdownMarkdown,
|
preprocessStreamdownMarkdown,
|
||||||
} from "@/core/streamdown/preprocess";
|
} from "@/core/streamdown/preprocess";
|
||||||
|
|
||||||
@ -100,7 +102,148 @@ test("capMarkdownNesting caps both blockquote and list nesting", () => {
|
|||||||
expect(/^[ \t]*/.exec(lines[1]!)![0].length).toBe(200);
|
expect(/^[ \t]*/.exec(lines[1]!)![0].length).toBe(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("preprocessStreamdownMarkdown leaves non-mermaid content unchanged", () => {
|
test("normalizeStreamdownMathMarkdown converts inline math delimiters", () => {
|
||||||
const input = "just some text";
|
expect(
|
||||||
expect(preprocessStreamdownMarkdown(input)).toBe(input);
|
normalizeStreamdownMathMarkdown("Given \\(x\\), compute \\(x^2\\)."),
|
||||||
|
).toBe("Given $x$, compute $x^2$.");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeStreamdownMathMarkdown converts multiline display math delimiters", () => {
|
||||||
|
const input = [
|
||||||
|
"Before",
|
||||||
|
"\\[",
|
||||||
|
"\\begin{aligned}",
|
||||||
|
"x_t &= \\sqrt{\\bar{\\alpha}_t}x_0 + \\sqrt{1-\\bar{\\alpha}_t}\\epsilon, \\\\",
|
||||||
|
"\\hat{x}_0 &= x_t",
|
||||||
|
"\\end{aligned}",
|
||||||
|
"\\]",
|
||||||
|
"After",
|
||||||
|
].join("\n");
|
||||||
|
const expected = [
|
||||||
|
"Before",
|
||||||
|
"$$",
|
||||||
|
"\\begin{aligned} x_t &= \\sqrt{\\bar{\\alpha}_t}x_0 + \\sqrt{1-\\bar{\\alpha}_t}\\epsilon, \\\\ \\hat{x}_0 &= x_t \\end{aligned}",
|
||||||
|
"$$",
|
||||||
|
"After",
|
||||||
|
].join("\n");
|
||||||
|
expect(normalizeStreamdownMathMarkdown(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeStreamdownMathMarkdown leaves fenced and indented code untouched", () => {
|
||||||
|
const input = [
|
||||||
|
"Text \\(x\\)",
|
||||||
|
"```tex",
|
||||||
|
"\\[",
|
||||||
|
"x^2",
|
||||||
|
"\\]",
|
||||||
|
"```",
|
||||||
|
" \\(literal\\)",
|
||||||
|
].join("\n");
|
||||||
|
const expected = [
|
||||||
|
"Text $x$",
|
||||||
|
"```tex",
|
||||||
|
"\\[",
|
||||||
|
"x^2",
|
||||||
|
"\\]",
|
||||||
|
"```",
|
||||||
|
" \\(literal\\)",
|
||||||
|
].join("\n");
|
||||||
|
expect(normalizeStreamdownMathMarkdown(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compactDisplayMathBlocks keeps display math as display math", () => {
|
||||||
|
const input = ["Before", "$$", "x", "=", "y", "$$", "After"].join("\n");
|
||||||
|
const expected = ["Before", "$$", "x = y", "$$", "After"].join("\n");
|
||||||
|
expect(compactDisplayMathBlocks(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compactDisplayMathBlocks preserves TeX comments in display math", () => {
|
||||||
|
const input = ["Before", "$$", "a % step 1", "+ b", "$$", "After"].join("\n");
|
||||||
|
expect(compactDisplayMathBlocks(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compactDisplayMathBlocks compacts escaped percent in display math", () => {
|
||||||
|
const input = ["Before", "$$", "a \\% step 1", "+ b", "$$", "After"].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
const expected = ["Before", "$$", "a \\% step 1 + b", "$$", "After"].join(
|
||||||
|
"\n",
|
||||||
|
);
|
||||||
|
expect(compactDisplayMathBlocks(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("compactDisplayMathBlocks leaves fenced code content untouched", () => {
|
||||||
|
const input = [
|
||||||
|
"```md",
|
||||||
|
"$$",
|
||||||
|
"x = y",
|
||||||
|
"$$",
|
||||||
|
"```",
|
||||||
|
"$$",
|
||||||
|
"a",
|
||||||
|
"=",
|
||||||
|
"b",
|
||||||
|
"$$",
|
||||||
|
].join("\n");
|
||||||
|
const expected = [
|
||||||
|
"```md",
|
||||||
|
"$$",
|
||||||
|
"x = y",
|
||||||
|
"$$",
|
||||||
|
"```",
|
||||||
|
"$$",
|
||||||
|
"a = b",
|
||||||
|
"$$",
|
||||||
|
].join("\n");
|
||||||
|
expect(compactDisplayMathBlocks(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preprocessStreamdownMarkdown applies only Mermaid fixes (not math)", () => {
|
||||||
|
const input = [
|
||||||
|
"Before \\(x\\)",
|
||||||
|
"```mermaid",
|
||||||
|
"graph TD",
|
||||||
|
" A -.-> B",
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
const expected = [
|
||||||
|
"Before \\(x\\)",
|
||||||
|
"```mermaid",
|
||||||
|
"graph TD",
|
||||||
|
" A -.-> B",
|
||||||
|
"```",
|
||||||
|
].join("\n");
|
||||||
|
expect(preprocessStreamdownMarkdown(input)).toBe(expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeStreamdownMathMarkdown preserves escaped backslash before parens", () => {
|
||||||
|
// When the backslash itself is escaped (\\), the following ( is not a math open
|
||||||
|
const input = "Use \\\\( to start inline math.";
|
||||||
|
expect(normalizeStreamdownMathMarkdown(input)).toBe(
|
||||||
|
"Use \\\\( to start inline math.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeStreamdownMathMarkdown preserves escaped backslash before brackets", () => {
|
||||||
|
const input = "Escape: \\\\[ is not math.";
|
||||||
|
expect(normalizeStreamdownMathMarkdown(input)).toBe(
|
||||||
|
"Escape: \\\\[ is not math.",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeStreamdownMathMarkdown preserves delimiters inside multi-line code spans", () => {
|
||||||
|
// A backtick code span opened on line 1 should protect line 2 content
|
||||||
|
const input = ["`code span", "with \\(x\\) inside`"].join("\n");
|
||||||
|
expect(normalizeStreamdownMathMarkdown(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeStreamdownMathMarkdown preserves delimiters inside multi-backtick code spans", () => {
|
||||||
|
const input = "Use ``\\(literal\\)`` here";
|
||||||
|
expect(normalizeStreamdownMathMarkdown(input)).toBe(input);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("normalizeStreamdownMathMarkdown requires matching backtick run to close code spans", () => {
|
||||||
|
const input = "Use ``\\(literal\\)` and still code`` then \\(x\\)";
|
||||||
|
const expected = "Use ``\\(literal\\)` and still code`` then $x$";
|
||||||
|
expect(normalizeStreamdownMathMarkdown(input)).toBe(expected);
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user