Skip to content

fix: markdown render #6428

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
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
12 changes: 6 additions & 6 deletions app/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import ReturnIcon from "../icons/return.svg";
import CopyIcon from "../icons/copy.svg";
import SpeakIcon from "../icons/speak.svg";
import SpeakStopIcon from "../icons/speak-stop.svg";
import LoadingIcon from "../icons/three-dots.svg";
import LoadingButtonIcon from "../icons/loading.svg";
import PromptIcon from "../icons/prompt.svg";
import MaskIcon from "../icons/mask.svg";
Expand Down Expand Up @@ -79,8 +78,6 @@ import {

import { uploadImage as uploadImageRemote } from "@/app/utils/chat";

import dynamic from "next/dynamic";

import { ChatControllerPool } from "../client/controller";
import { DalleQuality, DalleStyle, ModelSize } from "../typing";
import { Prompt, usePromptStore } from "../store/prompt";
Expand Down Expand Up @@ -125,14 +122,15 @@ import { getModelProvider } from "../utils/model";
import { RealtimeChat } from "@/app/components/realtime-chat";
import clsx from "clsx";
import { getAvailableClientsCount, isMcpEnabled } from "../mcp/actions";
import { Markdown } from "./markdown";

const localStorage = safeLocalStorage();

const ttsPlayer = createTTSPlayer();

const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
loading: () => <LoadingIcon />,
});
// const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
// loading: () => <LoadingIcon />,
// });

