From e3cbec30dedd349a84def7e5ea282bfe82c5a407 Mon Sep 17 00:00:00 2001 From: Kadxy <2230318258@qq.com> Date: Sat, 5 Apr 2025 12:56:52 +0800 Subject: [PATCH] refactor: clean up chat and markdown components, improve markdown rendering with lazy loading and streaming support --- app/components/chat.tsx | 12 +-- app/components/markdown.tsx | 166 ++++++++++++++++++++++++++++++++++++ app/styles/markdown.scss | 115 +++++++++++++++++++++++-- 3 files changed, 281 insertions(+), 12 deletions(-) diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 6691403e6..14a1f9659 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -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"; @@ -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"; @@ -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: () => , -}); +// const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { +// loading: () => , +// }); const MCPAction = () => { const navigate = useNavigate(); @@ -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 && ( { + 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(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 ( +
+ {!isLoaded ? ( +
{previewContent}
+ ) : ( + <_MarkDownContent content={content} /> + )} +
+ ); +} + +// 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(null); + + return ( +
+ {paragraphs.map((paragraph, index) => ( +
+ +
+ ))} +
+ ); +} + function _MarkDownContent(props: { content: string }) { const escapedContent = useMemo(() => { return tryWrapHtmlCode(escapeBrackets(props.content)); @@ -326,9 +456,27 @@ export function Markdown( fontFamily?: string; parentRef?: RefObject; defaultShow?: boolean; + immediatelyRender?: boolean; + streaming?: boolean; // Whether this is a streaming response } & React.DOMAttributes, ) { const mdRef = useRef(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 (
{props.loading ? ( + ) : props.streaming ? ( + // Use specialized component for streaming content + + ) : shouldAsyncRender ? ( +
+ {paragraphs.map((paragraph, index) => ( + setLoadedCount((prev) => prev + 1)} + /> + ))} + {loadedCount < paragraphs.length && loadedCount > 0 && ( +
+ +
+ )} +
) : ( )} diff --git a/app/styles/markdown.scss b/app/styles/markdown.scss index 672167d4d..c0ca8d34d 100644 --- a/app/styles/markdown.scss +++ b/app/styles/markdown.scss @@ -99,6 +99,7 @@ font-size: 14px; line-height: 1.5; word-wrap: break-word; + margin-bottom: 0; } .light { @@ -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; @@ -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; } @@ -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; + } +}