mirror of
				https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
				synced 2025-11-04 00:03:46 +08:00 
			
		
		
		
	Compare commits
	
		
			1 Commits
		
	
	
		
			3732a44983
			...
			feat/markd
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 
						 | 
					e3cbec30de | 
@@ -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: () => <LoadingIcon />,
 | 
			
		||||
});
 | 
			
		||||
// const Markdown = dynamic(async () => (await import("./markdown")).Markdown, {
 | 
			
		||||
//   loading: () => <LoadingIcon />,
 | 
			
		||||
// });
 | 
			
		||||
 | 
			
		||||
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 && (
 | 
			
		||||
                              <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 }) {
 | 
			
		||||
  const escapedContent = useMemo(() => {
 | 
			
		||||
    return tryWrapHtmlCode(escapeBrackets(props.content));
 | 
			
		||||
@@ -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
 | 
			
		||||
@@ -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} />
 | 
			
		||||
      )}
 | 
			
		||||
 
 | 
			
		||||
@@ -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;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user