mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-10-31 22:33:45 +08:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			58473d81d6
			...
			feat/markd
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | e3cbec30de | 
| @@ -18,7 +18,6 @@ import ReturnIcon from "../icons/return.svg"; | |||||||
| import CopyIcon from "../icons/copy.svg"; | import CopyIcon from "../icons/copy.svg"; | ||||||
| import SpeakIcon from "../icons/speak.svg"; | import SpeakIcon from "../icons/speak.svg"; | ||||||
| import SpeakStopIcon from "../icons/speak-stop.svg"; | import SpeakStopIcon from "../icons/speak-stop.svg"; | ||||||
| import LoadingIcon from "../icons/three-dots.svg"; |  | ||||||
| import LoadingButtonIcon from "../icons/loading.svg"; | import LoadingButtonIcon from "../icons/loading.svg"; | ||||||
| import PromptIcon from "../icons/prompt.svg"; | import PromptIcon from "../icons/prompt.svg"; | ||||||
| import MaskIcon from "../icons/mask.svg"; | import MaskIcon from "../icons/mask.svg"; | ||||||
| @@ -79,8 +78,6 @@ import { | |||||||
|  |  | ||||||
| import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; | import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; | ||||||
|  |  | ||||||
| import dynamic from "next/dynamic"; |  | ||||||
|  |  | ||||||
| import { ChatControllerPool } from "../client/controller"; | import { ChatControllerPool } from "../client/controller"; | ||||||
| import { DalleQuality, DalleStyle, ModelSize } from "../typing"; | import { DalleQuality, DalleStyle, ModelSize } from "../typing"; | ||||||
| import { Prompt, usePromptStore } from "../store/prompt"; | import { Prompt, usePromptStore } from "../store/prompt"; | ||||||
| @@ -125,14 +122,15 @@ import { getModelProvider } from "../utils/model"; | |||||||
| import { RealtimeChat } from "@/app/components/realtime-chat"; | import { RealtimeChat } from "@/app/components/realtime-chat"; | ||||||
| import clsx from "clsx"; | import clsx from "clsx"; | ||||||
| import { getAvailableClientsCount, isMcpEnabled } from "../mcp/actions"; | import { getAvailableClientsCount, isMcpEnabled } from "../mcp/actions"; | ||||||
|  | import { Markdown } from "./markdown"; | ||||||
|  |  | ||||||
| const localStorage = safeLocalStorage(); | const localStorage = safeLocalStorage(); | ||||||
|  |  | ||||||
| const ttsPlayer = createTTSPlayer(); | const ttsPlayer = createTTSPlayer(); | ||||||
|  |  | ||||||
| const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | // const Markdown = dynamic(async () => (await import("./markdown")).Markdown, { | ||||||
|   loading: () => <LoadingIcon />, | //   loading: () => <LoadingIcon />, | ||||||
| }); | // }); | ||||||
|  |  | ||||||
| const MCPAction = () => { | const MCPAction = () => { | ||||||
|   const navigate = useNavigate(); |   const navigate = useNavigate(); | ||||||
| @@ -1984,6 +1982,8 @@ function _Chat() { | |||||||
|                               fontFamily={fontFamily} |                               fontFamily={fontFamily} | ||||||
|                               parentRef={scrollRef} |                               parentRef={scrollRef} | ||||||
|                               defaultShow={i >= messages.length - 6} |                               defaultShow={i >= messages.length - 6} | ||||||
|  |                               immediatelyRender={i >= messages.length - 3} | ||||||
|  |                               streaming={message.streaming} | ||||||
|                             /> |                             /> | ||||||
|                             {getMessageImages(message).length == 1 && ( |                             {getMessageImages(message).length == 1 && ( | ||||||
|                               <img |                               <img | ||||||
|   | |||||||
| @@ -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 }) { | function _MarkDownContent(props: { content: string }) { | ||||||
|   const escapedContent = useMemo(() => { |   const escapedContent = useMemo(() => { | ||||||
|     return tryWrapHtmlCode(escapeBrackets(props.content)); |     return tryWrapHtmlCode(escapeBrackets(props.content)); | ||||||
| @@ -326,9 +456,27 @@ export function Markdown( | |||||||
|     fontFamily?: string; |     fontFamily?: string; | ||||||
|     parentRef?: RefObject<HTMLDivElement>; |     parentRef?: RefObject<HTMLDivElement>; | ||||||
|     defaultShow?: boolean; |     defaultShow?: boolean; | ||||||
|  |     immediatelyRender?: boolean; | ||||||
|  |     streaming?: boolean; // Whether this is a streaming response | ||||||
|   } & React.DOMAttributes<HTMLDivElement>, |   } & React.DOMAttributes<HTMLDivElement>, | ||||||
| ) { | ) { | ||||||
|   const mdRef = useRef<HTMLDivElement>(null); |   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 ( |   return ( | ||||||
|     <div |     <div | ||||||
| @@ -344,6 +492,24 @@ export function Markdown( | |||||||
|     > |     > | ||||||
|       {props.loading ? ( |       {props.loading ? ( | ||||||
|         <LoadingIcon /> |         <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} /> |         <MarkdownContent content={props.content} /> | ||||||
|       )} |       )} | ||||||
|   | |||||||
| @@ -99,6 +99,7 @@ | |||||||
|   font-size: 14px; |   font-size: 14px; | ||||||
|   line-height: 1.5; |   line-height: 1.5; | ||||||
|   word-wrap: break-word; |   word-wrap: break-word; | ||||||
|  |   margin-bottom: 0; | ||||||
| } | } | ||||||
|  |  | ||||||
| .light { | .light { | ||||||
| @@ -358,8 +359,14 @@ | |||||||
| .markdown-body kbd { | .markdown-body kbd { | ||||||
|   display: inline-block; |   display: inline-block; | ||||||
|   padding: 3px 5px; |   padding: 3px 5px; | ||||||
|   font: 11px ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, |   font: | ||||||
|     Liberation Mono, monospace; |     11px ui-monospace, | ||||||
|  |     SFMono-Regular, | ||||||
|  |     SF Mono, | ||||||
|  |     Menlo, | ||||||
|  |     Consolas, | ||||||
|  |     Liberation Mono, | ||||||
|  |     monospace; | ||||||
|   line-height: 10px; |   line-height: 10px; | ||||||
|   color: var(--color-fg-default); |   color: var(--color-fg-default); | ||||||
|   vertical-align: middle; |   vertical-align: middle; | ||||||
| @@ -448,16 +455,28 @@ | |||||||
| .markdown-body tt, | .markdown-body tt, | ||||||
| .markdown-body code, | .markdown-body code, | ||||||
| .markdown-body samp { | .markdown-body samp { | ||||||
|   font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, |   font-family: | ||||||
|     Liberation Mono, monospace; |     ui-monospace, | ||||||
|  |     SFMono-Regular, | ||||||
|  |     SF Mono, | ||||||
|  |     Menlo, | ||||||
|  |     Consolas, | ||||||
|  |     Liberation Mono, | ||||||
|  |     monospace; | ||||||
|   font-size: 12px; |   font-size: 12px; | ||||||
| } | } | ||||||
|  |  | ||||||
| .markdown-body pre { | .markdown-body pre { | ||||||
|   margin-top: 0; |   margin-top: 0; | ||||||
|   margin-bottom: 0; |   margin-bottom: 0; | ||||||
|   font-family: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, |   font-family: | ||||||
|     Liberation Mono, monospace; |     ui-monospace, | ||||||
|  |     SFMono-Regular, | ||||||
|  |     SF Mono, | ||||||
|  |     Menlo, | ||||||
|  |     Consolas, | ||||||
|  |     Liberation Mono, | ||||||
|  |     monospace; | ||||||
|   font-size: 12px; |   font-size: 12px; | ||||||
|   word-wrap: normal; |   word-wrap: normal; | ||||||
| } | } | ||||||
| @@ -1130,3 +1149,87 @@ | |||||||
| #dmermaid { | #dmermaid { | ||||||
|   display: none; |   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; | ||||||
|  |   } | ||||||
|  | } | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user