@@ -12,6 +12,7 @@ import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
1212// @ts -ignore
1313import { oneLight } from "react-syntax-highlighter/dist/esm/styles/prism" ;
1414import * as TooltipPrimitive from "@radix-ui/react-tooltip" ;
15+ import { visit } from "unist-util-visit" ;
1516
1617import { SearchResult } from "@/types/chat" ;
1718import {
@@ -54,6 +55,47 @@ const isVideoUrl = (url?: string): boolean => {
5455 return VIDEO_EXTENSIONS . includes ( extension ) ;
5556} ;
5657
58+ // extract block level elements from <p>
59+ const rehypeUnwrapMedia = ( ) => {
60+ return ( tree : any ) => {
61+ visit ( tree , "element" , ( node , index , parent ) => {
62+ // find <p> tags containing video or figure
63+ if ( node . tagName === "p" && node . children ) {
64+ const mediaChildIndex = node . children . findIndex (
65+ ( child : any ) =>
66+ child . tagName === "video" || child . tagName === "figure"
67+ ) ;
68+
69+ if ( mediaChildIndex !== - 1 ) {
70+ // extract media elements (video/figure)
71+ const mediaChild = node . children . splice ( mediaChildIndex , 1 ) [ 0 ] ;
72+
73+ // if <p> has other content after extraction, keep <p>; otherwise remove empty <p>
74+ if ( node . children . length === 0 ) {
75+ // replace original <p> node with media element
76+ if ( parent && index !== null ) {
77+ parent . children [ index as number ] = {
78+ tagName : "div" ,
79+ properties : { className : "markdown-media-container" } ,
80+ children : [ mediaChild ] ,
81+ } ;
82+ }
83+ } else {
84+ // if <p> has other content after extraction, keep <p>; otherwise remove empty <p>
85+ if ( parent && index !== null ) {
86+ parent . children . splice ( ( index as number ) + 1 , 0 , {
87+ tagName : "div" ,
88+ properties : { className : "markdown-media-container" } ,
89+ children : [ mediaChild ] ,
90+ } ) ;
91+ }
92+ }
93+ }
94+ }
95+ } ) ;
96+ } ;
97+ } ;
98+
5799// Get background color for different tool signs
58100const getBackgroundColor = ( toolSign : string ) => {
59101 switch ( toolSign ) {
@@ -381,6 +423,102 @@ const convertLatexDelimiters = (content: string): string => {
381423 ) ;
382424} ;
383425
426+ // Video component with error handling - defined outside to prevent re-creation on each render
427+ interface VideoWithErrorHandlingProps {
428+ src : string ;
429+ alt ?: string | null ;
430+ props ?: React . VideoHTMLAttributes < HTMLVideoElement > ;
431+ }
432+
433+ const VideoWithErrorHandling : React . FC < VideoWithErrorHandlingProps > = React . memo ( ( { src, alt, props = { } } ) => {
434+ const { t } = useTranslation ( "common" ) ;
435+ const [ hasError , setHasError ] = React . useState ( false ) ;
436+
437+ if ( hasError ) {
438+ return (
439+ < div className = "markdown-media-error" >
440+ < div className = "markdown-media-error-message" >
441+ { t ( "chatStreamMessage.videoLinkUnavailable" , {
442+ defaultValue : "This video link is unavailable" ,
443+ } ) }
444+ </ div >
445+ { alt && (
446+ < div className = "markdown-media-error-caption" > { alt } </ div >
447+ ) }
448+ </ div >
449+ ) ;
450+ }
451+
452+ return (
453+ < figure className = "markdown-video-wrapper" >
454+ < video
455+ className = "markdown-video"
456+ controls
457+ preload = "metadata"
458+ playsInline
459+ src = { src }
460+ onError = { ( ) => setHasError ( true ) }
461+ { ...props }
462+ >
463+ { t ( "chatStreamMessage.videoNotSupported" , {
464+ defaultValue : "Sorry, your browser does not support embedded videos." ,
465+ } ) }
466+ </ video >
467+ { alt ? (
468+ < figcaption className = "markdown-video-caption" > { alt } </ figcaption >
469+ ) : null }
470+ </ figure >
471+ ) ;
472+ } , ( prevProps , nextProps ) => {
473+ // Custom comparison function to prevent unnecessary re-renders
474+ // Only compare src and alt, props object reference may change but content is the same
475+ return prevProps . src === nextProps . src &&
476+ prevProps . alt === nextProps . alt ;
477+ } ) ;
478+
479+ VideoWithErrorHandling . displayName = "VideoWithErrorHandling" ;
480+
481+ // Image component with error handling - defined outside to prevent re-creation on each render
482+ interface ImageWithErrorHandlingProps {
483+ src : string ;
484+ alt ?: string | null ;
485+ }
486+
487+ const ImageWithErrorHandling : React . FC < ImageWithErrorHandlingProps > = React . memo ( ( { src, alt } ) => {
488+ const { t } = useTranslation ( "common" ) ;
489+ const [ hasError , setHasError ] = React . useState ( false ) ;
490+
491+ if ( hasError ) {
492+ return (
493+ < div className = "markdown-media-error" >
494+ < div className = "markdown-media-error-message" >
495+ { t ( "chatStreamMessage.imageLinkUnavailable" , {
496+ defaultValue : "This image link is unavailable" ,
497+ } ) }
498+ </ div >
499+ { alt && (
500+ < div className = "markdown-media-error-caption" > { alt } </ div >
501+ ) }
502+ </ div >
503+ ) ;
504+ }
505+
506+ return (
507+ < img
508+ src = { src }
509+ alt = { alt ?? undefined }
510+ className = "markdown-img"
511+ onError = { ( ) => setHasError ( true ) }
512+ />
513+ ) ;
514+ } , ( prevProps , nextProps ) => {
515+ // Custom comparison function to prevent unnecessary re-renders
516+ return prevProps . src === nextProps . src &&
517+ prevProps . alt === nextProps . alt ;
518+ } ) ;
519+
520+ ImageWithErrorHandling . displayName = "ImageWithErrorHandling" ;
521+
384522export const MarkdownRenderer : React . FC < MarkdownRendererProps > = ( {
385523 content,
386524 className,
@@ -475,25 +613,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
475613 return renderMediaFallback ( src , alt ) ;
476614 }
477615
478- return (
479- < figure className = "markdown-video-wrapper" >
480- < video
481- className = "markdown-video"
482- controls
483- preload = "metadata"
484- playsInline
485- src = { src }
486- { ...props }
487- >
488- { t ( "chatStreamMessage.videoNotSupported" , {
489- defaultValue : "Sorry, your browser does not support embedded videos." ,
490- } ) }
491- </ video >
492- { alt ? (
493- < figcaption className = "markdown-video-caption" > { alt } </ figcaption >
494- ) : null }
495- </ figure >
496- ) ;
616+ return < VideoWithErrorHandling key = { src } src = { src } alt = { alt } props = { props } /> ;
497617 } ;
498618
499619 // Modified processText function logic
@@ -612,6 +732,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
612732 remarkPlugins = { [ remarkGfm , remarkMath ] as any }
613733 rehypePlugins = {
614734 [
735+ rehypeUnwrapMedia ,
615736 [
616737 rehypeKatex ,
617738 {
@@ -786,7 +907,7 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
786907 </ code >
787908 ) ;
788909 } ,
789- // Image (also handles video previews emitted as image markdown)
910+ // Image
790911 img : ( { src, alt } : any ) => {
791912 if ( ! enableMultimodal ) {
792913 return renderMediaFallback ( src , alt ) ;
@@ -796,8 +917,13 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
796917 return renderVideoElement ( { src, alt } ) ;
797918 }
798919
799- return < img src = { src } alt = { alt } className = "markdown-img" /> ;
920+ if ( ! src || typeof src !== "string" ) {
921+ return null ;
922+ }
923+
924+ return < ImageWithErrorHandling key = { src } src = { src } alt = { alt } /> ;
800925 } ,
926+ // Video
801927 video : ( { children, ...props } : any ) => {
802928 const directSrc = props ?. src ;
803929 const childSource = React . Children . toArray ( children )
@@ -828,6 +954,4 @@ export const MarkdownRenderer: React.FC<MarkdownRendererProps> = ({
828954 </ div >
829955 </ >
830956 ) ;
831- } ;
832-
833-
957+ } ;
0 commit comments