const MCPAction = () => {
const navigate = useNavigate();
Expand Down Expand Up @@ -1984,6 +1982,8 @@ function _Chat() {
fontFamily={fontFamily}
parentRef={scrollRef}
defaultShow={i >= messages.length - 6}
immediatelyRender={i >= messages.length - 3}
streaming={message.streaming}
/>
{getMessageImages(message).length == 1 && (
<img
Expand Down
166 changes: 166 additions & 0 deletions app/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,136 @@ function tryWrapHtmlCode(text: string) {
);
}

// Split content into paragraphs while preserving code blocks
function splitContentIntoParagraphs(content: string) {
// Check for unclosed code blocks
const codeBlockStartCount = (content.match(/```/g) || []).length;
let processedContent = content;

// Add closing tag if there's an odd number of code block markers
if (codeBlockStartCount % 2 !== 0) {
processedContent = content + "\n```";
}

// Extract code blocks
const codeBlockRegex = /```[\s\S]*?```/g;
const codeBlocks: string[] = [];
let codeBlockCounter = 0;

// Replace code blocks with placeholders
const contentWithPlaceholders = processedContent.replace(
codeBlockRegex,
(match) => {
codeBlocks.push(match);
const placeholder = `__CODE_BLOCK_${codeBlockCounter++}__`;
return placeholder;
},
);

// Split by double newlines
const paragraphs = contentWithPlaceholders
.split(/\n\n+/)
.filter((p) => p.trim());

// Restore code blocks
return paragraphs.map((p) => {
if (p.match(/__CODE_BLOCK_\d+__/)) {
return p.replace(/__CODE_BLOCK_\d+__/g, (match) => {
const index = parseInt(match.match(/\d+/)?.[0] || "0");
return codeBlocks[index] || match;
});
}
return p;
});
}

// Lazy-loaded paragraph component
function MarkdownParagraph({
content,
onLoad,
}: {
content: string;
onLoad?: () => void;
}) {
const [isLoaded, setIsLoaded] = useState(false);
const placeholderRef = useRef<HTMLDivElement>(null);
const [isVisible, setIsVisible] = useState(false);

useEffect(() => {
let observer: IntersectionObserver;
if (placeholderRef.current) {
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) {
setIsVisible(true);
}
},
{ threshold: 0.1, rootMargin: "200px 0px" },
);
observer.observe(placeholderRef.current);
}
return () => observer?.disconnect();
}, []);

useEffect(() => {
if (isVisible && !isLoaded) {
setIsLoaded(true);
onLoad?.();
}
}, [isVisible, isLoaded, onLoad]);

// Generate preview content
const previewContent = useMemo(() => {
if (content.startsWith("```")) {
return "```" + (content.split("\n")[0] || "").slice(3) + "...```";
}
return content.length > 60 ? content.slice(0, 60) + "..." : content;
}, [content]);

return (
<div className="markdown-paragraph" ref={placeholderRef}>
{!isLoaded ? (
<div className="markdown-paragraph-placeholder">{previewContent}</div>
) : (
<_MarkDownContent content={content} />
)}
</div>
);
}

// Memoized paragraph component to prevent unnecessary re-renders
const MemoizedMarkdownParagraph = React.memo(
({ content }: { content: string }) => {
return <_MarkDownContent content={content} />;
},
(prevProps, nextProps) => prevProps.content === nextProps.content,
);

MemoizedMarkdownParagraph.displayName = "MemoizedMarkdownParagraph";

// Specialized component for streaming content
function StreamingMarkdownContent({ content }: { content: string }) {
const paragraphs = useMemo(
() => splitContentIntoParagraphs(content),
[content],
);
const lastParagraphRef = useRef<HTMLDivElement>(null);

return (
<div className="markdown-streaming-content">
{paragraphs.map((paragraph, index) => (
<div
key={`p-${index}-${paragraph.substring(0, 20)}`}
className="markdown-paragraph markdown-streaming-paragraph"
ref={index === paragraphs.length - 1 ? lastParagraphRef : null}
>
<MemoizedMarkdownParagraph content={paragraph} />
</div>
))}
</div>
);
}

function _MarkDownContent(props: { content: string }) {
const escapedContent = useMemo(() => {
return tryWrapHtmlCode(escapeBrackets(props.content));
Expand Down Expand Up @@ -326,9 +456,27 @@ export function Markdown(
fontFamily?: string;
parentRef?: RefObject<HTMLDivElement>;
defaultShow?: boolean;
immediatelyRender?: boolean;
streaming?: boolean; // Whether this is a streaming response
} & React.DOMAttributes<HTMLDivElement>,
) {
const mdRef = useRef<HTMLDivElement>(null);
const paragraphs = useMemo(
() => splitContentIntoParagraphs(props.content),
[props.content],
);
const [loadedCount, setLoadedCount] = useState(0);

// Determine rendering strategy based on props
const shouldAsyncRender =
!props.immediatelyRender && !props.streaming && paragraphs.length > 1;

useEffect(() => {
// Immediately render all paragraphs if specified
if (props.immediatelyRender) {
setLoadedCount(paragraphs.length);
}
}, [props.immediatelyRender, paragraphs.length]);

return (
<div
Expand All @@ -344,6 +492,24 @@ export function Markdown(
>
{props.loading ? (
<LoadingIcon />
) : props.streaming ? (
// Use specialized component for streaming content
<StreamingMarkdownContent content={props.content} />
) : shouldAsyncRender ? (
<div className="markdown-content">
{paragraphs.map((paragraph, index) => (
<MarkdownParagraph
key={index}
content={paragraph}
onLoad={() => setLoadedCount((prev) => prev + 1)}
/>
))}
{loadedCount < paragraphs.length && loadedCount > 0 && (
<div className="markdown-paragraph-loading">
<LoadingIcon />
</div>
)}
</div>
) : (
<MarkdownContent content={props.content} />
)}
Expand Down
115 changes: 109 additions & 6 deletions app/styles/markdown.scss
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@
font-size: 14px;
line-height: 1.5;
word-wrap: break-word;
margin-bottom: 0;
}

.light {
Expand Down Expand Up @@ -358,8 +359,14 @@
.markdown-body kbd {
display: inline-block;
padding: 3px 5px;
font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,
Liberation Mono, monospace;
font:
11px ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
line-height: 10px;
color: var(--color-fg-default);
vertical-align: middle;
Expand Down Expand Up @@ -448,16 +455,28 @@
.markdown-body tt,
.markdown-body code,
.markdown-body samp {
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,
Liberation Mono, monospace;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 12px;
}

.markdown-body pre {
margin-top: 0;
margin-bottom: 0;
font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas,
Liberation Mono, monospace;
font-family:
ui-monospace,
SFMono-Regular,
SF Mono,
Menlo,
Consolas,
Liberation Mono,
monospace;
font-size: 12px;
word-wrap: normal;
}
Expand Down Expand Up @@ -1130,3 +1149,87 @@
#dmermaid {
display: none;
}

.markdown-content {
width: 100%;
}

.markdown-paragraph {
transition: opacity 0.3s ease;
margin-bottom: 0.5em;

&.markdown-paragraph-visible {
opacity: 1;
}

&.markdown-paragraph-hidden {
opacity: 0.7;
}
}

.markdown-paragraph-placeholder {
padding: 8px;
color: var(--color-fg-subtle);
background-color: var(--color-canvas-subtle);
border-radius: 6px;
border-left: 3px solid var(--color-border-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-family: var(--font-family-sans);
font-size: 14px;
min-height: 1.2em;
}

.markdown-paragraph-loading {
height: 20px;
background-color: var(--color-canvas-subtle);
border-radius: 6px;
margin-bottom: 8px;
position: relative;
overflow: hidden;

&::after {
content: "";
position: absolute;
top: 0;
left: 0;
width: 30%;
height: 100%;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
animation: shimmer 1.5s infinite;
}
}

@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(200%);
}
}

.markdown-streaming-content {
width: 100%;
}

.markdown-streaming-paragraph {
opacity: 1;
animation: fadeIn 0.3s ease-in-out;
margin-bottom: 0.5em;
}

@keyframes fadeIn {
from {
opacity: 0.5;
}
to {
opacity: 1;
}
}